From 26a87b844590dcfe188aaea6f334339f3d22f3de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Fri, 31 Oct 2025 15:15:31 -0300 Subject: [PATCH] update --- id.saladeaula.digital/app/oauth2.py | 60 +++++++++++-------- id.saladeaula.digital/app/routes/token.py | 4 +- .../tests/routes/test_revoke.py | 1 + .../tests/routes/test_token.py | 30 ++++++++++ id.saladeaula.digital/uv.lock | 2 +- 5 files changed, 69 insertions(+), 28 deletions(-) diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index 7060af1..0ec507e 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -1,5 +1,3 @@ -from os import rename - from authlib.common.security import generate_token from authlib.common.urls import add_params_to_uri from authlib.jose import JsonWebKey @@ -13,7 +11,6 @@ from authlib.oidc.core import OpenIDCode as OpenIDCode_ from authlib.oidc.core import UserInfo from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Response -from aws_lambda_powertools.event_handler.exceptions import NotFoundError from layercake.dateutils import now, ttl from layercake.dynamodb import ( DynamoDBPersistenceLayer, @@ -42,9 +39,11 @@ private_key = read_file_path('private.pem') private_jwk = JsonWebKey.import_key(private_key) +# https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN GRANT_TYPES_EXPIRES_IN = { 'authorization_code': 60 * 3, # 3 minutes - 'refresh_token': 3600, # 1 hour + 'refresh_token': 60 * 3, # 3 minutes + # 'refresh_token': 3600, # 1 hour } @@ -83,6 +82,17 @@ class OpenIDCode(OpenIDCode_): class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): + """The authorization code grant type is used to obtain both access tokens and + refresh tokens and is optimized for confidential clients. + + Since this is a redirection-based flow, the client must be capable of + interacting with the resource owner’s user-agent (typically a web browser) + and capable of receiving incoming requests (via redirection) from the + authorization server: + + - https://docs.authlib.org/en/v0.12/specs/rfc6749.html#authlib.oauth2.rfc6749.grants.AuthorizationCodeGrant + """ + TOKEN_ENDPOINT_AUTH_METHODS = [ 'client_secret_basic', 'client_secret_post', @@ -206,11 +216,6 @@ class TokenExchangeGrant(grants.BaseGrant): raise NotImplementedError() -class RefreshTokenNotFoundError(NotFoundError): - def __init__(self, *_): - super().__init__('Refresh token not found') - - class RefreshTokenGrant(grants.RefreshTokenGrant): TOKEN_ENDPOINT_AUTH_METHODS = [ 'client_secret_basic', @@ -221,17 +226,22 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): # The authorization server MAY issue a new refresh token INCLUDE_NEW_REFRESH_TOKEN = True - def authenticate_refresh_token(self, refresh_token: str, **kwargs) -> TokenMixin: + def authenticate_refresh_token(self, refresh_token: str) -> TokenMixin: """Get token information with refresh_token string.""" token = dyn.collection.get_item( KeyPair( pk='OAUTH2#TOKEN', sk=f'REFRESH_TOKEN#{refresh_token}', ), - exc_cls=RefreshTokenNotFoundError, + raise_on_error=False, + default=False, ) - logger.info('Refresh token retrieved', token=token) + if not token: + logger.info('Refresh token not found ', refresh_token=token) + return None # type: ignore + + logger.info('Refresh token retrieved', refresh_token=token) return OAuth2Token( expires_in=int(token['expires_in']), @@ -249,13 +259,14 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): issuing a new refresh token to the client.""" token = getattr(refresh_token, 'refresh_token', None) - logger.debug('Revoking old refresh token', refresh_token=token) user = refresh_token.get_user() if not token: return None with dyn.transact_writer() as transact: + logger.debug('Revoking old refresh token', refresh_token=token) + transact.delete( key=KeyPair( pk='OAUTH2#TOKEN', @@ -276,24 +287,24 @@ class RevocationEndpoint(rfc7009.RevocationEndpoint): token_string: str, token_type_hint: str | None = None, ): - t = dyn.collection.get_items( + result = dyn.collection.get_items( TransactKey('OAUTH2#TOKEN') + SortKey(sk=f'REFRESH_TOKEN#{token_string}', rename_key='refresh_token') + SortKey(sk=f'ACCESS_TOKEN#{token_string}', rename_key='access_token'), flatten_top=False, ) - if not t: + if not result: return None - logger.debug('Tokens retrieved', result=t) + logger.debug('Tokens retrieved', result=result) if not token_type_hint: token_type_hint = ( - 'refresh_token' if 'refresh_token' in t else 'access_token' + 'refresh_token' if 'refresh_token' in result else 'access_token' ) - token = t[token_type_hint] + token = result[token_type_hint] return OAuth2Token( expires_in=int(token['expires_in']), @@ -319,24 +330,25 @@ class RevocationEndpoint(rfc7009.RevocationEndpoint): - https://docs.authlib.org/en/latest/specs/rfc7009.html#authlib.oauth2.rfc7009.RevocationEndpoint.revoke_token """ user_id = token.user['id'] - r = dyn.collection.query(KeyPair(pk=user_id, sk='SESSION')) + result = dyn.collection.query(KeyPair(pk=user_id, sk='SESSION')) with dyn.transact_writer() as transact: # Revoke all sessions, access tokens, and refresh tokens - for x in r['items']: - pk, sk = x['id'], x['sk'] - *_, kind, idx = sk.split('#') + for item in result['items']: + pk, sk = item['id'], item['sk'] + # Key pattern: SESSION#{}#{} + *_, kind, token_ = sk.split('#') transact.delete(key=KeyPair(pk, sk)) transact.delete( key=KeyPair( pk='SESSION', - sk=idx, + sk=token_, ) if kind == 'SESSION' else KeyPair( pk='OAUTH2#TOKEN', - sk=f'{kind}#{idx}', + sk=f'{kind}#{token_}', ) ) diff --git a/id.saladeaula.digital/app/routes/token.py b/id.saladeaula.digital/app/routes/token.py index 9fffb93..c680d67 100644 --- a/id.saladeaula.digital/app/routes/token.py +++ b/id.saladeaula.digital/app/routes/token.py @@ -9,6 +9,4 @@ router = Router() @router.post('/token') def issue_token(): - response = server.create_token_response(router.current_event) - logger.debug(getattr(response, 'body', None)) - return response + return server.create_token_response(router.current_event) diff --git a/id.saladeaula.digital/tests/routes/test_revoke.py b/id.saladeaula.digital/tests/routes/test_revoke.py index 3ed0e1c..cb3dafe 100644 --- a/id.saladeaula.digital/tests/routes/test_revoke.py +++ b/id.saladeaula.digital/tests/routes/test_revoke.py @@ -1,4 +1,5 @@ import json +import pprint from base64 import b64encode from http import HTTPMethod, HTTPStatus from urllib.parse import urlencode diff --git a/id.saladeaula.digital/tests/routes/test_token.py b/id.saladeaula.digital/tests/routes/test_token.py index b94b3c2..9455af6 100644 --- a/id.saladeaula.digital/tests/routes/test_token.py +++ b/id.saladeaula.digital/tests/routes/test_token.py @@ -1,4 +1,5 @@ import json +import pprint from base64 import b64encode from http import HTTPMethod, HTTPStatus from urllib.parse import urlencode @@ -125,3 +126,32 @@ def test_refresh_token( ) assert len(r['items']) == 2 + + +def test_refresh_token_invalid_grant( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/token', + method=HTTPMethod.POST, + headers={ + 'Authorization': f'Basic {AUTH}', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body=urlencode( + { + 'grant_type': 'refresh_token', + 'refresh_token': '1234', + 'client_id': CLIENT_ID, + } + ), + ), + lambda_context, + ) + + assert r['statusCode'] == 400 diff --git a/id.saladeaula.digital/uv.lock b/id.saladeaula.digital/uv.lock index ee3fb72..3000f02 100644 --- a/id.saladeaula.digital/uv.lock +++ b/id.saladeaula.digital/uv.lock @@ -507,7 +507,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.11.0" +version = "0.11.1" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" },