This commit is contained in:
2025-08-05 21:14:09 -03:00
parent 5c57da7ecb
commit f96ad67eeb
27 changed files with 1960 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import os
from collections import defaultdict
from authlib.oauth2 import AuthorizationServer as _AuthorizationServer
from authlib.oauth2.rfc6749 import 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 data(self):
return self._request.query_string_parameters
@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.payload = OAuth2Payload(request)
class OAuth2Client(ClientMixin):
def __init__(
self,
client_id: str,
redirect_uris: list,
response_types: list,
) -> None:
self.client_id = client_id
self.redirect_uris = redirect_uris
self.response_types = response_types
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
class OAuth2Token(TokenMixin): ...
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,
)

View File

@@ -0,0 +1,31 @@
from typing import Any
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
)
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext
from routes.authorize import router as authorize
from routes.jwks import router as jwks
from routes.login import router as login
from routes.openid_configuration import router as openid_configuration
from routes.token import router as token
from routes.userinfo import router as userinfo
logger = Logger(__name__)
tracer = Tracer()
app = APIGatewayHttpResolver(enable_validation=True)
app.include_router(login)
app.include_router(authorize)
app.include_router(jwks)
app.include_router(token)
app.include_router(userinfo)
app.include_router(openid_configuration)
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
@tracer.capture_lambda_handler
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:
return app.resolve(event, context)

View File

@@ -0,0 +1,19 @@
import os
import boto3
def get_dynamodb_client():
running_sam_local = os.getenv('AWS_SAM_LOCAL')
if os.getenv('AWS_LAMBDA_FUNCTION_NAME') and not running_sam_local:
return boto3.client('dynamodb')
dockerhost = 'host.docker.internal'
localhost = '127.0.0.1'
host = dockerhost if running_sam_local else localhost
return boto3.client('dynamodb', endpoint_url=f'http://{host}:8000')
dynamodb_client = get_dynamodb_client()

View File

@@ -0,0 +1,14 @@
import os
ISSUER: str = os.getenv('ISSUER') # type: ignore
OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY')
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore
JWT_ALGORITHM = 'HS256'
JWT_EXP_SECONDS = 900 # 15 minutes
REFRESH_TOKEN_EXP_SECONDS = 7 * 86400 # 7 days

View File

@@ -0,0 +1,47 @@
from datetime import timedelta
from aws_lambda_powertools.event_handler.exceptions import ForbiddenError
from jose import ExpiredSignatureError, JWTError, jwt
from layercake.dateutils import now
from config import (
ISSUER,
JWT_ALGORITHM,
JWT_EXP_SECONDS,
JWT_SECRET,
REFRESH_TOKEN_EXP_SECONDS,
)
def generate_jwt(user_id: str, email: str) -> str:
now_ = now()
payload = {
'sub': user_id,
'email': email,
'iat': int(now_.timestamp()),
'exp': int((now_ + timedelta(seconds=JWT_EXP_SECONDS)).timestamp()),
'iss': ISSUER,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def generate_refresh_token(user_id: str) -> str:
now_ = now()
payload = {
'sub': user_id,
'iat': int(now_.timestamp()),
'exp': int((now_ + timedelta(seconds=REFRESH_TOKEN_EXP_SECONDS)).timestamp()),
'iss': ISSUER,
'typ': 'refresh',
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_jwt(token: str) -> dict:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except ExpiredSignatureError:
raise ForbiddenError('Token expired')
except JWTError:
raise ForbiddenError('Invalid token')

View File

@@ -0,0 +1,131 @@
from authlib.oauth2.rfc6749.grants import (
AuthorizationCodeGrant as _AuthorizationCodeGrant,
)
from authlib.oidc.core import OpenIDCode as OpenIDCode_
from authlib.oidc.core import UserInfo
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from apigateway_oauth2 import (
AuthorizationServer,
OAuth2Client,
OAuth2Token,
)
from boto3clients import dynamodb_client
from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE
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:
return OAuth2Token()
return save_token
class ClientNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('Client not found')
def create_query_client_func(persistence_layer: DynamoDBPersistenceLayer):
def query_client(client_id) -> OAuth2Client:
client = persistence_layer.collection.get_item(
KeyPair('OAUTH2_CLIENT', f'CLIENT_ID#{client_id}'),
exc_cls=ClientNotFoundError,
)
_, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#')
return OAuth2Client(
client_id=client_id,
redirect_uris=client['redirect_uris'],
response_types=client['response_types'],
)
return query_client
def save_authorization_code(code, request):
data: dict = request.payload.data # type: ignore
user: dict = request.user # type: ignore
nonce: str | None = data.get('nonce')
now_ = now()
ttl_ = ttl(start_dt=now_, minutes=15)
with oauth2_layer.transact_writer() as transact:
transact.put(
item={
'id': f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
'sk': f'CODE#{code}',
'redirect_uri': request.payload.redirect_uri, # type: ignore
'scope': request.payload.scope, # type: ignore
'user_id': user['id'],
'nonce': nonce,
'created_at': now_,
'ttl': ttl_,
},
)
if nonce:
transact.put(
item={
'id': f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
'sk': f'NONCE#{nonce}',
'code': code,
'created_at': now_,
'ttl': ttl_,
},
)
def exists_nonce(nonce, request):
nonce_ = oauth2_layer.get_item(
KeyPair(
f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
f'NONCE#{nonce}',
)
)
return bool(nonce_)
class OpenIDCode(OpenIDCode_):
def exists_nonce(self, nonce, request):
return exists_nonce(nonce, request)
def get_jwt_config(self, grant):
return DUMMY_JWT_CONFIG
def generate_user_info(self, user, scope):
return UserInfo(
sub=user.id,
name=user.name,
email=user.email,
).filter(scope)
class AuthorizationCodeGrant(_AuthorizationCodeGrant):
TOKEN_ENDPOINT_AUTH_METHODS = [
'client_secret_basic',
'client_secret_post',
]
def save_authorization_code(self, code: str, request):
return save_authorization_code(code, request)
authorization = AuthorizationServer(
query_client=create_query_client_func(oauth2_layer),
save_token=create_save_token_func(oauth2_layer),
)
authorization.register_grant(AuthorizationCodeGrant, [OpenIDCode(require_nonce=False)])

View File

@@ -0,0 +1,33 @@
from uuid import uuid4
from authlib.oauth2 import OAuth2Error
from authlib.oauth2.rfc6749 import errors
from aws_lambda_powertools.event_handler.api_gateway import Router
from oauth2 import authorization
router = Router()
@router.get('/authorize')
def authorize():
user = {
'id': str(uuid4()),
'sub': 'sergio@somosbeta.com.br',
}
try:
grant = authorization.get_consent_grant(
request=router.current_event,
end_user=user,
)
except OAuth2Error as err:
return dict(err.get_body())
try:
return authorization.create_authorization_response(
request=router.current_event,
grant_user=user,
grant=grant,
)
except errors.OAuth2Error:
return {}

View File

@@ -0,0 +1,8 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
router = Router()
@router.get('/jwks.json')
def jwks():
return {}

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body
class="bg-black text-white flex items-center justify-center min-h-screen"
>
<form
method="POST"
action="/login"
class="bg-gray-900 p-8 rounded-lg shadow-lg w-full max-w-sm space-y-4"
>
<div>
<label for="username" class="block mb-1">Email ou CPF</label>
<input
type="text"
id="username"
name="username"
class="w-full p-2 rounded bg-gray-800 text-white border border-gray-700"
required
/>
</div>
<div>
<label for="password" class="block mb-1">Senha</label>
<input
type="password"
id="password"
name="password"
class="w-full p-2 rounded bg-gray-800 text-white border border-gray-700"
required
/>
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 transition-colors p-2 rounded font-semibold"
>
Entrar
</button>
</form>
</body>
</html>

View File

@@ -0,0 +1,82 @@
from http import HTTPStatus
from pathlib import Path
from typing import Annotated
from aws_lambda_powertools.event_handler import (
Response,
)
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import ForbiddenError, NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Form
from aws_lambda_powertools.shared.cookies import Cookie
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from passlib.hash import pbkdf2_sha256
from boto3clients import dynamodb_client
from config import OAUTH2_TABLE
from jose_ import generate_jwt
router = Router()
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
@router.get('/login')
def login_form():
html = Path(__file__).with_name('login.html').read_text(encoding='utf-8')
return Response(
body=html,
status_code=HTTPStatus.OK.value,
content_type='text/html',
)
@router.post('/login')
def login(
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
user_id, password_hash = _get_user(username)
if not pbkdf2_sha256.verify(password, password_hash):
raise ForbiddenError('Invalid credentials')
jwt_token = generate_jwt(user_id, username)
return Response(
status_code=HTTPStatus.OK,
cookies=[
Cookie(
name='id_token',
value=jwt_token,
http_only=True,
same_site=None,
),
],
)
def _get_user(username: str) -> tuple[str, str]:
r = oauth2_layer.collection.get_item(
# Post-migration: uncomment the following line
# KeyPair('EMAIL', username),
KeyPair('email', username),
exc_cls=EmailNotFoundError,
)
password = oauth2_layer.collection.get_item(
KeyPair(r['user_id'], 'PASSWORD'),
exc_cls=UserNotFoundError,
)
return r['user_id'], password['hash']
class EmailNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('Email not found')
class UserNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('User not found')

View File

@@ -0,0 +1,25 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from config import ISSUER, JWT_ALGORITHM
router = Router()
@router.get('/.well-known/openid-configuration')
def openid_configuration():
return {
'issuer': ISSUER,
'authorization_endpoint': f'{ISSUER}/authorize',
'token_endpoint': f'{ISSUER}/token',
'userinfo_endpoint': f'{ISSUER}/userinfo',
'jwks_uri': f'{ISSUER}/jwks.json',
'scopes_supported': ['openid', 'profile', 'email'],
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': [JWT_ALGORITHM],
'token_endpoint_auth_methods_supported': [
'client_secret_basic',
'client_secret_post',
],
}

View File

@@ -0,0 +1,8 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
router = Router()
@router.get('/token')
def token():
return {}

View File

@@ -0,0 +1,8 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
router = Router()
@router.get('/userinfo')
def userinfo():
return {}

View File

@@ -0,0 +1,11 @@
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))