diff --git a/api.saladeaula.digital/template.yaml b/api.saladeaula.digital/template.yaml index e66c177..24b225d 100644 --- a/api.saladeaula.digital/template.yaml +++ b/api.saladeaula.digital/template.yaml @@ -44,16 +44,6 @@ Resources: Authorizers: OAuth2Authorizer: IdentitySource: "$request.header.Authorization" - # AuthorizationScopes: - # - openid - # - profile - # - email - # - offline_access - # - read:users - # - read:enrollments - # - read:orders - # - read:courses - # - write:courses JwtConfiguration: issuer: "https://id.saladeaula.digital" audience: diff --git a/id.saladeaula.digital/app/config.py b/id.saladeaula.digital/app/config.py index 0c8206a..22523a0 100644 --- a/id.saladeaula.digital/app/config.py +++ b/id.saladeaula.digital/app/config.py @@ -7,6 +7,6 @@ OAUTH2_SCOPES_SUPPORTED: str = os.getenv('OAUTH2_SCOPES_SUPPORTED', '') JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore 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 diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py index a421518..2e990c7 100644 --- a/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/authorization_server.py @@ -17,7 +17,6 @@ from config import OAUTH2_REFRESH_TOKEN_EXPIRES_IN 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') logger = Logger(__name__) @@ -121,7 +120,6 @@ class AuthorizationServer(oauth2.AuthorizationServer): self, client_id: str, ): - """Query OAuth client by client_id.""" client = self._persistence_layer.collection.get_item( KeyPair( pk='OAUTH2', diff --git a/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py index 8bc6cfb..a6e5e63 100644 --- a/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py +++ b/id.saladeaula.digital/app/integrations/apigateway_oauth2/tokens.py @@ -1,4 +1,5 @@ import time +from dataclasses import dataclass from authlib.oauth2.rfc6749 import ( AuthorizationCodeMixin, @@ -8,6 +9,17 @@ from authlib.oauth2.rfc6749 import ( 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): def __init__( self, @@ -75,8 +87,8 @@ class OAuth2Token(TokenMixin): self.access_token = access_token self.refresh_token = refresh_token - def get_user(self) -> dict: - return self.user + def get_user(self) -> User: + return User(**self.user) def check_client(self, client: ClientMixin) -> bool: return self.client_id == client.get_client_id() diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index d1a0f6c..f092e3a 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -1,9 +1,6 @@ -import time -from dataclasses import dataclass - from authlib.common.security import generate_token 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.rfc6749 import ClientMixin, TokenMixin, grants from authlib.oauth2.rfc6750 import BearerTokenGenerator @@ -31,22 +28,14 @@ from integrations.apigateway_oauth2.authorization_server import ( from integrations.apigateway_oauth2.tokens import ( OAuth2AuthorizationCode, OAuth2Token, + User, ) from util import read_file_path logger = Logger(__name__) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) - - -@dataclass(frozen=True) -class User: - id: str - name: str - email: str - email_verified: bool = False - - def get_user_id(self): - return self.id +private_key = read_file_path('private.pem') +private_jwk = JsonWebKey.import_key(private_key) class OpenIDCode(OpenIDCode_): @@ -62,11 +51,10 @@ class OpenIDCode(OpenIDCode_): def get_jwt_config(self, grant): return { - 'key': read_file_path('private.pem'), + 'key': private_key, 'alg': 'RS256', - 'kid': 'test', 'iss': ISSUER, - 'exp': 3600 * 2, + 'exp': 3600, } def generate_user_info(self, user: User, scope: str) -> UserInfo: @@ -234,7 +222,7 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): ) transact.delete( key=KeyPair( - pk=user.get('id'), + pk=user.get_user_id(), sk=f'SESSION#REFRESH_TOKEN#{token}', ) ) @@ -320,104 +308,9 @@ class IssuerParameter(rfc9207.IssuerParameter): 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 = { - '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 +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.register_grant( AuthorizationCodeGrant, diff --git a/id.saladeaula.digital/app/routes/jwks.py b/id.saladeaula.digital/app/routes/jwks.py index 2b5aad5..0e1783d 100644 --- a/id.saladeaula.digital/app/routes/jwks.py +++ b/id.saladeaula.digital/app/routes/jwks.py @@ -4,14 +4,13 @@ from aws_lambda_powertools.event_handler.api_gateway import Router from util import read_file_path router = Router() - - -public_jwk = JsonWebKey.import_key(read_file_path('public.pem'), {'kty': 'RSA'}) +public_jwk = JsonWebKey.import_key(read_file_path('public.pem')) @router.get('/.well-known/jwks.json') def jwks(): - key = public_jwk.as_dict() - key['use'] = 'sig' - key['kid'] = 'k1' - return {'keys': [key]} + return { + 'keys': [ + public_jwk.as_dict(), + ] + } diff --git a/id.saladeaula.digital/app/routes/session.py b/id.saladeaula.digital/app/routes/session.py index 2bb12c0..7ae4c5a 100644 --- a/id.saladeaula.digital/app/routes/session.py +++ b/id.saladeaula.digital/app/routes/session.py @@ -19,8 +19,8 @@ from config import ( ISSUER, JWT_ALGORITHM, JWT_SECRET, - OAUTH2_REFRESH_TOKEN_EXPIRES_IN, OAUTH2_TABLE, + SESSION_EXPIRES_IN, ) router = Router() @@ -46,7 +46,7 @@ def session( http_only=True, secure=True, 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: session_id = str(uuid4()) 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( { 'sid': session_id, diff --git a/id.saladeaula.digital/app/util.py b/id.saladeaula.digital/app/util.py index a6b8a22..5db84b6 100644 --- a/id.saladeaula.digital/app/util.py +++ b/id.saladeaula.digital/app/util.py @@ -3,10 +3,10 @@ import os 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) -def read_file_path(name): +def read_file_path(name: str) -> str: with open(get_file_path(name)) as f: return f.read() diff --git a/id.saladeaula.digital/pyproject.toml b/id.saladeaula.digital/pyproject.toml index 6810a96..dbe9e4e 100644 --- a/id.saladeaula.digital/pyproject.toml +++ b/id.saladeaula.digital/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "auth" +name = "id-saladeaula-digital" version = "0.1.0" description = "Add your description here" readme = "" diff --git a/id.saladeaula.digital/tests/routes/test_jwks.py b/id.saladeaula.digital/tests/routes/test_jwks.py new file mode 100644 index 0000000..786c9f3 --- /dev/null +++ b/id.saladeaula.digital/tests/routes/test_jwks.py @@ -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) diff --git a/id.saladeaula.digital/tests/routes/test_revoke.py b/id.saladeaula.digital/tests/routes/test_revoke.py index 4df461d..dad17e5 100644 --- a/id.saladeaula.digital/tests/routes/test_revoke.py +++ b/id.saladeaula.digital/tests/routes/test_revoke.py @@ -1,5 +1,4 @@ import json -import pprint from base64 import b64encode from http import HTTPMethod, HTTPStatus from urllib.parse import urlencode diff --git a/id.saladeaula.digital/tests/routes/test_token.py b/id.saladeaula.digital/tests/routes/test_token.py index 46b648a..b80c9d0 100644 --- a/id.saladeaula.digital/tests/routes/test_token.py +++ b/id.saladeaula.digital/tests/routes/test_token.py @@ -39,45 +39,44 @@ def test_token( assert r['statusCode'] == HTTPStatus.OK r = json.loads(r['body']) - print(r) - # assert r['expires_in'] == 600 + assert r['expires_in'] == 600 - # 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 + 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='/token', - # method=HTTPMethod.POST, - # headers={ - # 'Content-Type': 'application/x-www-form-urlencoded', - # }, - # body=urlencode( - # { - # 'grant_type': 'refresh_token', - # 'refresh_token': r['refresh_token'], - # 'client_id': client_id, - # } - # ), - # ), - # lambda_context, - # ) + r = app.lambda_handler( + http_api_proxy( + raw_path='/token', + method=HTTPMethod.POST, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body=urlencode( + { + 'grant_type': 'refresh_token', + 'refresh_token': r['refresh_token'], + 'client_id': client_id, + } + ), + ), + lambda_context, + ) - # r = dynamodb_persistence_layer.query( - # key_cond_expr='#pk = :pk', - # expr_attr_name={ - # '#pk': 'id', - # }, - # expr_attr_values={ - # ':pk': 'OAUTH2#TOKEN', - # }, - # ) - # assert len(r['items']) == 3 + r = dynamodb_persistence_layer.query( + key_cond_expr='#pk = :pk', + expr_attr_name={ + '#pk': 'id', + }, + expr_attr_values={ + ':pk': 'OAUTH2#TOKEN', + }, + ) + assert len(r['items']) == 3 diff --git a/id.saladeaula.digital/uv.lock b/id.saladeaula.digital/uv.lock index a2e90ad..c9e5331 100644 --- a/id.saladeaula.digital/uv.lock +++ b/id.saladeaula.digital/uv.lock @@ -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" }, ] -[[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]] name = "authlib" 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" }, ] +[[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]] name = "idna" version = "3.10"