Repository: rucio/rucio

Issue:
please provide expiration time of the token in HTTP header
Motivation
----------
Once we perform authentication with rucio server it yield the valid token but does not provide lifetime of the token. Here is a typical example:

```
curl -vvv --key $X509_USER_PROXY --cert $X509_USER_PROXY -H "X-Rucio-Account:das" -A "rucio-clients/1.16.1" https://cms-rucio-authz.cern.ch/auth/x509
* About to connect() to cms-rucio-authz.cern.ch port 443 (#0)
*   Trying 188.184.86.14...
* Connected to cms-rucio-authz.cern.ch (188.184.86.14) port 443 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
*   CAfile: /etc/pki/tls/certs/ca-bundle.crt
  CApath: none
* NSS: client certificate from file
*       subject: CN=163734436,CN=Valentin Y Kuznetsov,CN=443502,CN=valya,OU=Users,OU=Organic Units,DC=cern,DC=ch
*       start date: Oct 31 21:49:00 2018 GMT
*       expire date: Nov 01 09:49:00 2018 GMT
*       common name: 163734436
*       issuer: CN=Valentin Y Kuznetsov,CN=443502,CN=valya,OU=Users,OU=Organic Units,DC=cern,DC=ch
* SSL connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate:
*       subject: CN=cms-rucio-authz.cern.ch,OU=computers,DC=cern,DC=ch
*       start date: Oct 05 15:38:50 2018 GMT
*       expire date: Nov 09 15:38:50 2019 GMT
*       common name: cms-rucio-authz.cern.ch
*       issuer: CN=CERN Grid Certification Authority,DC=cern,DC=ch
> GET /auth/x509 HTTP/1.1
> User-Agent: rucio-clients/1.16.1
> Host: cms-rucio-authz.cern.ch
> Accept: */*
> X-Rucio-Account:das
>
< HTTP/1.1 200 OK
< Date: Wed, 31 Oct 2018 21:49:50 GMT
< Server: Apache
< Access-Control-Allow-Origin: None
< Access-Control-Allow-Headers: None
< Access-Control-Allow-Methods: *
< Access-Control-Allow-Credentials: true
< Access-Control-Expose-Headers: X-Rucio-Auth-Token
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Cache-Control: post-check=0, pre-check=0
< Pragma: no-cache
< X-Rucio-Auth-Token: das-/DC=ch/DC=cern/OU=Organic Units/OU=Users/CN=valya/CN=443502/CN=Valentin Y Kuznetsov-unknown-5d16fe50fef3440694bbfcff899626e6
< X-Rucio-Host: cms-rucio-authz.cern.ch
< Content-Length: 0
< Content-Type: application/octet-stream
<
* Connection #0 to host cms-rucio-authz.cern.ch left intact
```

Out of this request I don't know how long my X-Ruxio-Auth-Token is valid. I would expect that you'll supply corresponding lifetime of the token in separate HTTP header.

Modification
------------



Discussion:
It's exactly one hour, but we can add this explicitly.

Proposal ISO8601 in UTC:
`X-Rucio-Auth-Token-Expiration: 2018-11-01T11:06:41Z`
from here https://rucio.readthedocs.io/en/latest/rest.html:
```
Date format

All dates returned are in UTC and are strings in the following format (RFC 1123, ex RFC 822):

Mon, 13 May 2013 10:23:03 UTC
In code format, which can be used in all programming languages that support strftime or strptime:

'%a, %d %b %Y %H:%M:%S UTC'
```

Hmmm, that might be the content of the docs, but in reality we are always returning ISO8601 in our JSONs :-)

e.g., `$ curl -H 'X-Rucio-Account: mlassnig' -H 'X-Rucio-Auth-Token: ..' https://rucio-lb-prod.cern.ch/accounts/mlassnig`
```
{..... "created_at": "2014-01-17T08:05:22", ....}
```
Maybe better to update the docs instead to reflect this.

For the HTTP headers that's a completely different story, there RFC1123 makes sense since it's also what the web servers return.
Strange for `2014-01-17T08:05:22`

```
# RFC 1123 (ex RFC 822)
DATE_FORMAT = '%a, %d %b %Y %H:%M:%S UTC'
```
is the format used in dumps/loads json (cf. utils.py)


Resolving patch:
diff --git a/lib/rucio/core/authentication.py b/lib/rucio/core/authentication.py
--- a/lib/rucio/core/authentication.py
+++ b/lib/rucio/core/authentication.py
@@ -86,7 +86,9 @@ def get_auth_token_user_pass(account, username, password, appid, ip=None, sessio
     :param ip: IP address of the client a a string.
     :param session: The database session in use.
 
-    :returns: Authentication token as a variable-length string.
+    :returns: Authentication token as a Python struct
+              .token string
+              .expired_at datetime
     """
 
     # Make sure the account exists
@@ -116,8 +118,9 @@ def get_auth_token_user_pass(account, username, password, appid, ip=None, sessio
     token = '%(account)s-%(username)s-%(appid)s-%(tuid)s' % locals()
     new_token = models.Token(account=db_account, token=token, ip=ip)
     new_token.save(session=session)
+    session.expunge(new_token)
 
-    return token
+    return new_token
 
 
 @transactional_session
@@ -133,7 +136,9 @@ def get_auth_token_x509(account, dn, appid, ip=None, session=None):
     :param ipaddr: IP address of the client as a string.
     :param session: The database session in use.
 
-    :returns: Authentication token as a variable-length string.
+    :returns: Authentication token as a Python struct
+              .token string
+              .expired_at datetime
     """
 
     # Make sure the account exists
@@ -149,8 +154,9 @@ def get_auth_token_x509(account, dn, appid, ip=None, session=None):
     token = '%(account)s-%(dn)s-%(appid)s-%(tuid)s' % locals()
     new_token = models.Token(account=account, token=token, ip=ip)
     new_token.save(session=session)
+    session.expunge(new_token)
 
-    return token
+    return new_token
 
 
 @transactional_session
@@ -166,7 +172,9 @@ def get_auth_token_gss(account, gsstoken, appid, ip=None, session=None):
     :param ip: IP address of the client as a string.
     :param session: The database session in use.
 
-    :returns: Authentication token as a variable-length string.
+    :returns: Authentication token as a Python struct
+              .token string
+              .expired_at datetime
     """
 
     # Make sure the account exists
@@ -182,8 +190,9 @@ def get_auth_token_gss(account, gsstoken, appid, ip=None, session=None):
     token = '%(account)s-%(gsstoken)s-%(appid)s-%(tuid)s' % locals()
     new_token = models.Token(account=account, token=token, ip=ip)
     new_token.save(session=session)
+    session.expunge(new_token)
 
-    return token
+    return new_token
 
 
 @transactional_session
@@ -199,7 +208,9 @@ def get_auth_token_ssh(account, signature, appid, ip=None, session=None):
     :param ip: IP address of the client as a string.
     :param session: The database session in use.
 
-    :returns: Authentication token as a variable-length string.
+    :returns: Authentication token as a Python struct
+              .token string
+              .expired_at datetime
     """
 
     # Make sure the account exists
@@ -242,10 +253,10 @@ def get_auth_token_ssh(account, signature, appid, ip=None, session=None):
     tuid = generate_uuid()  # NOQA
     token = '%(account)s-ssh:pubkey-%(appid)s-%(tuid)s' % locals()
     new_token = models.Token(account=account, token=token, ip=ip)
-
     new_token.save(session=session)
+    session.expunge(new_token)
 
-    return token
+    return new_token
 
 
 @transactional_session
@@ -258,7 +269,10 @@ def get_ssh_challenge_token(account, appid, ip=None, session=None):
     :param account: Account identifier as a string.
     :param appid: The application identifier as a string.
     :param ip: IP address of the client as a string.
-    :returns: Challenge as a variable-length string.
+
+    :returns: Challenge token token as a Python struct
+              .token string
+              .expired_at datetime
     """
 
     # Make sure the account exists
@@ -279,8 +293,9 @@ def get_ssh_challenge_token(account, appid, ip=None, session=None):
     new_challenge_token = models.Token(account=account, token=challenge_token, ip=ip,
                                        expired_at=expiration)
     new_challenge_token.save(session=session)
+    session.expunge(new_challenge_token)
 
-    return challenge_token
+    return new_challenge_token
 
 
 def validate_auth_token(token):
diff --git a/lib/rucio/web/rest/webpy/v1/authentication.py b/lib/rucio/web/rest/webpy/v1/authentication.py
--- a/lib/rucio/web/rest/webpy/v1/authentication.py
+++ b/lib/rucio/web/rest/webpy/v1/authentication.py
@@ -40,7 +40,7 @@
                                       get_ssh_challenge_token,
                                       validate_auth_token)
 from rucio.common.exception import AccessDenied, IdentityError, RucioException
-from rucio.common.utils import generate_http_error
+from rucio.common.utils import generate_http_error, date_to_str
 from rucio.web.rest.common import RucioController
 
 
@@ -124,7 +124,8 @@ def GET(self):
         if not result:
             raise generate_http_error(401, 'CannotAuthenticate', 'Cannot authenticate to account %(account)s with given credentials' % locals())
 
-        header('X-Rucio-Auth-Token', result)
+        header('X-Rucio-Auth-Token', result.token)
+        header('X-Rucio-Auth-Token-Expires', date_to_str(result.expired_at))
         return str()
 
 
@@ -190,7 +191,8 @@ def GET(self):
         if result is None:
             raise generate_http_error(401, 'CannotAuthenticate', 'Cannot authenticate to account %(account)s with given credentials' % locals())
         else:
-            header('X-Rucio-Auth-Token', result)
+            header('X-Rucio-Auth-Token', result.token)
+            header('X-Rucio-Auth-Token-Expires', date_to_str(result.expired_at))
             return str()
 
         raise BadRequest()
@@ -283,7 +285,8 @@ def GET(self):
             print('Cannot Authenticate', account, dn, appid, ip)
             raise generate_http_error(401, 'CannotAuthenticate', 'Cannot authenticate to account %(account)s with given credentials' % locals())
 
-        header('X-Rucio-Auth-Token', result)
+        header('X-Rucio-Auth-Token', result.token)
+        header('X-Rucio-Auth-Token-Expires', date_to_str(result.expired_at))
         return str()
 
 
@@ -360,7 +363,8 @@ def GET(self):
         if not result:
             raise generate_http_error(401, 'CannotAuthenticate', 'Cannot authenticate to account %(account)s with given credentials' % locals())
 
-        header('X-Rucio-Auth-Token', result)
+        header('X-Rucio-Auth-Token', result.token)
+        header('X-Rucio-Auth-Token-Expires', date_to_str(result.expired_at))
         return str()
 
 
@@ -427,7 +431,8 @@ def GET(self):
         if not result:
             raise generate_http_error(401, 'CannotAuthenticate', 'Cannot generate challenge for account %(account)s' % locals())
 
-        header('X-Rucio-SSH-Challenge-Token', result)
+        header('X-Rucio-SSH-Challenge-Token', result.token)
+        header('X-Rucio-SSH-Challenge-Token-Expires', date_to_str(result.expired_at))
         return str()
 
 


Test patch:
diff --git a/lib/rucio/tests/test_authentication.py b/lib/rucio/tests/test_authentication.py
--- a/lib/rucio/tests/test_authentication.py
+++ b/lib/rucio/tests/test_authentication.py
@@ -83,7 +83,7 @@ def test_get_auth_token_ssh_success(self):
         except Duplicate:
             pass  # might already exist, can skip
 
-        challenge_token = get_ssh_challenge_token(account='root', appid='test', ip='127.0.0.1')
+        challenge_token = get_ssh_challenge_token(account='root', appid='test', ip='127.0.0.1').token
 
         signature = base64.b64decode(ssh_sign(PRIVATE_KEY, challenge_token))