This commit is contained in:
2025-09-26 14:45:27 -03:00
parent eeeccaaaa8
commit 1b6e4b7b5e
13 changed files with 131 additions and 212 deletions

View File

@@ -44,16 +44,6 @@ Resources:
Authorizers: Authorizers:
OAuth2Authorizer: OAuth2Authorizer:
IdentitySource: "$request.header.Authorization" IdentitySource: "$request.header.Authorization"
# AuthorizationScopes:
# - openid
# - profile
# - email
# - offline_access
# - read:users
# - read:enrollments
# - read:orders
# - read:courses
# - write:courses
JwtConfiguration: JwtConfiguration:
issuer: "https://id.saladeaula.digital" issuer: "https://id.saladeaula.digital"
audience: audience:

View File

@@ -7,6 +7,6 @@ OAUTH2_SCOPES_SUPPORTED: str = 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
OAUTH2_REFRESH_TOKEN_EXPIRES_IN = 30 * 86400 # 30 days SESSION_EXPIRES_IN = 86400 * 30 # 30 days

View File

@@ -17,7 +17,6 @@ from config import OAUTH2_REFRESH_TOKEN_EXPIRES_IN
from .client import OAuth2Client from .client import OAuth2Client
from .requests import APIGatewayJsonRequest, APIGatewayOAuth2Request from .requests import APIGatewayJsonRequest, APIGatewayOAuth2Request
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY')
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED') OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
logger = Logger(__name__) logger = Logger(__name__)
@@ -121,7 +120,6 @@ class AuthorizationServer(oauth2.AuthorizationServer):
self, self,
client_id: str, client_id: str,
): ):
"""Query OAuth client by client_id."""
client = self._persistence_layer.collection.get_item( client = self._persistence_layer.collection.get_item(
KeyPair( KeyPair(
pk='OAUTH2', pk='OAUTH2',

View File

@@ -1,4 +1,5 @@
import time import time
from dataclasses import dataclass
from authlib.oauth2.rfc6749 import ( from authlib.oauth2.rfc6749 import (
AuthorizationCodeMixin, AuthorizationCodeMixin,
@@ -8,6 +9,17 @@ from authlib.oauth2.rfc6749 import (
from layercake.dateutils import fromisoformat, now from layercake.dateutils import fromisoformat, now
@dataclass(frozen=True)
class User:
id: str
name: str
email: str
email_verified: bool = False
def get_user_id(self):
return self.id
class OAuth2AuthorizationCode(AuthorizationCodeMixin): class OAuth2AuthorizationCode(AuthorizationCodeMixin):
def __init__( def __init__(
self, self,
@@ -75,8 +87,8 @@ class OAuth2Token(TokenMixin):
self.access_token = access_token self.access_token = access_token
self.refresh_token = refresh_token self.refresh_token = refresh_token
def get_user(self) -> dict: def get_user(self) -> User:
return self.user return User(**self.user)
def check_client(self, client: ClientMixin) -> bool: def check_client(self, client: ClientMixin) -> bool:
return self.client_id == client.get_client_id() return self.client_id == client.get_client_id()

View File

@@ -1,9 +1,6 @@
import time
from dataclasses import dataclass
from authlib.common.security import generate_token from authlib.common.security import generate_token
from authlib.common.urls import add_params_to_uri from authlib.common.urls import add_params_to_uri
from authlib.jose import jwt from authlib.jose import JsonWebKey
from authlib.oauth2 import OAuth2Request, rfc7009, 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.rfc6750 import BearerTokenGenerator from authlib.oauth2.rfc6750 import BearerTokenGenerator
@@ -31,22 +28,14 @@ from integrations.apigateway_oauth2.authorization_server import (
from integrations.apigateway_oauth2.tokens import ( from integrations.apigateway_oauth2.tokens import (
OAuth2AuthorizationCode, OAuth2AuthorizationCode,
OAuth2Token, OAuth2Token,
User,
) )
from util import read_file_path from util import read_file_path
logger = Logger(__name__) logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
private_key = read_file_path('private.pem')
private_jwk = JsonWebKey.import_key(private_key)
@dataclass(frozen=True)
class User:
id: str
name: str
email: str
email_verified: bool = False
def get_user_id(self):
return self.id
class OpenIDCode(OpenIDCode_): class OpenIDCode(OpenIDCode_):
@@ -62,11 +51,10 @@ class OpenIDCode(OpenIDCode_):
def get_jwt_config(self, grant): def get_jwt_config(self, grant):
return { return {
'key': read_file_path('private.pem'), 'key': private_key,
'alg': 'RS256', 'alg': 'RS256',
'kid': 'test',
'iss': ISSUER, 'iss': ISSUER,
'exp': 3600 * 2, 'exp': 3600,
} }
def generate_user_info(self, user: User, scope: str) -> UserInfo: def generate_user_info(self, user: User, scope: str) -> UserInfo:
@@ -234,7 +222,7 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
) )
transact.delete( transact.delete(
key=KeyPair( key=KeyPair(
pk=user.get('id'), pk=user.get_user_id(),
sk=f'SESSION#REFRESH_TOKEN#{token}', sk=f'SESSION#REFRESH_TOKEN#{token}',
) )
) )
@@ -320,104 +308,9 @@ class IssuerParameter(rfc9207.IssuerParameter):
return ISSUER return ISSUER
class JWTBearerTokenGenerator(JWTBearerTokenGenerator_):
def get_jwks(self):
return read_file_path('private.pem')
def access_token_generator(self, client, grant_type, user, scope):
now = int(time.time())
expires_in = now + self._get_expires_in(client, grant_type)
token_data = {
'iss': self.issuer,
'exp': expires_in,
'client_id': client.get_client_id(),
'iat': now,
'jti': self.get_jti(client, grant_type, user, scope),
'scope': scope,
}
# In cases of access tokens obtained through grants where a resource owner is
# involved, such as the authorization code grant, the value of 'sub' SHOULD
# correspond to the subject identifier of the resource owner.
if user:
token_data['sub'] = user.get_user_id()
# In cases of access tokens obtained through grants where no resource owner is
# involved, such as the client credentials grant, the value of 'sub' SHOULD
# correspond to an identifier the authorization server uses to indicate the
# client application.
else:
token_data['sub'] = client.get_client_id()
# If the request includes a 'resource' parameter (as defined in [RFC8707]), the
# resulting JWT access token 'aud' claim SHOULD have the same value as the
# 'resource' parameter in the request.
# TODO: Implement this with RFC8707
if False: # pragma: no cover
...
# If the request does not include a 'resource' parameter, the authorization
# server MUST use a default resource indicator in the 'aud' claim. If a 'scope'
# parameter is present in the request, the authorization server SHOULD use it to
# infer the value of the default resource indicator to be used in the 'aud'
# claim. The mechanism through which scopes are associated with default resource
# indicator values is outside the scope of this specification.
else:
token_data['aud'] = self.get_audiences(client, user, scope)
# If the values in the 'scope' parameter refer to different default resource
# indicator values, the authorization server SHOULD reject the request with
# 'invalid_scope' as described in Section 4.1.2.1 of [RFC6749].
# TODO: Implement this with RFC8707
if auth_time := self.get_auth_time(user):
token_data['auth_time'] = auth_time
# The meaning and processing of acr Claim Values is out of scope for this
# specification.
if acr := self.get_acr(user):
token_data['acr'] = acr
# The definition of particular values to be used in the amr Claim is beyond the
# scope of this specification.
if amr := self.get_amr(user):
token_data['amr'] = amr
# Authorization servers MAY return arbitrary attributes not defined in any
# existing specification, as long as the corresponding claim names are collision
# resistant or the access tokens are meant to be used only within a private
# subsystem. Please refer to Sections 4.2 and 4.3 of [RFC7519] for details.
token_data.update(self.get_extra_claims(client, grant_type, user, scope))
# This specification registers the 'application/at+jwt' media type, which can
# be used to indicate that the content is a JWT access token. JWT access tokens
# MUST include this media type in the 'typ' header parameter to explicitly
# declare that the JWT represents an access token complying with this profile.
# Per the definition of 'typ' in Section 4.1.9 of [RFC7515], it is RECOMMENDED
# that the 'application/' prefix be omitted. Therefore, the 'typ' value used
# SHOULD be 'at+jwt'.
header = {'alg': self.alg, 'typ': 'at+jwt', 'kid': 'k1'}
access_token = jwt.encode(
header,
token_data,
key=self.get_jwks(),
check=False,
)
return access_token.decode()
GRANT_TYPES_EXPIRES_IN = { GRANT_TYPES_EXPIRES_IN = {
'refresh_token': 600, 'authorization_code': 60 * 10, # 10 minutes
'refresh_token': 86_400 * 7, # 7 days
} }
@@ -434,6 +327,16 @@ def create_token_generator(length: int = 42):
return token_generator return token_generator
class JWTBearerTokenGenerator(JWTBearerTokenGenerator_):
def get_jwks(self) -> dict[str, list]: # type: ignore
"""Return the JWKs that will be used to sign the JWT access token."""
return {
'keys': [
private_jwk.as_dict(is_private=True),
]
}
server = AuthorizationServer(persistence_layer=dyn) server = AuthorizationServer(persistence_layer=dyn)
server.register_grant( server.register_grant(
AuthorizationCodeGrant, AuthorizationCodeGrant,

View File

@@ -4,14 +4,13 @@ from aws_lambda_powertools.event_handler.api_gateway import Router
from util import read_file_path from util import read_file_path
router = Router() router = Router()
public_jwk = JsonWebKey.import_key(read_file_path('public.pem'))
public_jwk = JsonWebKey.import_key(read_file_path('public.pem'), {'kty': 'RSA'})
@router.get('/.well-known/jwks.json') @router.get('/.well-known/jwks.json')
def jwks(): def jwks():
key = public_jwk.as_dict() return {
key['use'] = 'sig' 'keys': [
key['kid'] = 'k1' public_jwk.as_dict(),
return {'keys': [key]} ]
}

View File

@@ -19,8 +19,8 @@ from config import (
ISSUER, ISSUER,
JWT_ALGORITHM, JWT_ALGORITHM,
JWT_SECRET, JWT_SECRET,
OAUTH2_REFRESH_TOKEN_EXPIRES_IN,
OAUTH2_TABLE, OAUTH2_TABLE,
SESSION_EXPIRES_IN,
) )
router = Router() router = Router()
@@ -46,7 +46,7 @@ def session(
http_only=True, http_only=True,
secure=True, secure=True,
same_site=None, same_site=None,
max_age=OAUTH2_REFRESH_TOKEN_EXPIRES_IN, max_age=SESSION_EXPIRES_IN,
) )
], ],
) )
@@ -80,7 +80,7 @@ def _get_user(username: str) -> tuple[str, str]:
def new_session(sub: str) -> str: def new_session(sub: str) -> str:
session_id = str(uuid4()) session_id = str(uuid4())
now_ = now() now_ = now()
exp = ttl(start_dt=now_, seconds=OAUTH2_REFRESH_TOKEN_EXPIRES_IN) exp = ttl(start_dt=now_, seconds=SESSION_EXPIRES_IN)
token = jwt.encode( token = jwt.encode(
{ {
'sid': session_id, 'sid': session_id,

View File

@@ -3,10 +3,10 @@ import os
ROOT = os.path.abspath(os.path.dirname(__file__)) ROOT = os.path.abspath(os.path.dirname(__file__))
def get_file_path(name): def get_file_path(name: str) -> str:
return os.path.join(ROOT, name) return os.path.join(ROOT, name)
def read_file_path(name): def read_file_path(name: str) -> str:
with open(get_file_path(name)) as f: with open(get_file_path(name)) as f:
return f.read() return f.read()

View File

@@ -1,5 +1,5 @@
[project] [project]
name = "auth" name = "id-saladeaula-digital"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "" readme = ""

View File

@@ -0,0 +1,19 @@
from http import HTTPMethod
from ..conftest import HttpApiProxy, LambdaContext
def test_jwks(
app,
seeds,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/.well-known/jwks.json',
method=HTTPMethod.GET,
),
lambda_context,
)
print(r)

View File

@@ -1,5 +1,4 @@
import json import json
import pprint
from base64 import b64encode from base64 import b64encode
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from urllib.parse import urlencode from urllib.parse import urlencode

View File

@@ -39,45 +39,44 @@ def test_token(
assert r['statusCode'] == HTTPStatus.OK assert r['statusCode'] == HTTPStatus.OK
r = json.loads(r['body']) r = json.loads(r['body'])
print(r) assert r['expires_in'] == 600
# assert r['expires_in'] == 600
# tokens = dynamodb_persistence_layer.query( tokens = dynamodb_persistence_layer.query(
# key_cond_expr='#pk = :pk', key_cond_expr='#pk = :pk',
# expr_attr_name={ expr_attr_name={
# '#pk': 'id', '#pk': 'id',
# }, },
# expr_attr_values={ expr_attr_values={
# ':pk': 'OAUTH2#TOKEN', ':pk': 'OAUTH2#TOKEN',
# }, },
# ) )
# assert len(tokens['items']) == 2 assert len(tokens['items']) == 2
# r = app.lambda_handler( r = app.lambda_handler(
# http_api_proxy( http_api_proxy(
# raw_path='/token', raw_path='/token',
# method=HTTPMethod.POST, method=HTTPMethod.POST,
# headers={ headers={
# 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
# }, },
# body=urlencode( body=urlencode(
# { {
# 'grant_type': 'refresh_token', 'grant_type': 'refresh_token',
# 'refresh_token': r['refresh_token'], 'refresh_token': r['refresh_token'],
# 'client_id': client_id, 'client_id': client_id,
# } }
# ), ),
# ), ),
# lambda_context, lambda_context,
# ) )
# r = dynamodb_persistence_layer.query( r = dynamodb_persistence_layer.query(
# key_cond_expr='#pk = :pk', key_cond_expr='#pk = :pk',
# expr_attr_name={ expr_attr_name={
# '#pk': 'id', '#pk': 'id',
# }, },
# expr_attr_values={ expr_attr_values={
# ':pk': 'OAUTH2#TOKEN', ':pk': 'OAUTH2#TOKEN',
# }, },
# ) )
# assert len(r['items']) == 3 assert len(r['items']) == 3

View File

@@ -33,33 +33,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
] ]
[[package]]
name = "auth"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "layercake" },
]
[package.dev-dependencies]
dev = [
{ name = "jsonlines" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "layercake", directory = "../layercake" }]
[package.metadata.requires-dev]
dev = [
{ name = "jsonlines", specifier = ">=4.0.0" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
{ name = "ruff", specifier = ">=0.12.1" },
]
[[package]] [[package]]
name = "authlib" name = "authlib"
version = "1.6.1" version = "1.6.1"
@@ -404,6 +377,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" }, { url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" },
] ]
[[package]]
name = "id-saladeaula-digital"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "layercake" },
]
[package.dev-dependencies]
dev = [
{ name = "jsonlines" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "layercake", directory = "../layercake" }]
[package.metadata.requires-dev]
dev = [
{ name = "jsonlines", specifier = ">=4.0.0" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
{ name = "ruff", specifier = ">=0.12.1" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"