add revoke

This commit is contained in:
2025-09-17 16:51:35 -03:00
parent 207231cff6
commit b2303fc60a
18 changed files with 411 additions and 140 deletions

View File

@@ -10,6 +10,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
from routes.authorize import router as authorize from routes.authorize import router as authorize
from routes.jwks import router as jwks from routes.jwks import router as jwks
from routes.openid_configuration import router as openid_configuration 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.session import router as session
from routes.token import router as token from routes.token import router as token
from routes.userinfo import router as userinfo from routes.userinfo import router as userinfo
@@ -22,6 +23,7 @@ app.include_router(authorize)
app.include_router(jwks) app.include_router(jwks)
app.include_router(token) app.include_router(token)
app.include_router(userinfo) app.include_router(userinfo)
app.include_router(revoke)
app.include_router(openid_configuration) app.include_router(openid_configuration)

View File

@@ -68,15 +68,30 @@ class AuthorizationServer(oauth2.AuthorizationServer):
raise ValueError('Missing request user') raise ValueError('Missing request user')
now_ = now() 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'] access_token = token['access_token']
refresh_token = token.get('refresh_token') refresh_token = token.get('refresh_token')
token_type = token['token_type'] token_type = token['token_type']
scope = token['scope'] scope = token['scope']
expires_in = int(token['expires_in']) expires_in = int(token['expires_in'])
issued_at = int(now_.timestamp()) 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: 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( transact.put(
item={ item={
'id': 'OAUTH2#TOKEN', 'id': 'OAUTH2#TOKEN',
@@ -88,11 +103,19 @@ class AuthorizationServer(oauth2.AuthorizationServer):
'user': request.user, 'user': request.user,
'expires_in': expires_in, 'expires_in': expires_in,
'issued_at': issued_at, 'issued_at': issued_at,
'ttl': ttl(start_dt=now_, seconds=expires_in), 'ttl': access_token_ttl,
}, },
) )
if refresh_token: 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( transact.put(
item={ item={
'id': 'OAUTH2#TOKEN', 'id': 'OAUTH2#TOKEN',
@@ -104,9 +127,7 @@ class AuthorizationServer(oauth2.AuthorizationServer):
'user': request.user, 'user': request.user,
'expires_in': OAUTH2_REFRESH_TOKEN_EXPIRES_IN, 'expires_in': OAUTH2_REFRESH_TOKEN_EXPIRES_IN,
'issued_at': issued_at, 'issued_at': issued_at,
'ttl': ttl( 'ttl': refresh_token_ttl,
start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN
),
}, },
) )
@@ -114,7 +135,10 @@ class AuthorizationServer(oauth2.AuthorizationServer):
def query_client(self, client_id: str): def query_client(self, client_id: str):
client = self._persistence_layer.collection.get_item( 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, exc_cls=ClientNotFoundError,
) )
@@ -125,9 +149,7 @@ class AuthorizationServer(oauth2.AuthorizationServer):
redirect_uris=client['redirect_uris'], redirect_uris=client['redirect_uris'],
response_types=client['response_types'], response_types=client['response_types'],
grant_types=client['grant_types'], grant_types=client['grant_types'],
token_endpoint_auth_method=client.get( token_endpoint_auth_method=client.get('token_endpoint_auth_method', 'none'),
'token_endpoint_auth_method', 'client_secret_basic'
),
) )
def create_oauth2_request( def create_oauth2_request(

View File

@@ -1,5 +1,5 @@
from authlib.common.urls import add_params_to_uri 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.rfc6749 import ClientMixin, TokenMixin, grants
from authlib.oauth2.rfc7636 import CodeChallenge from authlib.oauth2.rfc7636 import CodeChallenge
from authlib.oidc.core import OpenIDCode as OpenIDCode_ 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.api_gateway import Response
from aws_lambda_powertools.event_handler.exceptions import NotFoundError from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from layercake.dateutils import now, ttl 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 layercake.funcs import omit, pick
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
@@ -22,7 +27,7 @@ from integrations.apigateway_oauth2.tokens import (
) )
logger = Logger(__name__) logger = Logger(__name__)
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
class OpenIDCode(OpenIDCode_): class OpenIDCode(OpenIDCode_):
@@ -30,7 +35,7 @@ class OpenIDCode(OpenIDCode_):
if not request.payload: if not request.payload:
raise ValueError('Missing request payload') raise ValueError('Missing request payload')
nonce_ = oauth2_layer.get_item( nonce_ = dyn.get_item(
KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'), KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'),
) )
@@ -65,6 +70,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
code: str, code: str,
request: OAuth2Request, request: OAuth2Request,
) -> None: ) -> None:
"""Save authorization_code for later use."""
if not request.payload: if not request.payload:
raise ValueError('Missing request payload') raise ValueError('Missing request payload')
@@ -81,7 +87,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
now_ = now() now_ = now()
ttl_ = ttl(start_dt=now_, minutes=10) ttl_ = ttl(start_dt=now_, minutes=10)
with oauth2_layer.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.put( transact.put(
item={ item={
'id': 'OAUTH2#CODE', 'id': 'OAUTH2#CODE',
@@ -116,7 +122,8 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
code: str, code: str,
client: ClientMixin, client: ClientMixin,
) -> OAuth2AuthorizationCode: ) -> 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}'), KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{code}'),
) )
@@ -129,16 +136,24 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
self, self,
authorization_code: OAuth2AuthorizationCode, authorization_code: OAuth2AuthorizationCode,
) -> None: ) -> None:
oauth2_layer.delete_item( """Delete authorization code from database or cache."""
KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{authorization_code.code}'), dyn.delete_item(
KeyPair(
pk='OAUTH2#CODE',
sk=f'CODE#{authorization_code.code}',
),
) )
def authenticate_user( def authenticate_user(
self, self,
authorization_code: OAuth2AuthorizationCode, authorization_code: OAuth2AuthorizationCode,
) -> dict: ) -> dict:
user = oauth2_layer.get_item( """Authenticate the user related to this authorization_code."""
KeyPair(pk=authorization_code.user_id, sk='0'), user = dyn.get_item(
KeyPair(
pk=authorization_code.user_id,
sk='0',
),
) )
return pick(('id', 'name', 'email', 'email_verified'), user) return pick(('id', 'name', 'email', 'email_verified'), user)
@@ -154,10 +169,13 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
'client_secret_post', 'client_secret_post',
'none', 'none',
] ]
# The authorization server MAY issue a new refresh token
INCLUDE_NEW_REFRESH_TOKEN = True INCLUDE_NEW_REFRESH_TOKEN = True
def authenticate_refresh_token(self, refresh_token: str, **kwargs) -> TokenMixin: 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( KeyPair(
pk='OAUTH2#TOKEN', pk='OAUTH2#TOKEN',
sk=f'REFRESH_TOKEN#{refresh_token}', sk=f'REFRESH_TOKEN#{refresh_token}',
@@ -175,16 +193,90 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
) )
def authenticate_user(self, refresh_token: TokenMixin): def authenticate_user(self, refresh_token: TokenMixin):
"""Authenticate the user related to this credential."""
return refresh_token.get_user() return refresh_token.get_user()
def revoke_old_credential(self, refresh_token: TokenMixin) -> None: def revoke_old_credential(self, refresh_token: TokenMixin) -> None:
logger.info('Revoking old refresh token', refresh_token=refresh_token) """The authorization server MAY revoke the old refresh token after
token = getattr(refresh_token, 'refresh_token', None) issuing a new refresh token to the client."""
if token: logger.debug('Revoking old refresh token', refresh_token=refresh_token)
oauth2_layer.delete_item( token = getattr(refresh_token, 'refresh_token', None)
KeyPair(pk='OAUTH2#TOKEN', sk=f'REFRESH_TOKEN#{token}') 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): class IssuerParameter(rfc9207.IssuerParameter):
@@ -207,7 +299,7 @@ class IssuerParameter(rfc9207.IssuerParameter):
return ISSUER return ISSUER
server = AuthorizationServer(persistence_layer=oauth2_layer) server = AuthorizationServer(persistence_layer=dyn)
server.register_grant( server.register_grant(
AuthorizationCodeGrant, AuthorizationCodeGrant,
[ [
@@ -216,4 +308,5 @@ server.register_grant(
], ],
) )
server.register_grant(RefreshTokenGrant) server.register_grant(RefreshTokenGrant)
server.register_endpoint(RevocationEndpoint)
server.register_extension(IssuerParameter()) server.register_extension(IssuerParameter())

View File

@@ -6,8 +6,12 @@ from authlib.oauth2.rfc6749 import errors
from authlib.oauth2.rfc6749.util import scope_to_list from authlib.oauth2.rfc6749.util import scope_to_list
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import BadRequestError, ServiceError from aws_lambda_powertools.event_handler.exceptions import (
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair BadRequestError,
ServiceError,
UnauthorizedError,
)
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ISSUER, JWT_ALGORITHM, JWT_SECRET, OAUTH2_TABLE from config import ISSUER, JWT_ALGORITHM, JWT_SECRET, OAUTH2_TABLE
@@ -15,7 +19,7 @@ from oauth2 import server
router = Router() router = Router()
logger = Logger(__name__) logger = Logger(__name__)
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
@router.get('/authorize') @router.get('/authorize')
@@ -36,9 +40,8 @@ def authorize():
client_scopes = set(scope_to_list(grant.client.scope)) client_scopes = set(scope_to_list(grant.client.scope))
user_scopes = set(scope_to_list(session_scope)) if session_scope else set() user_scopes = set(scope_to_list(session_scope)) if session_scope else set()
if not client_scopes.issubset( # Deny authorization if user has no scopes matching the client request
user_scopes | {'openid', 'email', 'profile', 'offline_access'} if not user_scopes & client_scopes:
):
raise errors.InvalidScopeError(status_code=HTTPStatus.UNAUTHORIZED) raise errors.InvalidScopeError(status_code=HTTPStatus.UNAUTHORIZED)
return server.create_authorization_response( 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( KeyPair(
pk='SESSION', pk='SESSION',
sk=payload['sid'], 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]: 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 return parsed_cookies
class SessionRevokedError(BadRequestError): class SessionRevokedError(UnauthorizedError): ...
def __init__(self, *_):
super().__init__('Session revoked')

View File

@@ -11,6 +11,7 @@ def openid_configuration():
'issuer': ISSUER, 'issuer': ISSUER,
'authorization_endpoint': f'{ISSUER}/authorize', 'authorization_endpoint': f'{ISSUER}/authorize',
'token_endpoint': f'{ISSUER}/token', 'token_endpoint': f'{ISSUER}/token',
'revocation_endpoint': f'{ISSUER}/revoke',
'userinfo_endpoint': f'{ISSUER}/userinfo', 'userinfo_endpoint': f'{ISSUER}/userinfo',
'jwks_uri': f'{ISSUER}/jwks.json', 'jwks_uri': f'{ISSUER}/jwks.json',
'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(), 'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(),

View File

@@ -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,
)

View File

@@ -15,10 +15,16 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from passlib.hash import pbkdf2_sha256 from passlib.hash import pbkdf2_sha256
from boto3clients import dynamodb_client 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() router = Router()
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
@router.post('/session') @router.post('/session')
@@ -26,11 +32,7 @@ def session(
username: Annotated[str, Body()], username: Annotated[str, Body()],
password: Annotated[str, Body()], password: Annotated[str, Body()],
): ):
( user_id, password_hash = _get_user(username)
user_id,
password_hash,
scope,
) = _get_user(username)
if not pbkdf2_sha256.verify(password, password_hash): if not pbkdf2_sha256.verify(password, password_hash):
raise ForbiddenError('Invalid credentials') raise ForbiddenError('Invalid credentials')
@@ -40,28 +42,27 @@ def session(
cookies=[ cookies=[
Cookie( Cookie(
name='session_id', name='session_id',
value=new_session(user_id, scope), value=new_session(user_id),
http_only=True, http_only=True,
secure=True, secure=True,
same_site=None, 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') 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='email', sk=sk, rename_key=sk.path_spec)
+ KeyPair(pk='cpf', sk=sk, rename_key=sk.path_spec), + KeyPair(pk='cpf', sk=sk, rename_key=sk.path_spec),
flatten_top=False,
) )
if not user: if not user:
raise UserNotFoundError() raise UserNotFoundError()
userdata = oauth2_layer.collection.get_items( password = dyn.collection.get_item(
KeyPair( KeyPair(
pk=user['user_id'], pk=user['user_id'],
sk=SortKey( sk=SortKey(
@@ -69,46 +70,34 @@ def _get_user(username: str) -> tuple[str, str, str | None]:
path_spec='hash', path_spec='hash',
rename_key='password', 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: return user['user_id'], password
raise UserNotFoundError()
return user['user_id'], userdata['password'], userdata.get('scope')
def new_session(sub: str, scope: str | None) -> str: def new_session(sub: str) -> str:
session_id = str(uuid4())
now_ = now() now_ = now()
sid = str(uuid4()) exp = ttl(start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN)
exp = ttl(start_dt=now_, seconds=JWT_EXP_SECONDS)
token = jwt.encode( token = jwt.encode(
{ {
'sid': sid, 'sid': session_id,
'sub': sub, 'sub': sub,
'iss': ISSUER, 'iss': ISSUER,
'iat': int(now_.timestamp()), 'iat': int(now_.timestamp()),
'exp': exp, 'exp': exp,
'scope': scope,
}, },
JWT_SECRET, JWT_SECRET,
algorithm=JWT_ALGORITHM, algorithm=JWT_ALGORITHM,
) )
with oauth2_layer.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.put( transact.put(
item={ item={
'id': 'SESSION', 'id': 'SESSION',
'sk': sid, 'sk': session_id,
'user_id': sub, 'user_id': sub,
'ttl': exp, 'ttl': exp,
'created_at': now_, 'created_at': now_,
@@ -117,7 +106,7 @@ def new_session(sub: str, scope: str | None) -> str:
transact.put( transact.put(
item={ item={
'id': sub, 'id': sub,
'sk': f'SESSION#{sid}', 'sk': f'SESSION#{session_id}',
'ttl': exp, 'ttl': exp,
'created_at': now_, 'created_at': now_,
} }

View File

@@ -1,4 +1,5 @@
export const OK = 200 export const OK = 200
export const FOUND = 302 export const FOUND = 302
export const BAD_REQUEST = 400 export const BAD_REQUEST = 400
export const UNAUTHORIZED = 401
export const INTERNAL_SERVER = 500 export const INTERNAL_SERVER = 500

View File

@@ -1,12 +1,13 @@
import { import {
type RouteConfig,
index, index,
layout, layout,
route route,
type RouteConfig
} from '@react-router/dev/routes' } from '@react-router/dev/routes'
export default [ export default [
layout('routes/layout.tsx', [index('routes/index.tsx')]), layout('routes/layout.tsx', [index('routes/index.tsx')]),
route('/authorize', 'routes/authorize.ts'), route('/authorize', 'routes/authorize.ts'),
route('/token', 'routes/token.ts') route('/token', 'routes/token.ts'),
route('/revoke', 'routes/revoke.ts')
] satisfies RouteConfig ] satisfies RouteConfig

View File

@@ -30,15 +30,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
redirect: 'manual' redirect: 'manual'
}) })
// if (r.status === httpStatus.BAD_REQUEST) {
// return new Response(null, {
// status: httpStatus.FOUND,
// headers: {
// Location: redirect.toString()
// }
// })
// }
if (r.status === httpStatus.FOUND) { if (r.status === httpStatus.FOUND) {
return new Response(await r.text(), { return new Response(await r.text(), {
status: r.status, status: r.status,
@@ -46,9 +37,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}) })
} }
return Response.json(await r.json(), { console.log('Issuer response', {
status: r.status, json: await r.json(),
headers: r.headers headers: r.headers,
status: r.status
})
return new Response(null, {
status: httpStatus.FOUND,
headers: {
Location: redirect.toString()
}
}) })
} catch { } catch {
return new Response(null, { status: httpStatus.INTERNAL_SERVER }) return new Response(null, { status: httpStatus.INTERNAL_SERVER })

View File

@@ -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
})
}

View File

@@ -82,6 +82,12 @@ Resources:
Path: /token Path: /token
Method: POST Method: POST
ApiId: !Ref HttpApi ApiId: !Ref HttpApi
Revoke:
Type: HttpApi
Properties:
Path: /revoke
Method: POST
ApiId: !Ref HttpApi
UserInfo: UserInfo:
Type: HttpApi Type: HttpApi
Properties: Properties:

View File

@@ -24,7 +24,6 @@ def pytest_configure():
os.environ['OAUTH2_SCOPES_SUPPORTED'] = ( os.environ['OAUTH2_SCOPES_SUPPORTED'] = (
'openid profile email offline_access read:users' 'openid profile email offline_access read:users'
) )
# os.environ['POWERTOOLS_LOGGER_LOG_EVENT'] = 'true'
@dataclass @dataclass

View File

@@ -6,7 +6,6 @@ from routes.session import new_session
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
CLIENT_ID = 'd72d4005-1fa7-4430-9754-80d5e2487bb6'
USER_ID = '357db1c5-7442-4075-98a3-fbe5c938a419' USER_ID = '357db1c5-7442-4075-98a3-fbe5c938a419'
@@ -17,7 +16,7 @@ def test_authorize(
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
session_id = new_session(USER_ID, 'read:users') session_id = new_session(USER_ID)
r = app.lambda_handler( r = app.lambda_handler(
http_api_proxy( http_api_proxy(
@@ -25,7 +24,7 @@ def test_authorize(
method=HTTPMethod.GET, method=HTTPMethod.GET,
query_string_parameters={ query_string_parameters={
'response_type': 'code', 'response_type': 'code',
'client_id': CLIENT_ID, 'client_id': 'd72d4005-1fa7-4430-9754-80d5e2487bb6',
'redirect_uri': 'https://localhost/callback', 'redirect_uri': 'https://localhost/callback',
'scope': 'openid offline_access read:users', 'scope': 'openid offline_access read:users',
'nonce': '123', 'nonce': '123',
@@ -61,7 +60,7 @@ def test_unauthorized(
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
session_id = new_session(USER_ID, 'read:enrollments') session_id = new_session(USER_ID)
r = app.lambda_handler( r = app.lambda_handler(
http_api_proxy( http_api_proxy(
@@ -69,7 +68,7 @@ def test_unauthorized(
method=HTTPMethod.GET, method=HTTPMethod.GET,
query_string_parameters={ query_string_parameters={
'response_type': 'code', 'response_type': 'code',
'client_id': CLIENT_ID, 'client_id': '6ebe1709-0831-455c-84c0-d4c753bf33c6',
'redirect_uri': 'https://localhost/callback', 'redirect_uri': 'https://localhost/callback',
'scope': 'openid email offline_access', 'scope': 'openid email offline_access',
'nonce': '123', 'nonce': '123',
@@ -100,7 +99,7 @@ def test_authorize_revoked(
method=HTTPMethod.GET, method=HTTPMethod.GET,
query_string_parameters={ query_string_parameters={
'response_type': 'code', 'response_type': 'code',
'client_id': CLIENT_ID, 'client_id': 'd72d4005-1fa7-4430-9754-80d5e2487bb6',
'redirect_uri': 'https://localhost/callback', 'redirect_uri': 'https://localhost/callback',
'scope': 'openid offline_access', 'scope': 'openid offline_access',
'nonce': '123', 'nonce': '123',

View File

@@ -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

View File

@@ -27,4 +27,5 @@ def test_session(
assert len(r['cookies']) == 1 assert len(r['cookies']) == 1
session = dynamodb_persistence_layer.collection.query(PartitionKey('SESSION')) session = dynamodb_persistence_layer.collection.query(PartitionKey('SESSION'))
assert len(session['items']) == 1 # One seesion if created from seeds
assert len(session['items']) == 2

View File

@@ -35,50 +35,50 @@ def test_token(
), ),
lambda_context, lambda_context,
) )
auth_token = json.loads(r['body'])
print(auth_token)
# assert r['statusCode'] == HTTPStatus.OK # print(r)
# assert auth_token['expires_in'] == 600
# r = dynamodb_persistence_layer.query( assert r['statusCode'] == HTTPStatus.OK
# key_cond_expr='#pk = :pk',
# expr_attr_name={
# '#pk': 'id',
# },
# expr_attr_values={
# ':pk': 'OAUTH2#TOKEN',
# },
# )
# assert len(r['items']) == 2
# r = app.lambda_handler( r = json.loads(r['body'])
# http_api_proxy( assert r['expires_in'] == 600
# 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,
# )
# 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( r = app.lambda_handler(
# key_cond_expr='#pk = :pk', http_api_proxy(
# expr_attr_name={ raw_path='/token',
# '#pk': 'id', method=HTTPMethod.POST,
# }, headers={
# expr_attr_values={ 'Content-Type': 'application/x-www-form-urlencoded',
# ':pk': 'OAUTH2#TOKEN', },
# }, body=urlencode(
# ) {
# assert len(r['items']) == 3 '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

View File

@@ -1,11 +1,17 @@
// OAuth2 // 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", "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#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#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": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"}
{"id": "cpf", "sk": "07879819908", "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 // 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": "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": "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}