Skip to content

Commit 4a32027

Browse files
committed
Merge branch '1654-application-token' into 'develop'
Resolve "Application Token" Closes #1654 See merge request nomad-lab/nomad-FAIR!1460
2 parents def05d2 + 8713445 commit 4a32027

File tree

6 files changed

+153
-46
lines changed

6 files changed

+153
-46
lines changed

docs/apis/api.md

+15
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,21 @@ To use authentication in the dashboard, simply use the Authorize button. The
390390
dashboard GUI will manage the access token and use it while you try out the various
391391
operations.
392392

393+
#### App token
394+
395+
If the short-term expiration of the default *access token* does not suit your needs,
396+
you can request an *app token* with a user-defined expiration. For example, you can
397+
send the GET request `/auth/app_token?expires_in=86400` together with some way of
398+
authentication, e.g. header `Authorization: Bearer <access token>`. The API will return
399+
an app token, which is valid for 24 hours in subsequent request headers with the format
400+
`Authorization: Bearer <app token>`. The request will be declined if the expiration is
401+
larger than the maximum expiration defined by the API config.
402+
403+
!!! warning
404+
Despite the name, the app token is used to impersonate the user who requested it.
405+
It does not discern between different uses and will only become invalid once it
406+
expires (or when the API's secret is changed).
407+
393408
## Search for entries
394409

395410
See [getting started](#getting-started) for a typical search example. Combine the [different

nomad/app/v1/routers/auth.py

+83-34
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import hashlib
2121
import uuid
2222
import requests
23-
from typing import Callable, cast
23+
from typing import Callable, cast, Union
2424
from inspect import Parameter, signature
2525
from functools import wraps
2626
from fastapi import APIRouter, Depends, Query as FastApiQuery, Request, HTTPException, status
@@ -51,6 +51,10 @@ class SignatureToken(BaseModel):
5151
signature_token: str
5252

5353

54+
class AppToken(BaseModel):
55+
app_token: str
56+
57+
5458
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f'{root_path}/auth/token', auto_error=False)
5559

5660

@@ -64,7 +68,6 @@ def create_user_dependency(
6468
Creates a dependency for getting the authenticated user. The parameters define if
6569
the authentication is required or not, and which authentication methods are allowed.
6670
'''
67-
6871
def user_dependency(**kwargs) -> User:
6972
user = None
7073
if basic_auth_allowed:
@@ -180,17 +183,26 @@ def _get_user_basic_auth(form_data: OAuth2PasswordRequestForm) -> User:
180183
def _get_user_bearer_token_auth(bearer_token: str) -> User:
181184
'''
182185
Verifies bearer_token (throwing exception if illegal value provided) and returns the
183-
corresponding user object, or None, if no bearer_token provided.
186+
corresponding user object, or None if no bearer_token provided.
184187
'''
185-
if bearer_token:
186-
try:
187-
user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token))
188+
if not bearer_token:
189+
return None
190+
191+
try:
192+
unverified_payload = jwt.decode(bearer_token, options={"verify_signature": False})
193+
if unverified_payload.keys() == set(['user', 'exp']):
194+
user = _get_user_from_simple_token(bearer_token)
188195
return user
189-
except infrastructure.KeycloakError as e:
190-
raise HTTPException(
191-
status_code=status.HTTP_401_UNAUTHORIZED,
192-
detail=str(e), headers={'WWW-Authenticate': 'Bearer'})
193-
return None
196+
except jwt.exceptions.DecodeError:
197+
pass # token could be non-JWT, e.g. for testing
198+
199+
try:
200+
user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token))
201+
return user
202+
except infrastructure.KeycloakError as e:
203+
raise HTTPException(
204+
status_code=status.HTTP_401_UNAUTHORIZED,
205+
detail=str(e), headers={'WWW-Authenticate': 'Bearer'})
194206

195207

196208
def _get_user_upload_token_auth(upload_token: str) -> User:
@@ -227,21 +239,8 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
227239
corresponding user object, or None, if no upload_token provided.
228240
'''
229241
if signature_token:
230-
try:
231-
decoded = jwt.decode(signature_token, config.services.api_secret, algorithms=['HS256'])
232-
return datamodel.User.get(user_id=decoded['user'])
233-
except KeyError:
234-
raise HTTPException(
235-
status_code=status.HTTP_401_UNAUTHORIZED,
236-
detail='Token with invalid/unexpected payload.')
237-
except jwt.ExpiredSignatureError:
238-
raise HTTPException(
239-
status_code=status.HTTP_401_UNAUTHORIZED,
240-
detail='Expired token.')
241-
except jwt.InvalidTokenError:
242-
raise HTTPException(
243-
status_code=status.HTTP_401_UNAUTHORIZED,
244-
detail='Invalid token.')
242+
user = _get_user_from_simple_token(signature_token)
243+
return user
245244
elif request:
246245
auth_cookie = request.cookies.get('Authorization')
247246
if auth_cookie:
@@ -261,6 +260,28 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
261260
return None
262261

263262

263+
def _get_user_from_simple_token(token):
264+
'''
265+
Verifies a simple token (throwing exception if illegal value provided) and returns the
266+
corresponding user object, or None if no token was provided.
267+
'''
268+
try:
269+
decoded = jwt.decode(token, config.services.api_secret, algorithms=['HS256'])
270+
return datamodel.User.get(user_id=decoded['user'])
271+
except KeyError:
272+
raise HTTPException(
273+
status_code=status.HTTP_401_UNAUTHORIZED,
274+
detail='Token with invalid/unexpected payload.')
275+
except jwt.ExpiredSignatureError:
276+
raise HTTPException(
277+
status_code=status.HTTP_401_UNAUTHORIZED,
278+
detail='Expired token.')
279+
except jwt.InvalidTokenError:
280+
raise HTTPException(
281+
status_code=status.HTTP_401_UNAUTHORIZED,
282+
detail='Invalid token.')
283+
284+
264285
_bad_credentials_response = status.HTTP_401_UNAUTHORIZED, {
265286
'model': HTTPExceptionModel,
266287
'description': strip('''
@@ -287,7 +308,6 @@ async def get_token(form_data: OAuth2PasswordRequestForm = Depends()):
287308
You only need to provide `username` and `password` values. You can ignore the other
288309
parameters.
289310
'''
290-
291311
try:
292312
access_token = infrastructure.keycloak.basicauth(
293313
form_data.username, form_data.password)
@@ -311,7 +331,6 @@ async def get_token_via_query(username: str, password: str):
311331
This is an convenience alternative to the **POST** version of this operation.
312332
It allows you to retrieve an *access token* by providing username and password.
313333
'''
314-
315334
try:
316335
access_token = infrastructure.keycloak.basicauth(username, password)
317336
except infrastructure.KeycloakError:
@@ -328,21 +347,51 @@ async def get_token_via_query(username: str, password: str):
328347
tags=[default_tag],
329348
summary='Get a signature token',
330349
response_model=SignatureToken)
331-
async def get_signature_token(user: User = Depends(create_user_dependency())):
350+
async def get_signature_token(
351+
user: Union[User, None] = Depends(create_user_dependency(required=True))):
332352
'''
333353
Generates and returns a signature token for the authenticated user. Authentication
334354
has to be provided with another method, e.g. access token.
335355
'''
356+
signature_token = generate_simple_token(user.user_id, expires_in=10)
357+
return {'signature_token': signature_token}
336358

337-
expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
338-
signature_token = jwt.encode(
339-
dict(user=user.user_id, exp=expires_at),
340-
config.services.api_secret, 'HS256')
341359

342-
return {'signature_token': signature_token}
360+
@router.get(
361+
'/app_token',
362+
tags=[default_tag],
363+
summary='Get an app token',
364+
response_model=AppToken)
365+
async def get_app_token(
366+
expires_in: int = FastApiQuery(gt=0, le=config.services.app_token_max_expires_in),
367+
user: User = Depends(create_user_dependency(required=True))):
368+
'''
369+
Generates and returns an app token with the requested expiration time for the
370+
authenticated user. Authentication has to be provided with another method,
371+
e.g. access token.
372+
373+
This app token can be used like the access token (see `/auth/token`) on subsequent API
374+
calls to authenticate you using the HTTP header `Authorization: Bearer <app token>`.
375+
It is provided for user convenience as a shorter token with a user-defined (probably
376+
longer) expiration time than the access token.
377+
'''
378+
app_token = generate_simple_token(user.user_id, expires_in)
379+
return {'app_token': app_token}
380+
381+
382+
def generate_simple_token(user_id, expires_in: int):
383+
'''
384+
Generates and returns JWT encoding just user_id and expiration time, signed with the
385+
API secret.
386+
'''
387+
expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)
388+
payload = dict(user=user_id, exp=expires_at)
389+
token = jwt.encode(payload, config.services.api_secret, 'HS256')
390+
return token
343391

344392

345393
def generate_upload_token(user):
394+
'''Generates and returns upload token for user.'''
346395
payload = uuid.UUID(user.user_id).bytes
347396
signature = hmac.new(
348397
bytes(config.services.api_secret, 'utf-8'),

nomad/config/models.py

+4
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ class Services(NomadSettings):
202202
Value that is used in `results` section Enum fields (e.g. system type, spacegroup, etc.)
203203
to indicate that the value could not be determined.
204204
''')
205+
app_token_max_expires_in = Field(1 * 24 * 60 * 60, description='''
206+
Maximum expiration time for an app token in seconds. Requests with a higher value
207+
will be declined.
208+
''')
205209

206210

207211
class Meta(NomadSettings):

tests/app/conftest.py

+19-10
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,43 @@
2121

2222
from nomad.app.main import app
2323
from nomad.datamodel import User
24-
from nomad.app.v1.routers.auth import generate_upload_token
24+
from nomad.app.v1.routers.auth import generate_upload_token, generate_simple_token
2525

2626

27-
def create_auth_headers(user: User):
28-
return {
29-
'Authorization': 'Bearer %s' % user.user_id
30-
}
27+
def create_auth_headers(token: str):
28+
return {'Authorization': f'Bearer {token}'}
3129

3230

3331
@pytest.fixture(scope='module')
3432
def test_user_auth(test_user: User):
35-
return create_auth_headers(test_user)
33+
return create_auth_headers(test_user.user_id)
3634

3735

3836
@pytest.fixture(scope='module')
3937
def other_test_user_auth(other_test_user: User):
40-
return create_auth_headers(other_test_user)
38+
return create_auth_headers(other_test_user.user_id)
4139

4240

4341
@pytest.fixture(scope='module')
4442
def admin_user_auth(admin_user: User):
45-
return create_auth_headers(admin_user)
43+
return create_auth_headers(admin_user.user_id)
44+
45+
46+
@pytest.fixture(scope='module')
47+
def invalid_user_auth():
48+
return create_auth_headers("invalid.bearer.token")
49+
50+
51+
@pytest.fixture(scope='module')
52+
def app_token_auth(test_user: User):
53+
app_token = generate_simple_token(test_user.user_id, expires_in=3600)
54+
return create_auth_headers(app_token)
4655

4756

4857
@pytest.fixture(scope='module')
4958
def test_auth_dict(
5059
test_user, other_test_user, admin_user,
51-
test_user_auth, other_test_user_auth, admin_user_auth):
60+
test_user_auth, other_test_user_auth, admin_user_auth, invalid_user_auth):
5261
'''
5362
Returns a dictionary of the form {user_name: (auth_headers, token)}. The key 'invalid'
5463
contains an example of invalid credentials, and the key None contains (None, None).
@@ -57,7 +66,7 @@ def test_auth_dict(
5766
'test_user': (test_user_auth, generate_upload_token(test_user)),
5867
'other_test_user': (other_test_user_auth, generate_upload_token(other_test_user)),
5968
'admin_user': (admin_user_auth, generate_upload_token(admin_user)),
60-
'invalid': ({'Authorization': 'Bearer JUST-MADE-IT-UP'}, 'invalid.token'),
69+
'invalid': (invalid_user_auth, 'invalid.upload.token'),
6170
None: (None, None)}
6271

6372

tests/app/v1/routers/test_auth.py

+27
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,30 @@ def test_get_signature_token(client, test_user_auth):
4646
response = client.get('auth/signature_token', headers=test_user_auth)
4747
assert response.status_code == 200
4848
assert response.json().get('signature_token') is not None
49+
50+
51+
def test_get_signature_token_unauthorized(client, invalid_user_auth):
52+
response = client.get('auth/signature_token', headers=None)
53+
assert response.status_code == 401
54+
response = client.get('auth/signature_token', headers=invalid_user_auth)
55+
assert response.status_code == 401
56+
57+
58+
@pytest.mark.parametrize(
59+
'expires_in, status_code',
60+
[(0, 422), (30 * 60, 200), (2 * 60 * 60, 200), (25 * 60 * 60, 422), (None, 422)])
61+
def test_get_app_token(client, test_user_auth, expires_in, status_code):
62+
response = client.get('auth/app_token', headers=test_user_auth,
63+
params={'expires_in': expires_in})
64+
assert response.status_code == status_code
65+
if status_code == 200:
66+
assert response.json().get('app_token') is not None
67+
68+
69+
def test_get_app_token_unauthorized(client, invalid_user_auth):
70+
response = client.get('auth/app_token', headers=None,
71+
params={'expires_in': 60})
72+
assert response.status_code == 401
73+
response = client.get('auth/app_token', headers=invalid_user_auth,
74+
params={'expires_in': 60})
75+
assert response.status_code == 401

tests/app/v1/routers/test_users.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
#
1919

2020
import pytest
21-
from tests.conftest import test_users as conf_test_users, test_user_uuid as conf_test_user_uuid
21+
from tests.conftest import (test_users as conf_test_users,
22+
test_user_uuid as conf_test_user_uuid)
2223

2324

2425
def assert_user(user, expected_user):
@@ -27,9 +28,11 @@ def assert_user(user, expected_user):
2728
assert 'email' not in user
2829

2930

30-
def test_me(client, test_user_auth):
31+
def test_me(client, test_user_auth, app_token_auth):
3132
response = client.get('users/me', headers=test_user_auth)
3233
assert response.status_code == 200
34+
response = client.get('users/me', headers=app_token_auth)
35+
assert response.status_code == 200
3336

3437

3538
def test_me_auth_required(client):

0 commit comments

Comments
 (0)