From 78c4a4ad30f15b0a4d7ed73e31b92b0d7143fc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 7 Aug 2025 22:10:10 -0300 Subject: [PATCH] wip --- id.saladeaula.digital/app/config.py | 7 +- .../app/integrations/apigateway_oauth2.py | 180 ---------------- .../apigateway_oauth2/__init__.py | 0 .../apigateway_oauth2/authorization_server.py | 154 ++++++++++++++ .../integrations/apigateway_oauth2/client.py | 60 ++++++ .../apigateway_oauth2/requests.py | 65 ++++++ .../integrations/apigateway_oauth2/tokens.py | 97 +++++++++ id.saladeaula.digital/app/oauth2.py | 199 ++++++++++-------- .../app/routes/openid_configuration.py | 5 +- id.saladeaula.digital/app/security.py | 11 - id.saladeaula.digital/template.yaml | 1 + id.saladeaula.digital/tests/conftest.py | 2 + .../tests/routes/test_authorize.py | 8 +- .../tests/routes/test_login.py | 13 +- .../tests/routes/test_token.py | 40 +++- id.saladeaula.digital/tests/seeds.jsonl | 4 +- id.saladeaula.digital/uv.lock | 13 +- 17 files changed, 555 insertions(+), 304 deletions(-) delete mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2.py create mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2/__init__.py create mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py create mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2/client.py create mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2/requests.py create mode 100644 id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py delete mode 100644 id.saladeaula.digital/app/security.py diff --git a/id.saladeaula.digital/app/config.py b/id.saladeaula.digital/app/config.py index 3ce8c69..0e63d61 100644 --- a/id.saladeaula.digital/app/config.py +++ b/id.saladeaula.digital/app/config.py @@ -3,12 +3,11 @@ import os ISSUER: str = os.getenv('ISSUER') # type: ignore OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore -DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY') - -OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED') +OAUTH2_SCOPES_SUPPORTED: str = os.getenv('OAUTH2_SCOPES_SUPPORTED', '') JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore JWT_ALGORITHM = 'HS256' JWT_EXP_SECONDS = 900 # 15 minutes -REFRESH_TOKEN_EXP_SECONDS = 7 * 86400 # 7 days +ACCESS_TOKEN_EXP_SECONDS = 3600 # 1 hour +REFRESH_TOKEN_EXP_SECONDS = 14 * 86400 # 14 days diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2.py deleted file mode 100644 index 8803d46..0000000 --- a/id.saladeaula.digital/app/integrations/apigateway_oauth2.py +++ /dev/null @@ -1,180 +0,0 @@ -import os -import secrets -from collections import defaultdict -from urllib.parse import parse_qs - -from authlib.oauth2 import AuthorizationServer as _AuthorizationServer -from authlib.oauth2.rfc6749 import AuthorizationCodeMixin, ClientMixin, TokenMixin -from authlib.oauth2.rfc6749.requests import JsonRequest as _JsonRequest -from authlib.oauth2.rfc6749.requests import OAuth2Payload as _OAuth2Payload -from authlib.oauth2.rfc6749.requests import OAuth2Request as _OAuth2Request -from aws_lambda_powertools.event_handler.api_gateway import Response -from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import ( - APIGatewayProxyEventV2, -) - -OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED') - - -class OAuth2Payload(_OAuth2Payload): - def __init__(self, request: APIGatewayProxyEventV2): - self._request = request - - @property - def decoded_body(self): - # TODO - body = parse_qs(self._request.decoded_body, keep_blank_values=True) - return {k: v[0] if len(v) == 1 else v for k, v in body.items()} - - @property - def data(self): - """Combines query string parameters and the request body""" - return self._request.query_string_parameters | self.decoded_body - - @property - def datalist(self) -> dict[str, list]: - values = defaultdict(list) - - for k, v in self.data.items(): - values[k].extend([v]) - return values - - -class JsonRequest(_JsonRequest): - def __init__(self, request: APIGatewayProxyEventV2): - uri = f'https://{request.request_context.domain_name}' - - super().__init__( - request.request_context.http.method, - uri, - request.headers, - ) - - -class OAuth2Request(_OAuth2Request): - def __init__(self, request: APIGatewayProxyEventV2): - uri = f'https://{request.request_context.domain_name}' - - super().__init__( - request.request_context.http.method, - uri, - request.headers, - ) - self._request = request - self.payload = OAuth2Payload(request) - - @property - def args(self): - return self._request.query_string_parameters - - @property - def form(self) -> dict[str, str]: - return self.payload.decoded_body - - -class OAuth2Client(ClientMixin): - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uris: list, - response_types: list, - grant_types: list, - token_endpoint_auth_method: str = 'client_secret_basic', - ) -> None: - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uris = redirect_uris - self.response_types = response_types - self.grant_types = grant_types - self.token_endpoint_auth_method = token_endpoint_auth_method - - def get_client_id(self): - return self.client_id - - def get_default_redirect_uri(self) -> str: # type: ignore - if self.redirect_uris: - return self.redirect_uris[0] - - def check_response_type(self, response_type): - return response_type in self.response_types - - def check_redirect_uri(self, redirect_uri): - return redirect_uri in self.redirect_uris - - def check_endpoint_auth_method(self, method, endpoint): - if endpoint == 'token': - return self.token_endpoint_auth_method == method - - return True - - def check_grant_type(self, grant_type): - return grant_type in self.grant_types - - def check_client_secret(self, client_secret): - return secrets.compare_digest(self.client_secret, client_secret) - - -class OAuth2Token(TokenMixin): ... - - -class AuthorizationCode(AuthorizationCodeMixin): - def __init__( - self, - user_id: str, - code: str, - client_id: str, - redirect_uri: str, - response_type: str, - scope: str, - code_challenge: str | None = None, - code_challenge_method: str | None = None, - nonce: str | None = None, - ) -> None: - self.user_id = user_id - self.code = code - self.client_id = client_id - self.redirect_uri = redirect_uri - self.response_type = response_type - self.scope = scope - self.code_challenge = code_challenge - self.code_challenge_method = code_challenge_method - self.nonce = nonce - - def get_redirect_uri(self): - return self.redirect_uri - - def get_scope(self): - return self.scope - - -class AuthorizationServer(_AuthorizationServer): - def __init__(self, query_client, save_token) -> None: - super().__init__( - scopes_supported=OAUTH2_SCOPES_SUPPORTED, - ) - - self._query_client = query_client - self._save_token = save_token - - def save_token(self, token, request): - return self._save_token(token, request) - - def query_client(self, client_id: str): - return self._query_client(client_id) - - def create_oauth2_request(self, request: APIGatewayProxyEventV2) -> OAuth2Request: - return OAuth2Request(request) - - def create_json_request(self, request: APIGatewayProxyEventV2) -> JsonRequest: - return JsonRequest(request) - - def handle_response(self, status, body, headers): - return Response( - status_code=status, - body=body, - headers=headers, - ) - - def send_signal(self, name, *args, **kwargs): - pass diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/__init__.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py new file mode 100644 index 0000000..451be90 --- /dev/null +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py @@ -0,0 +1,154 @@ +import os + +import authlib.oauth2 as oauth2 +import authlib.oauth2.rfc6749.requests as requests +from authlib.common.security import generate_token +from authlib.oauth2.rfc6750 import BearerTokenGenerator +from aws_lambda_powertools.event_handler.api_gateway import Response +from aws_lambda_powertools.event_handler.exceptions import NotFoundError +from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import ( + APIGatewayProxyEventV2, +) +from layercake.dateutils import now, ttl +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from config import ACCESS_TOKEN_EXP_SECONDS, REFRESH_TOKEN_EXP_SECONDS + +from .client import OAuth2Client +from .requests import APIGatewayJsonRequest, APIGatewayOAuth2Request + +DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY') +OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED') + + +class AuthorizationServer(oauth2.AuthorizationServer): + def __init__( + self, + *, + persistence_layer: DynamoDBPersistenceLayer, + ) -> None: + self._persistence_layer = persistence_layer + + super().__init__( + scopes_supported=( + set(OAUTH2_SCOPES_SUPPORTED.split()) if OAUTH2_SCOPES_SUPPORTED else [] + ) + ) + + self.register_token_generator( + 'default', + BearerTokenGenerator( + access_token_generator=create_token_generator(42), + refresh_token_generator=create_token_generator(48), + ), + ) + + def save_token( + self, + token: dict, + request: requests.OAuth2Request, + ) -> None: + if not request.payload: + raise ValueError('Missing request payload') + + if not request.user: + raise ValueError('Missing request user') + + now_ = now() + client_id = request.payload.client_id + access_token = token['access_token'] + refresh_token = token.get('refresh_token') + token_type = token['token_type'] + scope = token['scope'] + issued_at = int(now_.timestamp()) + + with self._persistence_layer.transact_writer() as transact: + transact.put( + item={ + 'id': 'OAUTH2#TOKEN', + 'sk': f'ACCESS_TOKEN#{access_token}', + 'client_id': client_id, + 'token_type': token_type, + 'refresh_token': refresh_token, + 'scope': scope, + 'user': request.user, + 'expires_in': ACCESS_TOKEN_EXP_SECONDS, + 'issued_at': issued_at, + 'ttl': ttl(start_dt=now_, seconds=ACCESS_TOKEN_EXP_SECONDS), + }, + ) + + if refresh_token: + transact.put( + item={ + 'id': 'OAUTH2#TOKEN', + 'sk': f'REFRESH_TOKEN#{refresh_token}', + 'client_id': client_id, + 'token_type': token_type, + 'access_token': access_token, + 'scope': scope, + 'user': request.user, + 'expires_in': REFRESH_TOKEN_EXP_SECONDS, + 'issued_at': issued_at, + 'ttl': ttl(start_dt=now_, seconds=REFRESH_TOKEN_EXP_SECONDS), + }, + ) + + return None + + def query_client(self, client_id: str): + client = self._persistence_layer.collection.get_item( + KeyPair(pk='OAUTH2', sk=f'CLIENT_ID#{client_id}'), + exc_cls=ClientNotFoundError, + ) + + _, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#') + + return OAuth2Client( + client_id=client_id, + client_secret=client['client_secret'], + scope=client['scope'], + redirect_uris=client['redirect_uris'], + response_types=client['response_types'], + grant_types=client['grant_types'], + token_endpoint_auth_method=client.get( + 'token_endpoint_auth_method', 'client_secret_basic' + ), + ) + + def create_oauth2_request( + self, request: APIGatewayProxyEventV2 + ) -> APIGatewayOAuth2Request: + return APIGatewayOAuth2Request(request) + + def create_json_request( + self, request: APIGatewayProxyEventV2 + ) -> APIGatewayJsonRequest: + return APIGatewayJsonRequest(request) + + def handle_response(self, status: int, body, headers): + return Response( + status_code=status, + body=body, + headers=headers, + ) + + def send_signal(self, name: str, *args, **kwargs) -> None: + # after_authenticate_client + # when client is authenticated + + # after_revoke_token + # when token is revoked + ... + + +class ClientNotFoundError(NotFoundError): + def __init__(self, *_): + super().__init__('Client not found') + + +def create_token_generator(length: int = 42): + def token_generator(*args, **kwargs): + return generate_token(length) + + return token_generator diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/client.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/client.py new file mode 100644 index 0000000..0480fd8 --- /dev/null +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/client.py @@ -0,0 +1,60 @@ +import secrets + +from authlib.oauth2.rfc6749 import ( + ClientMixin, + list_to_scope, + scope_to_list, +) + + +class OAuth2Client(ClientMixin): + def __init__( + self, + client_id: str, + client_secret: str, + scope: str, + redirect_uris: list, + response_types: list, + grant_types: list, + token_endpoint_auth_method: str = 'client_secret_basic', + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope + self.redirect_uris = redirect_uris + self.response_types = response_types + self.grant_types = grant_types + self.token_endpoint_auth_method = token_endpoint_auth_method + + def get_client_id(self): + return self.client_id + + def get_allowed_scope(self, scope) -> str: + if not scope: + return '' + + allowed = set(self.scope.split()) + scopes = scope_to_list(scope) + return list_to_scope([s for s in scopes if s in allowed]) + + def get_default_redirect_uri(self) -> str: # type: ignore + if self.redirect_uris: + return self.redirect_uris[0] + + def check_response_type(self, response_type): + return response_type in self.response_types + + def check_redirect_uri(self, redirect_uri): + return redirect_uri in self.redirect_uris + + def check_endpoint_auth_method(self, method, endpoint): + if endpoint == 'token': + return self.token_endpoint_auth_method == method + + return True + + def check_grant_type(self, grant_type): + return grant_type in self.grant_types + + def check_client_secret(self, client_secret): + return secrets.compare_digest(self.client_secret, client_secret) diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/requests.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/requests.py new file mode 100644 index 0000000..51d5a5f --- /dev/null +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/requests.py @@ -0,0 +1,65 @@ +from collections import defaultdict +from urllib.parse import parse_qs + +import authlib.oauth2.rfc6749.requests as requests +from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import ( + APIGatewayProxyEventV2, +) + + +class APIGatewayOAuth2Payload(requests.OAuth2Payload): + def __init__(self, request: APIGatewayProxyEventV2): + self._request = request + + @property + def decoded_body(self): + # TODO + body = parse_qs(self._request.decoded_body, keep_blank_values=True) + return {k: v[0] if len(v) == 1 else v for k, v in body.items()} + + @property + def data(self): + """Combines query string parameters and the request body""" + return self._request.query_string_parameters | self.decoded_body + + @property + def datalist(self) -> dict[str, list]: + values = defaultdict(list) + + for k, v in self.data.items(): + values[k].extend([v]) + return values + + +class APIGatewayJsonRequest(requests.JsonRequest): + def __init__(self, request: APIGatewayProxyEventV2): + uri = f'https://{request.request_context.domain_name}' + + super().__init__( + request.request_context.http.method, + uri, + request.headers, + ) + + +class APIGatewayOAuth2Request(requests.OAuth2Request): + def __init__(self, request: APIGatewayProxyEventV2): + uri = f'https://{request.request_context.domain_name}' + + super().__init__( + request.request_context.http.method, + uri, + request.headers, + ) + self._request = request + self.payload = APIGatewayOAuth2Payload(request) + + @property + def args(self): + # @TODO + return self._request.query_string_parameters + + @property + def form(self) -> dict[str, str]: + # @TODO + return self.payload.decoded_body diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py new file mode 100644 index 0000000..2698a15 --- /dev/null +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py @@ -0,0 +1,97 @@ +import time + +from authlib.oauth2.rfc6749 import ( + AuthorizationCodeMixin, + ClientMixin, + TokenMixin, +) +from layercake.dateutils import fromisoformat + + +class OAuth2AuthorizationCode(AuthorizationCodeMixin): + def __init__( + self, + user_id: str, + code: str, + client_id: str, + redirect_uri: str, + response_type: str, + scope: str, + code_challenge: str | None = None, + code_challenge_method: str | None = None, + nonce: str | None = None, + **kwargs, + ) -> None: + self.user_id = user_id + self.code = code + self.client_id = client_id + self.redirect_uri = redirect_uri + self.response_type = response_type + self.scope = scope + self.code_challenge = code_challenge + self.code_challenge_method = code_challenge_method + self.nonce = nonce + + auth_time = fromisoformat(kwargs.get('created_at', '')) or now() + self.auth_time = int(auth_time.timestamp()) + + def get_redirect_uri(self): + return self.redirect_uri + + def get_scope(self): + return self.scope + + def get_nonce(self): + return self.nonce + + def get_auth_time(self): + return self.auth_time + + def get_acr(self): + return '0' + + def get_amr(self): + return [] + + +class OAuth2Token(TokenMixin): + def __init__( + self, + user: dict, + client_id: str, + scope: str, + expires_in: int, + issued_at: int, + access_token: str | None = None, + refresh_token: str | None = None, + **_, + ) -> None: + self.user = user + self.client_id = client_id + self.scope = scope + self.expires_in = expires_in + self.issued_at = issued_at + self.access_token = access_token + self.refresh_token = refresh_token + + def get_user(self) -> dict: + return self.user + + def check_client(self, client: ClientMixin): + return self.client_id == client.get_client_id() + + def get_scope(self) -> str: + return self.scope + + def get_expires_in(self) -> int: + return self.expires_in + + def is_revoked(self) -> bool: + return False + + def is_expired(self) -> bool: + if not self.expires_in: + return False + + expires_at = self.issued_at + self.expires_in + return expires_at < time.time() diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index 72c8905..ec0d6fa 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -1,110 +1,89 @@ -from authlib.oauth2.rfc6749 import TokenMixin, grants +from authlib.oauth2 import OAuth2Request +from authlib.oauth2.rfc6749 import ClientMixin, TokenMixin, grants from authlib.oauth2.rfc7636 import CodeChallenge from authlib.oidc.core import OpenIDCode as OpenIDCode_ from authlib.oidc.core import UserInfo -from aws_lambda_powertools.event_handler.exceptions import NotFoundError from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair -from layercake.funcs import omit, pick +from layercake.funcs import pick from boto3clients import dynamodb_client -from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE -from integrations.apigateway_oauth2 import ( - AuthorizationCode, +from config import ISSUER, JWT_ALGORITHM, OAUTH2_TABLE +from integrations.apigateway_oauth2.authorization_server import ( AuthorizationServer, - OAuth2Client, +) +from integrations.apigateway_oauth2.tokens import ( + OAuth2AuthorizationCode, OAuth2Token, ) oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) -DUMMY_JWT_CONFIG = { - 'key': 'secret-key', - 'alg': 'HS256', - 'iss': 'https://authlib.org', - 'exp': 3600, -} - - -def create_save_token_func(persistence_layer: DynamoDBPersistenceLayer): - def save_token(token, request) -> OAuth2Token: - print('save_token') - return OAuth2Token() - - return save_token - - -def create_query_client_func(persistence_layer: DynamoDBPersistenceLayer): - class ClientNotFoundError(NotFoundError): - def __init__(self, *_): - super().__init__('Client not found') - - def query_client(client_id) -> OAuth2Client: - client = persistence_layer.collection.get_item( - KeyPair( - pk='OAUTH2_CLIENT', - sk=f'CLIENT_ID#{client_id}', - ), - exc_cls=ClientNotFoundError, - ) - - _, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#') - - return OAuth2Client( - client_id=client_id, - client_secret=client['secret'], - redirect_uris=client['redirect_uris'], - response_types=client['response_types'], - grant_types=client['grant_types'], - token_endpoint_auth_method=client['token_endpoint_auth_method'], - ) - - return query_client - - class OpenIDCode(OpenIDCode_): - def exists_nonce(self, nonce, request): + def exists_nonce(self, nonce: str, request: OAuth2Request) -> bool: + if not request.payload: + raise ValueError('Missing request payload') + nonce_ = oauth2_layer.get_item( - KeyPair( - f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}', # type:ignore - f'NONCE#{nonce}', - ) + KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'), ) return bool(nonce_) def get_jwt_config(self, grant): - return DUMMY_JWT_CONFIG + return { + 'key': 'secret-key', + 'alg': JWT_ALGORITHM, + 'iss': ISSUER, + 'exp': 3600, + } - def generate_user_info(self, user, scope): + def generate_user_info(self, user: dict, scope: str) -> UserInfo: return UserInfo( - sub=user.id, - name=user.name, - email=user.email, + sub=user['id'], + name=user['name'], + email=user['email'], + email_verified=user.get('email_verified', False), ).filter(scope) class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): - TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] + TOKEN_ENDPOINT_AUTH_METHODS = [ + 'client_secret_basic', + 'client_secret_post', + 'none', + ] - def save_authorization_code(self, code: str, request): - client_id: str = request.payload.client_id # type: ignore - data: dict = request.payload.data # type: ignore - user: dict = request.user # type: ignore + def save_authorization_code( + self, + code: str, + request: OAuth2Request, + ) -> None: + if not request.payload: + raise ValueError('Missing request payload') + + if not request.user: + raise ValueError('Missing request user') + + client_id: str = request.payload.client_id + data: dict = request.payload.data + user: dict = request.user nonce: str | None = data.get('nonce') code_challenge: str | None = data.get('code_challenge') code_challenge_method: str | None = data.get('code_challenge_method') now_ = now() - ttl_ = ttl(start_dt=now_, minutes=15) + ttl_ = ttl(start_dt=now_, minutes=10) with oauth2_layer.transact_writer() as transact: transact.put( item={ - 'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}', + 'id': 'OAUTH2#CODE', 'sk': f'CODE#{code}', - 'redirect_uri': request.payload.redirect_uri, # type: ignore - 'scope': request.payload.scope, # type: ignore + 'redirect_uri': request.payload.redirect_uri, + 'response_type': request.payload.response_type, + 'scope': request.payload.scope, + 'client_id': client_id, 'user_id': user['id'], 'nonce': nonce, 'code_challenge': code_challenge, @@ -117,56 +96,90 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): if nonce: transact.put( item={ - 'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}', + 'id': 'OAUTH2#CODE', 'sk': f'NONCE#{nonce}', + 'client_id': client_id, 'code': code, 'created_at': now_, 'ttl': ttl_, }, ) - def query_authorization_code(self, code, client): - client_id = client.get_client_id() + def query_authorization_code( + self, + code: str, + client: ClientMixin, + ) -> OAuth2AuthorizationCode: auth_code = oauth2_layer.get_item( + KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{code}'), + ) + + return OAuth2AuthorizationCode( + code=code, + **auth_code, + ) + + def delete_authorization_code( + self, + authorization_code: OAuth2AuthorizationCode, + ) -> None: + oauth2_layer.delete_item( KeyPair( - pk=f'OAUTH2_CODE#CLIENT_ID#{client_id}', - sk=f'CODE#{code}', + pk='OAUTH2#CODE', + sk=f'CODE#{authorization_code.code}', ), ) - return AuthorizationCode( - client_id=client_id, - code=code, - **omit(('id', 'sk'), auth_code), - ) - - def delete_authorization_code(self, authorization_code): - print('authorization_code') - - def authenticate_user(self, authorization_code): + def authenticate_user( + self, + authorization_code: OAuth2AuthorizationCode, + ) -> dict: user = oauth2_layer.get_item( KeyPair( pk=authorization_code.user_id, sk='0', ), ) - return pick(('id', 'name', 'email'), user) + return pick(('id', 'name', 'email', 'email_verified'), user) class RefreshTokenGrant(grants.RefreshTokenGrant): + TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] INCLUDE_NEW_REFRESH_TOKEN = True - def authenticate_refresh_token(self, refresh_token: str) -> TokenMixin: ... + def authenticate_refresh_token(self, refresh_token: str, **kwargs) -> TokenMixin: + token = oauth2_layer.get_item( + KeyPair( + pk='OAUTH2#TOKEN', + sk=f'REFRESH_TOKEN#{refresh_token}', + ) + ) - def authenticate_user(self, refresh_token): ... + return OAuth2Token( + client_id=token['client_id'], + scope=token['scope'], + expires_in=int(token['expires_in']), + issued_at=int(token['issued_at']), + user=token['user'], + refresh_token=refresh_token, + ) - def revoke_old_credential(self, refresh_token: TokenMixin) -> None: ... + def authenticate_user(self, refresh_token: TokenMixin): + return refresh_token.get_user() + + def revoke_old_credential(self, refresh_token: TokenMixin) -> None: + refresh_token_ = getattr(refresh_token, 'refresh_token') + + if refresh_token_: + oauth2_layer.delete_item( + KeyPair( + pk='OAUTH2#TOKEN', + sk=f'REFRESH_TOKEN#{refresh_token_}', + ) + ) -server = AuthorizationServer( - query_client=create_query_client_func(oauth2_layer), - save_token=create_save_token_func(oauth2_layer), -) +server = AuthorizationServer(persistence_layer=oauth2_layer) server.register_grant( AuthorizationCodeGrant, [ diff --git a/id.saladeaula.digital/app/routes/openid_configuration.py b/id.saladeaula.digital/app/routes/openid_configuration.py index ce4f8ef..ba6164e 100644 --- a/id.saladeaula.digital/app/routes/openid_configuration.py +++ b/id.saladeaula.digital/app/routes/openid_configuration.py @@ -1,6 +1,6 @@ from aws_lambda_powertools.event_handler.api_gateway import Router -from config import ISSUER, JWT_ALGORITHM +from config import ISSUER, JWT_ALGORITHM, OAUTH2_SCOPES_SUPPORTED router = Router() @@ -13,7 +13,7 @@ def openid_configuration(): 'token_endpoint': f'{ISSUER}/token', 'userinfo_endpoint': f'{ISSUER}/userinfo', 'jwks_uri': f'{ISSUER}/jwks.json', - 'scopes_supported': ['openid', 'profile', 'email'], + 'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(), 'response_types_supported': ['code'], 'grant_types_supported': ['authorization_code', 'refresh_token'], 'subject_types_supported': ['public'], @@ -21,5 +21,6 @@ def openid_configuration(): 'token_endpoint_auth_methods_supported': [ 'client_secret_basic', 'client_secret_post', + 'none', ], } diff --git a/id.saladeaula.digital/app/security.py b/id.saladeaula.digital/app/security.py deleted file mode 100644 index b5ab3d9..0000000 --- a/id.saladeaula.digital/app/security.py +++ /dev/null @@ -1,11 +0,0 @@ -import secrets - -SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - - -def gen_salt(length: int) -> str: - """Generate a random string of SALT_CHARS with specified ``length``.""" - if length <= 0: - raise ValueError('Salt length must be at least 1.') - - return ''.join(secrets.choice(SALT_CHARS) for _ in range(length)) diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index 055cfd9..b18ff93 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -26,6 +26,7 @@ Globals: OAUTH2_TABLE: !Ref OAuth2Table ISSUER: https://id.saladeaula.digital JWT_SECRET: 7DUTFB1iLeSpiXvmxbOZim1yPVmQbmBpAzgscob0RDzrL2wVwRi1ti2ZSry7jJAf + OAUTH2_SCOPES_SUPPORTED: openid profile email Resources: HttpLog: diff --git a/id.saladeaula.digital/tests/conftest.py b/id.saladeaula.digital/tests/conftest.py index 4812158..78d6d3d 100644 --- a/id.saladeaula.digital/tests/conftest.py +++ b/id.saladeaula.digital/tests/conftest.py @@ -20,6 +20,8 @@ def pytest_configure(): os.environ['JWT_SECRET'] = 'secret' os.environ['DYNAMODB_PARTITION_KEY'] = PK os.environ['DYNAMODB_SORT_KEY'] = SK + os.environ['OAUTH2_SCOPES_SUPPORTED'] = 'openid profile email' + # os.environ['POWERTOOLS_LOGGER_LOG_EVENT'] = 'true' @dataclass diff --git a/id.saladeaula.digital/tests/routes/test_authorize.py b/id.saladeaula.digital/tests/routes/test_authorize.py index 08d3923..c6e56c9 100644 --- a/id.saladeaula.digital/tests/routes/test_authorize.py +++ b/id.saladeaula.digital/tests/routes/test_authorize.py @@ -42,16 +42,14 @@ def test_authorize( assert 'Location' in r['headers'] r = dynamodb_persistence_layer.query( - key_cond_expr='#pk = :pk AND begins_with(#sk, :sk)', + key_cond_expr='#pk = :pk', expr_attr_name={ '#pk': 'id', - '#sk': 'sk', }, expr_attr_values={ - ':pk': f'OAUTH2_CODE#CLIENT_ID#{client_id}', - ':sk': 'CODE', + ':pk': 'OAUTH2#CODE', }, ) # One item was added from seeds - assert len(r['items']) == 2 + assert len(r['items']) == 3 diff --git a/id.saladeaula.digital/tests/routes/test_login.py b/id.saladeaula.digital/tests/routes/test_login.py index 1f44c0a..45e3a4f 100644 --- a/id.saladeaula.digital/tests/routes/test_login.py +++ b/id.saladeaula.digital/tests/routes/test_login.py @@ -1,4 +1,5 @@ from http import HTTPMethod +from urllib.parse import urlencode from ..conftest import HttpApiProxy, LambdaContext @@ -18,7 +19,7 @@ def test_html( lambda_context, ) - print(r) + # print(r) def test_login( @@ -34,9 +35,15 @@ def test_login( headers={ 'Content-Type': 'application/x-www-form-urlencoded', }, - body='username=sergio@somosbeta.com.br&password=pytest@123&continue=https://localhost', + body=urlencode( + { + 'username': 'sergio@somosbeta.com.br', + 'password': 'pytest@123', + 'continue': 'http://localhost', + } + ), ), lambda_context, ) - print(r) + # print(r) diff --git a/id.saladeaula.digital/tests/routes/test_token.py b/id.saladeaula.digital/tests/routes/test_token.py index 3d75a0e..cb27ccf 100644 --- a/id.saladeaula.digital/tests/routes/test_token.py +++ b/id.saladeaula.digital/tests/routes/test_token.py @@ -1,4 +1,6 @@ -from http import HTTPMethod +import json +import pprint +from http import HTTPMethod, HTTPStatus from urllib.parse import urlencode from layercake.dynamodb import DynamoDBPersistenceLayer @@ -29,11 +31,43 @@ def test_token( 'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9', 'client_id': client_id, 'code_verifier': '9072df2d3709425993e733f38fb27a825b8860e699364ce9abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc', - # 'client_secret': '1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W', + } + ), + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.OK + data = json.loads(r['body']) + + # print(data) + r = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + # pprint.pp(r['items']) + + r = app.lambda_handler( + http_api_proxy( + raw_path='/token', + method=HTTPMethod.POST, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body=urlencode( + { + 'grant_type': 'refresh_token', + 'refresh_token': data['refresh_token'], + 'client_id': client_id, } ), ), lambda_context, ) - print(r) + assert r['statusCode'] == HTTPStatus.OK + # print(r['body']) diff --git a/id.saladeaula.digital/tests/seeds.jsonl b/id.saladeaula.digital/tests/seeds.jsonl index 7b839ab..172511c 100644 --- a/id.saladeaula.digital/tests/seeds.jsonl +++ b/id.saladeaula.digital/tests/seeds.jsonl @@ -1,6 +1,6 @@ // OAuth2 -{"id": "OAUTH2_CLIENT", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none"} -{"id": "OAUTH2_CODE#CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "sk": "CODE#kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9", "redirect_uri": "https://localhost/callback", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419", "nonce": null, "scope": "openid profile email", "response_type": "code", "code_challenge": "ejYEIGKQUgMnNh4eV0sftb0hXdLwkvKm6OHXRYvC--I", "code_challenge_method": "S256"} +{"id": "OAUTH2", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email", "token_endpoint_auth_method": "none"} +{"id": "OAUTH2#CODE", "sk": "CODE#kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9", "client_id": "d72d4005-1fa7-4430-9754-80d5e2487bb6", "redirect_uri": "https://localhost/callback", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419", "nonce": null, "scope": "openid profile email", "response_type": "code", "code_challenge": "ejYEIGKQUgMnNh4eV0sftb0hXdLwkvKm6OHXRYvC--I", "code_challenge_method": "S256", "created_at": "2025-08-07T12:38:26.550431-03:00"} // Post-migration: uncomment the following line // {"id": "EMAIL", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} diff --git a/id.saladeaula.digital/uv.lock b/id.saladeaula.digital/uv.lock index b2a8a83..46ce7c6 100644 --- a/id.saladeaula.digital/uv.lock +++ b/id.saladeaula.digital/uv.lock @@ -481,7 +481,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.9.7" +version = "0.9.8" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, @@ -497,6 +497,7 @@ dependencies = [ { name = "pycpfcnpj" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-extra-types" }, + { name = "pyjwt" }, { name = "python-jose", extra = ["cryptography"] }, { name = "pytz" }, { name = "requests" }, @@ -520,6 +521,7 @@ requires-dist = [ { name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "pytz", specifier = ">=2025.1" }, { name = "requests", specifier = ">=2.32.3" }, @@ -750,6 +752,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + [[package]] name = "pytest" version = "8.4.1"