This commit is contained in:
2025-08-07 22:10:10 -03:00
parent b572291dff
commit 78c4a4ad30
17 changed files with 555 additions and 304 deletions

View File

@@ -3,12 +3,11 @@ import os
ISSUER: str = os.getenv('ISSUER') # type: ignore ISSUER: str = os.getenv('ISSUER') # type: ignore
OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY') OAUTH2_SCOPES_SUPPORTED: str = os.getenv('OAUTH2_SCOPES_SUPPORTED', '')
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore
JWT_ALGORITHM = 'HS256' JWT_ALGORITHM = 'HS256'
JWT_EXP_SECONDS = 900 # 15 minutes 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.oauth2.rfc7636 import CodeChallenge
from authlib.oidc.core import OpenIDCode as OpenIDCode_ from authlib.oidc.core import OpenIDCode as OpenIDCode_
from authlib.oidc.core import UserInfo from authlib.oidc.core import UserInfo
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
from layercake.funcs import omit, pick from layercake.funcs import pick
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE from config import ISSUER, JWT_ALGORITHM, OAUTH2_TABLE
from integrations.apigateway_oauth2 import ( from integrations.apigateway_oauth2.authorization_server import (
AuthorizationCode,
AuthorizationServer, AuthorizationServer,
OAuth2Client, )
from integrations.apigateway_oauth2.tokens import (
OAuth2AuthorizationCode,
OAuth2Token, OAuth2Token,
) )
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) 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_): 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( nonce_ = oauth2_layer.get_item(
KeyPair( KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'),
f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}', # type:ignore
f'NONCE#{nonce}',
)
) )
return bool(nonce_) return bool(nonce_)
def get_jwt_config(self, grant): 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( return UserInfo(
sub=user.id, sub=user['id'],
name=user.name, name=user['name'],
email=user.email, email=user['email'],
email_verified=user.get('email_verified', False),
).filter(scope) ).filter(scope)
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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): def save_authorization_code(
client_id: str = request.payload.client_id # type: ignore self,
data: dict = request.payload.data # type: ignore code: str,
user: dict = request.user # type: ignore 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') nonce: str | None = data.get('nonce')
code_challenge: str | None = data.get('code_challenge') code_challenge: str | None = data.get('code_challenge')
code_challenge_method: str | None = data.get('code_challenge_method') code_challenge_method: str | None = data.get('code_challenge_method')
now_ = now() now_ = now()
ttl_ = ttl(start_dt=now_, minutes=15) ttl_ = ttl(start_dt=now_, minutes=10)
with oauth2_layer.transact_writer() as transact: with oauth2_layer.transact_writer() as transact:
transact.put( transact.put(
item={ item={
'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}', 'id': 'OAUTH2#CODE',
'sk': f'CODE#{code}', 'sk': f'CODE#{code}',
'redirect_uri': request.payload.redirect_uri, # type: ignore 'redirect_uri': request.payload.redirect_uri,
'scope': request.payload.scope, # type: ignore 'response_type': request.payload.response_type,
'scope': request.payload.scope,
'client_id': client_id,
'user_id': user['id'], 'user_id': user['id'],
'nonce': nonce, 'nonce': nonce,
'code_challenge': code_challenge, 'code_challenge': code_challenge,
@@ -117,56 +96,90 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
if nonce: if nonce:
transact.put( transact.put(
item={ item={
'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}', 'id': 'OAUTH2#CODE',
'sk': f'NONCE#{nonce}', 'sk': f'NONCE#{nonce}',
'client_id': client_id,
'code': code, 'code': code,
'created_at': now_, 'created_at': now_,
'ttl': ttl_, 'ttl': ttl_,
}, },
) )
def query_authorization_code(self, code, client): def query_authorization_code(
client_id = client.get_client_id() self,
code: str,
client: ClientMixin,
) -> OAuth2AuthorizationCode:
auth_code = oauth2_layer.get_item( 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( KeyPair(
pk=f'OAUTH2_CODE#CLIENT_ID#{client_id}', pk='OAUTH2#CODE',
sk=f'CODE#{code}', sk=f'CODE#{authorization_code.code}',
), ),
) )
return AuthorizationCode( def authenticate_user(
client_id=client_id, self,
code=code, authorization_code: OAuth2AuthorizationCode,
**omit(('id', 'sk'), auth_code), ) -> dict:
)
def delete_authorization_code(self, authorization_code):
print('authorization_code')
def authenticate_user(self, authorization_code):
user = oauth2_layer.get_item( user = oauth2_layer.get_item(
KeyPair( KeyPair(
pk=authorization_code.user_id, pk=authorization_code.user_id,
sk='0', sk='0',
), ),
) )
return pick(('id', 'name', 'email'), user) return pick(('id', 'name', 'email', 'email_verified'), user)
class RefreshTokenGrant(grants.RefreshTokenGrant): class RefreshTokenGrant(grants.RefreshTokenGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none']
INCLUDE_NEW_REFRESH_TOKEN = True 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( server = AuthorizationServer(persistence_layer=oauth2_layer)
query_client=create_query_client_func(oauth2_layer),
save_token=create_save_token_func(oauth2_layer),
)
server.register_grant( server.register_grant(
AuthorizationCodeGrant, AuthorizationCodeGrant,
[ [

View File

@@ -1,6 +1,6 @@
from aws_lambda_powertools.event_handler.api_gateway import Router 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() router = Router()
@@ -13,7 +13,7 @@ def openid_configuration():
'token_endpoint': f'{ISSUER}/token', 'token_endpoint': f'{ISSUER}/token',
'userinfo_endpoint': f'{ISSUER}/userinfo', 'userinfo_endpoint': f'{ISSUER}/userinfo',
'jwks_uri': f'{ISSUER}/jwks.json', 'jwks_uri': f'{ISSUER}/jwks.json',
'scopes_supported': ['openid', 'profile', 'email'], 'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(),
'response_types_supported': ['code'], 'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code', 'refresh_token'], 'grant_types_supported': ['authorization_code', 'refresh_token'],
'subject_types_supported': ['public'], 'subject_types_supported': ['public'],
@@ -21,5 +21,6 @@ def openid_configuration():
'token_endpoint_auth_methods_supported': [ 'token_endpoint_auth_methods_supported': [
'client_secret_basic', 'client_secret_basic',
'client_secret_post', 'client_secret_post',
'none',
], ],
} }

View File

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

View File

@@ -26,6 +26,7 @@ Globals:
OAUTH2_TABLE: !Ref OAuth2Table OAUTH2_TABLE: !Ref OAuth2Table
ISSUER: https://id.saladeaula.digital ISSUER: https://id.saladeaula.digital
JWT_SECRET: 7DUTFB1iLeSpiXvmxbOZim1yPVmQbmBpAzgscob0RDzrL2wVwRi1ti2ZSry7jJAf JWT_SECRET: 7DUTFB1iLeSpiXvmxbOZim1yPVmQbmBpAzgscob0RDzrL2wVwRi1ti2ZSry7jJAf
OAUTH2_SCOPES_SUPPORTED: openid profile email
Resources: Resources:
HttpLog: HttpLog:

View File

@@ -20,6 +20,8 @@ def pytest_configure():
os.environ['JWT_SECRET'] = 'secret' os.environ['JWT_SECRET'] = 'secret'
os.environ['DYNAMODB_PARTITION_KEY'] = PK os.environ['DYNAMODB_PARTITION_KEY'] = PK
os.environ['DYNAMODB_SORT_KEY'] = SK os.environ['DYNAMODB_SORT_KEY'] = SK
os.environ['OAUTH2_SCOPES_SUPPORTED'] = 'openid profile email'
# os.environ['POWERTOOLS_LOGGER_LOG_EVENT'] = 'true'
@dataclass @dataclass

View File

@@ -42,16 +42,14 @@ def test_authorize(
assert 'Location' in r['headers'] assert 'Location' in r['headers']
r = dynamodb_persistence_layer.query( r = dynamodb_persistence_layer.query(
key_cond_expr='#pk = :pk AND begins_with(#sk, :sk)', key_cond_expr='#pk = :pk',
expr_attr_name={ expr_attr_name={
'#pk': 'id', '#pk': 'id',
'#sk': 'sk',
}, },
expr_attr_values={ expr_attr_values={
':pk': f'OAUTH2_CODE#CLIENT_ID#{client_id}', ':pk': 'OAUTH2#CODE',
':sk': 'CODE',
}, },
) )
# One item was added from seeds # One item was added from seeds
assert len(r['items']) == 2 assert len(r['items']) == 3

View File

@@ -1,4 +1,5 @@
from http import HTTPMethod from http import HTTPMethod
from urllib.parse import urlencode
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
@@ -18,7 +19,7 @@ def test_html(
lambda_context, lambda_context,
) )
print(r) # print(r)
def test_login( def test_login(
@@ -34,9 +35,15 @@ def test_login(
headers={ headers={
'Content-Type': 'application/x-www-form-urlencoded', '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, lambda_context,
) )
print(r) # print(r)

View File

@@ -1,4 +1,6 @@
from http import HTTPMethod import json
import pprint
from http import HTTPMethod, HTTPStatus
from urllib.parse import urlencode from urllib.parse import urlencode
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
@@ -29,11 +31,43 @@ def test_token(
'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9', 'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9',
'client_id': client_id, 'client_id': client_id,
'code_verifier': '9072df2d3709425993e733f38fb27a825b8860e699364ce9abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc', '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, lambda_context,
) )
print(r) assert r['statusCode'] == HTTPStatus.OK
# print(r['body'])

View File

@@ -1,6 +1,6 @@
// OAuth2 // 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", "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#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#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 // Post-migration: uncomment the following line
// {"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"}

View File

@@ -481,7 +481,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.9.7" version = "0.9.8"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },
@@ -497,6 +497,7 @@ dependencies = [
{ name = "pycpfcnpj" }, { name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "pyjwt" },
{ name = "python-jose", extra = ["cryptography"] }, { name = "python-jose", extra = ["cryptography"] },
{ name = "pytz" }, { name = "pytz" },
{ name = "requests" }, { name = "requests" },
@@ -520,6 +521,7 @@ requires-dist = [
{ name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "pytz", specifier = ">=2025.1" }, { name = "pytz", specifier = ">=2025.1" },
{ name = "requests", specifier = ">=2.32.3" }, { 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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.1" version = "8.4.1"