From b2303fc60a27e7a0b1efadd48119b84d5f8c132c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Wed, 17 Sep 2025 16:51:35 -0300 Subject: [PATCH] add revoke --- id.saladeaula.digital/app/app.py | 2 + .../apigateway_oauth2/authorization_server.py | 40 ++++-- id.saladeaula.digital/app/oauth2.py | 127 +++++++++++++++--- id.saladeaula.digital/app/routes/authorize.py | 37 +++-- .../app/routes/openid_configuration.py | 1 + id.saladeaula.digital/app/routes/revoke.py | 13 ++ id.saladeaula.digital/app/routes/session.py | 57 ++++---- .../client/app/lib/http-status.ts | 1 + id.saladeaula.digital/client/app/routes.ts | 7 +- .../client/app/routes/authorize.ts | 23 ++-- .../client/app/routes/revoke.ts | 17 +++ id.saladeaula.digital/template.yaml | 6 + id.saladeaula.digital/tests/conftest.py | 1 - .../tests/routes/test_authorize.py | 11 +- .../tests/routes/test_revoke.py | 109 +++++++++++++++ .../tests/routes/test_session.py | 3 +- .../tests/routes/test_token.py | 84 ++++++------ id.saladeaula.digital/tests/seeds.jsonl | 12 +- 18 files changed, 411 insertions(+), 140 deletions(-) create mode 100644 id.saladeaula.digital/app/routes/revoke.py create mode 100644 id.saladeaula.digital/client/app/routes/revoke.ts create mode 100644 id.saladeaula.digital/tests/routes/test_revoke.py diff --git a/id.saladeaula.digital/app/app.py b/id.saladeaula.digital/app/app.py index f7b3b03..9a0ac10 100644 --- a/id.saladeaula.digital/app/app.py +++ b/id.saladeaula.digital/app/app.py @@ -10,6 +10,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from routes.authorize import router as authorize from routes.jwks import router as jwks from routes.openid_configuration import router as openid_configuration +from routes.revoke import router as revoke from routes.session import router as session from routes.token import router as token from routes.userinfo import router as userinfo @@ -22,6 +23,7 @@ app.include_router(authorize) app.include_router(jwks) app.include_router(token) app.include_router(userinfo) +app.include_router(revoke) app.include_router(openid_configuration) diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py index 469efa0..ac81d76 100644 --- a/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py @@ -68,15 +68,30 @@ class AuthorizationServer(oauth2.AuthorizationServer): raise ValueError('Missing request user') now_ = now() - client_id = request.payload.client_id + client_id = ( + request.client.get_client_id() + if request.client + else request.payload.client_id + ) + user_id = request.user.get('id') access_token = token['access_token'] refresh_token = token.get('refresh_token') token_type = token['token_type'] scope = token['scope'] expires_in = int(token['expires_in']) issued_at = int(now_.timestamp()) + access_token_ttl = ttl(start_dt=now_, seconds=expires_in) + refresh_token_ttl = ttl(start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN) with self._persistence_layer.transact_writer() as transact: + transact.put( + item={ + 'id': user_id, + 'sk': f'SESSION#ACCESS_TOKEN#{access_token}', + 'ttl': access_token_ttl, + 'created_at': now_, + } + ) transact.put( item={ 'id': 'OAUTH2#TOKEN', @@ -88,11 +103,19 @@ class AuthorizationServer(oauth2.AuthorizationServer): 'user': request.user, 'expires_in': expires_in, 'issued_at': issued_at, - 'ttl': ttl(start_dt=now_, seconds=expires_in), + 'ttl': access_token_ttl, }, ) if refresh_token: + transact.put( + item={ + 'id': user_id, + 'sk': f'SESSION#REFRESH_TOKEN#{refresh_token}', + 'ttl': access_token_ttl, + 'created_at': now_, + } + ) transact.put( item={ 'id': 'OAUTH2#TOKEN', @@ -104,9 +127,7 @@ class AuthorizationServer(oauth2.AuthorizationServer): 'user': request.user, 'expires_in': OAUTH2_REFRESH_TOKEN_EXPIRES_IN, 'issued_at': issued_at, - 'ttl': ttl( - start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN - ), + 'ttl': refresh_token_ttl, }, ) @@ -114,7 +135,10 @@ class AuthorizationServer(oauth2.AuthorizationServer): def query_client(self, client_id: str): client = self._persistence_layer.collection.get_item( - KeyPair(pk='OAUTH2', sk=f'CLIENT_ID#{client_id}'), + KeyPair( + pk='OAUTH2', + sk=f'CLIENT_ID#{client_id}', + ), exc_cls=ClientNotFoundError, ) @@ -125,9 +149,7 @@ class AuthorizationServer(oauth2.AuthorizationServer): 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' - ), + token_endpoint_auth_method=client.get('token_endpoint_auth_method', 'none'), ) def create_oauth2_request( diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index 13b5c11..8e839c8 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -1,5 +1,5 @@ from authlib.common.urls import add_params_to_uri -from authlib.oauth2 import OAuth2Request, rfc9207 +from authlib.oauth2 import OAuth2Request, rfc7009, rfc9207 from authlib.oauth2.rfc6749 import ClientMixin, TokenMixin, grants from authlib.oauth2.rfc7636 import CodeChallenge from authlib.oidc.core import OpenIDCode as OpenIDCode_ @@ -8,7 +8,12 @@ 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, KeyPair +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + KeyPair, + SortKey, + TransactKey, +) from layercake.funcs import omit, pick from boto3clients import dynamodb_client @@ -22,7 +27,7 @@ from integrations.apigateway_oauth2.tokens import ( ) logger = Logger(__name__) -oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) +dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) class OpenIDCode(OpenIDCode_): @@ -30,7 +35,7 @@ class OpenIDCode(OpenIDCode_): if not request.payload: raise ValueError('Missing request payload') - nonce_ = oauth2_layer.get_item( + nonce_ = dyn.get_item( KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'), ) @@ -65,6 +70,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): code: str, request: OAuth2Request, ) -> None: + """Save authorization_code for later use.""" if not request.payload: raise ValueError('Missing request payload') @@ -81,7 +87,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): now_ = now() ttl_ = ttl(start_dt=now_, minutes=10) - with oauth2_layer.transact_writer() as transact: + with dyn.transact_writer() as transact: transact.put( item={ 'id': 'OAUTH2#CODE', @@ -116,7 +122,8 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): code: str, client: ClientMixin, ) -> OAuth2AuthorizationCode: - auth_code = oauth2_layer.get_item( + """Get authorization_code from previously savings.""" + auth_code = dyn.get_item( KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{code}'), ) @@ -129,16 +136,24 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): self, authorization_code: OAuth2AuthorizationCode, ) -> None: - oauth2_layer.delete_item( - KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{authorization_code.code}'), + """Delete authorization code from database or cache.""" + dyn.delete_item( + KeyPair( + pk='OAUTH2#CODE', + sk=f'CODE#{authorization_code.code}', + ), ) def authenticate_user( self, authorization_code: OAuth2AuthorizationCode, ) -> dict: - user = oauth2_layer.get_item( - KeyPair(pk=authorization_code.user_id, sk='0'), + """Authenticate the user related to this authorization_code.""" + user = dyn.get_item( + KeyPair( + pk=authorization_code.user_id, + sk='0', + ), ) return pick(('id', 'name', 'email', 'email_verified'), user) @@ -154,10 +169,13 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): 'client_secret_post', 'none', ] + + # The authorization server MAY issue a new refresh token INCLUDE_NEW_REFRESH_TOKEN = True def authenticate_refresh_token(self, refresh_token: str, **kwargs) -> TokenMixin: - token = oauth2_layer.collection.get_item( + """Get token information with refresh_token string.""" + token = dyn.collection.get_item( KeyPair( pk='OAUTH2#TOKEN', sk=f'REFRESH_TOKEN#{refresh_token}', @@ -175,16 +193,90 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): ) def authenticate_user(self, refresh_token: TokenMixin): + """Authenticate the user related to this credential.""" return refresh_token.get_user() def revoke_old_credential(self, refresh_token: TokenMixin) -> None: - logger.info('Revoking old refresh token', refresh_token=refresh_token) - token = getattr(refresh_token, 'refresh_token', None) + """The authorization server MAY revoke the old refresh token after + issuing a new refresh token to the client.""" - if token: - oauth2_layer.delete_item( - KeyPair(pk='OAUTH2#TOKEN', sk=f'REFRESH_TOKEN#{token}') + logger.debug('Revoking old refresh token', refresh_token=refresh_token) + token = getattr(refresh_token, 'refresh_token', None) + user = refresh_token.get_user() + + with dyn.transact_writer() as transact: + transact.delete( + key=KeyPair( + pk='OAUTH2#TOKEN', + sk=f'REFRESH_TOKEN#{token}', + ) ) + transact.delete( + key=KeyPair( + pk=user.get('id'), + sk=f'SESSION#REFRESH_TOKEN#{token}', + ) + ) + + +class RevocationEndpoint(rfc7009.RevocationEndpoint): + def query_token( # type: ignore + self, + token_string: str, + token_type_hint: str | None = None, + ): + 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 result: + return None + + logger.debug('Tokens retrieved', result=result) + + if not token_type_hint: + token_type_hint = ( + 'refresh_token' if 'refresh_token' in result else 'access_token' + ) + + token = result[token_type_hint] + + return OAuth2Token( + expires_in=int(token['expires_in']), + issued_at=int(token['issued_at']), + **{token_type_hint: token_string}, + **omit(('expires_in', 'issued_at', 'refresh_token', 'access_token'), token), + ) + + def revoke_token( + self, + token: OAuth2Token, + request: OAuth2Request, + ): + user_id = token.user['id'] + r = 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('#') + + transact.delete(key=KeyPair(pk, sk)) + transact.delete( + key=KeyPair( + pk='SESSION', + sk=idx, + ) + if kind == 'SESSION' + else KeyPair( + pk='OAUTH2#TOKEN', + sk=f'{kind}#{idx}', + ) + ) class IssuerParameter(rfc9207.IssuerParameter): @@ -207,7 +299,7 @@ class IssuerParameter(rfc9207.IssuerParameter): return ISSUER -server = AuthorizationServer(persistence_layer=oauth2_layer) +server = AuthorizationServer(persistence_layer=dyn) server.register_grant( AuthorizationCodeGrant, [ @@ -216,4 +308,5 @@ server.register_grant( ], ) server.register_grant(RefreshTokenGrant) +server.register_endpoint(RevocationEndpoint) server.register_extension(IssuerParameter()) diff --git a/id.saladeaula.digital/app/routes/authorize.py b/id.saladeaula.digital/app/routes/authorize.py index e821775..e40f256 100644 --- a/id.saladeaula.digital/app/routes/authorize.py +++ b/id.saladeaula.digital/app/routes/authorize.py @@ -6,8 +6,12 @@ from authlib.oauth2.rfc6749 import errors from authlib.oauth2.rfc6749.util import scope_to_list from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Router -from aws_lambda_powertools.event_handler.exceptions import BadRequestError, ServiceError -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from aws_lambda_powertools.event_handler.exceptions import ( + BadRequestError, + ServiceError, + UnauthorizedError, +) +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from boto3clients import dynamodb_client from config import ISSUER, JWT_ALGORITHM, JWT_SECRET, OAUTH2_TABLE @@ -15,7 +19,7 @@ from oauth2 import server router = Router() logger = Logger(__name__) -oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) +dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) @router.get('/authorize') @@ -36,9 +40,8 @@ def authorize(): client_scopes = set(scope_to_list(grant.client.scope)) user_scopes = set(scope_to_list(session_scope)) if session_scope else set() - if not client_scopes.issubset( - user_scopes | {'openid', 'email', 'profile', 'offline_access'} - ): + # Deny authorization if user has no scopes matching the client request + if not user_scopes & client_scopes: raise errors.InvalidScopeError(status_code=HTTPStatus.UNAUTHORIZED) return server.create_authorization_response( @@ -69,15 +72,27 @@ def verify_session(session_id: str) -> tuple[str, str | None]: }, ) - oauth2_layer.collection.get_item( + user = dyn.collection.get_items( KeyPair( pk='SESSION', sk=payload['sid'], + rename_key='session', + ) + + KeyPair( + pk=payload['sub'], + sk=SortKey( + sk='SCOPE', + path_spec='scope', + rename_key='scope', + ), ), - exc_cls=SessionRevokedError, + flatten_top=False, ) - return payload['sub'], payload.get('scope') + if 'session' not in user: + raise SessionRevokedError('Session revoked') + + return payload['sub'], user.get('scope') def _parse_cookies(cookies: list[str] | None) -> dict[str, str]: @@ -94,6 +109,4 @@ def _parse_cookies(cookies: list[str] | None) -> dict[str, str]: return parsed_cookies -class SessionRevokedError(BadRequestError): - def __init__(self, *_): - super().__init__('Session revoked') +class SessionRevokedError(UnauthorizedError): ... diff --git a/id.saladeaula.digital/app/routes/openid_configuration.py b/id.saladeaula.digital/app/routes/openid_configuration.py index ba6164e..176619a 100644 --- a/id.saladeaula.digital/app/routes/openid_configuration.py +++ b/id.saladeaula.digital/app/routes/openid_configuration.py @@ -11,6 +11,7 @@ def openid_configuration(): 'issuer': ISSUER, 'authorization_endpoint': f'{ISSUER}/authorize', 'token_endpoint': f'{ISSUER}/token', + 'revocation_endpoint': f'{ISSUER}/revoke', 'userinfo_endpoint': f'{ISSUER}/userinfo', 'jwks_uri': f'{ISSUER}/jwks.json', 'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(), diff --git a/id.saladeaula.digital/app/routes/revoke.py b/id.saladeaula.digital/app/routes/revoke.py new file mode 100644 index 0000000..49d6ba8 --- /dev/null +++ b/id.saladeaula.digital/app/routes/revoke.py @@ -0,0 +1,13 @@ +from aws_lambda_powertools.event_handler.api_gateway import Router + +from oauth2 import RevocationEndpoint, server + +router = Router() + + +@router.post('/revoke') +def revoke(): + return server.create_endpoint_response( + RevocationEndpoint.ENDPOINT_NAME, + router.current_event, + ) diff --git a/id.saladeaula.digital/app/routes/session.py b/id.saladeaula.digital/app/routes/session.py index d6b94ce..2bb12c0 100644 --- a/id.saladeaula.digital/app/routes/session.py +++ b/id.saladeaula.digital/app/routes/session.py @@ -15,10 +15,16 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from passlib.hash import pbkdf2_sha256 from boto3clients import dynamodb_client -from config import ISSUER, JWT_ALGORITHM, JWT_EXP_SECONDS, JWT_SECRET, OAUTH2_TABLE +from config import ( + ISSUER, + JWT_ALGORITHM, + JWT_SECRET, + OAUTH2_REFRESH_TOKEN_EXPIRES_IN, + OAUTH2_TABLE, +) router = Router() -oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) +dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) @router.post('/session') @@ -26,11 +32,7 @@ def session( username: Annotated[str, Body()], password: Annotated[str, Body()], ): - ( - user_id, - password_hash, - scope, - ) = _get_user(username) + user_id, password_hash = _get_user(username) if not pbkdf2_sha256.verify(password, password_hash): raise ForbiddenError('Invalid credentials') @@ -40,28 +42,27 @@ def session( cookies=[ Cookie( name='session_id', - value=new_session(user_id, scope), + value=new_session(user_id), http_only=True, secure=True, same_site=None, - max_age=JWT_EXP_SECONDS, + max_age=OAUTH2_REFRESH_TOKEN_EXPIRES_IN, ) ], ) -def _get_user(username: str) -> tuple[str, str, str | None]: +def _get_user(username: str) -> tuple[str, str]: sk = SortKey(username, path_spec='user_id') - user = oauth2_layer.collection.get_items( + user = dyn.collection.get_items( KeyPair(pk='email', sk=sk, rename_key=sk.path_spec) + KeyPair(pk='cpf', sk=sk, rename_key=sk.path_spec), - flatten_top=False, ) if not user: raise UserNotFoundError() - userdata = oauth2_layer.collection.get_items( + password = dyn.collection.get_item( KeyPair( pk=user['user_id'], sk=SortKey( @@ -69,46 +70,34 @@ def _get_user(username: str) -> tuple[str, str, str | None]: path_spec='hash', rename_key='password', ), - ) - + KeyPair( - pk=user['user_id'], - sk=SortKey( - sk='SCOPE', - path_spec='scope', - rename_key='scope', - ), ), - flatten_top=False, + exc_cls=UserNotFoundError, ) - if not userdata: - raise UserNotFoundError() - - return user['user_id'], userdata['password'], userdata.get('scope') + return user['user_id'], password -def new_session(sub: str, scope: str | None) -> str: +def new_session(sub: str) -> str: + session_id = str(uuid4()) now_ = now() - sid = str(uuid4()) - exp = ttl(start_dt=now_, seconds=JWT_EXP_SECONDS) + exp = ttl(start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN) token = jwt.encode( { - 'sid': sid, + 'sid': session_id, 'sub': sub, 'iss': ISSUER, 'iat': int(now_.timestamp()), 'exp': exp, - 'scope': scope, }, JWT_SECRET, algorithm=JWT_ALGORITHM, ) - with oauth2_layer.transact_writer() as transact: + with dyn.transact_writer() as transact: transact.put( item={ 'id': 'SESSION', - 'sk': sid, + 'sk': session_id, 'user_id': sub, 'ttl': exp, 'created_at': now_, @@ -117,7 +106,7 @@ def new_session(sub: str, scope: str | None) -> str: transact.put( item={ 'id': sub, - 'sk': f'SESSION#{sid}', + 'sk': f'SESSION#{session_id}', 'ttl': exp, 'created_at': now_, } diff --git a/id.saladeaula.digital/client/app/lib/http-status.ts b/id.saladeaula.digital/client/app/lib/http-status.ts index e20836a..1b52be5 100644 --- a/id.saladeaula.digital/client/app/lib/http-status.ts +++ b/id.saladeaula.digital/client/app/lib/http-status.ts @@ -1,4 +1,5 @@ export const OK = 200 export const FOUND = 302 export const BAD_REQUEST = 400 +export const UNAUTHORIZED = 401 export const INTERNAL_SERVER = 500 diff --git a/id.saladeaula.digital/client/app/routes.ts b/id.saladeaula.digital/client/app/routes.ts index c917e90..f03ed00 100644 --- a/id.saladeaula.digital/client/app/routes.ts +++ b/id.saladeaula.digital/client/app/routes.ts @@ -1,12 +1,13 @@ import { - type RouteConfig, index, layout, - route + route, + type RouteConfig } from '@react-router/dev/routes' export default [ layout('routes/layout.tsx', [index('routes/index.tsx')]), route('/authorize', 'routes/authorize.ts'), - route('/token', 'routes/token.ts') + route('/token', 'routes/token.ts'), + route('/revoke', 'routes/revoke.ts') ] satisfies RouteConfig diff --git a/id.saladeaula.digital/client/app/routes/authorize.ts b/id.saladeaula.digital/client/app/routes/authorize.ts index fa15283..d0e409f 100644 --- a/id.saladeaula.digital/client/app/routes/authorize.ts +++ b/id.saladeaula.digital/client/app/routes/authorize.ts @@ -30,15 +30,6 @@ export async function loader({ request, context }: Route.LoaderArgs) { redirect: 'manual' }) - // if (r.status === httpStatus.BAD_REQUEST) { - // return new Response(null, { - // status: httpStatus.FOUND, - // headers: { - // Location: redirect.toString() - // } - // }) - // } - if (r.status === httpStatus.FOUND) { return new Response(await r.text(), { status: r.status, @@ -46,9 +37,17 @@ export async function loader({ request, context }: Route.LoaderArgs) { }) } - return Response.json(await r.json(), { - status: r.status, - headers: r.headers + console.log('Issuer response', { + json: await r.json(), + headers: r.headers, + status: r.status + }) + + return new Response(null, { + status: httpStatus.FOUND, + headers: { + Location: redirect.toString() + } }) } catch { return new Response(null, { status: httpStatus.INTERNAL_SERVER }) diff --git a/id.saladeaula.digital/client/app/routes/revoke.ts b/id.saladeaula.digital/client/app/routes/revoke.ts new file mode 100644 index 0000000..d0807da --- /dev/null +++ b/id.saladeaula.digital/client/app/routes/revoke.ts @@ -0,0 +1,17 @@ +import type { Route } from './+types' + +export async function action({ request, context }: Route.ActionArgs) { + const issuerUrl = new URL('/revoke', context.cloudflare.env.ISSUER_URL) + const r = await fetch(issuerUrl.toString(), { + method: request.method, + headers: request.headers, + body: await request.text() + }) + + // console.log(await r.text(), r) + + return new Response(await r.text(), { + status: r.status, + headers: r.headers + }) +} diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index dd77258..5fa98b2 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -82,6 +82,12 @@ Resources: Path: /token Method: POST ApiId: !Ref HttpApi + Revoke: + Type: HttpApi + Properties: + Path: /revoke + Method: POST + ApiId: !Ref HttpApi UserInfo: Type: HttpApi Properties: diff --git a/id.saladeaula.digital/tests/conftest.py b/id.saladeaula.digital/tests/conftest.py index 7be3b34..912850f 100644 --- a/id.saladeaula.digital/tests/conftest.py +++ b/id.saladeaula.digital/tests/conftest.py @@ -24,7 +24,6 @@ def pytest_configure(): os.environ['OAUTH2_SCOPES_SUPPORTED'] = ( 'openid profile email offline_access read:users' ) - # 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 70d8153..8983fe8 100644 --- a/id.saladeaula.digital/tests/routes/test_authorize.py +++ b/id.saladeaula.digital/tests/routes/test_authorize.py @@ -6,7 +6,6 @@ from routes.session import new_session from ..conftest import HttpApiProxy, LambdaContext -CLIENT_ID = 'd72d4005-1fa7-4430-9754-80d5e2487bb6' USER_ID = '357db1c5-7442-4075-98a3-fbe5c938a419' @@ -17,7 +16,7 @@ def test_authorize( http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): - session_id = new_session(USER_ID, 'read:users') + session_id = new_session(USER_ID) r = app.lambda_handler( http_api_proxy( @@ -25,7 +24,7 @@ def test_authorize( method=HTTPMethod.GET, query_string_parameters={ 'response_type': 'code', - 'client_id': CLIENT_ID, + 'client_id': 'd72d4005-1fa7-4430-9754-80d5e2487bb6', 'redirect_uri': 'https://localhost/callback', 'scope': 'openid offline_access read:users', 'nonce': '123', @@ -61,7 +60,7 @@ def test_unauthorized( http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): - session_id = new_session(USER_ID, 'read:enrollments') + session_id = new_session(USER_ID) r = app.lambda_handler( http_api_proxy( @@ -69,7 +68,7 @@ def test_unauthorized( method=HTTPMethod.GET, query_string_parameters={ 'response_type': 'code', - 'client_id': CLIENT_ID, + 'client_id': '6ebe1709-0831-455c-84c0-d4c753bf33c6', 'redirect_uri': 'https://localhost/callback', 'scope': 'openid email offline_access', 'nonce': '123', @@ -100,7 +99,7 @@ def test_authorize_revoked( method=HTTPMethod.GET, query_string_parameters={ 'response_type': 'code', - 'client_id': CLIENT_ID, + 'client_id': 'd72d4005-1fa7-4430-9754-80d5e2487bb6', 'redirect_uri': 'https://localhost/callback', 'scope': 'openid offline_access', 'nonce': '123', diff --git a/id.saladeaula.digital/tests/routes/test_revoke.py b/id.saladeaula.digital/tests/routes/test_revoke.py new file mode 100644 index 0000000..4df461d --- /dev/null +++ b/id.saladeaula.digital/tests/routes/test_revoke.py @@ -0,0 +1,109 @@ +import json +import pprint +from base64 import b64encode +from http import HTTPMethod, HTTPStatus +from urllib.parse import urlencode + +import pytest +from layercake.dynamodb import DynamoDBPersistenceLayer + +from ..conftest import HttpApiProxy, LambdaContext + +CLIENT_ID = '1db63660-063d-4280-b2ea-388aca4a9459' +CLIENT_SECRET = '1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W' +AUTH = b64encode(f'{CLIENT_ID}:{CLIENT_SECRET}'.encode()).decode() + + +@pytest.fixture +def token( + 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={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': f'Basic {AUTH}', + }, + body=urlencode( + { + 'grant_type': 'authorization_code', + 'redirect_uri': 'https://localhost/callback', + 'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9', + 'code_verifier': '9072df2d3709425993e733f38fb27a825b8860e699364ce9abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc', + } + ), + ), + lambda_context, + ) + return json.loads(r['body']) + + +def test_token( + app, + token, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + access_token = token['access_token'] + + tokens = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + + assert len(tokens['items']) == 2 + + r = app.lambda_handler( + http_api_proxy( + raw_path='/revoke', + method=HTTPMethod.POST, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': f'Basic {AUTH}', + }, + body=urlencode( + { + 'token': access_token, + # 'token_type_hint': 'access_token', + } + ), + ), + lambda_context, + ) + + assert r['statusCode'] == HTTPStatus.OK + + tokens = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + assert len(tokens['items']) == 0 + + sessions = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'SESSION', + }, + ) + assert len(sessions['items']) == 0 diff --git a/id.saladeaula.digital/tests/routes/test_session.py b/id.saladeaula.digital/tests/routes/test_session.py index 9586795..07ffad2 100644 --- a/id.saladeaula.digital/tests/routes/test_session.py +++ b/id.saladeaula.digital/tests/routes/test_session.py @@ -27,4 +27,5 @@ def test_session( assert len(r['cookies']) == 1 session = dynamodb_persistence_layer.collection.query(PartitionKey('SESSION')) - assert len(session['items']) == 1 + # One seesion if created from seeds + assert len(session['items']) == 2 diff --git a/id.saladeaula.digital/tests/routes/test_token.py b/id.saladeaula.digital/tests/routes/test_token.py index fba8235..871ad2c 100644 --- a/id.saladeaula.digital/tests/routes/test_token.py +++ b/id.saladeaula.digital/tests/routes/test_token.py @@ -35,50 +35,50 @@ def test_token( ), lambda_context, ) - auth_token = json.loads(r['body']) - print(auth_token) - # assert r['statusCode'] == HTTPStatus.OK - # assert auth_token['expires_in'] == 600 + # print(r) - # r = dynamodb_persistence_layer.query( - # key_cond_expr='#pk = :pk', - # expr_attr_name={ - # '#pk': 'id', - # }, - # expr_attr_values={ - # ':pk': 'OAUTH2#TOKEN', - # }, - # ) - # assert len(r['items']) == 2 + assert r['statusCode'] == HTTPStatus.OK - # 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': auth_token['refresh_token'], - # 'client_id': client_id, - # } - # ), - # ), - # lambda_context, - # ) + r = json.loads(r['body']) + assert r['expires_in'] == 600 - # assert r['statusCode'] == HTTPStatus.OK + tokens = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + assert len(tokens['items']) == 2 - # r = dynamodb_persistence_layer.query( - # key_cond_expr='#pk = :pk', - # expr_attr_name={ - # '#pk': 'id', - # }, - # expr_attr_values={ - # ':pk': 'OAUTH2#TOKEN', - # }, - # ) - # assert len(r['items']) == 3 + 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': r['refresh_token'], + 'client_id': client_id, + } + ), + ), + lambda_context, + ) + + r = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + assert len(r['items']) == 3 diff --git a/id.saladeaula.digital/tests/seeds.jsonl b/id.saladeaula.digital/tests/seeds.jsonl index d6f9341..27ced47 100644 --- a/id.saladeaula.digital/tests/seeds.jsonl +++ b/id.saladeaula.digital/tests/seeds.jsonl @@ -1,11 +1,17 @@ // OAuth2 -{"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 offline_access read:users", "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"} +{"id": "OAUTH2", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest 1", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email offline_access read:users", "token_endpoint_auth_method": "none"} +{"id": "OAUTH2", "sk": "CLIENT_ID#6ebe1709-0831-455c-84c0-d4c753bf33c6", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest 2", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email offline_access", "token_endpoint_auth_method": "none"} +{"id": "OAUTH2", "sk": "CLIENT_ID#1db63660-063d-4280-b2ea-388aca4a9459", "client_secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest 3", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code", "refresh_token"], "scope": "openid profile email offline_access read:users", "token_endpoint_auth_method": "client_secret_basic"} +{"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 read:users", "response_type": "code", "code_challenge": "ejYEIGKQUgMnNh4eV0sftb0hXdLwkvKm6OHXRYvC--I", "code_challenge_method": "S256", "created_at": "2025-08-07T12:38:26.550431-03:00"} {"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} {"id": "cpf", "sk": "07879819908", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} +// Session +{"id": "SESSION", "sk": "36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} + // User data {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br"} {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"} -{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SCOPE", "scope": "read:users"} +{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SCOPE", "scope": "read:users read:enrollments"} +{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SESSION#36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "created_at": "2025-09-17T13:44:34.544491-03:00", "ttl": 1760719474}