From e058d1fad9e5e75ba580db1d89f26dcc1c02fcbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 24 May 2026 18:59:21 +0200 Subject: [PATCH 01/27] Implement OAuth server and MCP OAuth integration Phases 0-9 implemented: OAuth contrib skeleton, container-scoped annotation storage (.oauth), metadata, dynamic registration, authorization/consent UI, token/refresh/revoke endpoints, OAuth JWT validator, MCP protected resource metadata/challenges, MCP scope-aware cache keys, docs and tests.\n\nValidation attempted:\n- .venv/bin/pytest guillotina/tests/test_login.py guillotina/tests/mcp/test_mcp.py -> failed: .venv/bin/pytest not found in worktree.\n- /Users/rogerboixaderguell/.pyenv/versions/Guillotina/bin/python -m pytest guillotina/tests/oauth/test_oauth_metadata.py -q -> failed during pytest startup: pytest_asyncio has no attribute fixture.\n- python3 -m compileall -q guillotina/contrib/oauth guillotina/contrib/mcp guillotina/tests/oauth -> passed.\n- git diff --check -> passed. --- docs/source/contrib/index.rst | 1 + docs/source/contrib/oauth.rst | 63 ++++ guillotina/contrib/oauth/__init__.py | 30 ++ guillotina/contrib/oauth/content.py | 23 ++ guillotina/contrib/oauth/install.py | 23 ++ guillotina/contrib/oauth/interfaces.py | 5 + guillotina/contrib/oauth/permissions.py | 10 + guillotina/contrib/oauth/pkce.py | 11 + guillotina/contrib/oauth/services.py | 317 ++++++++++++++++++ guillotina/contrib/oauth/tokens.py | 44 +++ guillotina/contrib/oauth/utils.py | 168 ++++++++++ guillotina/contrib/oauth/validators.py | 51 +++ guillotina/tests/oauth/conftest.py | 69 ++++ guillotina/tests/oauth/test_mcp_oauth.py | 50 +++ .../tests/oauth/test_oauth_authorize.py | 67 ++++ guillotina/tests/oauth/test_oauth_metadata.py | 23 ++ guillotina/tests/oauth/test_oauth_register.py | 39 +++ guillotina/tests/oauth/test_oauth_revoke.py | 27 ++ guillotina/tests/oauth/test_oauth_token.py | 42 +++ .../tests/oauth/test_oauth_validator.py | 28 ++ 20 files changed, 1091 insertions(+) create mode 100644 docs/source/contrib/oauth.rst create mode 100644 guillotina/contrib/oauth/__init__.py create mode 100644 guillotina/contrib/oauth/content.py create mode 100644 guillotina/contrib/oauth/install.py create mode 100644 guillotina/contrib/oauth/interfaces.py create mode 100644 guillotina/contrib/oauth/permissions.py create mode 100644 guillotina/contrib/oauth/pkce.py create mode 100644 guillotina/contrib/oauth/services.py create mode 100644 guillotina/contrib/oauth/tokens.py create mode 100644 guillotina/contrib/oauth/utils.py create mode 100644 guillotina/contrib/oauth/validators.py create mode 100644 guillotina/tests/oauth/conftest.py create mode 100644 guillotina/tests/oauth/test_mcp_oauth.py create mode 100644 guillotina/tests/oauth/test_oauth_authorize.py create mode 100644 guillotina/tests/oauth/test_oauth_metadata.py create mode 100644 guillotina/tests/oauth/test_oauth_register.py create mode 100644 guillotina/tests/oauth/test_oauth_revoke.py create mode 100644 guillotina/tests/oauth/test_oauth_token.py create mode 100644 guillotina/tests/oauth/test_oauth_validator.py diff --git a/docs/source/contrib/index.rst b/docs/source/contrib/index.rst index 89224182b..4eb14bcb6 100644 --- a/docs/source/contrib/index.rst +++ b/docs/source/contrib/index.rst @@ -17,4 +17,5 @@ Contents: swagger mailer dbusers + oauth mcp diff --git a/docs/source/contrib/oauth.rst b/docs/source/contrib/oauth.rst new file mode 100644 index 000000000..981168ee2 --- /dev/null +++ b/docs/source/contrib/oauth.rst @@ -0,0 +1,63 @@ +OAuth authorization server +========================== + +Install ``guillotina.contrib.oauth`` as an application and install the +``oauth`` addon in each container that should act as an authorization server. +OAuth state is stored in the reserved container annotation ``.oauth``. + +Supported flow +-------------- + +The contrib implements public-client OAuth 2.1 Authorization Code with PKCE +(``S256``), dynamic client registration, opaque refresh tokens, revocation, and +JWT access tokens signed with Guillotina's configured JWT secret. +Authorization codes and refresh tokens are stored only as HMAC SHA-256 hashes. + +Endpoints are container scoped:: + + GET /db/container/.well-known/oauth-authorization-server + POST /db/container/oauth/register + GET /db/container/oauth/authorize + POST /db/container/oauth/authorize + POST /db/container/oauth/token + POST /db/container/oauth/revoke + +Examples +-------- + +Register a public client:: + + curl -X POST https://host/db/container/oauth/register \ + -H 'Content-Type: application/json' \ + -d '{"client_name":"MCP Client","redirect_uris":["http://127.0.0.1:12345/callback"],"token_endpoint_auth_method":"none"}' + +Open the authorize URL in a browser:: + + https://host/db/container/oauth/authorize?response_type=code&client_id=CLIENT&redirect_uri=http://127.0.0.1:12345/callback&scope=guillotina:mcp.read&code_challenge=CHALLENGE&code_challenge_method=S256&resource=https://host/db/container/@mcp/protocol + +Exchange the code:: + + curl -X POST https://host/db/container/oauth/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=authorization_code&client_id=CLIENT&redirect_uri=http://127.0.0.1:12345/callback&code=CODE&code_verifier=VERIFIER' + +Refresh and revoke:: + + curl -X POST https://host/db/container/oauth/token \ + -d 'grant_type=refresh_token&client_id=CLIENT&refresh_token=REFRESH' + + curl -X POST https://host/db/container/oauth/revoke \ + -d 'client_id=CLIENT&token=REFRESH&token_type_hint=refresh_token' + +MCP scopes +---------- + +``guillotina:mcp.read`` allows basic MCP discovery/read calls. +``guillotina:mcp.search`` allows search. ``guillotina:mcp.content.read`` is +required for serialized content responses. + +``@login`` JWTs authenticate Guillotina sessions directly. OAuth access tokens +include ``token_type=oauth_access_token``, ``client_id``, ``scope`` and +audience/resource claims and are validated by the OAuth validator. MCP clients +should use OAuth discovery and must not store manually copied bearer tokens in +configuration. diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py new file mode 100644 index 000000000..4fc3d9f98 --- /dev/null +++ b/guillotina/contrib/oauth/__init__.py @@ -0,0 +1,30 @@ +from guillotina import configure + + +app_settings = { + "oauth": { + "enabled": True, + "issuer": None, + "authorization_code_ttl": 600, + "access_token_ttl": 3600, + "refresh_token_ttl": 2592000, + "require_pkce": True, + "allowed_code_challenge_methods": ["S256"], + "scopes_supported": [ + "guillotina:mcp.read", + "guillotina:mcp.search", + "guillotina:mcp.content.read", + ], + }, + "auth_token_validators": [ + "guillotina.contrib.oauth.validators.OAuthJWTValidator", + "guillotina.auth.validators.SaltedHashPasswordValidator", + "guillotina.auth.validators.JWTValidator", + ], +} + + +def includeme(root, settings): + configure.scan("guillotina.contrib.oauth.install") + configure.scan("guillotina.contrib.oauth.permissions") + configure.scan("guillotina.contrib.oauth.services") diff --git a/guillotina/contrib/oauth/content.py b/guillotina/contrib/oauth/content.py new file mode 100644 index 000000000..0e861cbb9 --- /dev/null +++ b/guillotina/contrib/oauth/content.py @@ -0,0 +1,23 @@ +"""OAuth data model helpers. + +OAuth state is stored in a reserved container annotation named ``.oauth`` with +four dictionaries: ``clients``, ``codes``, ``refresh_tokens`` and ``consents``. +Authorization codes and refresh tokens are never stored in plaintext; only HMAC +SHA-256 digests are persisted. +""" + +from guillotina.annotations import AnnotationData + + +OAUTH_STORAGE_KEY = ".oauth" + + +def new_oauth_storage(): + return AnnotationData( + { + "clients": {}, + "codes": {}, + "refresh_tokens": {}, + "consents": {}, + } + ) diff --git a/guillotina/contrib/oauth/install.py b/guillotina/contrib/oauth/install.py new file mode 100644 index 000000000..6be61945d --- /dev/null +++ b/guillotina/contrib/oauth/install.py @@ -0,0 +1,23 @@ +from guillotina import configure +from guillotina.addons import Addon +from guillotina.contrib.oauth.content import OAUTH_STORAGE_KEY, new_oauth_storage +from guillotina.contrib.oauth.interfaces import IOAuthSettings +from guillotina.interfaces import IAnnotations +from guillotina.utils import get_registry + + +@configure.addon(name="oauth", title="Guillotina OAuth authorization server") +class OAuthAddon(Addon): + @classmethod + async def install(cls, container, request): + registry = await get_registry() + registry.register_interface(IOAuthSettings) + annotations = IAnnotations(container) + if await annotations.async_get(OAUTH_STORAGE_KEY) is None: + await annotations.async_set(OAUTH_STORAGE_KEY, new_oauth_storage()) + + @classmethod + async def uninstall(cls, container, request): + annotations = IAnnotations(container) + if await annotations.async_get(OAUTH_STORAGE_KEY) is not None: + await annotations.async_del(OAUTH_STORAGE_KEY) diff --git a/guillotina/contrib/oauth/interfaces.py b/guillotina/contrib/oauth/interfaces.py new file mode 100644 index 000000000..22bf8bc20 --- /dev/null +++ b/guillotina/contrib/oauth/interfaces.py @@ -0,0 +1,5 @@ +from zope.interface import Interface + + +class IOAuthSettings(Interface): + """OAuth contrib registry settings marker.""" diff --git a/guillotina/contrib/oauth/permissions.py b/guillotina/contrib/oauth/permissions.py new file mode 100644 index 000000000..9f40f04c6 --- /dev/null +++ b/guillotina/contrib/oauth/permissions.py @@ -0,0 +1,10 @@ +from guillotina import configure + + +configure.permission("guillotina.OAuthManageClients", "Manage OAuth clients") +configure.permission("guillotina.OAuthAuthorize", "Authorize OAuth clients") +configure.permission("guillotina.OAuthUseToken", "Use OAuth token endpoint") + +configure.grant(permission="guillotina.OAuthManageClients", role="guillotina.Manager") +configure.grant(permission="guillotina.OAuthManageClients", role="guillotina.ContainerAdmin") +configure.grant(permission="guillotina.OAuthAuthorize", role="guillotina.Authenticated") diff --git a/guillotina/contrib/oauth/pkce.py b/guillotina/contrib/oauth/pkce.py new file mode 100644 index 000000000..6b40e6fdc --- /dev/null +++ b/guillotina/contrib/oauth/pkce.py @@ -0,0 +1,11 @@ +import base64 +import hashlib + + +def s256_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def verify_s256(verifier: str, challenge: str) -> bool: + return s256_challenge(verifier) == challenge diff --git a/guillotina/contrib/oauth/services.py b/guillotina/contrib/oauth/services.py new file mode 100644 index 000000000..91f352489 --- /dev/null +++ b/guillotina/contrib/oauth/services.py @@ -0,0 +1,317 @@ +import json +from datetime import timedelta + +from guillotina import app_settings, configure +from guillotina.api.service import Service +from guillotina.auth.utils import set_authenticated_user +from guillotina.contrib.oauth.pkce import verify_s256 +from guillotina.contrib.oauth.tokens import issue_access_token, opaque_token, token_hash, utcnow +from guillotina.contrib.oauth.utils import ( + consent_key, + container_url, + create_code, + get_storage, + get_valid_code, + get_valid_refresh, + make_client, + normalize_list, + oauth_error, + parse_form_encoded, + redirect_with_params, + register_changed, + validate_resource, +) +from guillotina.interfaces import IContainer +from guillotina.response import HTTPBadRequest, HTTPFound, HTTPPreconditionFailed, HTTPUnauthorized, Response +from guillotina.utils import get_authenticated_user + + +OAUTH_SCOPES = ["guillotina:mcp.read", "guillotina:mcp.search", "guillotina:mcp.content.read"] + + +def _metadata(request, container): + issuer = container_url(request, container) + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "registration_endpoint": f"{issuer}/oauth/register", + "revocation_endpoint": f"{issuer}/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": app_settings.get("oauth", {}).get("scopes_supported", OAUTH_SCOPES), + } + + +class OAuthService(Service): + async def storage(self): + return await get_storage(self.context) + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name=".well-known/oauth-authorization-server", + allow_access=True, +) +class OAuthAuthorizationServerMetadata(OAuthService): + async def __call__(self): + await self.storage() + return _metadata(self.request, self.context) + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name=".well-known/openid-configuration", + allow_access=True, +) +class OAuthOpenIDMetadata(OAuthAuthorizationServerMetadata): + pass + + +@configure.service( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/register", + allow_access=True, +) +class OAuthRegister(OAuthService): + async def __call__(self): + storage = await self.storage() + data = await self.request.json() + client = make_client(data) + if client["client_id"] in storage["clients"]: + oauth_error("invalid_request", "client_id already exists") + storage["clients"][client["client_id"]] = client + register_changed(storage) + return {key: client[key] for key in ( + "client_id", + "client_name", + "redirect_uris", + "grant_types", + "response_types", + "token_endpoint_auth_method", + )} + + +async def _authenticate_basic(username, password): + creds = {"type": "basic", "token": password, "id": username} + for validator in app_settings["auth_token_validators"]: + if validator.for_validators is not None and "basic" not in validator.for_validators: + continue + user = await validator().validate(creds) + if user is not None: + set_authenticated_user(user) + return user + + +def _html(body, status=200): + return Response(body=body.encode("utf-8"), status=status, content_type="text/html") + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name="oauth/authorize", + allow_access=True, +) +@configure.service( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/authorize", + allow_access=True, +) +class OAuthAuthorize(OAuthService): + async def __call__(self): + storage = await self.storage() + params = dict(self.request.query) + if self.request.method == "POST": + content_type = self.request.headers.get("content-type", "") + if "application/json" in content_type: + data = await self.request.json() + else: + data = parse_form_encoded(await self.request.text()) + params.update(data) + client = storage["clients"].get(params.get("client_id")) + if client is None: + return _html("Unknown OAuth client", status=400) + redirect_uri = params.get("redirect_uri") + redirect_valid = redirect_uri in client["redirect_uris"] + if not redirect_valid: + return _html("Invalid redirect_uri", status=400) + if params.get("response_type") != "code": + raise HTTPBadRequest(content={"error": "unsupported_response_type"}) + if not params.get("code_challenge"): + return self._redirect_error(redirect_uri, "invalid_request", params.get("state")) + if params.get("code_challenge_method") != "S256": + return self._redirect_error(redirect_uri, "invalid_request", params.get("state")) + scopes = normalize_list(params.get("scope")) + resources = validate_resource(self.request, self.context, params.get("resource")) + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + if self.request.method == "POST" and params.get("username"): + user = await _authenticate_basic(params.get("username"), params.get("password", "")) + if user is None: + return _html("Login failed", status=401) + else: + return _html("
Username Password " + "
") + ckey = consent_key(user.id, client["client_id"], scopes, resources) + if ckey not in storage["consents"] and params.get("decision") != "allow": + if params.get("decision") == "deny": + return self._redirect_error(redirect_uri, "access_denied", params.get("state")) + return _html( + "

Allow {}

" + "
".format(client["client_name"]) + ) + if ckey not in storage["consents"]: + storage["consents"][ckey] = { + "user_id": user.id, + "client_id": client["client_id"], + "scope": scopes, + "resource": resources, + "granted_at": utcnow().isoformat(), + } + raw_code = opaque_token("goc_") + create_code( + storage, + raw_code=raw_code, + client_id=client["client_id"], + user_id=user.id, + redirect_uri=redirect_uri, + scope=scopes, + resource=resources, + code_challenge=params["code_challenge"], + ) + register_changed(storage) + raise HTTPFound(redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")})) + + def _redirect_error(self, redirect_uri, error, state): + raise HTTPFound(redirect_with_params(redirect_uri, {"error": error, "state": state})) + + +@configure.service( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/token", + allow_access=True, +) +class OAuthToken(OAuthService): + async def __call__(self): + storage = await self.storage() + data = parse_form_encoded(await self.request.text()) + grant_type = data.get("grant_type") + if grant_type == "authorization_code": + return self._authorization_code(storage, data) + if grant_type == "refresh_token": + return self._refresh_token(storage, data) + raise HTTPBadRequest(content={"error": "unsupported_grant_type"}) + + def _authorization_code(self, storage, data): + client = storage["clients"].get(data.get("client_id")) + record = get_valid_code(storage, data.get("code", "")) + if client is None or record is None: + raise HTTPBadRequest(content={"error": "invalid_grant"}) + if record["client_id"] != client["client_id"] or record["redirect_uri"] != data.get("redirect_uri"): + raise HTTPBadRequest(content={"error": "invalid_grant"}) + if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): + raise HTTPBadRequest(content={"error": "invalid_grant"}) + requested_resources = normalize_list(data.get("resource")) + if requested_resources and not set(requested_resources).issubset(set(record["resource"])): + raise HTTPBadRequest(content={"error": "invalid_target"}) + resources = requested_resources or record["resource"] + record["used_at"] = utcnow().isoformat() + access_token, _claims = issue_access_token( + issuer=container_url(self.request, self.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=record["scope"], + ) + refresh_token = opaque_token("gor_") + now = utcnow() + storage["refresh_tokens"][token_hash(refresh_token)] = { + "token_hash": token_hash(refresh_token), + "client_id": client["client_id"], + "user_id": record["user_id"], + "scope": record["scope"], + "resource": resources, + "expires_at": (now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000))).isoformat(), + "revoked_at": None, + "rotated_from": None, + "created_at": now.isoformat(), + "last_used_at": now.isoformat(), + } + register_changed(storage) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": refresh_token, + "scope": " ".join(record["scope"]), + } + + def _refresh_token(self, storage, data): + record = get_valid_refresh(storage, data.get("refresh_token", "")) + client = storage["clients"].get(data.get("client_id")) + if record is None or client is None or record["client_id"] != client["client_id"]: + raise HTTPBadRequest(content={"error": "invalid_grant"}) + scopes = normalize_list(data.get("scope")) or record["scope"] + resources = normalize_list(data.get("resource")) or record["resource"] + if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): + raise HTTPBadRequest(content={"error": "invalid_scope"}) + record["revoked_at"] = utcnow().isoformat() + new_refresh = opaque_token("gor_") + now = utcnow() + storage["refresh_tokens"][token_hash(new_refresh)] = { + **record, + "token_hash": token_hash(new_refresh), + "scope": scopes, + "resource": resources, + "revoked_at": None, + "rotated_from": record["token_hash"], + "created_at": now.isoformat(), + "last_used_at": now.isoformat(), + } + access_token, _claims = issue_access_token( + issuer=container_url(self.request, self.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=scopes, + ) + register_changed(storage) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": new_refresh, + "scope": " ".join(scopes), + } + + +@configure.service( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/revoke", + allow_access=True, +) +class OAuthRevoke(OAuthService): + async def __call__(self): + storage = await self.storage() + data = parse_form_encoded(await self.request.text()) + record = storage["refresh_tokens"].get(token_hash(data.get("token", ""))) + if record is not None and record.get("client_id") == data.get("client_id"): + record["revoked_at"] = utcnow().isoformat() + register_changed(storage) + return {} diff --git a/guillotina/contrib/oauth/tokens.py b/guillotina/contrib/oauth/tokens.py new file mode 100644 index 000000000..dfd9b79fb --- /dev/null +++ b/guillotina/contrib/oauth/tokens.py @@ -0,0 +1,44 @@ +import hashlib +import hmac +import secrets +from datetime import datetime, timedelta + +import jwt + +from guillotina import app_settings + + +def utcnow(): + return datetime.utcnow() + + +def timestamp(dt): + return int(dt.timestamp()) + + +def opaque_token(prefix=""): + value = secrets.token_urlsafe(48) + return f"{prefix}{value}" if prefix else value + + +def token_hash(token: str) -> str: + secret = app_settings.get("jwt", {}).get("secret", "") or "guillotina-oauth-dev-secret" + return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() + + +def issue_access_token(*, issuer, subject, audience, client_id, scope): + now = utcnow() + ttl = app_settings["oauth"].get("access_token_ttl", 3600) + claims = { + "iss": issuer, + "sub": subject, + "id": subject, + "aud": list(audience), + "client_id": client_id, + "scope": " ".join(scope), + "iat": timestamp(now), + "exp": timestamp(now + timedelta(seconds=ttl)), + "token_type": "oauth_access_token", + } + token = jwt.encode(claims, app_settings["jwt"]["secret"], algorithm=app_settings["jwt"]["algorithm"]) + return token, claims diff --git a/guillotina/contrib/oauth/utils.py b/guillotina/contrib/oauth/utils.py new file mode 100644 index 000000000..e6b1bfdf5 --- /dev/null +++ b/guillotina/contrib/oauth/utils.py @@ -0,0 +1,168 @@ +from datetime import timedelta +from urllib.parse import parse_qs, urlencode, urlparse +from uuid import uuid4 + +from guillotina import app_settings, task_vars +from guillotina.contrib.oauth.content import OAUTH_STORAGE_KEY, new_oauth_storage +from guillotina.contrib.oauth.tokens import token_hash, utcnow +from guillotina.interfaces import IAddons, IAnnotations +from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed + + +def container_url(request, container): + issuer = app_settings.get("oauth", {}).get("issuer") + if issuer: + return issuer.rstrip("/") + return f"{request.scheme}://{request.host}/db/{container.id}" + + +def mcp_resource(request, container): + return f"{container_url(request, container)}/@mcp/protocol" + + +def normalize_list(value): + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + values = [] + for item in value: + values.extend(normalize_list(item)) + return values + return [item for item in str(value).split() if item] + + +def parse_form_encoded(body): + parsed = parse_qs(body, keep_blank_values=True) + return {key: values if len(values) > 1 else values[0] for key, values in parsed.items()} + + +def oauth_error(error, description=None, status=400): + content = {"error": error} + if description: + content["error_description"] = description + raise HTTPBadRequest(content=content) if status == 400 else HTTPPreconditionFailed(content=content) + + +def is_installed(container): + registry = task_vars.registry.get(None) + if registry is None: + return False + try: + return "oauth" in registry.for_interface(IAddons)["enabled"] + except Exception: + return False + + +async def get_storage(container, *, require_installed=True): + if require_installed and not is_installed(container): + raise HTTPPreconditionFailed(content={"reason": "OAuth addon is not installed"}) + annotations = IAnnotations(container) + storage = await annotations.async_get(OAUTH_STORAGE_KEY) + if storage is None: + storage = new_oauth_storage() + await annotations.async_set(OAUTH_STORAGE_KEY, storage) + return storage + + +def register_changed(storage): + txn = getattr(storage, "__txn__", None) + if txn is not None: + txn.register(storage) + + +def validate_redirect_uri(uri): + if not uri: + return False + if "*" in uri: + return False + parsed = urlparse(uri) + if parsed.scheme in ("javascript", "data"): + return False + if parsed.scheme not in ("http", "https"): + return False + if not parsed.netloc: + return False + return True + + +def make_client(data): + redirect_uris = data.get("redirect_uris") or [] + if not redirect_uris or not isinstance(redirect_uris, list): + oauth_error("invalid_request", "redirect_uris is required") + if any(not validate_redirect_uri(uri) for uri in redirect_uris): + oauth_error("invalid_request", "unsafe redirect_uri") + method = data.get("token_endpoint_auth_method", "none") + if method != "none": + oauth_error("unsupported_token_endpoint_auth_method") + now = utcnow().isoformat() + return { + "client_id": data.get("client_id") or uuid4().hex, + "client_name": data.get("client_name") or "OAuth Client", + "redirect_uris": redirect_uris, + "grant_types": data.get("grant_types") or ["authorization_code", "refresh_token"], + "response_types": data.get("response_types") or ["code"], + "token_endpoint_auth_method": "none", + "scope": " ".join(normalize_list(data.get("scope"))), + "created_at": now, + "updated_at": now, + } + + +def consent_key(user_id, client_id, scopes, resources): + return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) + + +def redirect_with_params(uri, params): + sep = "&" if "?" in uri else "?" + return f"{uri}{sep}{urlencode({k: v for k, v in params.items() if v is not None})}" + + +def validate_resource(request, container, resources): + base = container_url(request, container) + allowed = {base, f"{base}/@mcp/protocol"} + if not resources: + return [base] + resources = normalize_list(resources) + for resource in resources: + if resource not in allowed: + oauth_error("invalid_target", "resource is not allowed") + return resources + + +def create_code(storage, *, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): + now = utcnow() + ttl = app_settings["oauth"].get("authorization_code_ttl", 600) + code_record = { + "code_hash": token_hash(raw_code), + "client_id": client_id, + "user_id": user_id, + "redirect_uri": redirect_uri, + "scope": list(scope), + "resource": list(resource), + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "expires_at": (now + timedelta(seconds=ttl)).isoformat(), + "used_at": None, + "created_at": now.isoformat(), + } + storage["codes"][code_record["code_hash"]] = code_record + register_changed(storage) + return code_record + + +def get_valid_code(storage, code): + record = storage["codes"].get(token_hash(code)) + if record is None or record.get("used_at"): + return None + if utcnow().isoformat() > record["expires_at"]: + return None + return record + + +def get_valid_refresh(storage, token): + record = storage["refresh_tokens"].get(token_hash(token)) + if record is None or record.get("revoked_at"): + return None + if utcnow().isoformat() > record["expires_at"]: + return None + return record diff --git a/guillotina/contrib/oauth/validators.py b/guillotina/contrib/oauth/validators.py new file mode 100644 index 000000000..e6c261ad9 --- /dev/null +++ b/guillotina/contrib/oauth/validators.py @@ -0,0 +1,51 @@ +import jwt + +from guillotina import app_settings, task_vars +from guillotina.auth import find_user +from guillotina.contrib.oauth.utils import container_url + + +class OAuthJWTValidator: + for_validators = ("bearer", "wstoken", "cookie") + + async def validate(self, token): + if token.get("type") not in self.for_validators: + return + raw = token.get("token", "") + if "." not in raw: + return + try: + claims = jwt.decode( + raw, + app_settings["jwt"]["secret"], + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) + except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError): + return + if claims.get("token_type") != "oauth_access_token": + return + request = task_vars.request.get(None) + container = task_vars.container.get(None) + if request is not None and container is not None: + issuer = container_url(request, container) + if claims.get("iss") != issuer: + return + aud = set(claims.get("aud") or []) + # Generic API accepts the container audience. MCP performs stricter audience checks. + if issuer not in aud and not request.path.endswith("/@mcp/protocol"): + return + if not claims.get("client_id"): + return + token["id"] = claims.get("id", claims.get("sub")) + token["decoded"] = claims + user = await find_user(token) + if user is not None and user.id == token["id"]: + if request is not None: + request.oauth = { + "client_id": claims.get("client_id"), + "scopes": set((claims.get("scope") or "").split()), + "resources": set(claims.get("aud") or []), + "claims": claims, + } + return user diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py new file mode 100644 index 000000000..1dc0677ec --- /dev/null +++ b/guillotina/tests/oauth/conftest.py @@ -0,0 +1,69 @@ +import base64 +import hashlib +import json +from urllib.parse import parse_qs, urlparse + +import pytest + + +pytestmark = pytest.mark.asyncio + +OAUTH_SETTINGS = {"applications": ["guillotina", "guillotina.contrib.oauth"]} +OAUTH_MCP_SETTINGS = {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"]} + + +def verifier_pair(verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"): + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +async def register_client(requester, redirect_uri="http://127.0.0.1:12345/callback"): + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps({"client_name": "Test", "redirect_uris": [redirect_uri], "scope": "guillotina:mcp.read guillotina:mcp.search"}), + ) + assert status == 200 + return response + + +async def authorize_code(requester, client, *, scope="guillotina:mcp.read", resource=None, verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"): + verifier, challenge = verifier_pair(verifier) + data = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": scope, + "state": "abc", + "code_challenge": challenge, + "code_challenge_method": "S256", + "decision": "allow", + } + if resource: + data["resource"] = resource + value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data="&".join(f"{k}={v}" for k, v in data.items()), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + query = parse_qs(urlparse(headers["Location"]).query) + return query["code"][0], verifier + + +async def token_from_code(requester, client, code, verifier): + body = ( + f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}" + f"&code={code}&code_verifier={verifier}" + ) + response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + return response diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py new file mode 100644 index 000000000..25f88c022 --- /dev/null +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -0,0 +1,50 @@ +import json + +import pytest + +from guillotina.tests.mcp.test_mcp import PROTOCOL_HEADERS, _skip_if_protocol_unavailable +from guillotina.tests.oauth.conftest import OAUTH_MCP_SETTINGS, authorize_code, register_client, token_from_code + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_protected_resource_metadata(container_install_requester): + async with container_install_requester as requester: + response, status = await requester("GET", "/db/guillotina/.well-known/oauth-protected-resource") + assert status == 200 + assert response["resource"].endswith("/db/guillotina/@mcp/protocol") + assert response["authorization_servers"][0].endswith("/db/guillotina") + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_without_token_challenges(container_install_requester): + async with container_install_requester as requester: + _value, status, headers = await requester.make_request("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), headers=PROTOCOL_HEADERS, authenticated=False) + assert status == 401 + assert "resource_metadata" in headers["WWW-Authenticate"] + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_with_oauth_token(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina/@mcp/protocol") + token = await token_from_code(requester, client, code, verifier) + response, status = await requester("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), headers=PROTOCOL_HEADERS, auth_type="Bearer", token=token["access_token"]) + _skip_if_protocol_unavailable(response, status) + assert status == 200 + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_rejects_missing_mcp_audience(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), headers=PROTOCOL_HEADERS, auth_type="Bearer", token=token["access_token"]) + assert status == 401 diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py new file mode 100644 index 000000000..a7dd278cb --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -0,0 +1,67 @@ +import json +from urllib.parse import parse_qs, urlparse + +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, register_client, verifier_pair + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_unknown_client(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester("GET", "/db/guillotina/oauth/authorize", params={"client_id": "missing"}) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_bad_redirect_does_not_redirect(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _response, status = await requester("GET", "/db/guillotina/oauth/authorize", params={"client_id": client["client_id"], "redirect_uri": "https://evil.example/cb", "response_type": "code"}) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("challenge_method", [None, "plain"]) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_pkce_required(challenge_method, container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + data = {"client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "response_type": "code"} + if challenge_method: + data.update({"code_challenge": "x", "code_challenge_method": challenge_method}) + _value, status, headers = await requester.make_request("GET", "/db/guillotina/oauth/authorize", params=data, allow_redirects=False) + assert status == 302 + assert "error=invalid_request" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_allow_and_remember_consent(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + verifier, challenge = verifier_pair() + body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&state=s&code_challenge={challenge}&code_challenge_method=S256&decision=allow" + _value, status, headers = await requester.make_request("POST", "/db/guillotina/oauth/authorize", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False) + assert status == 302 + query = parse_qs(urlparse(headers["Location"]).query) + assert query["code"][0] + assert query["state"][0] == "s" + _value, status, _headers = await requester.make_request("GET", "/db/guillotina/oauth/authorize", params={"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "scope": "guillotina:mcp.read", "code_challenge": challenge, "code_challenge_method": "S256"}, allow_redirects=False) + assert status == 302 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_deny(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&code_challenge={challenge}&code_challenge_method=S256&decision=deny" + _value, status, headers = await requester.make_request("POST", "/db/guillotina/oauth/authorize", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False) + assert status == 302 + assert "error=access_denied" in headers["Location"] diff --git a/guillotina/tests/oauth/test_oauth_metadata.py b/guillotina/tests/oauth/test_oauth_metadata.py new file mode 100644 index 000000000..3b1d016d1 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_metadata.py @@ -0,0 +1,23 @@ +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_metadata(container_install_requester): + async with container_install_requester as requester: + response, status = await requester("GET", "/db/guillotina/.well-known/oauth-authorization-server") + assert status == 200 + assert response["issuer"].endswith("/db/guillotina") + assert response["authorization_endpoint"].endswith("/oauth/authorize") + assert response["registration_endpoint"].endswith("/oauth/register") + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +async def test_metadata_requires_addon(container_requester): + async with container_requester as requester: + _response, status = await requester("GET", "/db/guillotina/.well-known/oauth-authorization-server") + assert status == 412 diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py new file mode 100644 index 000000000..04ca0a4cc --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -0,0 +1,39 @@ +import json + +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_client(container_install_requester): + async with container_install_requester as requester: + response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps({"client_name": "Example", "redirect_uris": ["http://127.0.0.1:12345/callback"]})) + assert status == 200 + assert response["client_id"] + assert response["token_endpoint_auth_method"] == "none" + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("payload", [ + {"redirect_uris": []}, + {"redirect_uris": ["javascript:alert(1)"]}, + {"redirect_uris": ["https://example.com/*"]}, + {"redirect_uris": ["http://localhost/cb"], "token_endpoint_auth_method": "client_secret_basic"}, +]) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_rejects_invalid(payload, container_install_requester): + async with container_install_requester as requester: + _response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps(payload)) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_accepts_loopback(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]})) + assert status == 200 diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py new file mode 100644 index 000000000..315d2d02b --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -0,0 +1,27 @@ +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_refresh_token(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester("POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token={token['refresh_token']}&token_type_hint=refresh_token") + assert status == 200 + _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_unknown_token(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _response, status = await requester("POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token=unknown") + assert status == 200 diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py new file mode 100644 index 000000000..031db0007 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -0,0 +1,42 @@ +import jwt +import pytest + +from guillotina import app_settings +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_code_token_and_refresh_rotation(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") + token = await token_from_code(requester, client, code, verifier) + claims = jwt.decode(token["access_token"], app_settings["jwt"]["secret"], algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}) + assert claims["iss"].endswith("/db/guillotina") + assert claims["sub"] == claims["id"] + assert claims["client_id"] == client["client_id"] + assert claims["scope"] == "guillotina:mcp.read" + assert claims["aud"] + _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}") + assert status == 400 + refreshed, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + assert status == 200 + assert refreshed["refresh_token"] != token["refresh_token"] + _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier=bad") + assert status == 400 + code, verifier = await authorize_code(requester, client, scope="guillotina:mcp.read guillotina:mcp.search") + _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri=http://127.0.0.1:9999/cb&code={code}&code_verifier={verifier}") + assert status == 400 diff --git a/guillotina/tests/oauth/test_oauth_validator.py b/guillotina/tests/oauth/test_oauth_validator.py new file mode 100644 index 000000000..e1f172451 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -0,0 +1,28 @@ +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_oauth_access_token_authenticates(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") + token = await token_from_code(requester, client, code, verifier) + response, status = await requester("GET", "/db/guillotina/@addons", authenticated=True, auth_type="Bearer", token=token["access_token"]) + assert status == 200 + assert "oauth" in response["installed"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_oauth_access_token_wrong_audience_fails_generic_api(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina/@mcp/protocol") + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester("GET", "/db/guillotina/@addons", authenticated=True, auth_type="Bearer", token=token["access_token"]) + assert status in (401, 403) From ed7a61b9f7913b6009bba7a48b75cf4eab96dc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 24 May 2026 19:13:14 +0200 Subject: [PATCH 02/27] Fix OAuth routing and validation Continue OAuth/MCP phases after creating the worktree virtual environment.\n\nChanges:\n- Consolidate OAuth routes under oauth/{action} and .well-known/{action} to avoid Guillotina route-prefix conflicts.\n- Serve MCP protected resource metadata from the OAuth well-known dispatcher, preserving OAuth -> MCP independence by avoiding MCP imports.\n- Allow GET /oauth/authorize to write authorization codes after remembered consent.\n- Return redirects instead of raising them so authorization-code persistence commits.\n- Use UTC-safe JWT timestamps.\n- Make core JWT validators ignore PyJWT audience errors so OAuth audience rejection does not become a 500.\n- URL-encode OAuth test form bodies.\n- Ignore local .venv.\n\nValidation:\n- .venv/bin/pytest guillotina/tests/oauth -q -> 24 passed, 11 warnings.\n- .venv/bin/pytest guillotina/tests/mcp/test_mcp.py guillotina/tests/test_login.py -q -> 18 passed, 8 warnings.\n- .venv/bin/pytest guillotina/tests/oauth guillotina/tests/mcp/test_mcp.py guillotina/tests/test_login.py -q -> 42 passed, 19 warnings. --- .gitignore | 1 + guillotina/auth/validators.py | 4 +- guillotina/contrib/oauth/__init__.py | 1 + guillotina/contrib/oauth/services.py | 415 +++++++++++++-------------- guillotina/contrib/oauth/tokens.py | 3 +- guillotina/contrib/oauth/utils.py | 7 + guillotina/tests/oauth/conftest.py | 17 +- 7 files changed, 231 insertions(+), 217 deletions(-) diff --git a/.gitignore b/.gitignore index 76c89512d..2aec1c5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ pip-wheel-metadata /.idea/ /.Python /venv +/.venv # files *.log *.swp diff --git a/guillotina/auth/validators.py b/guillotina/auth/validators.py index 508c4dec6..7ac547159 100644 --- a/guillotina/auth/validators.py +++ b/guillotina/auth/validators.py @@ -132,7 +132,7 @@ async def validate(self, token): user = await find_user(token) if user is not None and user.id == token["id"]: return user - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError): + except (jwt.exceptions.PyJWTError, KeyError): pass return @@ -167,7 +167,7 @@ async def validate(self, token): return user else: return - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError): + except (jwt.exceptions.PyJWTError, KeyError): pass return diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index 4fc3d9f98..5b094713c 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -16,6 +16,7 @@ "guillotina:mcp.content.read", ], }, + "check_writable_request": "guillotina.contrib.oauth.utils.check_writable_request", "auth_token_validators": [ "guillotina.contrib.oauth.validators.OAuthJWTValidator", "guillotina.auth.validators.SaltedHashPasswordValidator", diff --git a/guillotina/contrib/oauth/services.py b/guillotina/contrib/oauth/services.py index 91f352489..a2fbefdfe 100644 --- a/guillotina/contrib/oauth/services.py +++ b/guillotina/contrib/oauth/services.py @@ -1,4 +1,3 @@ -import json from datetime import timedelta from guillotina import app_settings, configure @@ -22,7 +21,7 @@ validate_resource, ) from guillotina.interfaces import IContainer -from guillotina.response import HTTPBadRequest, HTTPFound, HTTPPreconditionFailed, HTTPUnauthorized, Response +from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, Response from guillotina.utils import get_authenticated_user @@ -45,6 +44,15 @@ def _metadata(request, container): } +def _protected_resource_metadata(request, container): + issuer = container_url(request, container) + return { + "resource": f"{issuer}/@mcp/protocol", + "authorization_servers": [issuer], + "scopes_supported": OAUTH_SCOPES, + } + + class OAuthService(Service): async def storage(self): return await get_storage(self.context) @@ -54,50 +62,75 @@ async def storage(self): context=IContainer, method="GET", permission="guillotina.Public", - name=".well-known/oauth-authorization-server", + name=".well-known/{action}", allow_access=True, ) -class OAuthAuthorizationServerMetadata(OAuthService): +class OAuthWellKnown(OAuthService): async def __call__(self): await self.storage() - return _metadata(self.request, self.context) + action = self.request.matchdict.get("action", "") + if action in ("oauth-authorization-server", "openid-configuration"): + return _metadata(self.request, self.context) + if action == "oauth-protected-resource": + return _protected_resource_metadata(self.request, self.context) + raise HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) @configure.service( context=IContainer, method="GET", permission="guillotina.Public", - name=".well-known/openid-configuration", + name="oauth/{action}", allow_access=True, ) -class OAuthOpenIDMetadata(OAuthAuthorizationServerMetadata): - pass +class OAuthGet(OAuthService): + async def __call__(self): + action = self.request.matchdict.get("action", "") + if action == "authorize": + return await _authorize(self, await self.storage()) + raise HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) @configure.service( context=IContainer, method="POST", permission="guillotina.Public", - name="oauth/register", + name="oauth/{action}", allow_access=True, ) -class OAuthRegister(OAuthService): +class OAuthPost(OAuthService): async def __call__(self): storage = await self.storage() - data = await self.request.json() - client = make_client(data) - if client["client_id"] in storage["clients"]: - oauth_error("invalid_request", "client_id already exists") - storage["clients"][client["client_id"]] = client - register_changed(storage) - return {key: client[key] for key in ( + action = self.request.matchdict.get("action", "") + if action == "register": + return await _register(self, storage) + if action == "authorize": + return await _authorize(self, storage) + if action == "token": + return await _token(self, storage) + if action == "revoke": + return await _revoke(self, storage) + raise HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) + + +async def _register(service, storage): + data = await service.request.json() + client = make_client(data) + if client["client_id"] in storage["clients"]: + oauth_error("invalid_request", "client_id already exists") + storage["clients"][client["client_id"]] = client + register_changed(storage) + return { + key: client[key] + for key in ( "client_id", "client_name", "redirect_uris", "grant_types", "response_types", "token_endpoint_auth_method", - )} + ) + } async def _authenticate_basic(username, password): @@ -115,203 +148,169 @@ def _html(body, status=200): return Response(body=body.encode("utf-8"), status=status, content_type="text/html") -@configure.service( - context=IContainer, - method="GET", - permission="guillotina.Public", - name="oauth/authorize", - allow_access=True, -) -@configure.service( - context=IContainer, - method="POST", - permission="guillotina.Public", - name="oauth/authorize", - allow_access=True, -) -class OAuthAuthorize(OAuthService): - async def __call__(self): - storage = await self.storage() - params = dict(self.request.query) - if self.request.method == "POST": - content_type = self.request.headers.get("content-type", "") - if "application/json" in content_type: - data = await self.request.json() - else: - data = parse_form_encoded(await self.request.text()) - params.update(data) - client = storage["clients"].get(params.get("client_id")) - if client is None: - return _html("Unknown OAuth client", status=400) - redirect_uri = params.get("redirect_uri") - redirect_valid = redirect_uri in client["redirect_uris"] - if not redirect_valid: - return _html("Invalid redirect_uri", status=400) - if params.get("response_type") != "code": - raise HTTPBadRequest(content={"error": "unsupported_response_type"}) - if not params.get("code_challenge"): - return self._redirect_error(redirect_uri, "invalid_request", params.get("state")) - if params.get("code_challenge_method") != "S256": - return self._redirect_error(redirect_uri, "invalid_request", params.get("state")) - scopes = normalize_list(params.get("scope")) - resources = validate_resource(self.request, self.context, params.get("resource")) - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - if self.request.method == "POST" and params.get("username"): - user = await _authenticate_basic(params.get("username"), params.get("password", "")) - if user is None: - return _html("Login failed", status=401) - else: - return _html("
Username Password " - "
") - ckey = consent_key(user.id, client["client_id"], scopes, resources) - if ckey not in storage["consents"] and params.get("decision") != "allow": - if params.get("decision") == "deny": - return self._redirect_error(redirect_uri, "access_denied", params.get("state")) +async def _authorize(service, storage): + params = dict(service.request.query) + if service.request.method == "POST": + content_type = service.request.headers.get("content-type", "") + if "application/json" in content_type: + data = await service.request.json() + else: + data = parse_form_encoded(await service.request.text()) + params.update(data) + client = storage["clients"].get(params.get("client_id")) + if client is None: + return _html("Unknown OAuth client", status=400) + redirect_uri = params.get("redirect_uri") + if redirect_uri not in client["redirect_uris"]: + return _html("Invalid redirect_uri", status=400) + if params.get("response_type") != "code": + raise HTTPBadRequest(content={"error": "unsupported_response_type"}) + if not params.get("code_challenge"): + return HTTPFound(redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")})) + if params.get("code_challenge_method") != "S256": + return HTTPFound(redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")})) + scopes = normalize_list(params.get("scope")) + resources = validate_resource(service.request, service.context, params.get("resource")) + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + if service.request.method == "POST" and params.get("username"): + user = await _authenticate_basic(params.get("username"), params.get("password", "")) + if user is None: + return _html("Login failed", status=401) + else: return _html( - "

Allow {}

" - "
".format(client["client_name"]) + "
Username Password " + "
" ) - if ckey not in storage["consents"]: - storage["consents"][ckey] = { - "user_id": user.id, - "client_id": client["client_id"], - "scope": scopes, - "resource": resources, - "granted_at": utcnow().isoformat(), - } - raw_code = opaque_token("goc_") - create_code( - storage, - raw_code=raw_code, - client_id=client["client_id"], - user_id=user.id, - redirect_uri=redirect_uri, - scope=scopes, - resource=resources, - code_challenge=params["code_challenge"], + ckey = consent_key(user.id, client["client_id"], scopes, resources) + if ckey not in storage["consents"] and params.get("decision") != "allow": + if params.get("decision") == "deny": + return HTTPFound(redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")})) + return _html( + "

Allow {}

" + "
".format(client["client_name"]) ) - register_changed(storage) - raise HTTPFound(redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")})) + if ckey not in storage["consents"]: + storage["consents"][ckey] = { + "user_id": user.id, + "client_id": client["client_id"], + "scope": scopes, + "resource": resources, + "granted_at": utcnow().isoformat(), + } + raw_code = opaque_token("goc_") + create_code( + storage, + raw_code=raw_code, + client_id=client["client_id"], + user_id=user.id, + redirect_uri=redirect_uri, + scope=scopes, + resource=resources, + code_challenge=params["code_challenge"], + ) + register_changed(storage) + return HTTPFound(redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")})) - def _redirect_error(self, redirect_uri, error, state): - raise HTTPFound(redirect_with_params(redirect_uri, {"error": error, "state": state})) +async def _token(service, storage): + data = parse_form_encoded(await service.request.text()) + grant_type = data.get("grant_type") + if grant_type == "authorization_code": + return _authorization_code(service, storage, data) + if grant_type == "refresh_token": + return _refresh_token(service, storage, data) + raise HTTPBadRequest(content={"error": "unsupported_grant_type"}) -@configure.service( - context=IContainer, - method="POST", - permission="guillotina.Public", - name="oauth/token", - allow_access=True, -) -class OAuthToken(OAuthService): - async def __call__(self): - storage = await self.storage() - data = parse_form_encoded(await self.request.text()) - grant_type = data.get("grant_type") - if grant_type == "authorization_code": - return self._authorization_code(storage, data) - if grant_type == "refresh_token": - return self._refresh_token(storage, data) - raise HTTPBadRequest(content={"error": "unsupported_grant_type"}) - def _authorization_code(self, storage, data): - client = storage["clients"].get(data.get("client_id")) - record = get_valid_code(storage, data.get("code", "")) - if client is None or record is None: - raise HTTPBadRequest(content={"error": "invalid_grant"}) - if record["client_id"] != client["client_id"] or record["redirect_uri"] != data.get("redirect_uri"): - raise HTTPBadRequest(content={"error": "invalid_grant"}) - if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): - raise HTTPBadRequest(content={"error": "invalid_grant"}) - requested_resources = normalize_list(data.get("resource")) - if requested_resources and not set(requested_resources).issubset(set(record["resource"])): - raise HTTPBadRequest(content={"error": "invalid_target"}) - resources = requested_resources or record["resource"] - record["used_at"] = utcnow().isoformat() - access_token, _claims = issue_access_token( - issuer=container_url(self.request, self.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=record["scope"], - ) - refresh_token = opaque_token("gor_") - now = utcnow() - storage["refresh_tokens"][token_hash(refresh_token)] = { - "token_hash": token_hash(refresh_token), - "client_id": client["client_id"], - "user_id": record["user_id"], - "scope": record["scope"], - "resource": resources, - "expires_at": (now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000))).isoformat(), - "revoked_at": None, - "rotated_from": None, - "created_at": now.isoformat(), - "last_used_at": now.isoformat(), - } - register_changed(storage) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": refresh_token, - "scope": " ".join(record["scope"]), - } +def _authorization_code(service, storage, data): + client = storage["clients"].get(data.get("client_id")) + record = get_valid_code(storage, data.get("code", "")) + if client is None or record is None: + raise HTTPBadRequest(content={"error": "invalid_grant"}) + if record["client_id"] != client["client_id"] or record["redirect_uri"] != data.get("redirect_uri"): + raise HTTPBadRequest(content={"error": "invalid_grant"}) + if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): + raise HTTPBadRequest(content={"error": "invalid_grant"}) + requested_resources = normalize_list(data.get("resource")) + if requested_resources and not set(requested_resources).issubset(set(record["resource"])): + raise HTTPBadRequest(content={"error": "invalid_target"}) + resources = requested_resources or record["resource"] + record["used_at"] = utcnow().isoformat() + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=record["scope"], + ) + refresh_token = opaque_token("gor_") + now = utcnow() + storage["refresh_tokens"][token_hash(refresh_token)] = { + "token_hash": token_hash(refresh_token), + "client_id": client["client_id"], + "user_id": record["user_id"], + "scope": record["scope"], + "resource": resources, + "expires_at": (now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000))).isoformat(), + "revoked_at": None, + "rotated_from": None, + "created_at": now.isoformat(), + "last_used_at": now.isoformat(), + } + register_changed(storage) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": refresh_token, + "scope": " ".join(record["scope"]), + } - def _refresh_token(self, storage, data): - record = get_valid_refresh(storage, data.get("refresh_token", "")) - client = storage["clients"].get(data.get("client_id")) - if record is None or client is None or record["client_id"] != client["client_id"]: - raise HTTPBadRequest(content={"error": "invalid_grant"}) - scopes = normalize_list(data.get("scope")) or record["scope"] - resources = normalize_list(data.get("resource")) or record["resource"] - if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): - raise HTTPBadRequest(content={"error": "invalid_scope"}) - record["revoked_at"] = utcnow().isoformat() - new_refresh = opaque_token("gor_") - now = utcnow() - storage["refresh_tokens"][token_hash(new_refresh)] = { - **record, - "token_hash": token_hash(new_refresh), - "scope": scopes, - "resource": resources, - "revoked_at": None, - "rotated_from": record["token_hash"], - "created_at": now.isoformat(), - "last_used_at": now.isoformat(), - } - access_token, _claims = issue_access_token( - issuer=container_url(self.request, self.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=scopes, - ) - register_changed(storage) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": new_refresh, - "scope": " ".join(scopes), - } +def _refresh_token(service, storage, data): + record = get_valid_refresh(storage, data.get("refresh_token", "")) + client = storage["clients"].get(data.get("client_id")) + if record is None or client is None or record["client_id"] != client["client_id"]: + raise HTTPBadRequest(content={"error": "invalid_grant"}) + scopes = normalize_list(data.get("scope")) or record["scope"] + resources = normalize_list(data.get("resource")) or record["resource"] + if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): + raise HTTPBadRequest(content={"error": "invalid_scope"}) + record["revoked_at"] = utcnow().isoformat() + new_refresh = opaque_token("gor_") + now = utcnow() + storage["refresh_tokens"][token_hash(new_refresh)] = { + **record, + "token_hash": token_hash(new_refresh), + "scope": scopes, + "resource": resources, + "revoked_at": None, + "rotated_from": record["token_hash"], + "created_at": now.isoformat(), + "last_used_at": now.isoformat(), + } + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=scopes, + ) + register_changed(storage) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": new_refresh, + "scope": " ".join(scopes), + } -@configure.service( - context=IContainer, - method="POST", - permission="guillotina.Public", - name="oauth/revoke", - allow_access=True, -) -class OAuthRevoke(OAuthService): - async def __call__(self): - storage = await self.storage() - data = parse_form_encoded(await self.request.text()) - record = storage["refresh_tokens"].get(token_hash(data.get("token", ""))) - if record is not None and record.get("client_id") == data.get("client_id"): - record["revoked_at"] = utcnow().isoformat() - register_changed(storage) - return {} + +async def _revoke(service, storage): + data = parse_form_encoded(await service.request.text()) + record = storage["refresh_tokens"].get(token_hash(data.get("token", ""))) + if record is not None and record.get("client_id") == data.get("client_id"): + record["revoked_at"] = utcnow().isoformat() + register_changed(storage) + return {} diff --git a/guillotina/contrib/oauth/tokens.py b/guillotina/contrib/oauth/tokens.py index dfd9b79fb..859941102 100644 --- a/guillotina/contrib/oauth/tokens.py +++ b/guillotina/contrib/oauth/tokens.py @@ -1,3 +1,4 @@ +import calendar import hashlib import hmac import secrets @@ -13,7 +14,7 @@ def utcnow(): def timestamp(dt): - return int(dt.timestamp()) + return int(calendar.timegm(dt.utctimetuple())) def opaque_token(prefix=""): diff --git a/guillotina/contrib/oauth/utils.py b/guillotina/contrib/oauth/utils.py index e6b1bfdf5..7f9ec0db7 100644 --- a/guillotina/contrib/oauth/utils.py +++ b/guillotina/contrib/oauth/utils.py @@ -3,12 +3,19 @@ from uuid import uuid4 from guillotina import app_settings, task_vars +from guillotina.interfaces import WRITING_VERBS from guillotina.contrib.oauth.content import OAUTH_STORAGE_KEY, new_oauth_storage from guillotina.contrib.oauth.tokens import token_hash, utcnow from guillotina.interfaces import IAddons, IAnnotations from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed +def check_writable_request(request): + return request.method in WRITING_VERBS or ( + request.method == "GET" and str(getattr(request, "path", "")).endswith("/oauth/authorize") + ) + + def container_url(request, container): issuer = app_settings.get("oauth", {}).get("issuer") if issuer: diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index 1dc0677ec..df77cc4f8 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -1,7 +1,7 @@ import base64 import hashlib import json -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlencode, urlparse import pytest @@ -45,7 +45,7 @@ async def authorize_code(requester, client, *, scope="guillotina:mcp.read", reso value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", - data="&".join(f"{k}={v}" for k, v in data.items()), + data=urlencode(data), headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False, ) @@ -55,9 +55,14 @@ async def authorize_code(requester, client, *, scope="guillotina:mcp.read", reso async def token_from_code(requester, client, code, verifier): - body = ( - f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}" - f"&code={code}&code_verifier={verifier}" + body = urlencode( + { + "grant_type": "authorization_code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "code": code, + "code_verifier": verifier, + } ) response, status = await requester( "POST", @@ -65,5 +70,5 @@ async def token_from_code(requester, client, code, verifier): data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - assert status == 200 + assert status == 200, response return response From 4aa21b2728b19333b4f0b91b7b5efdae79e3b71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 24 May 2026 22:13:21 +0200 Subject: [PATCH 03/27] Complete OAuth MCP phase coverage Finish remaining OAuth/MCP phase work.\n\nChanges:\n- Keep OAuth independent of MCP by making OAuth expose a generic well-known dispatcher and letting MCP register its protected-resource metadata provider.\n- Add MCP preflight OAuth scope checks for search and serialized content paths so insufficient scopes return HTTP 403.\n- Replay request bodies after preflight inspection so MCP streamable HTTP handling still receives the JSON-RPC payload.\n- Add validation coverage for expired authorization codes, expired refresh tokens, MCP insufficient search scope, and MCP insufficient content scope.\n- Black-format OAuth/MCP touched files.\n\nValidation:\n- .venv/bin/pytest guillotina/tests/oauth guillotina/tests/mcp/test_mcp.py guillotina/tests/test_login.py -q -> 46 passed, 24 warnings.\n- git diff --check -> passed. --- guillotina/contrib/oauth/services.py | 36 ++++--- guillotina/tests/oauth/conftest.py | 17 ++- guillotina/tests/oauth/test_mcp_oauth.py | 100 +++++++++++++++++- .../tests/oauth/test_oauth_authorize.py | 54 ++++++++-- guillotina/tests/oauth/test_oauth_register.py | 27 +++-- guillotina/tests/oauth/test_oauth_revoke.py | 16 ++- guillotina/tests/oauth/test_oauth_token.py | 82 ++++++++++++-- .../tests/oauth/test_oauth_validator.py | 20 +++- 8 files changed, 302 insertions(+), 50 deletions(-) diff --git a/guillotina/contrib/oauth/services.py b/guillotina/contrib/oauth/services.py index a2fbefdfe..10f340ddc 100644 --- a/guillotina/contrib/oauth/services.py +++ b/guillotina/contrib/oauth/services.py @@ -26,6 +26,11 @@ OAUTH_SCOPES = ["guillotina:mcp.read", "guillotina:mcp.search", "guillotina:mcp.content.read"] +WELL_KNOWN_HANDLERS = {} + + +def register_well_known_handler(name, handler): + WELL_KNOWN_HANDLERS[name] = handler def _metadata(request, container): @@ -44,13 +49,8 @@ def _metadata(request, container): } -def _protected_resource_metadata(request, container): - issuer = container_url(request, container) - return { - "resource": f"{issuer}/@mcp/protocol", - "authorization_servers": [issuer], - "scopes_supported": OAUTH_SCOPES, - } +register_well_known_handler("oauth-authorization-server", _metadata) +register_well_known_handler("openid-configuration", _metadata) class OAuthService(Service): @@ -69,10 +69,8 @@ class OAuthWellKnown(OAuthService): async def __call__(self): await self.storage() action = self.request.matchdict.get("action", "") - if action in ("oauth-authorization-server", "openid-configuration"): - return _metadata(self.request, self.context) - if action == "oauth-protected-resource": - return _protected_resource_metadata(self.request, self.context) + if action in WELL_KNOWN_HANDLERS: + return WELL_KNOWN_HANDLERS[action](self.request, self.context) raise HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) @@ -166,9 +164,13 @@ async def _authorize(service, storage): if params.get("response_type") != "code": raise HTTPBadRequest(content={"error": "unsupported_response_type"}) if not params.get("code_challenge"): - return HTTPFound(redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")})) + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) if params.get("code_challenge_method") != "S256": - return HTTPFound(redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")})) + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) scopes = normalize_list(params.get("scope")) resources = validate_resource(service.request, service.context, params.get("resource")) user = get_authenticated_user() @@ -185,7 +187,9 @@ async def _authorize(service, storage): ckey = consent_key(user.id, client["client_id"], scopes, resources) if ckey not in storage["consents"] and params.get("decision") != "allow": if params.get("decision") == "deny": - return HTTPFound(redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")})) + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")}) + ) return _html( "

Allow {}

" "
".format(client["client_name"]) @@ -252,7 +256,9 @@ def _authorization_code(service, storage, data): "user_id": record["user_id"], "scope": record["scope"], "resource": resources, - "expires_at": (now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000))).isoformat(), + "expires_at": ( + now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000)) + ).isoformat(), "revoked_at": None, "rotated_from": None, "created_at": now.isoformat(), diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index df77cc4f8..3990f5fd3 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -22,13 +22,26 @@ async def register_client(requester, redirect_uri="http://127.0.0.1:12345/callba response, status = await requester( "POST", "/db/guillotina/oauth/register", - data=json.dumps({"client_name": "Test", "redirect_uris": [redirect_uri], "scope": "guillotina:mcp.read guillotina:mcp.search"}), + data=json.dumps( + { + "client_name": "Test", + "redirect_uris": [redirect_uri], + "scope": "guillotina:mcp.read guillotina:mcp.search", + } + ), ) assert status == 200 return response -async def authorize_code(requester, client, *, scope="guillotina:mcp.read", resource=None, verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"): +async def authorize_code( + requester, + client, + *, + scope="guillotina:mcp.read", + resource=None, + verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", +): verifier, challenge = verifier_pair(verifier) data = { "response_type": "code", diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index 25f88c022..7e3344799 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -3,7 +3,12 @@ import pytest from guillotina.tests.mcp.test_mcp import PROTOCOL_HEADERS, _skip_if_protocol_unavailable -from guillotina.tests.oauth.conftest import OAUTH_MCP_SETTINGS, authorize_code, register_client, token_from_code +from guillotina.tests.oauth.conftest import ( + OAUTH_MCP_SETTINGS, + authorize_code, + register_client, + token_from_code, +) pytestmark = pytest.mark.asyncio @@ -22,7 +27,13 @@ async def test_mcp_protected_resource_metadata(container_install_requester): @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) async def test_mcp_without_token_challenges(container_install_requester): async with container_install_requester as requester: - _value, status, headers = await requester.make_request("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), headers=PROTOCOL_HEADERS, authenticated=False) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + headers=PROTOCOL_HEADERS, + authenticated=False, + ) assert status == 401 assert "resource_metadata" in headers["WWW-Authenticate"] @@ -32,9 +43,18 @@ async def test_mcp_without_token_challenges(container_install_requester): async def test_mcp_with_oauth_token(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina/@mcp/protocol") + code, verifier = await authorize_code( + requester, client, resource="http://localhost/db/guillotina/@mcp/protocol" + ) token = await token_from_code(requester, client, code, verifier) - response, status = await requester("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), headers=PROTOCOL_HEADERS, auth_type="Bearer", token=token["access_token"]) + response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) _skip_if_protocol_unavailable(response, status) assert status == 200 @@ -46,5 +66,75 @@ async def test_mcp_rejects_missing_mcp_audience(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") token = await token_from_code(requester, client, code, verifier) - _response, status = await requester("POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), headers=PROTOCOL_HEADERS, auth_type="Bearer", token=token["access_token"]) + _response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) assert status == 401 + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_search_requires_search_scope(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code( + requester, + client, + scope="guillotina:mcp.read", + resource="http://localhost/db/guillotina/@mcp/protocol", + ) + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "search", "arguments": {"query": {}}}, + } + ), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) + assert status == 403 + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_serialized_content_requires_content_scope(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code( + requester, + client, + scope="guillotina:mcp.read", + resource="http://localhost/db/guillotina/@mcp/protocol", + ) + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "resolve_path", + "arguments": {"path": "/", "include_serialized": True}, + }, + } + ), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) + assert status == 403 diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index a7dd278cb..4f5e34819 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -12,7 +12,9 @@ @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_unknown_client(container_install_requester): async with container_install_requester as requester: - _response, status = await requester("GET", "/db/guillotina/oauth/authorize", params={"client_id": "missing"}) + _response, status = await requester( + "GET", "/db/guillotina/oauth/authorize", params={"client_id": "missing"} + ) assert status == 400 @@ -21,7 +23,15 @@ async def test_authorize_unknown_client(container_install_requester): async def test_authorize_bad_redirect_does_not_redirect(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - _response, status = await requester("GET", "/db/guillotina/oauth/authorize", params={"client_id": client["client_id"], "redirect_uri": "https://evil.example/cb", "response_type": "code"}) + _response, status = await requester( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": "https://evil.example/cb", + "response_type": "code", + }, + ) assert status == 400 @@ -31,10 +41,16 @@ async def test_authorize_bad_redirect_does_not_redirect(container_install_reques async def test_authorize_pkce_required(challenge_method, container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - data = {"client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "response_type": "code"} + data = { + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + } if challenge_method: data.update({"code_challenge": "x", "code_challenge_method": challenge_method}) - _value, status, headers = await requester.make_request("GET", "/db/guillotina/oauth/authorize", params=data, allow_redirects=False) + _value, status, headers = await requester.make_request( + "GET", "/db/guillotina/oauth/authorize", params=data, allow_redirects=False + ) assert status == 302 assert "error=invalid_request" in headers["Location"] @@ -46,12 +62,30 @@ async def test_authorize_allow_and_remember_consent(container_install_requester) client = await register_client(requester) verifier, challenge = verifier_pair() body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&state=s&code_challenge={challenge}&code_challenge_method=S256&decision=allow" - _value, status, headers = await requester.make_request("POST", "/db/guillotina/oauth/authorize", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) assert status == 302 query = parse_qs(urlparse(headers["Location"]).query) assert query["code"][0] assert query["state"][0] == "s" - _value, status, _headers = await requester.make_request("GET", "/db/guillotina/oauth/authorize", params={"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "scope": "guillotina:mcp.read", "code_challenge": challenge, "code_challenge_method": "S256"}, allow_redirects=False) + _value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:mcp.read", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) assert status == 302 @@ -62,6 +96,12 @@ async def test_authorize_deny(container_install_requester): client = await register_client(requester) _verifier, challenge = verifier_pair() body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&code_challenge={challenge}&code_challenge_method=S256&decision=deny" - _value, status, headers = await requester.make_request("POST", "/db/guillotina/oauth/authorize", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) assert status == 302 assert "error=access_denied" in headers["Location"] diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index 04ca0a4cc..8aa702e13 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -11,19 +11,26 @@ @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_client(container_install_requester): async with container_install_requester as requester: - response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps({"client_name": "Example", "redirect_uris": ["http://127.0.0.1:12345/callback"]})) + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps({"client_name": "Example", "redirect_uris": ["http://127.0.0.1:12345/callback"]}), + ) assert status == 200 assert response["client_id"] assert response["token_endpoint_auth_method"] == "none" @pytest.mark.app_settings(OAUTH_SETTINGS) -@pytest.mark.parametrize("payload", [ - {"redirect_uris": []}, - {"redirect_uris": ["javascript:alert(1)"]}, - {"redirect_uris": ["https://example.com/*"]}, - {"redirect_uris": ["http://localhost/cb"], "token_endpoint_auth_method": "client_secret_basic"}, -]) +@pytest.mark.parametrize( + "payload", + [ + {"redirect_uris": []}, + {"redirect_uris": ["javascript:alert(1)"]}, + {"redirect_uris": ["https://example.com/*"]}, + {"redirect_uris": ["http://localhost/cb"], "token_endpoint_auth_method": "client_secret_basic"}, + ], +) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_rejects_invalid(payload, container_install_requester): async with container_install_requester as requester: @@ -35,5 +42,9 @@ async def test_register_rejects_invalid(payload, container_install_requester): @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_accepts_loopback(container_install_requester): async with container_install_requester as requester: - _response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]})) + _response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}), + ) assert status == 200 diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index 315d2d02b..3349f1a89 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -12,9 +12,17 @@ async def test_revoke_refresh_token(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code(requester, client) token = await token_from_code(requester, client, code, verifier) - _response, status = await requester("POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token={token['refresh_token']}&token_type_hint=refresh_token") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token={token['refresh_token']}&token_type_hint=refresh_token", + ) assert status == 200 - _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + ) assert status == 400 @@ -23,5 +31,7 @@ async def test_revoke_refresh_token(container_install_requester): async def test_revoke_unknown_token(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - _response, status = await requester("POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token=unknown") + _response, status = await requester( + "POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token=unknown" + ) assert status == 200 diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index 031db0007..3de8ca674 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -6,6 +6,15 @@ pytestmark = pytest.mark.asyncio +EXPIRED_CODE_SETTINGS = { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"authorization_code_ttl": 0}, +} +EXPIRED_REFRESH_SETTINGS = { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"refresh_token_ttl": 0}, +} + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) @@ -14,18 +23,35 @@ async def test_code_token_and_refresh_rotation(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") token = await token_from_code(requester, client, code, verifier) - claims = jwt.decode(token["access_token"], app_settings["jwt"]["secret"], algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}) + claims = jwt.decode( + token["access_token"], + app_settings["jwt"]["secret"], + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) assert claims["iss"].endswith("/db/guillotina") assert claims["sub"] == claims["id"] assert claims["client_id"] == client["client_id"] assert claims["scope"] == "guillotina:mcp.read" assert claims["aud"] - _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}", + ) assert status == 400 - refreshed, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + refreshed, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + ) assert status == 200 assert refreshed["refresh_token"] != token["refresh_token"] - _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + ) assert status == 400 @@ -35,8 +61,50 @@ async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) code, verifier = await authorize_code(requester, client) - _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier=bad") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier=bad", + ) + assert status == 400 + code, verifier = await authorize_code( + requester, client, scope="guillotina:mcp.read guillotina:mcp.search" + ) + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri=http://127.0.0.1:9999/cb&code={code}&code_verifier={verifier}", + ) + assert status == 400 + + +@pytest.mark.app_settings(EXPIRED_CODE_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_expired_authorization_code_fails(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=( + f"grant_type=authorization_code&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}" + ), + ) assert status == 400 - code, verifier = await authorize_code(requester, client, scope="guillotina:mcp.read guillotina:mcp.search") - _response, status = await requester("POST", "/db/guillotina/oauth/token", data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri=http://127.0.0.1:9999/cb&code={code}&code_verifier={verifier}") + + +@pytest.mark.app_settings(EXPIRED_REFRESH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_expired_refresh_token_fails(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token = await token_from_code(requester, client, code, verifier) + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + ) assert status == 400 diff --git a/guillotina/tests/oauth/test_oauth_validator.py b/guillotina/tests/oauth/test_oauth_validator.py index e1f172451..90018fb6f 100644 --- a/guillotina/tests/oauth/test_oauth_validator.py +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -12,7 +12,13 @@ async def test_oauth_access_token_authenticates(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") token = await token_from_code(requester, client, code, verifier) - response, status = await requester("GET", "/db/guillotina/@addons", authenticated=True, auth_type="Bearer", token=token["access_token"]) + response, status = await requester( + "GET", + "/db/guillotina/@addons", + authenticated=True, + auth_type="Bearer", + token=token["access_token"], + ) assert status == 200 assert "oauth" in response["installed"] @@ -22,7 +28,15 @@ async def test_oauth_access_token_authenticates(container_install_requester): async def test_oauth_access_token_wrong_audience_fails_generic_api(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina/@mcp/protocol") + code, verifier = await authorize_code( + requester, client, resource="http://localhost/db/guillotina/@mcp/protocol" + ) token = await token_from_code(requester, client, code, verifier) - _response, status = await requester("GET", "/db/guillotina/@addons", authenticated=True, auth_type="Bearer", token=token["access_token"]) + _response, status = await requester( + "GET", + "/db/guillotina/@addons", + authenticated=True, + auth_type="Bearer", + token=token["access_token"], + ) assert status in (401, 403) From 5dfdce462635f47ebc52d964a42f3e49184d0a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 29 May 2026 22:45:48 +0200 Subject: [PATCH 04/27] feat: Enhance OAuth functionality and documentation --- CHANGELOG.rst | 7 + docs/source/contrib/oauth.md | 299 ++++++++ docs/source/contrib/oauth.rst | 63 -- documentacio_tasques_oauth.md | 30 + guillotina/contrib/mcp/interfaces.py | 11 + guillotina/contrib/mcp/permissions.py | 2 + guillotina/contrib/oauth/__init__.py | 28 +- guillotina/contrib/oauth/api/__init__.py | 0 guillotina/contrib/oauth/api/request.py | 33 + guillotina/contrib/oauth/api/services.py | 513 ++++++++++++++ .../contrib/oauth/api/templates/base.html | 21 + .../contrib/oauth/api/templates/consent.html | 33 + .../contrib/oauth/api/templates/error.html | 4 + .../oauth/api/templates/hidden_input.html | 1 + .../oauth/api/templates/list_item.html | 1 + .../contrib/oauth/api/templates/login.html | 21 + .../contrib/oauth/api/templates/oauth.css | 151 ++++ .../oauth/api/templates/plain_item.html | 1 + .../oauth/api/templates/scope_item.html | 4 + guillotina/contrib/oauth/api/urls.py | 61 ++ guillotina/contrib/oauth/api/well_known.py | 40 ++ guillotina/contrib/oauth/auth/__init__.py | 0 .../contrib/oauth/{ => auth}/validators.py | 2 +- guillotina/contrib/oauth/content.py | 23 - guillotina/contrib/oauth/flow/__init__.py | 0 guillotina/contrib/oauth/flow/clients.py | 68 ++ guillotina/contrib/oauth/flow/pkce.py | 30 + guillotina/contrib/oauth/flow/resources.py | 55 ++ guillotina/contrib/oauth/flow/scopes.py | 15 + guillotina/contrib/oauth/{ => flow}/tokens.py | 0 guillotina/contrib/oauth/install.py | 16 +- .../contrib/oauth/integrations/__init__.py | 1 + guillotina/contrib/oauth/integrations/mcp.py | 54 ++ guillotina/contrib/oauth/interfaces.py | 4 +- guillotina/contrib/oauth/permissions.py | 10 - guillotina/contrib/oauth/pkce.py | 11 - guillotina/contrib/oauth/services.py | 322 --------- guillotina/contrib/oauth/storage/__init__.py | 0 guillotina/contrib/oauth/storage/access.py | 41 ++ .../contrib/oauth/storage/interfaces.py | 77 ++ .../contrib/oauth/storage/pg/__init__.py | 0 .../contrib/oauth/storage/pg/repository.py | 462 ++++++++++++ guillotina/contrib/oauth/storage/pg/schema.py | 111 +++ guillotina/contrib/oauth/storage/utility.py | 120 ++++ guillotina/contrib/oauth/utils.py | 175 ----- guillotina/tests/oauth/conftest.py | 31 +- guillotina/tests/oauth/test_mcp_oauth.py | 180 ++++- .../tests/oauth/test_oauth_authorize.py | 300 +++++++- guillotina/tests/oauth/test_oauth_metadata.py | 19 +- guillotina/tests/oauth/test_oauth_register.py | 73 +- guillotina/tests/oauth/test_oauth_revoke.py | 11 +- .../tests/oauth/test_oauth_storage_backend.py | 41 ++ .../tests/oauth/test_oauth_store_contract.py | 131 ++++ guillotina/tests/oauth/test_oauth_token.py | 140 +++- .../tests/oauth/test_oauth_validator.py | 16 +- .../tests/oauth/test_oauth_well_known.py | 69 ++ oauth-rfc-theory.html | 649 +++++++++++++++++ oauth_docs_html.html | 663 ++++++++++++++++++ 58 files changed, 4572 insertions(+), 672 deletions(-) create mode 100644 docs/source/contrib/oauth.md delete mode 100644 docs/source/contrib/oauth.rst create mode 100644 documentacio_tasques_oauth.md create mode 100644 guillotina/contrib/oauth/api/__init__.py create mode 100644 guillotina/contrib/oauth/api/request.py create mode 100644 guillotina/contrib/oauth/api/services.py create mode 100644 guillotina/contrib/oauth/api/templates/base.html create mode 100644 guillotina/contrib/oauth/api/templates/consent.html create mode 100644 guillotina/contrib/oauth/api/templates/error.html create mode 100644 guillotina/contrib/oauth/api/templates/hidden_input.html create mode 100644 guillotina/contrib/oauth/api/templates/list_item.html create mode 100644 guillotina/contrib/oauth/api/templates/login.html create mode 100644 guillotina/contrib/oauth/api/templates/oauth.css create mode 100644 guillotina/contrib/oauth/api/templates/plain_item.html create mode 100644 guillotina/contrib/oauth/api/templates/scope_item.html create mode 100644 guillotina/contrib/oauth/api/urls.py create mode 100644 guillotina/contrib/oauth/api/well_known.py create mode 100644 guillotina/contrib/oauth/auth/__init__.py rename guillotina/contrib/oauth/{ => auth}/validators.py (96%) delete mode 100644 guillotina/contrib/oauth/content.py create mode 100644 guillotina/contrib/oauth/flow/__init__.py create mode 100644 guillotina/contrib/oauth/flow/clients.py create mode 100644 guillotina/contrib/oauth/flow/pkce.py create mode 100644 guillotina/contrib/oauth/flow/resources.py create mode 100644 guillotina/contrib/oauth/flow/scopes.py rename guillotina/contrib/oauth/{ => flow}/tokens.py (100%) create mode 100644 guillotina/contrib/oauth/integrations/__init__.py create mode 100644 guillotina/contrib/oauth/integrations/mcp.py delete mode 100644 guillotina/contrib/oauth/permissions.py delete mode 100644 guillotina/contrib/oauth/pkce.py delete mode 100644 guillotina/contrib/oauth/services.py create mode 100644 guillotina/contrib/oauth/storage/__init__.py create mode 100644 guillotina/contrib/oauth/storage/access.py create mode 100644 guillotina/contrib/oauth/storage/interfaces.py create mode 100644 guillotina/contrib/oauth/storage/pg/__init__.py create mode 100644 guillotina/contrib/oauth/storage/pg/repository.py create mode 100644 guillotina/contrib/oauth/storage/pg/schema.py create mode 100644 guillotina/contrib/oauth/storage/utility.py delete mode 100644 guillotina/contrib/oauth/utils.py create mode 100644 guillotina/tests/oauth/test_oauth_storage_backend.py create mode 100644 guillotina/tests/oauth/test_oauth_store_contract.py create mode 100644 guillotina/tests/oauth/test_oauth_well_known.py create mode 100644 oauth-rfc-theory.html create mode 100644 oauth_docs_html.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0326c4a9d..6adda7f32 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,13 @@ CHANGELOG responses by principal/container/context, and invalidate MCP cache on permission changes. [rboixaderg] +- OAuth: add extensible ``resource`` resolvers (:mod:`guillotina.contrib.oauth.flow.resources`), apply MCP + protocol URLs only when ``guillotina.contrib.mcp`` is enabled, require redirect URIs to be merged via + ``POST /oauth/register`` before ``/oauth/authorize``, validate PKCE ``code_verifier`` (RFC 7636), + atomically finalize authorization-code exchange after checks, defend refresh-token rotation against reuse. + [rboixaderg] +- OAuth: drop in-memory and Redis storage backends; PostgreSQL is the only store. + [rboixaderg] 7.1.2 (2026-05-22) diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md new file mode 100644 index 000000000..84cd7f0e4 --- /dev/null +++ b/docs/source/contrib/oauth.md @@ -0,0 +1,299 @@ +# OAuth authorization server + +Install `guillotina.contrib.oauth` as an application and install the `oauth` addon in each container that should act as an authorization server. OAuth state is stored in PostgreSQL tables, configured via the `oauth_storage` utility settings. + +## Configuration + +To enable and configure the OAuth 2.1 authorization server, the following settings must be defined in your Guillotina configuration (e.g., `config.yaml`). + +### 1. Enable the Application + +Add `guillotina.contrib.oauth` to your list of active applications: + +```yaml +applications: + - guillotina.contrib.oauth +``` + +### 2. Configure JWT Secrets + +Since OAuth Access Tokens are issued as signed JSON Web Tokens (JWT), you **must** configure the global JWT signing settings: + +```yaml +jwt: + secret: YOUR_SECURE_JWT_SECRET_KEY # Change this to a secure key! + algorithm: HS256 +``` + +### 3. Configure Authentication Extractors and Validators + +To support browser-based authentication (cookie-based login/consent session) and to validate incoming OAuth Access Tokens, you must configure the extractors and validators: + +```yaml +auth_extractors: + - guillotina.auth.extractors.BearerAuthPolicy + - guillotina.auth.extractors.BasicAuthPolicy + - guillotina.auth.extractors.WSTokenAuthPolicy + - guillotina.auth.extractors.CookiePolicy # Required for browser login & consent form + +auth_token_validators: + - guillotina.contrib.oauth.auth.validators.OAuthJWTValidator # Required to validate OAuth Access Tokens + - guillotina.auth.validators.SaltedHashPasswordValidator + - guillotina.auth.validators.JWTValidator +``` + +### 4. Set Write Permissions for GET Requests + +Guillotina normally prevents database writes on GET requests. Since the `/oauth/authorize` endpoint (which is a GET request) needs to create/validate authorization states, you must override `check_writable_request`: + +```yaml +check_writable_request: guillotina.contrib.oauth.api.request.check_writable_request +``` + +### 5. Customize OAuth Server Settings (Optional) + +Protocol settings (issuer, token TTLs, PKCE, scopes) live under the `oauth` block. PostgreSQL cleanup tuning lives under `load_utilities.oauth_storage.settings`: + +```yaml +oauth: + enabled: true + issuer: null # Custom token issuer URL (e.g. "https://auth.example.com") + authorization_code_ttl: 600 # Time to live in seconds for Authorization Codes (default 10 min) + access_token_ttl: 3600 # Time to live in seconds for Access Tokens (default 1 hour) + refresh_token_ttl: 2592000 # Time to live in seconds for Refresh Tokens (default 30 days) + require_pkce: true # Whether PKCE is strictly required (always true for OAuth 2.1) + scopes_supported: # Optional OAuth protocol label (not used for authorization) + - guillotina:access + +load_utilities: + oauth_storage: + settings: + cleanup_interval: 900 # seconds between expired-row cleanup runs + cleanup_batch_size: 5000 # rows deleted per cleanup batch +``` + +The same cleanup keys may still be set under `oauth` for backward compatibility; utility settings take precedence. + +OAuth state is always persisted in PostgreSQL tables (`oauth_clients`, `oauth_authorization_codes`, …). A PostgreSQL database storage is required. + +## Discovery and OAuth vs OpenID (`openid-configuration`) + +The primary metadata URL is `/.well-known/oauth-authorization-server` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)). The same JSON is also served at `/.well-known/openid-configuration` (container-scoped and RFC 8414 root variants) **as a compatibility alias** for clients that probe the OpenID path. This is still **OAuth authorization server metadata only**—not full OpenID Connect (no `id_token`, `userinfo`, OIDC JWKS, etc.). + +## Allowed `resource` values (RFC 8707) + +The `resource` parameter is restricted to URLs returned by registered resolvers in `guillotina.contrib.oauth.flow.resources`. The oauth application registers the **container issuer** by default (`https://host/db/container`). When both `guillotina.contrib.oauth` and `guillotina.contrib.mcp` are in `applications`, OAuth also loads the MCP integration, registers `{container}/@mcp/protocol`, and exposes MCP protected-resource metadata. That MCP resolver is ignored for OAuth-only deployments, so the MCP protocol URL is not accepted as a `resource` unless MCP is enabled. + +From your addon `includeme` (or startup hook): + +```python +from guillotina.contrib.oauth.flow.resources import register_oauth_resource_resolver + +def my_resolver(request, container): + from guillotina.contrib.oauth.api.urls import container_url + base = container_url(request, container) + return {f"{base}/@services/my-hook"} + +register_oauth_resource_resolver(my_resolver) +``` + +## Dynamic client registration and redirect URIs + +`/oauth/authorize` accepts only redirect URIs that are already present on the client record. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. + +## Supported flow + +The contrib implements public-client OAuth 2.1 Authorization Code with PKCE (`S256`), dynamic client registration, opaque refresh tokens, revocation, and JWT access tokens signed with Guillotina's configured JWT secret. + +Endpoints are container scoped: + +```text +GET /db/container/.well-known/oauth-authorization-server +GET /db/container/.well-known/openid-configuration +POST /db/container/oauth/register +GET /db/container/oauth/authorize +``` + +RFC 8414 discovery for issuers with a path component (such as `/db/container`) is also exposed at the application root: + +```text +GET /.well-known/oauth-authorization-server/db/container +GET /.well-known/openid-configuration/db/container +``` + +When using MCP, protected resource metadata follows [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728): + +```text +GET /db/container/.well-known/oauth-protected-resource +GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol +``` + +Other container-scoped endpoints: + +```text +POST /db/container/oauth/authorize +POST /db/container/oauth/token +POST /db/container/oauth/revoke +``` + +## How to Use PKCE and the OAuth Flow (Step-by-Step) + +Follow these steps to generate PKCE credentials, register a client, authorize a user, and exchange the resulting authorization code for an Access Token. + +### Step 1: Generate PKCE Secrets on the Client + +Clients must generate a high-entropy random `code_verifier` between **43 and 128** characters from the unreserved set in RFC 7636 (`[A-Z] [a-z] [0-9] - . _ ~`), and compute its `code_challenge` using SHA-256 (BASE64URL encoding without padding). + +#### **Bash / OpenSSL Example:** + +If you are working on the terminal, you can quickly generate both secrets in your shell using `openssl`: + +```bash +# 1. Generate a secure random code_verifier (URL-safe base64 encoded) +code_verifier=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-') +echo "code_verifier: $code_verifier" + +# 2. Compute the S256 code_challenge +code_challenge=$(echo -n "$code_verifier" | openssl dgst -binary -sha256 | openssl base64 | tr -d '=' | tr '/+' '_-') +echo "code_challenge: $code_challenge" +``` + +#### **Python Example:** + +```python +import base64 +import hashlib +import secrets + +# 1. Generate the random code_verifier (keep this secret on the client!) +code_verifier = secrets.token_urlsafe(64) + +# 2. Compute the code_challenge (SHA-256 hashed and encoded as URL-safe base64 with no padding) +hash_digest = hashlib.sha256(code_verifier.encode('ascii')).digest() +code_challenge = base64.urlsafe_b64encode(hash_digest).rstrip(b'=').decode('ascii') +``` + +#### **JavaScript Example:** + +```javascript +// 1. Generate the random code_verifier (keep this secret!) +function generateCodeVerifier() { + const array = new Uint32Array(56); + window.crypto.getRandomValues(array); + return Array.from(array, dec => dec.toString(16).padStart(2, "0")).join(""); +} + +// 2. Compute the S256 code_challenge +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashed = await window.crypto.subtle.digest("SHA-256", data); + + return btoa(String.fromCharCode(...new Uint8Array(hashed))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} +``` + +### Step 2: Register a Public Client + +Register your public client on the Guillotina container: + +```bash +curl -X POST http://localhost:8080/db/container/oauth/register \ + -H 'Content-Type: application/json' \ + -d '{"client_name":"MCP Client","redirect_uris":["http://127.0.0.1:12345/callback"],"token_endpoint_auth_method":"none"}' +``` + +Save the resulting `client_id` returned by the server. + +### Step 3: Direct the User to the Authorization Endpoint (Send Challenge) + +Direct the user's browser to the authorize URL. **Here you must append the `code_challenge` and set `code_challenge_method=S256`** as query parameters. + +For a **REST API client** (container-wide access), omit `resource` or set it to the container URL: + +```text +http://localhost:8080/db/container/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=http://127.0.0.1:12345/callback&scope=guillotina:access&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256&state=some_random_state +``` + +For an **MCP client**, include the MCP protocol endpoint as `resource`: + +```text +http://localhost:8080/db/container/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=http://127.0.0.1:12345/callback&scope=guillotina:access&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256&state=some_random_state&resource=http://localhost:8080/db/container/@mcp/protocol +``` + +The `scope` parameter is optional. When omitted, the token is issued with an empty `scope` claim. When present, use `guillotina:access`. + +Once the user logs in and consents, they will be redirected back to your `redirect_uri` with an authorization code parameter: +`http://127.0.0.1:12345/callback?code=goc_XYZ123&state=some_random_state` + +### Step 4: Exchange the Code for Access & Refresh Tokens (Send Verifier) + +Now, send a POST request to the token endpoint to exchange the received code for actual tokens. **Here you must provide the original `code_verifier` (in plaintext) as a parameter** so the server can verify it against the challenge from Step 3: + +```bash +curl -X POST http://localhost:8080/db/container/oauth/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=authorization_code&client_id=CLIENT_ID&redirect_uri=http://127.0.0.1:12345/callback&code=goc_XYZ123&code_verifier=YOUR_CODE_VERIFIER' +``` + +If successful, the response will contain your `access_token` and `refresh_token`. + +### Step 5: Refresh and Revoke (Optional) + +To obtain a new access token using your refresh token: + +```bash +curl -X POST http://localhost:8080/db/container/oauth/token \ + -d 'grant_type=refresh_token&client_id=CLIENT_ID&refresh_token=YOUR_REFRESH_TOKEN' +``` + +Refresh tokens are rotated on every successful refresh. Reusing an older refresh token fails and revokes the token family created from the same authorization code. + +To revoke an active refresh token: + +```bash +curl -X POST http://localhost:8080/db/container/oauth/revoke \ + -d 'client_id=CLIENT_ID&token=YOUR_REFRESH_TOKEN&token_type_hint=refresh_token' +``` + +## Authorization model + +OAuth provides **authentication** and **resource binding**. **Authorization** is always enforced with native Guillotina permissions on the authenticated user. + +| Concern | Mechanism | +|---------|-----------| +| Who is the user? | OAuth token `sub` claim | +| Which client? | OAuth token `client_id` claim | +| Which resource? | Token audience (`aud`) — container URL or MCP endpoint | +| What can they do? | Guillotina roles and ACLs (`AddContent`, `ModifyContent`, `MCPExecute`, …) | + +The OAuth scope `guillotina:access` is **protocol metadata only**. It appears in discovery, consent screens, and token claims, but it is **not checked** when calling the REST API or MCP tools. + +### REST API clients + +Authorize without `resource` (defaults to the container). Use the access token as a Bearer token on any Guillotina API endpoint. The user's existing roles and ACLs apply. + +```bash +curl http://localhost:8080/db/container/@addons \ + -H "Authorization: Bearer ACCESS_TOKEN" +``` + +### MCP clients (Cursor) + +Authorize with `resource` set to the MCP protocol URL. MCP additionally verifies that the token audience includes that endpoint. + +Example Cursor `mcp.json`: + +```json +{ + "auth": { + "CLIENT_ID": "...", + "scopes": ["guillotina:access"] + } +} +``` + +`@login` JWTs authenticate Guillotina sessions directly. OAuth access tokens include `token_type=oauth_access_token`, `client_id`, `scope` and audience/resource claims and are validated by the OAuth validator. MCP clients should use OAuth discovery and must not store manually copied bearer tokens in configuration. diff --git a/docs/source/contrib/oauth.rst b/docs/source/contrib/oauth.rst deleted file mode 100644 index 981168ee2..000000000 --- a/docs/source/contrib/oauth.rst +++ /dev/null @@ -1,63 +0,0 @@ -OAuth authorization server -========================== - -Install ``guillotina.contrib.oauth`` as an application and install the -``oauth`` addon in each container that should act as an authorization server. -OAuth state is stored in the reserved container annotation ``.oauth``. - -Supported flow --------------- - -The contrib implements public-client OAuth 2.1 Authorization Code with PKCE -(``S256``), dynamic client registration, opaque refresh tokens, revocation, and -JWT access tokens signed with Guillotina's configured JWT secret. -Authorization codes and refresh tokens are stored only as HMAC SHA-256 hashes. - -Endpoints are container scoped:: - - GET /db/container/.well-known/oauth-authorization-server - POST /db/container/oauth/register - GET /db/container/oauth/authorize - POST /db/container/oauth/authorize - POST /db/container/oauth/token - POST /db/container/oauth/revoke - -Examples --------- - -Register a public client:: - - curl -X POST https://host/db/container/oauth/register \ - -H 'Content-Type: application/json' \ - -d '{"client_name":"MCP Client","redirect_uris":["http://127.0.0.1:12345/callback"],"token_endpoint_auth_method":"none"}' - -Open the authorize URL in a browser:: - - https://host/db/container/oauth/authorize?response_type=code&client_id=CLIENT&redirect_uri=http://127.0.0.1:12345/callback&scope=guillotina:mcp.read&code_challenge=CHALLENGE&code_challenge_method=S256&resource=https://host/db/container/@mcp/protocol - -Exchange the code:: - - curl -X POST https://host/db/container/oauth/token \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - -d 'grant_type=authorization_code&client_id=CLIENT&redirect_uri=http://127.0.0.1:12345/callback&code=CODE&code_verifier=VERIFIER' - -Refresh and revoke:: - - curl -X POST https://host/db/container/oauth/token \ - -d 'grant_type=refresh_token&client_id=CLIENT&refresh_token=REFRESH' - - curl -X POST https://host/db/container/oauth/revoke \ - -d 'client_id=CLIENT&token=REFRESH&token_type_hint=refresh_token' - -MCP scopes ----------- - -``guillotina:mcp.read`` allows basic MCP discovery/read calls. -``guillotina:mcp.search`` allows search. ``guillotina:mcp.content.read`` is -required for serialized content responses. - -``@login`` JWTs authenticate Guillotina sessions directly. OAuth access tokens -include ``token_type=oauth_access_token``, ``client_id``, ``scope`` and -audience/resource claims and are validated by the OAuth validator. MCP clients -should use OAuth discovery and must not store manually copied bearer tokens in -configuration. diff --git a/documentacio_tasques_oauth.md b/documentacio_tasques_oauth.md new file mode 100644 index 000000000..495887218 --- /dev/null +++ b/documentacio_tasques_oauth.md @@ -0,0 +1,30 @@ +Tasques revisades: + +Implementacio well known: + +Documentacio: https://www.rfc-editor.org/info/rfc9728/ + +Explicacio principal: + +- Quan fem la peticio a la peticio de @mcp/protocol, si l'usuari no esta autenticat tornem unes capçaleres perque el client pugui autenticarse amb oauth. + +La reposta es : +```json +{ + "resource": "http://localhost:8080/db/container/@mcp/protocol", + "authorization_servers": [ + "http://localhost:8080/db/container" + ], + "scopes_supported": [ + "guillotina:access" + ] +} +``` + + + +Preguntes: + +Quin RFC ens diu que el validador del JWT normal, s'ha de bloquejar si el token es d'oauth? + +A parlar amb el ramon, estem fent un lock per la bd al crear les taules en la utility, realment pg no te problemes en diferens instancies intantant crear estructura, com a molt retornaria un error, es una mica de sobreenyinyeria o codi inecessari aquest lock? Realment amb les utilities no hauriem de tenir mai una mateixa instància dos processos a la vegada. diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index 957f61cbc..b1307dc5b 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -30,3 +30,14 @@ async def invalidate_cache(reason="manual"): def create_lowlevel_server(context=None, request=None): """Build a low-level MCP server object.""" + + +class IMCPAuthPolicy(Interface): + def is_enabled(request, context): + """Return whether this policy applies to the current MCP request.""" + + def unauthorized_headers(request, context): + """Return extra headers for an unauthenticated MCP protocol response.""" + + def is_authorized(request, context): + """Return whether the current authenticated request may use this MCP endpoint.""" diff --git a/guillotina/contrib/mcp/permissions.py b/guillotina/contrib/mcp/permissions.py index 9e0d23880..97935cce1 100644 --- a/guillotina/contrib/mcp/permissions.py +++ b/guillotina/contrib/mcp/permissions.py @@ -7,3 +7,5 @@ configure.grant(permission="guillotina.MCPView", role="guillotina.Manager") configure.grant(permission="guillotina.MCPView", role="guillotina.Owner") configure.grant(permission="guillotina.MCPExecute", role="guillotina.Manager") +configure.grant(permission="guillotina.MCPExecute", role="guillotina.Owner") +configure.grant(permission="guillotina.MCPExecute", role="guillotina.Editor") diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index 5b094713c..b69f31c66 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -10,22 +10,32 @@ "refresh_token_ttl": 2592000, "require_pkce": True, "allowed_code_challenge_methods": ["S256"], - "scopes_supported": [ - "guillotina:mcp.read", - "guillotina:mcp.search", - "guillotina:mcp.content.read", - ], + "scopes_supported": ["guillotina:access"], }, - "check_writable_request": "guillotina.contrib.oauth.utils.check_writable_request", + "check_writable_request": "guillotina.contrib.oauth.api.request.check_writable_request", "auth_token_validators": [ - "guillotina.contrib.oauth.validators.OAuthJWTValidator", + "guillotina.contrib.oauth.auth.validators.OAuthJWTValidator", "guillotina.auth.validators.SaltedHashPasswordValidator", "guillotina.auth.validators.JWTValidator", ], + "load_utilities": { + "oauth_storage": { + "provides": "guillotina.contrib.oauth.interfaces.IOAuthStorageUtility", + "factory": "guillotina.contrib.oauth.storage.utility.OAuthStorageUtility", + "settings": { + "cleanup_interval": 900, + "cleanup_batch_size": 5000, + }, + } + }, } def includeme(root, settings): + from guillotina.contrib.oauth.flow.resources import ensure_default_oauth_resources_registered + + ensure_default_oauth_resources_registered() configure.scan("guillotina.contrib.oauth.install") - configure.scan("guillotina.contrib.oauth.permissions") - configure.scan("guillotina.contrib.oauth.services") + configure.scan("guillotina.contrib.oauth.api.services") + if "guillotina.contrib.mcp" in set(settings.get("applications") or []): + configure.scan("guillotina.contrib.oauth.integrations.mcp") diff --git a/guillotina/contrib/oauth/api/__init__.py b/guillotina/contrib/oauth/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/api/request.py b/guillotina/contrib/oauth/api/request.py new file mode 100644 index 000000000..15c61d246 --- /dev/null +++ b/guillotina/contrib/oauth/api/request.py @@ -0,0 +1,33 @@ +from urllib.parse import parse_qs + +from guillotina.interfaces import WRITING_VERBS +from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed + + +def check_writable_request(request): + return request.method in WRITING_VERBS or ( + request.method == "GET" and str(getattr(request, "path", "")).endswith("/oauth/authorize") + ) + + +def normalize_list(value): + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + values = [] + for item in value: + values.extend(normalize_list(item)) + return values + return [item for item in str(value).split() if item] + + +def parse_form_encoded(body): + parsed = parse_qs(body, keep_blank_values=True) + return {key: values if len(values) > 1 else values[0] for key, values in parsed.items()} + + +def oauth_error(error, description=None, status=400): + content = {"error": error} + if description: + content["error_description"] = description + raise HTTPBadRequest(content=content) if status == 400 else HTTPPreconditionFailed(content=content) diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py new file mode 100644 index 000000000..86cbeea57 --- /dev/null +++ b/guillotina/contrib/oauth/api/services.py @@ -0,0 +1,513 @@ +from base64 import b64encode +from functools import lru_cache +from html import escape as html_escape +from pathlib import Path +from string import Template + +from guillotina import app_settings, configure +from guillotina.api.service import Service +from guillotina.auth.utils import set_authenticated_user +from guillotina.contrib.oauth.api.request import normalize_list, parse_form_encoded +from guillotina.contrib.oauth.api.urls import container_url, validate_resource +from guillotina.contrib.oauth.api.well_known import rfc_well_known_response +from guillotina.contrib.oauth.flow.clients import ( + consent_key, + make_client, + redirect_uri_registered_for_client, + redirect_with_params, +) +from guillotina.contrib.oauth.flow.pkce import verify_s256 +from guillotina.contrib.oauth.flow.scopes import OAUTH_SCOPE_DESCRIPTIONS, oauth_scopes_supported +from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash +from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.interfaces import IApplication, IContainer +from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, Response +from guillotina.utils import get_authenticated_user + + +WELL_KNOWN_HANDLERS = {} +TEMPLATE_DIR = Path(__file__).parent / "templates" +BRAND_LOGO_PATH = Path(__file__).parents[3] / "static" / "assets" / "brand" / "guillotina-logo-horizontal.svg" + + +def register_well_known_handler(name, handler): + WELL_KNOWN_HANDLERS[name] = handler + + +def _metadata(request, container): + issuer = container_url(request, container) + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "registration_endpoint": f"{issuer}/oauth/register", + "revocation_endpoint": f"{issuer}/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": oauth_scopes_supported(), + } + + +register_well_known_handler("oauth-authorization-server", _metadata) +# Compatibility alias: some clients probe `openid-configuration`; payload is OAuth AS metadata, not full OIDC. +register_well_known_handler("openid-configuration", _metadata) + + +class OAuthService(Service): + def oauth_store(self): + return get_oauth_store(self.context) + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name=".well-known/{action}", + allow_access=True, +) +class OAuthWellKnown(OAuthService): + async def __call__(self): + self.oauth_store() + action = self.request.matchdict.get("action", "") + if action in WELL_KNOWN_HANDLERS: + return WELL_KNOWN_HANDLERS[action](self.request, self.context) + return HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) + + +@configure.service( + context=IApplication, + method="GET", + permission="guillotina.Public", + name=".well-known/{action}/{target_path:path}", + allow_access=True, +) +class OAuthRFCWellKnown(Service): + async def __call__(self): + action = self.request.matchdict.get("action", "") + if action not in WELL_KNOWN_HANDLERS: + return HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) + target_path = self.request.matchdict.get("target_path", "") + try: + return await rfc_well_known_response(self.request, action, target_path, WELL_KNOWN_HANDLERS) + except HTTPNotFound as exc: + return exc + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name="oauth/{action}", + allow_access=True, +) +class OAuthGet(OAuthService): + async def __call__(self): + action = self.request.matchdict.get("action", "") + if action == "authorize": + return await _authorize(self, self.oauth_store()) + return HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) + + +@configure.service( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/{action}", + allow_access=True, +) +class OAuthPost(OAuthService): + async def __call__(self): + store = self.oauth_store() + action = self.request.matchdict.get("action", "") + if action == "register": + return await _register(self, store) + if action == "authorize": + return await _authorize(self, store) + if action == "token": + return await _token(self, store) + if action == "revoke": + return await _revoke(self, store) + return HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) + + +async def _register(service, store): + data = await service.request.json() + try: + client = make_client(data) + except HTTPBadRequest as exc: + return exc + await store.create_client(client) + return { + key: client[key] + for key in ( + "client_id", + "client_name", + "redirect_uris", + "grant_types", + "response_types", + "token_endpoint_auth_method", + ) + } + + +async def _authenticate_basic(username, password): + creds = {"type": "basic", "token": password, "id": username} + for validator in app_settings["auth_token_validators"]: + if validator.for_validators is not None and "basic" not in validator.for_validators: + continue + user = await validator().validate(creds) + if user is not None: + set_authenticated_user(user) + return user + + +def _html(body, status=200): + return Response(body=body.encode("utf-8"), status=status, content_type="text/html") + + +@lru_cache(maxsize=None) +def _template(name): + return Template((TEMPLATE_DIR / name).read_text(encoding="utf-8")) + + +@lru_cache(maxsize=None) +def _template_text(name): + return (TEMPLATE_DIR / name).read_text(encoding="utf-8") + + +@lru_cache(maxsize=None) +def _logo_data_uri(): + encoded = b64encode(BRAND_LOGO_PATH.read_bytes()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def _render_template(template_name, **context): + return _template(template_name).substitute(context) + + +def _oauth_page(title, heading, body, *, status=200, tone="default"): + return _html( + _render_template( + "base.html", + title=html_escape(title), + logo_src=_logo_data_uri(), + style=_template_text("oauth.css"), + tone=html_escape(tone), + heading=html_escape(heading), + body=body, + ), + status=status, + ) + + +def _hidden_inputs(params): + fields = ( + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "resource", + ) + html = [] + for field in fields: + value = params.get(field) + if value is None: + continue + values = value if isinstance(value, list) else [value] + for item in values: + html.append( + _render_template( + "hidden_input.html", + name=html_escape(field, quote=True), + value=html_escape(str(item), quote=True), + ) + ) + return "\n".join(html) + + +def _oauth_error_page(title, message, *, status): + return _oauth_page( + title, + title, + _render_template("error.html", message=html_escape(message)), + status=status, + tone="error", + ) + + +def _login_form(params, client): + client_name = html_escape(client.get("client_name") or client["client_id"]) + body = _render_template( + "login.html", + client_name=client_name, + client_id=html_escape(client["client_id"]), + redirect_uri=html_escape(params.get("redirect_uri", "")), + hidden_inputs=_hidden_inputs(params), + ) + return _oauth_page("Login to Guillotina", "Login required", body) + + +def _list_items(values, *, empty): + if not values: + return _render_template("plain_item.html", value=html_escape(empty)) + return "".join(_render_template("list_item.html", value=html_escape(str(value))) for value in values) + + +def _scope_items(scopes): + if not scopes: + return _render_template("plain_item.html", value="No extra scopes were requested.") + return "".join( + _render_template( + "scope_item.html", + scope=html_escape(str(scope)), + description=html_escape( + OAUTH_SCOPE_DESCRIPTIONS.get(scope, "Access requested by this OAuth client.") + ), + ) + for scope in scopes + ) + + +def _consent_form(params, client, scopes, resources, user): + raw_client_name = client.get("client_name") or client["client_id"] + client_name = html_escape(raw_client_name) + body = _render_template( + "consent.html", + client_name=client_name, + user_id=html_escape(str(user.id)), + client_id=html_escape(client["client_id"]), + redirect_uri=html_escape(params.get("redirect_uri", "")), + scope_items=_scope_items(scopes), + resource_items=_list_items(resources, empty="Default Guillotina container"), + hidden_inputs=_hidden_inputs(params), + ) + return _oauth_page("Authorize OAuth Client", f"Allow {raw_client_name}?", body) + + +async def _authorize(service, store): + params = dict(service.request.query) + if service.request.method == "POST": + content_type = service.request.headers.get("content-type", "") + if "application/json" in content_type: + data = await service.request.json() + else: + data = parse_form_encoded(await service.request.text()) + params.update(data) + client = await store.get_client(params.get("client_id")) + if client is None: + return _oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) + redirect_uri = params.get("redirect_uri") + if not redirect_uri_registered_for_client(client, redirect_uri): + return _oauth_error_page( + "Invalid redirect URI", + "The requested redirect URI is not allowed for this OAuth client.", + status=400, + ) + if params.get("response_type") != "code": + return HTTPFound( + redirect_with_params( + redirect_uri, {"error": "unsupported_response_type", "state": params.get("state")} + ) + ) + require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) + allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) + code_challenge = params.get("code_challenge") + code_challenge_method = params.get("code_challenge_method") + + if require_pkce and not code_challenge: + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) + if code_challenge and code_challenge_method not in allowed_methods: + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) + scopes = normalize_list(params.get("scope")) + supported_scopes = set(oauth_scopes_supported()) + if scopes and not set(scopes).issubset(supported_scopes): + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_scope", "state": params.get("state")}) + ) + try: + resources = validate_resource(service.request, service.context, params.get("resource")) + except HTTPBadRequest: + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_target", "state": params.get("state")}) + ) + user = get_authenticated_user() + newly_authenticated_token = None + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + if service.request.method == "POST" and params.get("username"): + user = await _authenticate_basic(params.get("username"), params.get("password", "")) + if user is None: + return _oauth_error_page( + "Login failed", + "The username or password could not be verified.", + status=401, + ) + from guillotina.auth import authenticate_user + + newly_authenticated_token, _ = authenticate_user(user.id) + else: + return _login_form(params, client) + response_obj = None + ckey = consent_key(user.id, client["client_id"], scopes, resources) + if not await store.has_consent(ckey) and params.get("decision") != "allow": + if params.get("decision") == "deny": + response_obj = HTTPFound( + redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")}) + ) + else: + response_obj = _consent_form(params, client, scopes, resources, user) + else: + if not await store.has_consent(ckey): + await store.create_consent( + ckey, + user_id=user.id, + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + raw_code = opaque_token("goc_") + await store.create_code( + raw_code=raw_code, + client_id=client["client_id"], + user_id=user.id, + redirect_uri=redirect_uri, + scope=scopes, + resource=resources, + code_challenge=params.get("code_challenge"), + ) + response_obj = HTTPFound( + redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")}) + ) + + if newly_authenticated_token is not None: + secure = "" + if str(getattr(service.request, "scheme", "") or "").lower() == "https": + secure = "; Secure" + response_obj.headers["Set-Cookie"] = ( + f"auth_token={newly_authenticated_token}; Path=/; HttpOnly; SameSite=Lax{secure}" + ) + return response_obj + + +async def _token(service, store): + data = parse_form_encoded(await service.request.text()) + grant_type = data.get("grant_type") + if grant_type == "authorization_code": + return await _authorization_code(service, store, data) + if grant_type == "refresh_token": + return await _refresh_token(service, store, data) + return HTTPBadRequest(content={"error": "unsupported_grant_type"}) + + +async def _authorization_code(service, store, data): + client = await store.get_client(data.get("client_id")) + code_raw = data.get("code", "") + code_hash_val = token_hash(code_raw) + record = await store.get_active_code(code_raw) + if record is None: + await store.revoke_refresh_tokens_by_auth_code(code_hash_val) + return HTTPBadRequest(content={"error": "invalid_grant"}) + if client is None or record["client_id"] != client["client_id"]: + return HTTPBadRequest(content={"error": "invalid_grant"}) + if record["redirect_uri"] != data.get("redirect_uri"): + return HTTPBadRequest(content={"error": "invalid_grant"}) + require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) + if record.get("code_challenge"): + if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): + return HTTPBadRequest(content={"error": "invalid_grant"}) + elif require_pkce: + return HTTPBadRequest(content={"error": "invalid_grant"}) + requested_resources = normalize_list(data.get("resource")) + if requested_resources and not set(requested_resources).issubset(set(record["resource"])): + return HTTPBadRequest(content={"error": "invalid_target"}) + resources = requested_resources or record["resource"] + + consumed = await store.consume_code(code_raw) + if consumed is None: + return HTTPBadRequest(content={"error": "invalid_grant"}) + record = consumed + + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=record["scope"], + ) + refresh_token = opaque_token("gor_") + await store.create_refresh_token( + raw_token=refresh_token, + client_id=client["client_id"], + user_id=record["user_id"], + scope=record["scope"], + resource=resources, + auth_code_hash=record["code_hash"], + ) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": refresh_token, + "scope": " ".join(record["scope"]), + } + + +async def _refresh_token(service, store, data): + refresh_raw = data.get("refresh_token", "") + client = await store.get_client(data.get("client_id")) + record = await store.get_valid_refresh(refresh_raw) + if record is None: + cand = await store.get_refresh_token(refresh_raw) + if cand is not None and cand.get("revoked_at"): + await store.revoke_refresh_family_for_reuse( + client_id=cand["client_id"], + user_id=cand["user_id"], + auth_code_hash=cand.get("auth_code_hash"), + ) + return HTTPBadRequest(content={"error": "invalid_grant"}) + if client is None or record["client_id"] != client["client_id"]: + return HTTPBadRequest(content={"error": "invalid_grant"}) + scopes = normalize_list(data.get("scope")) or record["scope"] + resources = normalize_list(data.get("resource")) or record["resource"] + if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): + return HTTPBadRequest(content={"error": "invalid_scope"}) + new_refresh = opaque_token("gor_") + rotated = await store.rotate_refresh_token( + old_refresh_raw=refresh_raw, + new_refresh_raw=new_refresh, + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + if not rotated: + return HTTPBadRequest(content={"error": "invalid_grant"}) + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=scopes, + ) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": new_refresh, + "scope": " ".join(scopes), + } + + +async def _revoke(service, store): + data = parse_form_encoded(await service.request.text()) + record = await store.get_refresh_token(data.get("token", "")) + if record is not None and record.get("client_id") == data.get("client_id"): + await store.delete_refresh_token(data.get("token", "")) + return {} diff --git a/guillotina/contrib/oauth/api/templates/base.html b/guillotina/contrib/oauth/api/templates/base.html new file mode 100644 index 000000000..864af602a --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/base.html @@ -0,0 +1,21 @@ + + + + + + $title + + + +
+
+ +

OAuth

+
+

$heading

+$body +
+ + diff --git a/guillotina/contrib/oauth/api/templates/consent.html b/guillotina/contrib/oauth/api/templates/consent.html new file mode 100644 index 000000000..de839b671 --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/consent.html @@ -0,0 +1,33 @@ +

+ $client_name is requesting access to this Guillotina container as + $user_id. Review the permissions before continuing. +

+
+
+
Application
+
$client_name
+
Client ID
+
$client_id
+
Redirect URI
+
$redirect_uri
+
+
+
+

Requested permissions

+
    $scope_items
+
+
+

Resources this client can access

+
    $resource_items
+
+

+ If you allow access, Guillotina will send an authorization code back to the redirect URI above. + Denying access returns an access_denied response to the application. +

+
+$hidden_inputs +
+ + +
+
diff --git a/guillotina/contrib/oauth/api/templates/error.html b/guillotina/contrib/oauth/api/templates/error.html new file mode 100644 index 000000000..dd337107c --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/error.html @@ -0,0 +1,4 @@ +
+

$message

+
+

Please go back to the application and start the OAuth flow again.

diff --git a/guillotina/contrib/oauth/api/templates/hidden_input.html b/guillotina/contrib/oauth/api/templates/hidden_input.html new file mode 100644 index 000000000..64472443e --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/hidden_input.html @@ -0,0 +1 @@ + diff --git a/guillotina/contrib/oauth/api/templates/list_item.html b/guillotina/contrib/oauth/api/templates/list_item.html new file mode 100644 index 000000000..a22574b53 --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/list_item.html @@ -0,0 +1 @@ +
  • $value
  • diff --git a/guillotina/contrib/oauth/api/templates/login.html b/guillotina/contrib/oauth/api/templates/login.html new file mode 100644 index 000000000..9676b3f01 --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/login.html @@ -0,0 +1,21 @@ +

    Sign in to continue authorizing $client_name.

    +
    +
    +
    Application
    +
    $client_name
    +
    Client ID
    +
    $client_id
    +
    Redirect URI
    +
    $redirect_uri
    +
    +
    +
    +$hidden_inputs + + + + +
    + +
    +
    diff --git a/guillotina/contrib/oauth/api/templates/oauth.css b/guillotina/contrib/oauth/api/templates/oauth.css new file mode 100644 index 000000000..6b91999a3 --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/oauth.css @@ -0,0 +1,151 @@ + :root { + color-scheme: light; + --bg: #ffffff; + --card: #ffffff; + --text: #231f20; + --muted: #6f6668; + --line: #eadfe0; + --primary: #ff4400; + --primary-text: #ffffff; + --danger: #b83000; + --danger-bg: #fff6f6; + --soft: #fbf7f7; + } + * { box-sizing: border-box; } + body { + min-height: 100vh; + margin: 0; + display: grid; + place-items: center; + padding: 32px 16px; + background: var(--bg); + color: var(--text); + font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .card { + width: min(100%, 640px); + padding: 40px; + border: 1px solid var(--line); + border-top: 4px solid var(--primary); + border-radius: 6px; + background: var(--card); + } + .brand { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0 0 28px; + } + .brand-logo { + display: block; + width: 150px; + height: auto; + } + .eyebrow { + margin: 0; + color: var(--primary); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + } + h1 { + margin: 0 0 16px; + font-size: clamp(26px, 5vw, 36px); + font-weight: 600; + line-height: 1.15; + } + p { margin: 0 0 16px; } + .muted { color: var(--muted); } + .panel { + margin: 18px 0; + padding: 18px 0; + border: 1px solid var(--line); + border-width: 1px 0; + background: transparent; + } + .danger { + padding: 16px; + border: 1px solid #f3c4c7; + border-radius: 4px; + background: var(--danger-bg); + color: var(--danger); + } + dl { + display: grid; + grid-template-columns: 140px 1fr; + gap: 10px 16px; + margin: 0; + } + dt { color: var(--muted); font-weight: 700; } + dd { margin: 0; overflow-wrap: anywhere; } + ul { margin: 12px 0 0; padding-left: 22px; } + li + li { margin-top: 8px; } + code { + padding: 1px 5px; + border-radius: 4px; + background: var(--soft); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; + } + label { + display: block; + margin: 16px 0 6px; + color: var(--muted); + font-weight: 700; + } + input { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--card); + color: var(--text); + font: inherit; + } + input:focus { + border-color: var(--primary); + outline: 2px solid #ffe0d6; + outline-offset: 0; + } + .actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; + } + button { + border: 0; + border-radius: 4px; + padding: 12px 18px; + background: var(--primary); + color: var(--primary-text); + cursor: pointer; + font: inherit; + font-weight: 700; + } + button.secondary { + border: 1px solid var(--line); + background: transparent; + color: var(--text); + } + button.danger { + border: 1px solid var(--primary); + background: transparent; + color: var(--primary); + } + button:hover { filter: brightness(0.96); } + @media (max-width: 560px) { + .card { padding: 24px; } + .brand { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .brand-logo { width: 132px; } + dl { grid-template-columns: 1fr; gap: 4px; } + dt:not(:first-child) { margin-top: 10px; } + .actions button { width: 100%; } + } diff --git a/guillotina/contrib/oauth/api/templates/plain_item.html b/guillotina/contrib/oauth/api/templates/plain_item.html new file mode 100644 index 000000000..ba0d9720c --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/plain_item.html @@ -0,0 +1 @@ +
  • $value
  • diff --git a/guillotina/contrib/oauth/api/templates/scope_item.html b/guillotina/contrib/oauth/api/templates/scope_item.html new file mode 100644 index 000000000..d6f2e3bc0 --- /dev/null +++ b/guillotina/contrib/oauth/api/templates/scope_item.html @@ -0,0 +1,4 @@ +
  • + $scope
    + $description +
  • diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/api/urls.py new file mode 100644 index 000000000..d096b500e --- /dev/null +++ b/guillotina/contrib/oauth/api/urls.py @@ -0,0 +1,61 @@ +from urllib.parse import urlparse + +from guillotina import app_settings +from guillotina.interfaces import IContainer +from guillotina.utils import get_current_container, get_full_content_path, get_url + + +def container_url(request, container): + issuer = app_settings.get("oauth", {}).get("issuer") + if issuer: + return issuer.rstrip("/") + if not IContainer.providedBy(container): + try: + container = get_current_container() + except (ValueError, AttributeError, RuntimeError, LookupError): + pass + if not IContainer.providedBy(container): + raise RuntimeError("OAuth container URL requires a container context") + return get_url(request, get_full_content_path(container)).rstrip("/") + + +def mcp_resource(request, container): + return f"{container_url(request, container)}/@mcp/protocol" + + +def issuer_path(request, container): + return urlparse(container_url(request, container)).path.lstrip("/") + + +def well_known_authorization_server_url(request, container): + return ( + f"{request.scheme}://{request.host}/.well-known/oauth-authorization-server/" + f"{issuer_path(request, container)}" + ) + + +def well_known_openid_configuration_url(request, container): + return ( + f"{request.scheme}://{request.host}/.well-known/openid-configuration/" + f"{issuer_path(request, container)}" + ) + + +def well_known_protected_resource_url(request, container): + parsed = urlparse(mcp_resource(request, container)) + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource/{parsed.path.lstrip('/')}" + + +def validate_resource(request, container, resources): + from guillotina.contrib.oauth.api.request import normalize_list, oauth_error + from guillotina.contrib.oauth.flow.resources import oauth_allowed_resources + + base = container_url(request, container) + allowed = oauth_allowed_resources(request, container) + if not resources: + return [base] + resources = normalize_list(resources) + for resource in resources: + if resource not in allowed: + oauth_error("invalid_target", "resource is not allowed") + return resources diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py new file mode 100644 index 000000000..b3a4df326 --- /dev/null +++ b/guillotina/contrib/oauth/api/well_known.py @@ -0,0 +1,40 @@ +from guillotina import task_vars +from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.interfaces import IContainer +from guillotina.response import HTTPNotFound +from guillotina.transactions import transaction +from guillotina.utils import get_database, get_registry + + +def _container_path_parts(path_value, *, allow_mcp_suffix=False): + parts = [part for part in path_value.strip("/").split("/") if part] + if len(parts) < 2: + raise HTTPNotFound(content={"reason": "Invalid path"}) + suffix = parts[2:] + if allow_mcp_suffix: + if suffix and suffix != ["@mcp", "protocol"]: + raise HTTPNotFound(content={"reason": "Invalid resource path"}) + elif suffix: + raise HTTPNotFound(content={"reason": "Invalid issuer path"}) + return parts[0], parts[1] + + +async def rfc_well_known_response(request, action, target_path, handlers): + if action == "oauth-protected-resource": + db_id, container_id = _container_path_parts(target_path, allow_mcp_suffix=True) + else: + db_id, container_id = _container_path_parts(target_path) + db = await get_database(db_id) + async with transaction(db=db): + root = await db.get_transaction_manager().get_root() + try: + container = await root.async_get(container_id) + except KeyError: + raise HTTPNotFound(content={"reason": "Container not found"}) + if not IContainer.providedBy(container): + raise HTTPNotFound(content={"reason": "Container not found"}) + task_vars.container.set(container) + task_vars.registry.set(None) + await get_registry(container) + get_oauth_store(container, require_installed=True) + return handlers[action](request, container) diff --git a/guillotina/contrib/oauth/auth/__init__.py b/guillotina/contrib/oauth/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/validators.py b/guillotina/contrib/oauth/auth/validators.py similarity index 96% rename from guillotina/contrib/oauth/validators.py rename to guillotina/contrib/oauth/auth/validators.py index e6c261ad9..b7a19310e 100644 --- a/guillotina/contrib/oauth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -2,7 +2,7 @@ from guillotina import app_settings, task_vars from guillotina.auth import find_user -from guillotina.contrib.oauth.utils import container_url +from guillotina.contrib.oauth.api.urls import container_url class OAuthJWTValidator: diff --git a/guillotina/contrib/oauth/content.py b/guillotina/contrib/oauth/content.py deleted file mode 100644 index 0e861cbb9..000000000 --- a/guillotina/contrib/oauth/content.py +++ /dev/null @@ -1,23 +0,0 @@ -"""OAuth data model helpers. - -OAuth state is stored in a reserved container annotation named ``.oauth`` with -four dictionaries: ``clients``, ``codes``, ``refresh_tokens`` and ``consents``. -Authorization codes and refresh tokens are never stored in plaintext; only HMAC -SHA-256 digests are persisted. -""" - -from guillotina.annotations import AnnotationData - - -OAUTH_STORAGE_KEY = ".oauth" - - -def new_oauth_storage(): - return AnnotationData( - { - "clients": {}, - "codes": {}, - "refresh_tokens": {}, - "consents": {}, - } - ) diff --git a/guillotina/contrib/oauth/flow/__init__.py b/guillotina/contrib/oauth/flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py new file mode 100644 index 000000000..0b75cc9e9 --- /dev/null +++ b/guillotina/contrib/oauth/flow/clients.py @@ -0,0 +1,68 @@ +from urllib.parse import urlencode, urlparse +from uuid import uuid4 + +from guillotina.contrib.oauth.api.request import normalize_list, oauth_error +from guillotina.contrib.oauth.flow.tokens import utcnow + + +def validate_redirect_uri(uri): + if not uri: + return False + if "*" in uri: + return False + parsed = urlparse(uri) + if parsed.scheme in ("javascript", "data"): + return False + if not parsed.netloc or not parsed.path.startswith("/"): + return False + if parsed.scheme in ("http", "https"): + return True + return parsed.scheme.isalpha() + + +def is_native_redirect_uri(uri): + parsed = urlparse(uri) + return parsed.scheme not in ("http", "https") + + +def redirect_uri_registered_for_client(client, redirect_uri): + """Return True only if redirect_uri was registered for this client (no side effects). + + Native redirects must be included in the client's dynamic registration request. + """ + redirect_uris = client.get("redirect_uris") or [] + return redirect_uri in redirect_uris + + +def make_client(data): + if data.get("client_id"): + oauth_error("invalid_request", "client_id is server-issued") + redirect_uris = data.get("redirect_uris") or [] + if not redirect_uris or not isinstance(redirect_uris, list): + oauth_error("invalid_request", "redirect_uris is required") + if any(not validate_redirect_uri(uri) for uri in redirect_uris): + oauth_error("invalid_request", "unsafe redirect_uri") + method = data.get("token_endpoint_auth_method", "none") + if method != "none": + oauth_error("unsupported_token_endpoint_auth_method") + now = utcnow().isoformat() + return { + "client_id": uuid4().hex, + "client_name": data.get("client_name") or "OAuth Client", + "redirect_uris": redirect_uris, + "grant_types": data.get("grant_types") or ["authorization_code", "refresh_token"], + "response_types": data.get("response_types") or ["code"], + "token_endpoint_auth_method": "none", + "scope": " ".join(normalize_list(data.get("scope"))), + "created_at": now, + "updated_at": now, + } + + +def consent_key(user_id, client_id, scopes, resources): + return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) + + +def redirect_with_params(uri, params): + sep = "&" if "?" in uri else "?" + return f"{uri}{sep}{urlencode({k: v for k, v in params.items() if v is not None})}" diff --git a/guillotina/contrib/oauth/flow/pkce.py b/guillotina/contrib/oauth/flow/pkce.py new file mode 100644 index 000000000..5d5cabdc7 --- /dev/null +++ b/guillotina/contrib/oauth/flow/pkce.py @@ -0,0 +1,30 @@ +import base64 +import hashlib +import re +from typing import Optional + + +_VERIFIER_CHARS = re.compile(r"^[A-Za-z0-9\-._~]{43,128}$") + + +def pkce_verifier_valid(verifier: Optional[str]) -> bool: + """Return True when ``code_verifier`` conforms to RFC 7636.""" + + if not verifier or not isinstance(verifier, str): + return False + return _VERIFIER_CHARS.fullmatch(verifier) is not None + + +def s256_challenge_from_bytes(verifier: bytes) -> str: + digest = hashlib.sha256(verifier).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def s256_challenge(verifier: str) -> str: + return s256_challenge_from_bytes(verifier.encode("ascii")) + + +def verify_s256(verifier: str, challenge: str) -> bool: + if not pkce_verifier_valid(verifier): + return False + return s256_challenge(verifier) == challenge diff --git a/guillotina/contrib/oauth/flow/resources.py b/guillotina/contrib/oauth/flow/resources.py new file mode 100644 index 000000000..cc0e15835 --- /dev/null +++ b/guillotina/contrib/oauth/flow/resources.py @@ -0,0 +1,55 @@ +"""Extensible OAuth `resource` identifiers (RFC 8707 style) for this authorization server. + +Each resolver is a callable ``(request, container) -> Iterable[str]`` of absolute +resource URIs allowed in authorize/token requests. + +The oauth contrib registers the container issuer URL by default. Other packages +(for example MCP) register additional URIs via :func:`register_oauth_resource_resolver`. +""" + +from __future__ import annotations + +from typing import Callable, FrozenSet, Iterable, List + + +ResourceResolver = Callable[..., Iterable[str]] + +_resource_resolvers: List[ResourceResolver] = [] +_default_registered = False + + +def register_oauth_resource_resolver(resolver: ResourceResolver) -> None: + if resolver not in _resource_resolvers: + _resource_resolvers.append(resolver) + + +def _default_container_resolver(request, container): + from guillotina.contrib.oauth.api.urls import container_url + + return {container_url(request, container)} + + +def ensure_default_oauth_resources_registered() -> None: + global _default_registered + if _default_registered: + return + register_oauth_resource_resolver(_default_container_resolver) + _default_registered = True + + +def oauth_allowed_resources(request, container) -> FrozenSet[str]: + from guillotina import app_settings as _apps + + ensure_default_oauth_resources_registered() + applications = set(_apps.get("applications") or []) + out: set = set() + for resolver in _resource_resolvers: + if ( + getattr(resolver, "_oauth_resource_source", None) == "mcp" + and "guillotina.contrib.mcp" not in applications + ): + continue + urls = resolver(request, container) + if urls: + out.update(urls) + return frozenset(out) diff --git a/guillotina/contrib/oauth/flow/scopes.py b/guillotina/contrib/oauth/flow/scopes.py new file mode 100644 index 000000000..d1891c8e0 --- /dev/null +++ b/guillotina/contrib/oauth/flow/scopes.py @@ -0,0 +1,15 @@ +from guillotina import app_settings + + +OAUTH_DEFAULT_SCOPE = "guillotina:access" +OAUTH_SCOPES_SUPPORTED = (OAUTH_DEFAULT_SCOPE,) +OAUTH_SCOPE_DESCRIPTIONS = { + OAUTH_DEFAULT_SCOPE: "Access Guillotina on behalf of the authenticated user.", +} + + +def oauth_scopes_supported(): + configured = app_settings.get("oauth", {}).get("scopes_supported") + if configured is None: + return list(OAUTH_SCOPES_SUPPORTED) + return list(configured) diff --git a/guillotina/contrib/oauth/tokens.py b/guillotina/contrib/oauth/flow/tokens.py similarity index 100% rename from guillotina/contrib/oauth/tokens.py rename to guillotina/contrib/oauth/flow/tokens.py diff --git a/guillotina/contrib/oauth/install.py b/guillotina/contrib/oauth/install.py index 6be61945d..b4d3c57d3 100644 --- a/guillotina/contrib/oauth/install.py +++ b/guillotina/contrib/oauth/install.py @@ -1,23 +1,15 @@ from guillotina import configure from guillotina.addons import Addon -from guillotina.contrib.oauth.content import OAUTH_STORAGE_KEY, new_oauth_storage -from guillotina.contrib.oauth.interfaces import IOAuthSettings -from guillotina.interfaces import IAnnotations -from guillotina.utils import get_registry +from guillotina.contrib.oauth.storage.access import get_oauth_store @configure.addon(name="oauth", title="Guillotina OAuth authorization server") class OAuthAddon(Addon): @classmethod async def install(cls, container, request): - registry = await get_registry() - registry.register_interface(IOAuthSettings) - annotations = IAnnotations(container) - if await annotations.async_get(OAUTH_STORAGE_KEY) is None: - await annotations.async_set(OAUTH_STORAGE_KEY, new_oauth_storage()) + pass @classmethod async def uninstall(cls, container, request): - annotations = IAnnotations(container) - if await annotations.async_get(OAUTH_STORAGE_KEY) is not None: - await annotations.async_del(OAUTH_STORAGE_KEY) + store = get_oauth_store(container, require_installed=False) + await store.delete_container_data() diff --git a/guillotina/contrib/oauth/integrations/__init__.py b/guillotina/contrib/oauth/integrations/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/guillotina/contrib/oauth/integrations/__init__.py @@ -0,0 +1 @@ + diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py new file mode 100644 index 000000000..407db280e --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -0,0 +1,54 @@ +from zope.interface import implementer + +from guillotina import app_settings, configure +from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy +from guillotina.contrib.oauth.api.services import register_well_known_handler +from guillotina.contrib.oauth.api.urls import container_url, mcp_resource, well_known_protected_resource_url +from guillotina.contrib.oauth.flow.resources import register_oauth_resource_resolver +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported + + +def _mcp_protocol_resource_resolver(request, container): + return {mcp_resource(request, container)} + + +_mcp_protocol_resource_resolver._oauth_resource_source = "mcp" +register_oauth_resource_resolver(_mcp_protocol_resource_resolver) + + +def _protected_resource_metadata(request, context): + issuer = container_url(request, context) + return { + "resource": mcp_resource(request, context), + "authorization_servers": [issuer], + "scopes_supported": oauth_scopes_supported(), + } + + +register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) + + +@configure.utility(provides=IMCPAuthPolicy) +@implementer(IMCPAuthPolicy) +class OAuthMCPAuthPolicy: + def is_enabled(self, request, context): + app = getattr(getattr(request, "application", None), "app", None) + settings = getattr(app, "settings", None) or app_settings + applications = set(settings.get("applications") or []) + return "guillotina.contrib.oauth" in applications and "guillotina.contrib.mcp" in applications + + def unauthorized_headers(self, request, context): + metadata = well_known_protected_resource_url(request, context) + return { + "WWW-Authenticate": ( + 'Bearer realm="guillotina-mcp", ' + f'resource_metadata="{metadata}", ' + f'scope="{OAUTH_DEFAULT_SCOPE}"' + ) + } + + def is_authorized(self, request, context): + oauth = getattr(request, "oauth", None) + if oauth is None: + return True + return mcp_resource(request, context) in oauth.get("resources", set()) diff --git a/guillotina/contrib/oauth/interfaces.py b/guillotina/contrib/oauth/interfaces.py index 22bf8bc20..0fc635b87 100644 --- a/guillotina/contrib/oauth/interfaces.py +++ b/guillotina/contrib/oauth/interfaces.py @@ -1,5 +1,5 @@ from zope.interface import Interface -class IOAuthSettings(Interface): - """OAuth contrib registry settings marker.""" +class IOAuthStorageUtility(Interface): + """Utility that initializes OAuth storage backends and runs periodic cleanup.""" diff --git a/guillotina/contrib/oauth/permissions.py b/guillotina/contrib/oauth/permissions.py deleted file mode 100644 index 9f40f04c6..000000000 --- a/guillotina/contrib/oauth/permissions.py +++ /dev/null @@ -1,10 +0,0 @@ -from guillotina import configure - - -configure.permission("guillotina.OAuthManageClients", "Manage OAuth clients") -configure.permission("guillotina.OAuthAuthorize", "Authorize OAuth clients") -configure.permission("guillotina.OAuthUseToken", "Use OAuth token endpoint") - -configure.grant(permission="guillotina.OAuthManageClients", role="guillotina.Manager") -configure.grant(permission="guillotina.OAuthManageClients", role="guillotina.ContainerAdmin") -configure.grant(permission="guillotina.OAuthAuthorize", role="guillotina.Authenticated") diff --git a/guillotina/contrib/oauth/pkce.py b/guillotina/contrib/oauth/pkce.py deleted file mode 100644 index 6b40e6fdc..000000000 --- a/guillotina/contrib/oauth/pkce.py +++ /dev/null @@ -1,11 +0,0 @@ -import base64 -import hashlib - - -def s256_challenge(verifier: str) -> str: - digest = hashlib.sha256(verifier.encode("ascii")).digest() - return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - - -def verify_s256(verifier: str, challenge: str) -> bool: - return s256_challenge(verifier) == challenge diff --git a/guillotina/contrib/oauth/services.py b/guillotina/contrib/oauth/services.py deleted file mode 100644 index 10f340ddc..000000000 --- a/guillotina/contrib/oauth/services.py +++ /dev/null @@ -1,322 +0,0 @@ -from datetime import timedelta - -from guillotina import app_settings, configure -from guillotina.api.service import Service -from guillotina.auth.utils import set_authenticated_user -from guillotina.contrib.oauth.pkce import verify_s256 -from guillotina.contrib.oauth.tokens import issue_access_token, opaque_token, token_hash, utcnow -from guillotina.contrib.oauth.utils import ( - consent_key, - container_url, - create_code, - get_storage, - get_valid_code, - get_valid_refresh, - make_client, - normalize_list, - oauth_error, - parse_form_encoded, - redirect_with_params, - register_changed, - validate_resource, -) -from guillotina.interfaces import IContainer -from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, Response -from guillotina.utils import get_authenticated_user - - -OAUTH_SCOPES = ["guillotina:mcp.read", "guillotina:mcp.search", "guillotina:mcp.content.read"] -WELL_KNOWN_HANDLERS = {} - - -def register_well_known_handler(name, handler): - WELL_KNOWN_HANDLERS[name] = handler - - -def _metadata(request, container): - issuer = container_url(request, container) - return { - "issuer": issuer, - "authorization_endpoint": f"{issuer}/oauth/authorize", - "token_endpoint": f"{issuer}/oauth/token", - "registration_endpoint": f"{issuer}/oauth/register", - "revocation_endpoint": f"{issuer}/oauth/revoke", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["none"], - "scopes_supported": app_settings.get("oauth", {}).get("scopes_supported", OAUTH_SCOPES), - } - - -register_well_known_handler("oauth-authorization-server", _metadata) -register_well_known_handler("openid-configuration", _metadata) - - -class OAuthService(Service): - async def storage(self): - return await get_storage(self.context) - - -@configure.service( - context=IContainer, - method="GET", - permission="guillotina.Public", - name=".well-known/{action}", - allow_access=True, -) -class OAuthWellKnown(OAuthService): - async def __call__(self): - await self.storage() - action = self.request.matchdict.get("action", "") - if action in WELL_KNOWN_HANDLERS: - return WELL_KNOWN_HANDLERS[action](self.request, self.context) - raise HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) - - -@configure.service( - context=IContainer, - method="GET", - permission="guillotina.Public", - name="oauth/{action}", - allow_access=True, -) -class OAuthGet(OAuthService): - async def __call__(self): - action = self.request.matchdict.get("action", "") - if action == "authorize": - return await _authorize(self, await self.storage()) - raise HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) - - -@configure.service( - context=IContainer, - method="POST", - permission="guillotina.Public", - name="oauth/{action}", - allow_access=True, -) -class OAuthPost(OAuthService): - async def __call__(self): - storage = await self.storage() - action = self.request.matchdict.get("action", "") - if action == "register": - return await _register(self, storage) - if action == "authorize": - return await _authorize(self, storage) - if action == "token": - return await _token(self, storage) - if action == "revoke": - return await _revoke(self, storage) - raise HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) - - -async def _register(service, storage): - data = await service.request.json() - client = make_client(data) - if client["client_id"] in storage["clients"]: - oauth_error("invalid_request", "client_id already exists") - storage["clients"][client["client_id"]] = client - register_changed(storage) - return { - key: client[key] - for key in ( - "client_id", - "client_name", - "redirect_uris", - "grant_types", - "response_types", - "token_endpoint_auth_method", - ) - } - - -async def _authenticate_basic(username, password): - creds = {"type": "basic", "token": password, "id": username} - for validator in app_settings["auth_token_validators"]: - if validator.for_validators is not None and "basic" not in validator.for_validators: - continue - user = await validator().validate(creds) - if user is not None: - set_authenticated_user(user) - return user - - -def _html(body, status=200): - return Response(body=body.encode("utf-8"), status=status, content_type="text/html") - - -async def _authorize(service, storage): - params = dict(service.request.query) - if service.request.method == "POST": - content_type = service.request.headers.get("content-type", "") - if "application/json" in content_type: - data = await service.request.json() - else: - data = parse_form_encoded(await service.request.text()) - params.update(data) - client = storage["clients"].get(params.get("client_id")) - if client is None: - return _html("Unknown OAuth client", status=400) - redirect_uri = params.get("redirect_uri") - if redirect_uri not in client["redirect_uris"]: - return _html("Invalid redirect_uri", status=400) - if params.get("response_type") != "code": - raise HTTPBadRequest(content={"error": "unsupported_response_type"}) - if not params.get("code_challenge"): - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) - if params.get("code_challenge_method") != "S256": - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) - scopes = normalize_list(params.get("scope")) - resources = validate_resource(service.request, service.context, params.get("resource")) - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - if service.request.method == "POST" and params.get("username"): - user = await _authenticate_basic(params.get("username"), params.get("password", "")) - if user is None: - return _html("Login failed", status=401) - else: - return _html( - "
    Username Password " - "
    " - ) - ckey = consent_key(user.id, client["client_id"], scopes, resources) - if ckey not in storage["consents"] and params.get("decision") != "allow": - if params.get("decision") == "deny": - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")}) - ) - return _html( - "

    Allow {}

    " - "
    ".format(client["client_name"]) - ) - if ckey not in storage["consents"]: - storage["consents"][ckey] = { - "user_id": user.id, - "client_id": client["client_id"], - "scope": scopes, - "resource": resources, - "granted_at": utcnow().isoformat(), - } - raw_code = opaque_token("goc_") - create_code( - storage, - raw_code=raw_code, - client_id=client["client_id"], - user_id=user.id, - redirect_uri=redirect_uri, - scope=scopes, - resource=resources, - code_challenge=params["code_challenge"], - ) - register_changed(storage) - return HTTPFound(redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")})) - - -async def _token(service, storage): - data = parse_form_encoded(await service.request.text()) - grant_type = data.get("grant_type") - if grant_type == "authorization_code": - return _authorization_code(service, storage, data) - if grant_type == "refresh_token": - return _refresh_token(service, storage, data) - raise HTTPBadRequest(content={"error": "unsupported_grant_type"}) - - -def _authorization_code(service, storage, data): - client = storage["clients"].get(data.get("client_id")) - record = get_valid_code(storage, data.get("code", "")) - if client is None or record is None: - raise HTTPBadRequest(content={"error": "invalid_grant"}) - if record["client_id"] != client["client_id"] or record["redirect_uri"] != data.get("redirect_uri"): - raise HTTPBadRequest(content={"error": "invalid_grant"}) - if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): - raise HTTPBadRequest(content={"error": "invalid_grant"}) - requested_resources = normalize_list(data.get("resource")) - if requested_resources and not set(requested_resources).issubset(set(record["resource"])): - raise HTTPBadRequest(content={"error": "invalid_target"}) - resources = requested_resources or record["resource"] - record["used_at"] = utcnow().isoformat() - access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=record["scope"], - ) - refresh_token = opaque_token("gor_") - now = utcnow() - storage["refresh_tokens"][token_hash(refresh_token)] = { - "token_hash": token_hash(refresh_token), - "client_id": client["client_id"], - "user_id": record["user_id"], - "scope": record["scope"], - "resource": resources, - "expires_at": ( - now + timedelta(seconds=app_settings["oauth"].get("refresh_token_ttl", 2592000)) - ).isoformat(), - "revoked_at": None, - "rotated_from": None, - "created_at": now.isoformat(), - "last_used_at": now.isoformat(), - } - register_changed(storage) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": refresh_token, - "scope": " ".join(record["scope"]), - } - - -def _refresh_token(service, storage, data): - record = get_valid_refresh(storage, data.get("refresh_token", "")) - client = storage["clients"].get(data.get("client_id")) - if record is None or client is None or record["client_id"] != client["client_id"]: - raise HTTPBadRequest(content={"error": "invalid_grant"}) - scopes = normalize_list(data.get("scope")) or record["scope"] - resources = normalize_list(data.get("resource")) or record["resource"] - if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): - raise HTTPBadRequest(content={"error": "invalid_scope"}) - record["revoked_at"] = utcnow().isoformat() - new_refresh = opaque_token("gor_") - now = utcnow() - storage["refresh_tokens"][token_hash(new_refresh)] = { - **record, - "token_hash": token_hash(new_refresh), - "scope": scopes, - "resource": resources, - "revoked_at": None, - "rotated_from": record["token_hash"], - "created_at": now.isoformat(), - "last_used_at": now.isoformat(), - } - access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=scopes, - ) - register_changed(storage) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": new_refresh, - "scope": " ".join(scopes), - } - - -async def _revoke(service, storage): - data = parse_form_encoded(await service.request.text()) - record = storage["refresh_tokens"].get(token_hash(data.get("token", ""))) - if record is not None and record.get("client_id") == data.get("client_id"): - record["revoked_at"] = utcnow().isoformat() - register_changed(storage) - return {} diff --git a/guillotina/contrib/oauth/storage/__init__.py b/guillotina/contrib/oauth/storage/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/storage/access.py b/guillotina/contrib/oauth/storage/access.py new file mode 100644 index 000000000..0a9a26f55 --- /dev/null +++ b/guillotina/contrib/oauth/storage/access.py @@ -0,0 +1,41 @@ +from guillotina import task_vars +from guillotina.db.interfaces import IPostgresStorage +from guillotina.interfaces import IAddons +from guillotina.response import HTTPPreconditionFailed +from guillotina.transactions import get_transaction + + +def oauth_container_db_key(container): + txn = get_transaction() + db_id = None + if txn is not None: + db_id = getattr(getattr(txn, "manager", None), "db_id", None) + if not db_id: + db = task_vars.db.get(None) + db_id = getattr(db, "id", None) or getattr(db, "__db_id__", None) + if not db_id: + raise RuntimeError("OAuth storage requires an active database context") + return f"{db_id}/{container.id}" + + +def is_installed(container): + registry = task_vars.registry.get(None) + if registry is None: + return False + try: + return "oauth" in registry.for_interface(IAddons)["enabled"] + except Exception: + return False + + +def get_oauth_store(container, *, require_installed=True): + if require_installed and not is_installed(container): + raise HTTPPreconditionFailed(content={"reason": "OAuth addon is not installed"}) + txn = get_transaction() + if txn is None or not IPostgresStorage.providedBy(txn.storage): + raise RuntimeError( + "OAuth storage requires PostgreSQL but the active database storage is not PostgreSQL" + ) + from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + + return OAuthRepository(oauth_container_db_key(container)) diff --git a/guillotina/contrib/oauth/storage/interfaces.py b/guillotina/contrib/oauth/storage/interfaces.py new file mode 100644 index 000000000..5d003ac6a --- /dev/null +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -0,0 +1,77 @@ +from zope.interface import Interface + + +class IOAuthStore(Interface): + """Persistent OAuth state for a single container. + + Implementations must scope all data by the database-qualified container key + passed to ``__init__``. + All methods are async. Record dict shapes returned by read methods: + + - **client**: ``client_id``, ``client_name``, ``redirect_uris``, ``grant_types``, + ``response_types``, ``token_endpoint_auth_method``, ``scope``, ``created_at``, + ``updated_at`` + - **code**: ``code_hash``, ``client_id``, ``user_id``, ``redirect_uri``, ``scope``, + ``resource``, ``code_challenge``, ``code_challenge_method``, ``expires_at``, + ``created_at`` + - **refresh**: ``token_hash``, ``client_id``, ``user_id``, ``scope``, ``resource``, + ``expires_at``, ``rotated_from``, ``auth_code_hash``, ``created_at``, ``last_used_at``, + optional ``revoked_at``, ``replaced_by`` + """ + + def get_client(self, client_id): + """Return a client record or ``None``.""" + + def create_client(self, client): + """Create a dynamically registered client.""" + + def has_consent(self, consent_key): + """Return whether the user already granted consent for this key.""" + + def create_consent(self, consent_key, user_id, client_id, scope, resource): + """Persist a consent decision.""" + + def create_code(self, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): + """Store a new authorization code and return its record.""" + + def get_active_code(self, code): + """Return a valid, unexpired authorization code record or ``None``.""" + + def consume_code(self, code): + """Atomically return and delete an unexpired code, or ``None`` if unavailable.""" + + def delete_code(self, code_hash_val): + """Remove an authorization code after use or cleanup.""" + + def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): + """Revoke refresh tokens issued from a code; return ``True`` if any were removed.""" + + def create_refresh_token( + self, + raw_token, + client_id, + user_id, + scope, + resource, + auth_code_hash=None, + rotated_from=None, + ): + """Store a refresh token and return the opaque token string.""" + + def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client_id, scope, resource): + """Mark ``old_refresh_raw`` revoked and persist ``new_refresh_raw``. Return ``False`` if not rotatable.""" + + def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): + """Revoke all refresh tokens in the reuse-compromise rotation family.""" + + def get_valid_refresh(self, token): + """Return a valid, unexpired refresh token record or ``None``.""" + + def get_refresh_token(self, token): + """Return a refresh token record regardless of expiry, or ``None``.""" + + def delete_refresh_token(self, token): + """Remove a refresh token.""" + + def delete_container_data(self): + """Remove all OAuth state for this container (addon uninstall).""" diff --git a/guillotina/contrib/oauth/storage/pg/__init__.py b/guillotina/contrib/oauth/storage/pg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py new file mode 100644 index 000000000..f127225e1 --- /dev/null +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -0,0 +1,462 @@ +import json +from datetime import datetime, timedelta, timezone + +from zope.interface import implementer + +from guillotina import app_settings +from guillotina.contrib.oauth.flow.tokens import token_hash, utcnow +from guillotina.contrib.oauth.storage.interfaces import IOAuthStore +from guillotina.exceptions import TransactionNotFound +from guillotina.transactions import get_transaction + + +def _iso(value): + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _jsonb(value): + return json.dumps(value) + + +def _load_jsonb(value): + if value is None: + return [] + if isinstance(value, str): + return json.loads(value) + return list(value) + + +def _aware(value): + if value is None: + return None + if isinstance(value, datetime) and value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def _parse_dt(value): + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + return value + + +def _row_to_client(row): + if row is None: + return None + return { + "client_id": row["client_id"], + "client_name": row["client_name"], + "redirect_uris": _load_jsonb(row["redirect_uris"]), + "grant_types": _load_jsonb(row["grant_types"]), + "response_types": _load_jsonb(row["response_types"]), + "token_endpoint_auth_method": "none", + "scope": row["scope"] or "", + "created_at": _iso(row["created_at"]), + "updated_at": _iso(row["updated_at"]), + } + + +def _row_to_code(row): + if row is None: + return None + return { + "code_hash": row["code_hash"], + "client_id": row["client_id"], + "user_id": row["user_id"], + "redirect_uri": row["redirect_uri"], + "scope": _load_jsonb(row["scope"]), + "resource": _load_jsonb(row["resource"]), + "code_challenge": row["code_challenge"], + "code_challenge_method": "S256", + "expires_at": _iso(row["expires_at"]), + "created_at": _iso(row["created_at"]), + } + + +def _row_to_refresh(row): + if row is None: + return None + return { + "token_hash": row["token_hash"], + "client_id": row["client_id"], + "user_id": row["user_id"], + "scope": _load_jsonb(row["scope"]), + "resource": _load_jsonb(row["resource"]), + "expires_at": _iso(row["expires_at"]), + "rotated_from": row["rotated_from"], + "auth_code_hash": row["auth_code_hash"], + "created_at": _iso(row["created_at"]), + "last_used_at": _iso(row["last_used_at"]), + "revoked_at": _iso(row["revoked_at"]), + "replaced_by": row["replaced_by"], + } + + +@implementer(IOAuthStore) +class OAuthRepository: + def __init__(self, container_db_key: str): + self.container_db_key = container_db_key + + async def _connection(self): + txn = get_transaction() + if txn is None: + raise TransactionNotFound() + conn = await txn.get_connection() + return txn, conn + + async def get_client(self, client_id): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + SELECT client_id, client_name, redirect_uris, grant_types, response_types, + scope, created_at, updated_at + FROM oauth_clients + WHERE container_db_key = $1 AND client_id = $2 + """, + self.container_db_key, + client_id, + ) + return _row_to_client(row) + + async def create_client(self, client): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + INSERT INTO oauth_clients ( + container_db_key, client_id, client_name, redirect_uris, grant_types, + response_types, scope, created_at, updated_at + ) VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6::jsonb, $7, $8, $9) + """, + self.container_db_key, + client["client_id"], + client["client_name"], + _jsonb(client["redirect_uris"]), + _jsonb(client["grant_types"]), + _jsonb(client["response_types"]), + client["scope"], + _parse_dt(client["created_at"]), + _parse_dt(client["updated_at"]), + ) + + async def has_consent(self, consent_key): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + SELECT 1 FROM oauth_consents + WHERE container_db_key = $1 AND consent_key = $2 + """, + self.container_db_key, + consent_key, + ) + return row is not None + + async def create_consent(self, consent_key, *, user_id, client_id, scope, resource): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + INSERT INTO oauth_consents ( + container_db_key, consent_key, user_id, client_id, scope, resource + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (container_db_key, consent_key) DO NOTHING + """, + self.container_db_key, + consent_key, + user_id, + client_id, + _jsonb(list(scope)), + _jsonb(list(resource)), + ) + + async def create_code( + self, + *, + raw_code, + client_id, + user_id, + redirect_uri, + scope, + resource, + code_challenge, + ): + now = utcnow() + ttl = app_settings["oauth"].get("authorization_code_ttl", 600) + code_hash_val = token_hash(raw_code) + expires_at = _aware(now + timedelta(seconds=ttl)) + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + INSERT INTO oauth_authorization_codes ( + container_db_key, code_hash, client_id, user_id, redirect_uri, + scope, resource, code_challenge, expires_at, created_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10) + """, + self.container_db_key, + code_hash_val, + client_id, + user_id, + redirect_uri, + _jsonb(list(scope)), + _jsonb(list(resource)), + code_challenge, + expires_at, + _aware(now), + ) + return _row_to_code( + { + "code_hash": code_hash_val, + "client_id": client_id, + "user_id": user_id, + "redirect_uri": redirect_uri, + "scope": list(scope), + "resource": list(resource), + "code_challenge": code_challenge, + "expires_at": expires_at, + "created_at": now, + } + ) + + async def get_active_code(self, code): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + SELECT code_hash, client_id, user_id, redirect_uri, scope, resource, + code_challenge, expires_at, created_at + FROM oauth_authorization_codes + WHERE container_db_key = $1 + AND code_hash = $2 + AND expires_at > $3 + """, + self.container_db_key, + token_hash(code), + _aware(utcnow()), + ) + return _row_to_code(row) + + async def consume_code(self, code): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + DELETE FROM oauth_authorization_codes + WHERE container_db_key = $1 + AND code_hash = $2 + AND expires_at > $3 + RETURNING code_hash, client_id, user_id, redirect_uri, + scope, resource, code_challenge, expires_at, created_at + """, + self.container_db_key, + token_hash(code), + _aware(utcnow()), + ) + return _row_to_code(row) + + async def delete_code(self, code_hash_val): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + DELETE FROM oauth_authorization_codes + WHERE container_db_key = $1 AND code_hash = $2 + """, + self.container_db_key, + code_hash_val, + ) + + async def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): + txn, conn = await self._connection() + async with txn.lock: + result = await conn.execute( + """ + DELETE FROM oauth_refresh_tokens + WHERE container_db_key = $1 AND auth_code_hash = $2 + """, + self.container_db_key, + auth_code_hash, + ) + return int(result.split()[-1]) > 0 + + async def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + DELETE FROM oauth_refresh_tokens + WHERE container_db_key = $1 + AND client_id = $2 + AND user_id = $3 + AND ( + ($4::text IS NULL AND auth_code_hash IS NULL) + OR auth_code_hash = $4 + ) + """, + self.container_db_key, + client_id, + user_id, + auth_code_hash, + ) + + async def create_refresh_token( + self, + *, + raw_token, + client_id, + user_id, + scope, + resource, + auth_code_hash=None, + rotated_from=None, + ): + now = utcnow() + ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) + hash_val = token_hash(raw_token) + expires_at = _aware(now + timedelta(seconds=ttl)) + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + INSERT INTO oauth_refresh_tokens ( + container_db_key, token_hash, client_id, user_id, scope, resource, + expires_at, rotated_from, auth_code_hash, created_at, last_used_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) + """, + self.container_db_key, + hash_val, + client_id, + user_id, + _jsonb(list(scope)), + _jsonb(list(resource)), + expires_at, + rotated_from, + auth_code_hash, + _aware(now), + _aware(now), + ) + return raw_token + + async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client_id, scope, resource): + oh = token_hash(old_refresh_raw) + nh = token_hash(new_refresh_raw) + now = utcnow() + ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) + new_expires = _aware(now + timedelta(seconds=ttl)) + txn, conn = await self._connection() + async with txn.lock: + upd = await conn.fetchrow( + """ + UPDATE oauth_refresh_tokens + SET revoked_at = now(), replaced_by = $4 + WHERE container_db_key = $1 + AND token_hash = $2 + AND client_id = $3 + AND revoked_at IS NULL + AND expires_at > $5 + RETURNING user_id, auth_code_hash + """, + self.container_db_key, + oh, + client_id, + nh, + _aware(now), + ) + if upd is None: + return False + await conn.execute( + """ + INSERT INTO oauth_refresh_tokens ( + container_db_key, token_hash, client_id, user_id, scope, resource, + expires_at, rotated_from, auth_code_hash, created_at, last_used_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) + """, + self.container_db_key, + nh, + client_id, + upd["user_id"], + _jsonb(list(scope)), + _jsonb(list(resource)), + new_expires, + oh, + upd["auth_code_hash"], + _aware(now), + _aware(now), + ) + return True + + async def get_valid_refresh(self, token): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + SELECT token_hash, client_id, user_id, scope, resource, expires_at, + rotated_from, auth_code_hash, created_at, last_used_at, + revoked_at, replaced_by + FROM oauth_refresh_tokens + WHERE container_db_key = $1 + AND token_hash = $2 + AND expires_at > $3 + AND revoked_at IS NULL + """, + self.container_db_key, + token_hash(token), + _aware(utcnow()), + ) + return _row_to_refresh(row) + + async def get_refresh_token(self, token): + txn, conn = await self._connection() + async with txn.lock: + row = await conn.fetchrow( + """ + SELECT token_hash, client_id, user_id, scope, resource, expires_at, + rotated_from, auth_code_hash, created_at, last_used_at, + revoked_at, replaced_by + FROM oauth_refresh_tokens + WHERE container_db_key = $1 AND token_hash = $2 + """, + self.container_db_key, + token_hash(token), + ) + return _row_to_refresh(row) + + async def delete_refresh_token(self, token): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + DELETE FROM oauth_refresh_tokens + WHERE container_db_key = $1 AND token_hash = $2 + """, + self.container_db_key, + token_hash(token), + ) + + async def delete_container_data(self): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + "DELETE FROM oauth_consents WHERE container_db_key = $1", self.container_db_key + ) + await conn.execute( + "DELETE FROM oauth_refresh_tokens WHERE container_db_key = $1", self.container_db_key + ) + await conn.execute( + "DELETE FROM oauth_authorization_codes WHERE container_db_key = $1", self.container_db_key + ) + await conn.execute("DELETE FROM oauth_clients WHERE container_db_key = $1", self.container_db_key) + + +async def cleanup_expired(conn, batch_size=5000): + await conn.execute("SELECT oauth_cleanup_expired($1)", batch_size) diff --git a/guillotina/contrib/oauth/storage/pg/schema.py b/guillotina/contrib/oauth/storage/pg/schema.py new file mode 100644 index 000000000..18646507f --- /dev/null +++ b/guillotina/contrib/oauth/storage/pg/schema.py @@ -0,0 +1,111 @@ +OAUTH_DDL = [ + """ +CREATE TABLE IF NOT EXISTS oauth_clients ( + container_db_key text NOT NULL, + client_id text NOT NULL, + client_name text NOT NULL, + redirect_uris jsonb NOT NULL DEFAULT '[]', + grant_types jsonb NOT NULL DEFAULT '[]', + response_types jsonb NOT NULL DEFAULT '[]', + scope text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (container_db_key, client_id) +) +""", + """ +CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + container_db_key text NOT NULL, + code_hash text NOT NULL, + client_id text NOT NULL, + user_id text NOT NULL, + redirect_uri text NOT NULL, + scope jsonb NOT NULL DEFAULT '[]', + resource jsonb NOT NULL DEFAULT '[]', + code_challenge text, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (container_db_key, code_hash) +) +""", + """ +CREATE INDEX IF NOT EXISTS oauth_codes_expires_idx + ON oauth_authorization_codes (expires_at) +""", + """ +CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + container_db_key text NOT NULL, + token_hash text NOT NULL, + client_id text NOT NULL, + user_id text NOT NULL, + scope jsonb NOT NULL DEFAULT '[]', + resource jsonb NOT NULL DEFAULT '[]', + expires_at timestamptz NOT NULL, + rotated_from text, + auth_code_hash text, + revoked_at timestamptz, + replaced_by text, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + PRIMARY KEY (container_db_key, token_hash) +) +""", + """ +CREATE INDEX IF NOT EXISTS oauth_refresh_expires_idx + ON oauth_refresh_tokens (expires_at) +""", + """ +CREATE INDEX IF NOT EXISTS oauth_refresh_auth_code_idx + ON oauth_refresh_tokens (container_db_key, auth_code_hash) + WHERE auth_code_hash IS NOT NULL +""", + """ +ALTER TABLE oauth_refresh_tokens ADD COLUMN IF NOT EXISTS revoked_at timestamptz +""", + """ +ALTER TABLE oauth_refresh_tokens ADD COLUMN IF NOT EXISTS replaced_by text +""", + """ +CREATE TABLE IF NOT EXISTS oauth_consents ( + container_db_key text NOT NULL, + consent_key text NOT NULL, + user_id text NOT NULL, + client_id text NOT NULL, + scope jsonb NOT NULL DEFAULT '[]', + resource jsonb NOT NULL DEFAULT '[]', + granted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (container_db_key, consent_key) +) +""", + """ +CREATE OR REPLACE FUNCTION oauth_cleanup_expired(batch_size int DEFAULT 5000) +RETURNS int AS $$ +DECLARE + deleted int := 0; + batch int; +BEGIN + WITH doomed AS ( + SELECT ctid FROM oauth_authorization_codes + WHERE expires_at < now() + LIMIT batch_size + ) + DELETE FROM oauth_authorization_codes o + USING doomed d WHERE o.ctid = d.ctid; + GET DIAGNOSTICS batch = ROW_COUNT; + deleted := deleted + batch; + + WITH doomed AS ( + SELECT ctid FROM oauth_refresh_tokens + WHERE expires_at < now() + LIMIT batch_size + ) + DELETE FROM oauth_refresh_tokens o + USING doomed d WHERE o.ctid = d.ctid; + GET DIAGNOSTICS batch = ROW_COUNT; + deleted := deleted + batch; + + RETURN deleted; +END; +$$ LANGUAGE plpgsql +""", +] diff --git a/guillotina/contrib/oauth/storage/utility.py b/guillotina/contrib/oauth/storage/utility.py new file mode 100644 index 000000000..b4b018189 --- /dev/null +++ b/guillotina/contrib/oauth/storage/utility.py @@ -0,0 +1,120 @@ +import asyncio +import logging + +from zope.interface import implementer + +from guillotina import app_settings +from guillotina.component import get_utility +from guillotina.contrib.oauth.interfaces import IOAuthStorageUtility +from guillotina.contrib.oauth.storage.pg.repository import cleanup_expired +from guillotina.contrib.oauth.storage.pg.schema import OAUTH_DDL +from guillotina.db.interfaces import IPostgresStorage +from guillotina.interfaces import IApplication, IDatabase + + +logger = logging.getLogger("guillotina.contrib.oauth") + +_ddl_lock = asyncio.Lock() +_ddl_initialized = False + +OAUTH_STORAGE_DEFAULTS = { + "cleanup_interval": 900, + "cleanup_batch_size": 5000, +} + + +def get_oauth_storage_settings(): + settings = dict(OAUTH_STORAGE_DEFAULTS) + oauth = app_settings.get("oauth") or {} + for key in OAUTH_STORAGE_DEFAULTS: + if key in oauth: + settings[key] = oauth[key] + try: + utility = get_utility(IOAuthStorageUtility) + utility_settings = getattr(utility, "_settings", None) or {} + for key in OAUTH_STORAGE_DEFAULTS: + if key in utility_settings: + settings[key] = utility_settings[key] + except Exception: + pass + return settings + + +async def ensure_oauth_tables(storage): + import asyncpg.exceptions + + global _ddl_initialized + async with _ddl_lock: + if _ddl_initialized: + return + async with storage.pool.acquire() as conn: + for ddl in OAUTH_DDL: + for attempt in range(3): + try: + await conn.execute(ddl) + break + except asyncpg.exceptions.UniqueViolationError: + if attempt == 2: + raise + await asyncio.sleep(0.05) + _ddl_initialized = True + + +@implementer(IOAuthStorageUtility) +class OAuthStorageUtility: + def __init__(self, settings=None): + self._settings = settings or {} + self._task = None + self._closing = False + + async def initialize(self, app=None): + initialized = False + root = get_utility(IApplication, name="root") + for _id, db in root: + if not IDatabase.providedBy(db): + continue + tm = db.get_transaction_manager() + if not IPostgresStorage.providedBy(tm.storage): + continue + await ensure_oauth_tables(tm.storage) + initialized = True + if initialized: + self._closing = False + self._task = asyncio.create_task(self._cleanup_loop()) + logger.info("OAuth storage initialized (PostgreSQL)") + else: + logger.info("OAuth PostgreSQL tables skipped (no PostgreSQL database found)") + + async def finalize(self, app=None): + self._closing = True + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _cleanup_loop(self): + storage_settings = get_oauth_storage_settings() + interval = storage_settings.get("cleanup_interval", 900) + batch_size = storage_settings.get("cleanup_batch_size", 5000) + while not self._closing: + try: + await asyncio.sleep(interval) + await self.run_cleanup(batch_size=batch_size) + except asyncio.CancelledError: + return + except Exception: + logger.warning("OAuth cleanup failed", exc_info=True) + + async def run_cleanup(self, batch_size=5000): + root = get_utility(IApplication, name="root") + for _id, db in root: + if not IDatabase.providedBy(db): + continue + tm = db.get_transaction_manager() + if not IPostgresStorage.providedBy(tm.storage): + continue + async with tm.storage.pool.acquire() as conn: + await cleanup_expired(conn, batch_size=batch_size) diff --git a/guillotina/contrib/oauth/utils.py b/guillotina/contrib/oauth/utils.py deleted file mode 100644 index 7f9ec0db7..000000000 --- a/guillotina/contrib/oauth/utils.py +++ /dev/null @@ -1,175 +0,0 @@ -from datetime import timedelta -from urllib.parse import parse_qs, urlencode, urlparse -from uuid import uuid4 - -from guillotina import app_settings, task_vars -from guillotina.interfaces import WRITING_VERBS -from guillotina.contrib.oauth.content import OAUTH_STORAGE_KEY, new_oauth_storage -from guillotina.contrib.oauth.tokens import token_hash, utcnow -from guillotina.interfaces import IAddons, IAnnotations -from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed - - -def check_writable_request(request): - return request.method in WRITING_VERBS or ( - request.method == "GET" and str(getattr(request, "path", "")).endswith("/oauth/authorize") - ) - - -def container_url(request, container): - issuer = app_settings.get("oauth", {}).get("issuer") - if issuer: - return issuer.rstrip("/") - return f"{request.scheme}://{request.host}/db/{container.id}" - - -def mcp_resource(request, container): - return f"{container_url(request, container)}/@mcp/protocol" - - -def normalize_list(value): - if value is None: - return [] - if isinstance(value, (list, tuple, set)): - values = [] - for item in value: - values.extend(normalize_list(item)) - return values - return [item for item in str(value).split() if item] - - -def parse_form_encoded(body): - parsed = parse_qs(body, keep_blank_values=True) - return {key: values if len(values) > 1 else values[0] for key, values in parsed.items()} - - -def oauth_error(error, description=None, status=400): - content = {"error": error} - if description: - content["error_description"] = description - raise HTTPBadRequest(content=content) if status == 400 else HTTPPreconditionFailed(content=content) - - -def is_installed(container): - registry = task_vars.registry.get(None) - if registry is None: - return False - try: - return "oauth" in registry.for_interface(IAddons)["enabled"] - except Exception: - return False - - -async def get_storage(container, *, require_installed=True): - if require_installed and not is_installed(container): - raise HTTPPreconditionFailed(content={"reason": "OAuth addon is not installed"}) - annotations = IAnnotations(container) - storage = await annotations.async_get(OAUTH_STORAGE_KEY) - if storage is None: - storage = new_oauth_storage() - await annotations.async_set(OAUTH_STORAGE_KEY, storage) - return storage - - -def register_changed(storage): - txn = getattr(storage, "__txn__", None) - if txn is not None: - txn.register(storage) - - -def validate_redirect_uri(uri): - if not uri: - return False - if "*" in uri: - return False - parsed = urlparse(uri) - if parsed.scheme in ("javascript", "data"): - return False - if parsed.scheme not in ("http", "https"): - return False - if not parsed.netloc: - return False - return True - - -def make_client(data): - redirect_uris = data.get("redirect_uris") or [] - if not redirect_uris or not isinstance(redirect_uris, list): - oauth_error("invalid_request", "redirect_uris is required") - if any(not validate_redirect_uri(uri) for uri in redirect_uris): - oauth_error("invalid_request", "unsafe redirect_uri") - method = data.get("token_endpoint_auth_method", "none") - if method != "none": - oauth_error("unsupported_token_endpoint_auth_method") - now = utcnow().isoformat() - return { - "client_id": data.get("client_id") or uuid4().hex, - "client_name": data.get("client_name") or "OAuth Client", - "redirect_uris": redirect_uris, - "grant_types": data.get("grant_types") or ["authorization_code", "refresh_token"], - "response_types": data.get("response_types") or ["code"], - "token_endpoint_auth_method": "none", - "scope": " ".join(normalize_list(data.get("scope"))), - "created_at": now, - "updated_at": now, - } - - -def consent_key(user_id, client_id, scopes, resources): - return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) - - -def redirect_with_params(uri, params): - sep = "&" if "?" in uri else "?" - return f"{uri}{sep}{urlencode({k: v for k, v in params.items() if v is not None})}" - - -def validate_resource(request, container, resources): - base = container_url(request, container) - allowed = {base, f"{base}/@mcp/protocol"} - if not resources: - return [base] - resources = normalize_list(resources) - for resource in resources: - if resource not in allowed: - oauth_error("invalid_target", "resource is not allowed") - return resources - - -def create_code(storage, *, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): - now = utcnow() - ttl = app_settings["oauth"].get("authorization_code_ttl", 600) - code_record = { - "code_hash": token_hash(raw_code), - "client_id": client_id, - "user_id": user_id, - "redirect_uri": redirect_uri, - "scope": list(scope), - "resource": list(resource), - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "expires_at": (now + timedelta(seconds=ttl)).isoformat(), - "used_at": None, - "created_at": now.isoformat(), - } - storage["codes"][code_record["code_hash"]] = code_record - register_changed(storage) - return code_record - - -def get_valid_code(storage, code): - record = storage["codes"].get(token_hash(code)) - if record is None or record.get("used_at"): - return None - if utcnow().isoformat() > record["expires_at"]: - return None - return record - - -def get_valid_refresh(storage, token): - record = storage["refresh_tokens"].get(token_hash(token)) - if record is None or record.get("revoked_at"): - return None - if utcnow().isoformat() > record["expires_at"]: - return None - return record diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index 3990f5fd3..c5cd5abec 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -5,11 +5,34 @@ import pytest +from guillotina.tests.fixtures import annotations + pytestmark = pytest.mark.asyncio -OAUTH_SETTINGS = {"applications": ["guillotina", "guillotina.contrib.oauth"]} -OAUTH_MCP_SETTINGS = {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"]} +requires_pg = pytest.mark.skipif( + annotations["testdatabase"] == "DUMMY", + reason="requires PostgreSQL (set DATABASE=postgresql)", +) + +OAUTH_SETTINGS = { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "auth_extractors": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], +} +OAUTH_MCP_SETTINGS = { + "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], + "auth_extractors": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], +} def verifier_pair(verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"): @@ -26,7 +49,7 @@ async def register_client(requester, redirect_uri="http://127.0.0.1:12345/callba { "client_name": "Test", "redirect_uris": [redirect_uri], - "scope": "guillotina:mcp.read guillotina:mcp.search", + "scope": "guillotina:access", } ), ) @@ -38,7 +61,7 @@ async def authorize_code( requester, client, *, - scope="guillotina:mcp.read", + scope="guillotina:access", resource=None, verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", ): diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index 7e3344799..fe1dbd359 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -7,10 +7,12 @@ OAUTH_MCP_SETTINGS, authorize_code, register_client, + requires_pg, token_from_code, ) -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @@ -21,6 +23,13 @@ async def test_mcp_protected_resource_metadata(container_install_requester): assert status == 200 assert response["resource"].endswith("/db/guillotina/@mcp/protocol") assert response["authorization_servers"][0].endswith("/db/guillotina") + assert response["scopes_supported"] == ["guillotina:access"] + + response, status = await requester( + "GET", "/.well-known/oauth-protected-resource/db/guillotina/@mcp/protocol" + ) + assert status == 200 + assert response["resource"].endswith("/db/guillotina/@mcp/protocol") @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @@ -35,7 +44,10 @@ async def test_mcp_without_token_challenges(container_install_requester): authenticated=False, ) assert status == 401 - assert "resource_metadata" in headers["WWW-Authenticate"] + www_authenticate = headers["WWW-Authenticate"] + assert "resource_metadata" in www_authenticate + assert "/.well-known/oauth-protected-resource/db/guillotina/@mcp/protocol" in www_authenticate + assert 'scope="guillotina:access"' in www_authenticate @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @@ -79,17 +91,16 @@ async def test_mcp_rejects_missing_mcp_audience(container_install_requester): @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) -async def test_mcp_search_requires_search_scope(container_install_requester): +async def test_mcp_search_with_read_only_token(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) code, verifier = await authorize_code( requester, client, - scope="guillotina:mcp.read", resource="http://localhost/db/guillotina/@mcp/protocol", ) token = await token_from_code(requester, client, code, verifier) - _response, status = await requester( + response, status = await requester( "POST", "/db/guillotina/@mcp/protocol", data=json.dumps( @@ -104,22 +115,22 @@ async def test_mcp_search_requires_search_scope(container_install_requester): auth_type="Bearer", token=token["access_token"], ) - assert status == 403 + _skip_if_protocol_unavailable(response, status) + assert status == 200 @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) -async def test_mcp_serialized_content_requires_content_scope(container_install_requester): +async def test_mcp_serialized_content_with_oauth_token(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) code, verifier = await authorize_code( requester, client, - scope="guillotina:mcp.read", resource="http://localhost/db/guillotina/@mcp/protocol", ) token = await token_from_code(requester, client, code, verifier) - _response, status = await requester( + response, status = await requester( "POST", "/db/guillotina/@mcp/protocol", data=json.dumps( @@ -137,4 +148,153 @@ async def test_mcp_serialized_content_requires_content_scope(container_install_r auth_type="Bearer", token=token["access_token"], ) - assert status == 403 + _skip_if_protocol_unavailable(response, status) + assert status == 200 + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_create_content(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code( + requester, + client, + resource="http://localhost/db/guillotina/@mcp/protocol", + ) + token = await token_from_code(requester, client, code, verifier) + response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "create_content", + "arguments": { + "data": {"@type": "Folder", "id": "oauth-mcp-folder", "title": "OAuth MCP Folder"} + }, + }, + } + ), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) + _skip_if_protocol_unavailable(response, status) + assert status == 200 + assert "oauth-mcp-folder" in response["result"]["content"][0]["text"] + + +NO_PKCE_SETTINGS = { + "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], + "auth_extractors": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], + "oauth": { + "require_pkce": False, + }, +} + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_subresource_mcp_unauthorized(container_install_requester): + async with container_install_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina", + data=json.dumps({"@type": "Folder", "id": "subfolder", "title": "Subfolder"}), + ) + assert status == 201 + + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/subfolder/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + headers=PROTOCOL_HEADERS, + authenticated=False, + ) + assert status == 401 + www_authenticate = headers["WWW-Authenticate"] + assert "resource_metadata" in www_authenticate + assert "/.well-known/oauth-protected-resource/db/guillotina/@mcp/protocol" in www_authenticate + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_subresource_mcp_authorized(container_install_requester): + async with container_install_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina", + data=json.dumps({"@type": "Folder", "id": "subfolder", "title": "Subfolder"}), + ) + assert status == 201 + + client = await register_client(requester) + code, verifier = await authorize_code( + requester, client, resource="http://localhost/db/guillotina/@mcp/protocol" + ) + token = await token_from_code(requester, client, code, verifier) + + response, status = await requester( + "POST", + "/db/guillotina/subfolder/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), + headers=PROTOCOL_HEADERS, + auth_type="Bearer", + token=token["access_token"], + ) + _skip_if_protocol_unavailable(response, status) + assert status == 200 + + +@pytest.mark.app_settings(NO_PKCE_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_authorize_without_pkce(container_install_requester): + from urllib.parse import parse_qs, urlencode, urlparse + + async with container_install_requester as requester: + client = await register_client(requester) + data = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "state": "abc", + "decision": "allow", + } + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=urlencode(data), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + query = parse_qs(urlparse(headers["Location"]).query) + code = query["code"][0] + + body = urlencode( + { + "grant_type": "authorization_code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "code": code, + } + ) + response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + assert "access_token" in response + assert "refresh_token" in response diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index 4f5e34819..bec84e246 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -3,9 +3,17 @@ import pytest -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, register_client, verifier_pair +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, + verifier_pair, +) -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -18,6 +26,93 @@ async def test_authorize_unknown_client(container_install_requester): assert status == 400 +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_accepts_cursor_redirect_registered_with_client(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_name": "Cursor", + "redirect_uris": [ + "http://127.0.0.1:12345/callback", + "cursor://anysphere.cursor-mcp/oauth/callback", + ], + } + ), + authenticated=False, + ) + assert status == 200 + client = response + _response, status = await requester( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": "cursor://anysphere.cursor-mcp/oauth/callback", + "response_type": "code", + "code_challenge": verifier_pair()[1], + "code_challenge_method": "S256", + "scope": "guillotina:access", + }, + ) + assert status == 200 + body = _response.decode("utf-8") if isinstance(_response, bytes) else _response + assert "Login" in body or "Allow" in body + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_consent_page_describes_requested_access(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + "scope": "guillotina:access", + }, + allow_redirects=False, + ) + body = value.decode("utf-8") + assert status == 200 + assert "Allow Test" in body + assert "Requested permissions" in body + assert "Access Guillotina on behalf" in body + assert "Resources this client can access" in body + assert client["redirect_uris"][0] in body + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_rejects_client_supplied_client_id(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester, redirect_uri="http://127.0.0.1:12345/callback") + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_id": client["client_id"], + "client_name": "Cursor", + "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], + } + ), + authenticated=False, + ) + assert status == 400 + assert response["error"] == "invalid_request" + assert response["error_description"] == "client_id is server-issued" + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_bad_redirect_does_not_redirect(container_install_requester): @@ -60,8 +155,15 @@ async def test_authorize_pkce_required(challenge_method, container_install_reque async def test_authorize_allow_and_remember_consent(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - verifier, challenge = verifier_pair() - body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&state=s&code_challenge={challenge}&code_challenge_method=S256&decision=allow" + _verifier, challenge = verifier_pair() + body = ( + "response_type=code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + "&scope=guillotina:access&state=s" + f"&code_challenge={challenge}" + "&code_challenge_method=S256&decision=allow" + ) _value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", @@ -80,7 +182,7 @@ async def test_authorize_allow_and_remember_consent(container_install_requester) "response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], - "scope": "guillotina:mcp.read", + "scope": "guillotina:access", "code_challenge": challenge, "code_challenge_method": "S256", }, @@ -95,7 +197,14 @@ async def test_authorize_deny(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) _verifier, challenge = verifier_pair() - body = f"response_type=code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&scope=guillotina:mcp.read&code_challenge={challenge}&code_challenge_method=S256&decision=deny" + body = ( + "response_type=code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + "&scope=guillotina:access" + f"&code_challenge={challenge}" + "&code_challenge_method=S256&decision=deny" + ) _value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", @@ -105,3 +214,182 @@ async def test_authorize_deny(container_install_requester): ) assert status == 302 assert "error=access_denied" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_invalid_response_type_redirects(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "token", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=unsupported_response_type" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_invalid_scope_redirects(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "scope": "unsupported_scope", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_scope" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_without_scope(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, scope="") + token = await token_from_code(requester, client, code, verifier) + assert token["access_token"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_invalid_target_redirects(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "resource": "http://invalid-target.com", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_target" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_oauth_only_rejects_mcp_protocol_resource(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "resource": "http://localhost/db/guillotina/@mcp/protocol", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_target" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_sets_auth_token_cookie(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + body = ( + "response_type=code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + "&scope=guillotina:access" + f"&code_challenge={challenge}" + "&code_challenge_method=S256" + "&username=root&password=admin&decision=allow" + ) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + authenticated=False, + allow_redirects=False, + ) + assert status == 302 + assert "Set-Cookie" in headers + assert "auth_token=" in headers["Set-Cookie"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_cookie_authenticates_get_request(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + + # 1. First authenticate with POST login to get the auth_token cookie + body = ( + "response_type=code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + "&scope=guillotina:access" + f"&code_challenge={challenge}" + "&code_challenge_method=S256" + "&username=root&password=admin" + ) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + authenticated=False, + allow_redirects=False, + ) + assert "Set-Cookie" in headers + cookie_header = headers["Set-Cookie"] + assert "auth_token=" in cookie_header + + # Extract cookie value + cookie_token = cookie_header.split(";")[0].split("=")[1] + + # 2. Make a GET request with the extracted cookie (authenticated=False so no basic auth is sent) + # It should bypass login and show the consent form (status 200) instead of prompting for login again! + value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + headers={"Cookie": f"auth_token={cookie_token}"}, + authenticated=False, + allow_redirects=False, + ) + assert status == 200 + assert b"Allow Test" in value diff --git a/guillotina/tests/oauth/test_oauth_metadata.py b/guillotina/tests/oauth/test_oauth_metadata.py index 3b1d016d1..df5969439 100644 --- a/guillotina/tests/oauth/test_oauth_metadata.py +++ b/guillotina/tests/oauth/test_oauth_metadata.py @@ -1,8 +1,9 @@ import pytest -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, requires_pg -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -16,6 +17,20 @@ async def test_metadata(container_install_requester): assert response["registration_endpoint"].endswith("/oauth/register") +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_rfc_metadata(container_install_requester): + async with container_install_requester as requester: + response, status = await requester("GET", "/.well-known/oauth-authorization-server/db/guillotina") + assert status == 200 + assert response["issuer"].endswith("/db/guillotina") + assert response["authorization_endpoint"].endswith("/oauth/authorize") + + response, status = await requester("GET", "/.well-known/openid-configuration/db/guillotina") + assert status == 200 + assert response["issuer"].endswith("/db/guillotina") + + @pytest.mark.app_settings(OAUTH_SETTINGS) async def test_metadata_requires_addon(container_requester): async with container_requester as requester: diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index 8aa702e13..e651052e3 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -2,9 +2,10 @@ import pytest -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, requires_pg -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -15,6 +16,7 @@ async def test_register_client(container_install_requester): "POST", "/db/guillotina/oauth/register", data=json.dumps({"client_name": "Example", "redirect_uris": ["http://127.0.0.1:12345/callback"]}), + authenticated=False, ) assert status == 200 assert response["client_id"] @@ -48,3 +50,70 @@ async def test_register_accepts_loopback(container_install_requester): data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}), ) assert status == 200 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_accepts_cursor_native_redirect(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_name": "Cursor", + "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], + } + ), + authenticated=False, + ) + assert status == 200 + assert response["redirect_uris"] == ["cursor://anysphere.cursor-mcp/oauth/callback"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_accepts_multiple_redirect_uris(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_name": "Cursor", + "redirect_uris": [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + ], + } + ), + authenticated=False, + ) + assert status == 200 + assert response["redirect_uris"] == [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + ] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_rejects_client_supplied_client_id(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_id": "cursor", + "client_name": "Cursor", + "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], + } + ), + authenticated=False, + ) + assert status == 400 + assert response["error"] == "invalid_request" + assert response["error_description"] == "client_id is server-issued" diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index 3349f1a89..86bb4f59a 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -1,8 +1,15 @@ import pytest -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, +) -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_SETTINGS) diff --git a/guillotina/tests/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py new file mode 100644 index 000000000..f5f34414e --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -0,0 +1,41 @@ +import asyncio + +import pytest + +from guillotina import task_vars +from guillotina.contrib.oauth.storage.access import get_oauth_store, oauth_container_db_key +from guillotina.contrib.oauth.storage.interfaces import IOAuthStore +from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository +from guillotina.contrib.oauth.storage.pg.schema import OAUTH_DDL + + +def assert_oauth_store(store): + assert IOAuthStore.providedBy(store) + for name in IOAuthStore.names(): + assert asyncio.iscoroutinefunction(getattr(store, name)), name + + +def test_oauth_repository_implements_interface(): + store = OAuthRepository("db/guillotina") + assert_oauth_store(store) + + +def test_oauth_container_db_key_includes_database_id(): + db = type("DB", (), {"id": "db"})() + container = type("Container", (), {"id": "guillotina"})() + token = task_vars.db.set(db) + try: + assert oauth_container_db_key(container) == "db/guillotina" + finally: + task_vars.db.reset(token) + + +def test_oauth_schema_uses_container_db_key(): + ddl = "\n".join(OAUTH_DDL) + assert "container_db_key text NOT NULL" in ddl + assert "container_id text NOT NULL" not in ddl + + +def test_get_oauth_store_without_pg_raises(): + with pytest.raises(RuntimeError, match="PostgreSQL"): + get_oauth_store(type("Container", (), {"id": "guillotina"})(), require_installed=False) diff --git a/guillotina/tests/oauth/test_oauth_store_contract.py b/guillotina/tests/oauth/test_oauth_store_contract.py new file mode 100644 index 000000000..4f46dbd17 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_store_contract.py @@ -0,0 +1,131 @@ +import pytest + +from guillotina.contrib.oauth.flow.clients import consent_key +from guillotina.contrib.oauth.flow.tokens import opaque_token +from guillotina.tests.oauth.conftest import requires_pg +from guillotina.tests.oauth.test_oauth_storage_backend import assert_oauth_store +from guillotina.transactions import transaction + + +pytestmark = [pytest.mark.asyncio, requires_pg] + + +async def run_oauth_store_contract(store): + assert_oauth_store(store) + + client = { + "client_id": "contract-client", + "client_name": "Contract Test", + "redirect_uris": ["http://127.0.0.1:12345/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "scope": "guillotina:access", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + await store.create_client(client) + loaded = await store.get_client("contract-client") + assert loaded["client_name"] == "Contract Test" + + scopes = ["guillotina:access"] + resources = ["http://localhost/db/guillotina"] + ckey = consent_key("root", client["client_id"], scopes, resources) + assert await store.has_consent(ckey) is False + await store.create_consent( + ckey, + user_id="root", + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + assert await store.has_consent(ckey) is True + + raw_code = opaque_token("goc_") + code_record = await store.create_code( + raw_code=raw_code, + client_id=client["client_id"], + user_id="root", + redirect_uri=client["redirect_uris"][0], + scope=scopes, + resource=resources, + code_challenge="challenge", + ) + assert await store.get_active_code(raw_code) is not None + + standalone_refresh = opaque_token("gor_") + await store.create_refresh_token( + raw_token=standalone_refresh, + client_id=client["client_id"], + user_id="root", + scope=scopes, + resource=resources, + ) + assert await store.get_valid_refresh(standalone_refresh) is not None + await store.delete_refresh_token(standalone_refresh) + assert await store.get_refresh_token(standalone_refresh) is None + + linked_refresh = opaque_token("gor_") + await store.create_refresh_token( + raw_token=linked_refresh, + client_id=client["client_id"], + user_id="root", + scope=scopes, + resource=resources, + auth_code_hash=code_record["code_hash"], + ) + await store.delete_code(code_record["code_hash"]) + assert await store.get_active_code(raw_code) is None + assert await store.revoke_refresh_tokens_by_auth_code(code_record["code_hash"]) is True + assert await store.get_valid_refresh(linked_refresh) is None + + await store.delete_container_data() + assert await store.get_client("contract-client") is None + assert await store.has_consent(ckey) is False + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + } +) +async def test_postgresql_oauth_store_contract(guillotina_main): + from guillotina.component import get_utility + from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables + from guillotina.interfaces import IApplication + + root = get_utility(IApplication, name="root") + await ensure_oauth_tables(root["db"].storage) + + async with transaction(db=root["db"]): + store = OAuthRepository("db/pg-contract") + await run_oauth_store_contract(store) + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "auth_extractors": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], + } +) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_oauth_flow_with_postgresql_store(container_install_requester): + from guillotina.component import get_utility + from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables + from guillotina.interfaces import IApplication + from guillotina.tests.oauth.conftest import authorize_code, register_client, token_from_code + + root = get_utility(IApplication, name="root") + await ensure_oauth_tables(root["db"].storage) + + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token = await token_from_code(requester, client, code, verifier) + assert token["access_token"] diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index 3de8ca674..c3415f69e 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -2,9 +2,16 @@ import pytest from guillotina import app_settings -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, +) -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] EXPIRED_CODE_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth"], @@ -32,14 +39,8 @@ async def test_code_token_and_refresh_rotation(container_install_requester): assert claims["iss"].endswith("/db/guillotina") assert claims["sub"] == claims["id"] assert claims["client_id"] == client["client_id"] - assert claims["scope"] == "guillotina:mcp.read" + assert claims["scope"] == "guillotina:access" assert claims["aud"] - _response, status = await requester( - "POST", - "/db/guillotina/oauth/token", - data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}", - ) - assert status == 400 refreshed, status = await requester( "POST", "/db/guillotina/oauth/token", @@ -64,16 +65,98 @@ async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): _response, status = await requester( "POST", "/db/guillotina/oauth/token", - data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier=bad", + data=( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + f"&code={code}&code_verifier=bad" + ), ) assert status == 400 - code, verifier = await authorize_code( - requester, client, scope="guillotina:mcp.read guillotina:mcp.search" - ) + code, verifier = await authorize_code(requester, client, scope="guillotina:access") _response, status = await requester( "POST", "/db/guillotina/oauth/token", - data=f"grant_type=authorization_code&client_id={client['client_id']}&redirect_uri=http://127.0.0.1:9999/cb&code={code}&code_verifier={verifier}", + data=( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + "&redirect_uri=http://127.0.0.1:9999/cb" + f"&code={code}&code_verifier={verifier}" + ), + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_rejects_pkce_verifier_below_min_length(container_install_requester): + from urllib.parse import parse_qs, urlparse + + from guillotina.contrib.oauth.flow.pkce import s256_challenge + + async with container_install_requester as requester: + client = await register_client(requester) + verifier_42 = "a" * 42 + challenge = s256_challenge(verifier_42) + body = ( + "response_type=code&decision=allow&" + f"client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&" + f"scope=guillotina:access&code_challenge={challenge}&code_challenge_method=S256" + ) + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + code = parse_qs(urlparse(headers["Location"]).query)["code"][0] + + payload = ( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier_42}" + ) + _resp, tok_status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert tok_status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_refresh_token_reuse_invalidates_rotation_family(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token_a = await token_from_code(requester, client, code, verifier) + + rotated, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token_a['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + new_rt = rotated["refresh_token"] + + reused, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token_a['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + + _also_bad, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={new_rt}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -108,3 +191,32 @@ async def test_expired_refresh_token_fails(container_install_requester): data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", ) assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_code_reuse_revokes_tokens(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") + token = await token_from_code(requester, client, code, verifier) + assert "refresh_token" in token + + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + f"&code={code}&code_verifier={verifier}" + ), + ) + assert status == 400 + + _refresh_response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + ) + assert status == 400 diff --git a/guillotina/tests/oauth/test_oauth_validator.py b/guillotina/tests/oauth/test_oauth_validator.py index 90018fb6f..d4b29cc05 100644 --- a/guillotina/tests/oauth/test_oauth_validator.py +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -1,8 +1,16 @@ import pytest -from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, authorize_code, register_client, token_from_code +from guillotina.tests.oauth.conftest import ( + OAUTH_MCP_SETTINGS, + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, +) -pytestmark = pytest.mark.asyncio + +pytestmark = [pytest.mark.asyncio, requires_pg] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -23,8 +31,8 @@ async def test_oauth_access_token_authenticates(container_install_requester): assert "oauth" in response["installed"] -@pytest.mark.app_settings(OAUTH_SETTINGS) -@pytest.mark.parametrize("install_addons", [["oauth"]]) +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) async def test_oauth_access_token_wrong_audience_fails_generic_api(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) diff --git a/guillotina/tests/oauth/test_oauth_well_known.py b/guillotina/tests/oauth/test_oauth_well_known.py new file mode 100644 index 000000000..8974047d4 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_well_known.py @@ -0,0 +1,69 @@ +import pytest + +from guillotina.contrib.oauth.api import well_known +from guillotina.response import HTTPNotFound + + +class _FakeRequest: + def __init__(self, protected_path=None): + self.oauth_protected_resource_path = protected_path + + +@pytest.mark.asyncio +async def test_register_protected_resource_provider_appends_provider(monkeypatch): + providers = [] + monkeypatch.setattr(well_known, "_PROTECTED_RESOURCE_PROVIDERS", providers) + + def provider(request, container, protected_path): + return None + + well_known.register_protected_resource_provider(provider) + assert providers == [provider] + + +@pytest.mark.asyncio +async def test_protected_resource_metadata_uses_first_matching_provider(monkeypatch): + def provider_a(request, container, protected_path): + return None + + def provider_b(request, container, protected_path): + return {"resource": "b", "authorization_servers": ["issuer"]} + + def provider_c(request, container, protected_path): + raise AssertionError("should not be called after a matching provider") + + monkeypatch.setattr( + well_known, + "_PROTECTED_RESOURCE_PROVIDERS", + [provider_a, provider_b, provider_c], + ) + request = _FakeRequest("/db/guillotina/@mcp/protocol") + result = well_known._protected_resource_metadata(request, None) + assert result == {"resource": "b", "authorization_servers": ["issuer"]} + + +@pytest.mark.asyncio +async def test_protected_resource_metadata_returns_404_when_no_provider_matches(monkeypatch): + def provider(request, container, protected_path): + return None + + monkeypatch.setattr(well_known, "_PROTECTED_RESOURCE_PROVIDERS", [provider]) + request = _FakeRequest("/db/guillotina/unknown-resource") + with pytest.raises(HTTPNotFound): + well_known._protected_resource_metadata(request, None) + + +@pytest.mark.asyncio +async def test_container_path_parts_allows_resource_suffix(): + db_id, container_id, protected_path = well_known._container_path_parts( + "/db/guillotina/subfolder/@mcp/protocol", allow_resource_path=True + ) + assert db_id == "db" + assert container_id == "guillotina" + assert protected_path == "/db/guillotina/subfolder/@mcp/protocol" + + +@pytest.mark.asyncio +async def test_container_path_parts_rejects_suffix_for_issuer_metadata(): + with pytest.raises(HTTPNotFound): + well_known._container_path_parts("/db/guillotina/extra") diff --git a/oauth-rfc-theory.html b/oauth-rfc-theory.html new file mode 100644 index 000000000..cd766f50f --- /dev/null +++ b/oauth-rfc-theory.html @@ -0,0 +1,649 @@ + + + + + + Guia teòrica dels RFC d'OAuth 2.0 del PR #1218 + + + + +
    +

    Guia teòrica dels RFC d'OAuth 2.0

    +

    Referència per entendre els estàndards que cobreix el perfil OAuth 2.0 Authorization Code + PKCE implementat al PR #1218 de Guillotina, sense entrar en detalls de codi.

    +
    + + + +
    + +
    +

    Introducció: què és OAuth 2.0?

    +

    OAuth 2.0 és un framework d'autorització, no un protocol d'autenticació. Permet que una aplicació (el client) obtingui permisos limitats d'un usuari (el resource owner) per accedir a recursos protegits per un servidor, sense que l'usuari li hagi de donar la seva contrasenya.

    + +

    Actors principals

    +
      +
    • Resource Owner: l'usuari final que posseeix les dades i pot concedir accés.
    • +
    • Client: l'aplicació que vol accedir als recursos. Pot ser una aplicació web servidor, una app nativa, una SPA o un script.
    • +
    • Authorization Server (AS): emiteix els access tokens després d'autenticar l'usuari i obtenir el seu consentiment.
    • +
    • Resource Server (RS): l'API que protegeix els recursos i valida els access tokens.
    • +
    + +

    Token d'accés

    +

    El resultat del flux és un access token: una credencial que el client presenta al resource server per demanar dades. Els access tokens poden tenir temps de vida curt, i es poden renovar mitjançant refresh tokens.

    + +
    + Nota clau: El PR implementa un perfil molt concret: només clients públics, només Authorization Code amb PKCE S256, sense secrets de client i sense OpenID Connect. Això és una elecció de seguretat deliberada, no una omissió. +
    +
    + +
    +

    Resum dels RFC que cobreix el perfil

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RFCTemaQuè aporta al perfil
    RFC 6749OAuth 2.0 Authorization FrameworkFlux Authorization Code, token response, errors, scopes, refresh tokens.
    RFC 6750Bearer Token UsageCom presentar i validar tokens Authorization: Bearer.
    RFC 7636PKCEProtecció del code exchange amb code_verifier i code_challenge.
    RFC 8252OAuth 2.0 for Native AppsRegles per a redirect URIs d'apps nadiues (loopback, private-use schemes).
    RFC 7591Dynamic Client RegistrationEndpoint per registrar clients de forma programàtica.
    RFC 8414Authorization Server MetadataDescobriment d'endpoints i capacitats de l'AS (.well-known).
    RFC 9207Issuer IdentificationParàmetre iss a la resposta d'autorització contra mix-up.
    RFC 8707Resource IndicatorsParàmetre resource per lligar tokens a audiències concretes.
    RFC 9728Protected Resource MetadataMetadades dels recursos protegits i WWW-Authenticate hints.
    RFC 7009Token RevocationEndpoint per revocar refresh tokens i invalidar families.
    RFC 9700OAuth 2.0 Security BCPRecomanacions de seguretat actuals (PKCE, rotació, exact match...).
    +
    + +
    +

    RFC 6749 OAuth 2.0 Authorization Framework

    +

    És el document fonamental d'OAuth 2.0. Defineix els grant types (formes d'obtenir un token), els endpoints, els paràmetres i els errors.

    + +

    Authorization Code Grant

    +

    El flux consta de dues crides:

    +
      +
    1. El client redirigeix l'usuari al authorization endpoint de l'AS. Si l'usuari accepta, l'AS retorna un authorization code via redirecció.
    2. +
    3. El client intercanvia el code pel access token (i opcionalment el refresh token) al token endpoint.
    4. +
    + +

    Paràmetres clau de l'endpoint d'autorització

    +
      +
    • response_type=code: indica que volem el Authorization Code grant.
    • +
    • client_id: identificador del client registrat.
    • +
    • redirect_uri: on s'ha de redirigir la resposta. Ha de coincidir amb un URI registrat.
    • +
    • scope: àmbits de permís sol·licitats.
    • +
    • state: valor opac que el client envia i rep de tornada; protegeix contra atacs CSRF.
    • +
    + +

    Paràmetres clau del token endpoint

    +
      +
    • grant_type=authorization_code
    • +
    • code: el codi rebut.
    • +
    • redirect_uri: el mateix URI usat a la primera crida.
    • +
    • client_id: per clients públics.
    • +
    + +

    Token response

    +

    La resposta és un JSON amb:

    +
    {
    +  "access_token": "...",
    +  "token_type": "Bearer",
    +  "expires_in": 3600,
    +  "refresh_token": "...",
    +  "scope": "read write"
    +}
    + +

    Errors OAuth

    +

    Els errors del token endpoint han de tornar 400 Bad Request (o 401 quan toqui) amb un JSON que inclogui error i opcionalment error_description. Exemples: invalid_request, invalid_client, invalid_grant, unsupported_grant_type, invalid_scope.

    + +
    + Què validar al codi: que l'authorize valida client_id, redirect_uri i scope; que el token requereix el code i el mateix redirect_uri; que els errors segueixen el format de l'RFC; que el state es retorna intacte. +
    +
    + +
    +

    RFC 6750 Bearer Token Usage

    +

    Defineix com utilitzar un token d'accés per accedir a recursos protegits. Els access tokens són bearer tokens: qualsevol que el posseeix pot usar-lo (com un bitllet).

    + +

    Formes de presentar el token

    +
      +
    • Authorization header (recomanada): Authorization: Bearer <token>
    • +
    • Form-encoded body parameter (menys recomanada).
    • +
    • Query parameter (desaconsellada perquè queda als logs).
    • +
    + +

    WWW-Authenticate

    +

    Quan una petició no porta token o és invàlid, el resource server ha de respondre 401 Unauthorized amb una capçalera WWW-Authenticate indicant l'esquema i l'error OAuth:

    +
    WWW-Authenticate: Bearer error="invalid_token",
    +                    error_description="The access token expired"
    + +
    + Què validar al codi: que l'API rebutja peticions sense token o amb token caducat; que es torna WWW-Authenticate; que el token conté o permet verificar issuer, audience i scope abans d'autoritzar. +
    +
    + +
    +

    RFC 7636 Proof Key for Code Exchange (PKCE)

    +

    PKCE va néixer per protegir el flux Authorization Code quan el client no pot mantenir un secret (apps mòbils, SPAs). Impedeix que un atacant que intercepti l'authorization code pugui intercanviar-lo per un token.

    + +

    Com funciona

    +
      +
    1. El client genera un code_verifier: string aleatori de 43-128 caràcters (A-Z, a-z, 0-9, -, ., _, ~).
    2. +
    3. En calcula el hash SHA-256 i el codifica en Base64URL sense padding. Això és el code_challenge.
    4. +
    5. A l'/authorize envia code_challenge i code_challenge_method=S256.
    6. +
    7. L'AS guarda el challenge.
    8. +
    9. Al /token el client envia el code_verifier.
    10. +
    11. L'AS en calcula el S256 i comprova que coincideix amb el challenge guardat.
    12. +
    + +

    Per què S256 obligatori

    +

    El mètode plain (enviar el verifier sense transformar) és més feble. El perfil del PR exigeix S256 per a tots els clients públics, tal com recomana el BCP de seguretat.

    + +
    + Atenció: Si un client envia code_challenge_method=plain o no en envia cap, l'AS ha de rebutjar la petició. +
    + +
    + Què validar al codi: que /authorize exigeix code_challenge i code_challenge_method=S256; que /token exigeix code_verifier; que la validació és estricta (no permet pla, no accepta challenges curts); que el verifier es descarta després de l'ús. +
    +
    + +
    +

    RFC 8252 OAuth 2.0 for Native Apps

    +

    Aquest RFC aplica el flux Authorization Code a aplicacions nadiues (mòbil, escriptori, CLI) que no poden emmagatzemar secrets de manera segura.

    + +

    Principis clau

    +
      +
    • No utilitzar secrets de client: les apps nadiues són public clients.
    • +
    • PKCE obligatori: per protegir l'intercanvi de codi.
    • +
    • Redirect URI controlat: ha de redirigir a una destinació que només l'app pugui capturar.
    • +
    + +

    Tipus de redirect URI permesos

    +
      +
    • Loopback: http://127.0.0.1:PORT o http://[::1]:PORT. El port pot ser dinàmic. És segur perquè només l'app local pot escoltar allà.
    • +
    • Private-use URI scheme: com.example.myapp:/callback. El sistema operatiu redirigeix aquest esquema a l'app corresponent.
    • +
    • HTTPS per a apps amb Universal Links / App Links: requereix validació addicional del domini.
    • +
    + +

    Què NO s'ha de permetre

    +
      +
    • http:// que no sigui loopback.
    • +
    • Wildcards (*) als dominis o paths.
    • +
    • Fragments (#) al redirect URI.
    • +
    • URI que no estiguin exactament registrats.
    • +
    + +
    + Què validar al codi: que només es permeten loopback amb port dinàmic, private-use schemes o https; que el redirect_uri de la peticiatoken coincideix exactament amb el registrat (tret del port loopback); que es rebutgen fragments, wildcards i HTTP no loopback. +
    +
    + +
    +

    RFC 7591 Dynamic Client Registration

    +

    Permet que els clients es registrin sols a l'AS enviant un JSON al registration endpoint, en lloc de necessitar un procés manual.

    + +

    Metadades de client rellevants

    +
      +
    • redirect_uris: llista d'URIs vàlids.
    • +
    • grant_types: ha de contenir authorization_code i, si escau, refresh_token.
    • +
    • response_types: ha de contenir code.
    • +
    • token_endpoint_auth_method: en aquest perfil sempre none (client públic).
    • +
    • scope: àmbits per defecte sol·licitats.
    • +
    • client_name, client_uri, etc.: metadades informatives.
    • +
    + +

    Errors específics

    +

    L'RFC defineix noms d'error concrets que han de tornar-se quan falla el registre:

    +
      +
    • invalid_redirect_uri: URI no vàlid o insegur.
    • +
    • invalid_client_metadata: altres metadades incorrectes.
    • +
    • unsupported_token_endpoint_auth_method: mètode d'autenticació no permès.
    • +
    + +
    + Què validar al codi: que només s'accepten clients públics (none); que client_id el genera l'AS i es rebutja quan el client l'envia; que es validen grant_types, response_types i scope; que els errors segueixen els noms de l'RFC. +
    +
    + +
    +

    RFC 8414 Authorization Server Metadata

    +

    Defineix un format estàndard perquè els clients descobreixin els endpoints i capacitats de l'AS sense haver-los configurats manualment.

    + +

    Endpoint de metadades

    +

    El client pot fer GET a:

    +
    /.well-known/oauth-authorization-server/<issuer-path>
    +

    o, si l'issuer no té path:

    +
    <issuer>/.well-known/oauth-authorization-server
    + +

    Contingut típic de la resposta

    +
      +
    • issuer: l'identificador canònic de l'AS.
    • +
    • authorization_endpoint, token_endpoint, revocation_endpoint, registration_endpoint.
    • +
    • grant_types_supported, response_types_supported.
    • +
    • scopes_supported.
    • +
    • code_challenge_methods_supported: ha de contenir S256.
    • +
    • token_endpoint_auth_methods_supported: en aquest perfil, ["none"].
    • +
    + +
    + Què validar al codi: que les URLs del metadata coincideixen amb les reals; que l'issuer és estable i no depèn de capçaleres insegures; que només s'anuncien els valors que realment es suporten; que els dos well-known paths funcionen segons l'RFC. +
    +
    + +
    +

    RFC 9207 Authorization Server Issuer Identification

    +

    Afegeix el paràmetre iss a la resposta del /authorize per evitar l'atac authorization server mix-up.

    + +

    El problema

    +

    Un client pot tenir configurats diversos AS. Si un atacant aconsegueix que el client enviï una petició a un AS maliciós però rebi una resposta que sembla d'un AS de confiança, el client podria enviar el code al AS equivocat.

    + +

    La solució

    +

    La resposta d'autorització inclou iss=<issuer URL>. El client ha de comprovar que coincideix amb l'issuer esperat abans d'anar al token endpoint.

    + +
    https://client.example/callback?code=abc&state=xyz&iss=https%3A%2F%2Fas.example
    + +
    + Què validar al codi: que la redirecció d'autorització inclou iss; que el seu valor és exactament l'URL de l'issuer del metadata; que els tests comproven la presència i el format. +
    +
    + +
    +

    RFC 8707 Resource Indicators for OAuth 2.0

    +

    Permet que el client indiqui a quin recurs vol accedir mitjançant el paràmetre resource. Això lliga l'access token a una audiència concreta.

    + +

    Funcionament

    +
      +
    • El paràmetre resource és una URL que identifica el recurs.
    • +
    • Es pot repetir diverses vegades si el token ha de servir per a múltiples recursos.
    • +
    • L'AS valida que els recursos sol·licitats estiguin permesos.
    • +
    • L'access token emès hauria de reflectir l'audiència (aud) del token.
    • +
    + +
    GET /authorize?response_type=code&client_id=...
    +    &resource=https%3A%2F%2Fapi.example%2Fcontainer
    +    &resource=https%3A%2F%2Fapi.example%2Fmcp
    + +
    + Què validar al codi: que resource es pot repetir i es preserven tots els valors; que es validen contra els resolvers registrats; que el token emès porta l'audiència correcta; que es rebutgen recursos desconeguts. +
    +
    + +
    +

    RFC 9728 OAuth 2.0 Protected Resource Metadata

    +

    Permet que un recurs protegit (una API) publiqui metadades sobre com els clients han de demanar tokens per accedir-hi.

    + +

    Endpoint i contingut

    +

    El resource server pot exposar:

    +
    /.well-known/oauth-protected-resource
    +

    Amb informació com:

    +
      +
    • resource: identificador del recurs.
    • +
    • authorization_servers: llista d'AS que poden emetre tokens per aquest recurs.
    • +
    • bearer_methods: per exemple ["header"].
    • +
    • scopes_supported: àmbits disponibles al recurs.
    • +
    + +

    WWW-Authenticate hints

    +

    Quan una petició arriba sense token o amb token insuficient, el RS pot respondre amb:

    +
    WWW-Authenticate: Bearer
    +  error="insufficient_user_authentication",
    +  error_description="...",
    +  issuer="https://as.example",
    +  authorization_uri="https://as.example/authorize"
    + +
    + Què validar al codi: que les metadades del recurs protegit són coherents amb les de l'AS; que els WWW-Authenticate hints inclouen issuer i authorization_uri; que es distingeix entre recursos OAuth i recursos MCP. +
    +
    + +
    +

    RFC 7009 Token Revocation

    +

    Defineix un endpoint perquè els clients informin l'AS que un token ja no s'ha d'acceptar.

    + +

    Revocació de refresh tokens

    +

    En aquest perfil el endpoint de revocació s'usa principalment per als refresh tokens. Quan un usuari revoca una aplicació, l'AS ha d'evitar que torni a obtenir tokens nous.

    + +

    Family invalidation

    +

    Si un refresh token es revoca o es detecta reús, tota la família de refresh tokens associada a aquella concessió s'ha d'invalidar. Això impedeix la reemissió silenciosa després de la revocació.

    + +

    Resposta

    +

    L'AS retorna 200 OK independentment de si el token existia o no (per no filtrar informació).

    + +
    + Què validar al codi: que /revoke accepta token i opcionalment token_type_hint; que revocar un refresh token invalida la seva família; que també esborra el consentiment recordat per aquell client; que la resposta és sempre 200. +
    +
    + +
    +

    RFC 9700 OAuth 2.0 Security Best Current Practice

    +

    No és un RFC de funcionalitat nova, sinó un recull de recomanacions de seguretat actualitzades. El perfil del PR l'aplica de forma integral.

    + +

    Recomanacions clau que cal exigir

    +
      +
    • Clients públics amb PKCE: tots els clients són públics i usen PKCE S256.
    • +
    • Exact redirect URI matching: el URI de la petició ha de coincidir exactament amb un URI registrat (tret del port loopback dinàmic).
    • +
    • State parameter: s'ha d'usar i validar per evitar CSRF.
    • +
    • Authorization code d'un sol ús: un cop canviat per token, s'ha d'invalidar.
    • +
    • Codi de curta durada: preferiblement menys d'un minut.
    • +
    • Refresh token rotation: cada cop que s'usa un refresh token, se n'emet un de nou i s'invalida l'anterior.
    • +
    • Reuse detection: si es rep un refresh token ja usat, s'invalida tota la família.
    • +
    • Issuer segur: l'issuer s'ha de derivar de fonts de confiança per evitar mix-up.
    • +
    • No enviar secrets: els clients públics no autentiquen el token endpoint amb secret.
    • +
    + +
    + Alineació amb el perfil: el fet que el PR només permeti clients públics, PKCE obligatori, rotació de refresh tokens i detecció de reús no és casualitat: és l'aplicació directa del BCP. +
    + +
    + Què validar al codi: que no hi ha camí que permeti un client confidencial; que PKCE és irrenunciable; que els redirect URIs es comparen exactament; que els refresh tokens roten; que un refresh token reutilitzat mata la família; que els auth codes caduquen ràpid i només serveixen una vegada. +
    +
    + +
    +

    Checklist per validar el codi del PR

    +

    Aquesta llista resumeix el que hauries de poder comprovar sense necessitat de conèixer la implementació interna.

    + +

    Flux Authorization Code + PKCE

    +
    + + + + + + + +
    + +

    Tokens i Bearer

    +
    + + + + +
    + +

    Registre i clients

    +
    + + + + +
    + +

    Metadata i recursos

    +
    + + + + +
    + +

    Revocació i consentiment

    +
    + + + +
    +
    + +
    +

    Què NO està implementat (i per què és correcte que no hi sigui)

    +

    El PR defineix un perfil intencionadament estret. Aquests estàndards són vàlids però no formen part de l'abast:

    +
      +
    • OpenID Connect (OIDC): no s'emeten id_token, no hi ha endpoint UserInfo ni descobriment OIDC (/.well-known/openid-configuration).
    • +
    • RFC 7662 Token Introspection: no hi ha endpoint d'introspecció; la validació es fa amb JWT.
    • +
    • RFC 7523 JWT Profile for Client Authentication: no s'usa private_key_jwt ni assertions JWT per autenticar el client.
    • +
    • Clients confidencials: no hi ha client_secret ni autenticació al token endpoint.
    • +
    • RFC 9068 JWT Access Token Profile: encara que els access tokens són JWT, el PR no reclama complir estrictament el perfil interoperable d'aquest RFC.
    • +
    +
    + Això és una decisió de disseny, no un error: el perfil és més segur i més senzill limitant-se a clients públics + PKCE, tal com recomana el BCP. +
    +
    + +
    +

    Flux resumit en 6 passos

    +
      +
    1. Registre (opcional per client): el client es registra a /oauth/register i obté un client_id públic.
    2. +
    3. Descobriment: el client llegeix .well-known/oauth-authorization-server per saber els endpoints.
    4. +
    5. Autorització: el client redirigeix l'usuari a /oauth/authorize amb PKCE, state i resource.
    6. +
    7. Consentiment: l'usuari inicia sessió i aprova els àmbits sol·licitats.
    8. +
    9. Intercanvi: el client envia el code i el code_verifier a /oauth/token i rep access_token i refresh_token.
    10. +
    11. Accés: el client usa l'access token com a Bearer per cridar l'API. Quan vol, pot revocar el refresh token o el consentiment.
    12. +
    +
    + +
    + +
    +

    Document generat per ajudar a la revisió teòrica del PR #1218 de Guillotina.

    +
    + + + diff --git a/oauth_docs_html.html b/oauth_docs_html.html new file mode 100644 index 000000000..8e2481076 --- /dev/null +++ b/oauth_docs_html.html @@ -0,0 +1,663 @@ + + + + + +Guillotina OAuth + MCP — Mapa visual d'implementació + + + +
    + + +
    +
    +

    Guillotina OAuth 2.0 + MCP

    +

    Servidor d'autorització OAuth integrat a Guillotina (perfil OAuth 2.0 Authorization Code + PKCE per a clients públics): PKCE obligatori, grants authorization_code i refresh_token, confinament d'audiència (resource indicators) i integració amb el protocol MCP com a recurs protegit.

    +

    Aquest document és un mapa visual de tot el que s'ha implementat a guillotina.contrib.oauth: fluxos de dades, camins alternatius, control d'errors, opcions de configuració i els RFC que es compleixen.

    +
    + RFC 6749RFC 6750RFC 7636 · PKCE + RFC 7591 · DCRRFC 7009 · RevokeRFC 8414 · Metadata + RFC 8707 · ResourcesRFC 9207 · issRFC 9700 · Security BCP + RFC 9728 · Protected Resource +
    +
    + + +
    +

    🗺️ Visió general

    +

    L'AS viu dins de cada container de Guillotina. Tot l'estat (clients, codis, refresh tokens, consentiments) es desa a PostgreSQL, aïllat per container_db_key = db_id/container.id. Els access tokens són JWT sense estat, signats amb una subclau dedicada derivada del secret de l'app (derive_key("access-token")), separada de la clau de signatura JWT genèrica.

    +
    +
    5

    Accions OAuth: register, authorize, token, revoke, consents

    +
    2

    Endpoints de descobriment .well-known (AS metadata; protected-resource només amb MCP)

    +
    PKCE

    S256 obligatori per defecte · plain rebutjat

    +
    PG

    PostgreSQL és l'únic backend · neteja periòdica d'expirats

    +
    + +

    Arquitectura a vista d'ocell

    +
    + + + + + + + + 🧑 Usuari + Navegador + + + + 📱 Client + App / MCP client + + + + 🛡️ Guillotina AS + contrib.oauth · per-container + + authorize · token · register · revoke + PKCE · CSRF · rate-limit · keys + JWT validator (bearer/cookie/ws) + .well-known metadata + + + + 🗄️ PostgreSQL + hashed store + + + + 🔌 Recurs + API · MCP protocol + + + + register / token + + authorize (login+consent) + + desa hash + + valida JWT / aud + + Bearer access_token → recurs + +
    +
    + + +
    +

    🎭 Actors i components

    +
    + 🧑 Usuari (navegador) + 📱 Client OAuth públic + 🛡️ AS · Authorization Server + 🗄️ Store · PostgreSQL + 🔌 RS · Recurs (API/MCP) +
    +
    +

    🛡️ Servidor d'autorització

    Serveis a api/services.py. Emet codis, tokens i gestiona consentiment. Un per container.

    +

    🔑 Mòdul de claus

    flow/keys.py · derive_key() deriva subclaus HMAC distintes per propòsit (access-token, token-hash, csrf) des de jwt.secret.

    +

    🗄️ Repositori PG

    storage/pg/repository.py · operacions atòmiques (DELETE…RETURNING) i rotació amb detecció de reús.

    +

    🧩 PKCE / CSRF

    flow/pkce.py (S256) i flow/csrf.py (token HMAC signat amb TTL).

    +

    🚦 Rate limiter

    flow/ratelimit.py · finestra lliscant en memòria per registre i per login fallit.

    +

    🔌 Integració MCP

    integrations/mcp.py · política IMCPAuthPolicy + metadata de recurs protegit (RFC 9728).

    +
    +
    + + +
    +

    🔗 Endpoints

    + + + + + + + + + +
    MètodeRutaFuncióAuthDescripció
    POST/{container}/oauth/register_registercap (públic)Registre dinàmic de client (RFC 7591). Rate-limited.
    GET POST/{container}/oauth/authorize_authorizelogin/cookieAutorització: login, consentiment i emissió de codi.
    POST/{container}/oauth/token_tokenPKCEBescanvi de codi i rotació de refresh.
    POST/{container}/oauth/revoke_revokecap (públic)Revocació de refresh token (RFC 7009).
    GET POST/{container}/oauth/consents_list_consents · _revoke_consentusuari (no anònim)Llistar i revocar consentiments propis.
    GET/{container}/.well-known/{action}OAuthWellKnownpúblicMetadata AS / alias OIDC (relatiu al container).
    GET/.well-known/{action}/{path}OAuthRFCWellKnownpúblicMetadata a l'arrel (RFC 8414/9728), resol container des del path.
    +

    {action}oauth-authorization-server, oauth-protected-resource (registrat per guillotina.contrib.mcp).

    +
    + + +
    +

    🔄 Cicle de vida del client (extrem a extrem)

    +

    Què ha de fer un client públic (app nativa, SPA o client MCP) de principi a fi per integrar-se: des del descobriment fins a la revocació. Tot el flux assumeix client públic amb PKCE S256 i scope guillotina:access.

    + +
    +
    Cicle complet: descobriment → ús del recurs → refresc → revocaciópúblic · PKCE S256
    + +
    0
    📱 Client🛡️ AS
    +
    Descobriment. GET /{container}/.well-known/oauth-authorization-server per obtenir authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, code_challenge_methods_supported.MCP: parteix del WWW-Authenticate 401 → oauth-protected-resourceauthorization_servers.
    + +
    1
    📱 Client🛡️ AS
    +
    Registre dinàmic (un cop). POST /oauth/register amb client_name + redirect_uris[] → desa el client_id retornat. El client no tria el client_id; el genera el servidor.
    + +
    2
    📱 Client
    +
    Genera PKCE (per petició). code_verifier = aleatori 43–128 chars; code_challenge = BASE64URL(SHA256(verifier)). Genera també un state aleatori i guarda'l.
    + +
    3
    📱 Client🧑 Usuari🛡️ AS
    +
    Sol·licitud d'autorització. Obre al navegador GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=guillotina:access&state=…&code_challenge=…&code_challenge_method=S256 (opcional resource=… si vol un recurs concret com MCP).
    + +
    4
    🧑 Usuari🛡️ AS
    +
    Login + consentiment a l'AS. L'usuari s'autentica i aprova els scopes/recursos. El client no veu mai les credencials.
    + +
    5
    🛡️ AS📱 Client
    +
    Recepció del codi. Redirecció a redirect_uri?code=…&state=…&iss=…. El client HA de verificar que state coincideix amb el desat i que iss és l'issuer esperat (anti mix-up).
    + +
    6
    📱 Client🛡️ AS
    +
    Bescanvi del codi. POST /oauth/token (application/x-www-form-urlencoded) amb grant_type=authorization_code, code, client_id, redirect_uri, code_verifier → rep access_token (JWT), refresh_token, expires_in, scope.
    + +
    7
    📱 Client🔌 RS
    +
    Ús del recurs. Cada petició a l'API o al servidor MCP amb Authorization: Bearer <access_token>. El token ha de portar dins aud l'audiència requerida pel request concret.
    + +
    8
    📱 Client🛡️ AS
    +
    Refresc (en expirar). POST /oauth/token amb grant_type=refresh_token, refresh_token, client_id → nou access_token i nou refresh_token. El client ha de substituir el refresh token desat (rotació).
    + +
    9
    📱 Client🛡️ AS
    +
    Tancament de sessió. POST /oauth/revoke amb token=<refresh_token> + client_id per revocar tota la família.
    +
    + +
    Re-autorització silenciosa: mentre el consentiment segueixi vàlid, repetir el pas 3 amb els mateixos paràmetres salta login/consentiment i retorna el codi directament. Quan el refresh token caduca o es revoca, cal tornar al pas 2.
    + +

    Checklist d'obligacions del client

    +
    +
      +
    • Generar un code_verifier nou i aleatori per cada autorització
    • +
    • Generar i verificar state a la tornada
    • +
    • Verificar que iss retornat == issuer esperat
    • +
    • Usar el mateix redirect_uri a authorize i a token
    • +
    • Enviar token com a x-www-form-urlencoded, no JSON
    • +
    +
      +
    • Desar access_token i refresh_token de forma segura
    • +
    • Substituir el refresh token a cada rotació (mai reusar l'antic)
    • +
    • Refrescar abans/quan expires_in s'esgoti
    • +
    • Per recursos especialitzats: demanar el resource correcte perquè entri a aud
    • +
    • Revocar el refresh token en tancar sessió
    • +
    +
    + +
    +
    0–1 descobr.+registre
    +
    2–3 PKCE+authorize
    +
    4–5 login+codi
    +
    6 token
    +
    7–9 ús/refresh/revoke
    +
    +
    + + +
    +

    1 · Registre dinàmic de client RFC 7591

    +

    El client s'auto-registra abans d'autoritzar. Només clients públics (token_endpoint_auth_method=none). El servidor genera el client_id; mai l'accepta del client.

    +
    +
    Registre dinàmicPOST /oauth/register
    +
    1
    📱 Client🛡️ AS
    +
    JSON amb client_name, redirect_uris[], opcional scope.Comprova rate limit per IP de transport (client_identifier).
    +
    2
    🛡️ AS
    +
    make_client() valida: redirect_uris no buit, cada URI segura, auth_method=none, grants/response_types suportats.clients.py · validate_redirect_uri()
    +
    3
    🛡️ AS🗄️ Store
    +
    Genera client_id = uuid4().hex i persisteix.
    +
    4
    🛡️ AS📱 Client
    +
    201 amb client_id, client_id_issued_at, redirect_uris, grant_types, response_types, scope, token_endpoint_auth_method i headers Cache-Control: no-store + Pragma: no-cache.
    +
    +
    Camí d'error: rate limit superat → 429 temporarily_unavailable. Metadades invàlides → 400 invalid_request / invalid_client_metadata. Enviar client_id propi → 400 «client_id is server-issued».
    +

    Validació de redirect_uri (anti open-redirect)

    +
    +

    ✓ Acceptat

    +
      +
    • https://host/path amb netloc i path
    • +
    • http://localhost|127.0.0.1|::1/path (loopback natiu)
    • +
    • esquema custom natiu app.scheme://host/path
    • +
    +

    ✗ Rebutjat

    +
      +
    • comodins *, fragments #…
    • +
    • javascript: / data:
    • +
    • http:// no-loopback
    • +
    +
    +
    + + +
    +

    2 · Autorització + PKCE RFC 6749 RFC 7636 RFC 9207

    +

    El cor del sistema. Tres sub-camins segons l'estat de sessió i consentiment. Tot abans de validar el redirect_uri que mostra pàgina d'error (mai redirigeix); tot després redirigeix amb error + state + iss.

    + +

    Validacions prèvies (en ordre)

    + + + + + + + + + + +
    #ComprovacióSi falla
    0Paràmetres singleton duplicats (reject_duplicate_params)400
    1Client existeix400 pàgina d'error
    2redirect_uri registrat (match exacte)400 pàgina d'error
    3response_type == code302 unsupported_response_type
    4Client té code a response_types302 unauthorized_client
    5PKCE obligatori: code_challenge present, sintaxi vàlida i mètode ∈ S256302 invalid_request
    6Scope: conté guillotina:access, és subconjunt dels scopes suportats i també dels scopes registrats pel client302 invalid_scope
    7Resource ∈ recursos permesos (RFC 8707). Si no s'envia resource, s'usa per defecte el container_url.302 invalid_target
    + +

    Camí A — Usuari NO autenticat → login al propi AS

    +
    +
    Sub-flux de login (credencials a l'AS, no és ROPC)GET/POST /oauth/authorize
    +
    1
    🧑 Usuari🛡️ AS
    GET sense sessió → es renderitza login.html amb tots els paràmetres com a camps ocults.
    +
    2
    🧑 Usuari🛡️ AS
    POST amb username/password.Comprova rate_limit_check abans d'autenticar.
    +
    3
    🛡️ AS
    _authenticate_basic() via validators tipus «basic». Èxit → emet cookie auth_token (HttpOnly, SameSite=Lax, Secure si HTTPS) i força pantalla de consentiment.
    +
    +
    Errors: credencials incorrectes → registra fallada + 401. Massa fallades a la finestra → 429 «Too many attempts». (login_rate_limit / login_rate_window)
    +
    El login ocorre a l'AS, no al client → no és el grant «password» (prohibit per RFC 9700 §2.4). A més, en autenticar-se en aquesta petició, la decision es força a None: mai s'auto-aprova consentiment en el mateix POST que les credencials.
    + +

    Camí B — Autenticat, sense consentiment previ → pantalla de consentiment

    +
    +
    Sub-flux de consentiment (protegit amb CSRF)GET → POST /oauth/authorize
    +
    1
    🛡️ AS🧑 Usuari
    consent.html amb scopes, recursos i un token CSRF HMAC signat (lligat a user, client, redirect, scope, state, challenge, resource + iat).
    +
    2
    🧑 Usuari🛡️ AS
    POST decision=allow|deny + oauth_csrf.
    +
    3
    🛡️ AS
    csrf_valid(): compara HMAC (constant-time), TTL i tots els camps. Si allow → desa consentiment.
    +
    4
    🛡️ AS🗄️ Store
    Crea codi opac goc_… (hash desat), lligat a client, user, redirect, scope, resource, code_challenge.
    +
    5
    🛡️ AS🧑 Usuari
    302 a redirect_uri?code=…&state=…&iss=…
    +
    +
    decision=deny302 access_denied.  |  CSRF invàlid302 invalid_request.  |  decision=allow per GET (sense POST) → mostra consentiment, no crea codi.
    + +

    Camí C — Consentiment ja existent → emissió silenciosa

    +
    Re-autorització silenciosa: si has_consent(ckey) és cert, un GET amb els mateixos paràmetres salta consentiment i redirigeix directament amb el codi. La clau de consentiment és user|client|scopes|resources.
    +
    +
    🧑 GET authorize
    +
    ¿sessió?
    +
    ¿consentiment?
    +
    crea codi
    +
    302 + code+iss
    +
    +
    + + +
    +

    3 · Bescanvi de codi per token RFC 6749 RFC 7636 RFC 9700

    +
    +
    grant_type = authorization_codePOST /oauth/token
    +
    1
    📱 Client🛡️ AS
    application/x-www-form-urlencoded amb code, client_id, redirect_uri, code_verifier.
    +
    2
    🛡️ AS🗄️ Store
    get_active_code() per hash + no expirat. Si no existeix → revoca família del codi (anti-replay) i invalid_grant.
    +
    3
    🛡️ AS
    Comprova: client coincideix, grant permès, redirect_uri idèntic, PKCE verify_s256(verifier, challenge).
    +
    4
    🛡️ AS🗄️ Store
    consume_code() = DELETE … RETURNING atòmic (un sol ús garantit fins i tot en concurrència).
    +
    5
    🛡️ AS
    Emet access_token JWT (iss, sub, aud=recursos, client_id, scope, exp) signat amb la subclau derive_key("access-token") + refresh_token opac gor_… lligat al hash del codi.
    +
    6
    🛡️ AS📱 Client
    200 Cache-Control: no-store amb access_token, token_type=Bearer, expires_in, refresh_token, scope.
    +
    +
    Matriu d'errors: content-type incorrecte → 400 invalid_request · grant desconegut → unsupported_grant_type · codi/redirect/PKCE incorrectes → invalid_grant · grant no permès al client → unauthorized_client · recurs no subconjunt → invalid_target.
    +
    PKCE no és desactivable: si el codi nocode_challenge registrat, el bescanvi retorna invalid_grant. Aquest perfil només suporta clients públics i requereix PKCE sempre.
    +
    + + +
    +

    4 · Refresh amb rotació i detecció de reús RFC 9700 §4.14

    +

    Els refresh tokens per a clients públics es roten sempre. Reutilitzar un token ja rotat indica compromís → es revoca tota la família de l'autorització.

    +
    +
    grant_type = refresh_tokenPOST /oauth/token
    +
    1
    📱 Client🛡️ AS
    refresh_token + client_id (opcional scope/resource per reduir).
    +
    2
    🛡️ AS🗄️ Store
    get_valid_refresh(): no expirat i revoked_at IS NULL.
    +
    3
    🛡️ AS
    Scope/resource han de ser subconjunt dels originals. Client coincident i grant permès.
    +
    4
    🛡️ AS🗄️ Store
    rotate_refresh_token(): revoca l'antic (replaced_by) i crea el nou en una operació condicional. Si ja estava rotat → fallida.
    +
    5
    🛡️ AS📱 Client
    200 nou access_token + nou refresh_token.
    +
    +
    Detecció de reús: si arriba un refresh token que ja té revoked_at (reutilitzat) → revoke_refresh_family_for_reuse() revoca tots els tokens de la mateixa auth_code_hash i retorna invalid_grant.
    +
    + + +
    +

    5 · Revocació RFC 7009

    +
    +
    Revocació de refresh tokenPOST /oauth/revoke
    +
    1
    📱 Client🛡️ AS
    token + client_id (opcional token_type_hint), form-urlencoded.
    +
    2
    🛡️ AS🗄️ Store
    Busca per hash; si el client_id és el propietari → revoke_refresh_family() (revoca tota la família) i esborra el consentiment associat (consent_key del registre) perquè no es torni a emetre en silenci.
    +
    3
    🛡️ AS📱 Client
    200 {} sempre (també per tokens desconeguts, per RFC 7009).
    +
    +
    Límit conegut: els access token són JWT sense estat → no es poden revocar individualment abans del seu exp (TTL 1h). La revocació afecta la cadena de refresh. (decisió de disseny documentada)
    +
    + + +
    +

    8 · Gestió de consentiments

    +

    Els consentiments es desen a oauth_consents (per container_db_key + consent_key = user|client|scopes|resources) i permeten la re-autorització silenciosa. Ara tenen caducitat configurable i l'usuari els pot llistar i revocar ell mateix.

    +
    +

    ⏳ Caducitat (TTL)

    El setting consent_ttl (per defecte 2592000s = 30 dies) fixa expires_at en crear/renovar el consentiment. has_consent() ignora els caducats i oauth_cleanup_expired els purga. consent_ttl=0expires_at NULL (mai caduca). Renovar un consentiment existent (ON CONFLICT DO UPDATE) refresca l'expires_at.

    +

    🗑️ Revocació completa

    Revocar un consentiment fa una deautorització total del parell (usuari, client): esborra el registre de consentiment i revoca tots els refresh tokens vius via revoke_user_client_refresh_tokens(). La següent autorització tornarà a mostrar la pantalla de consentiment.

    +
    +
    +
    Llistar i revocar consentiments propisGET · POST /oauth/consents
    +
    1
    🧑 Usuari🛡️ AS
    GET /oauth/consents (usuari autenticat, no anònim) → 200 {"consents":[…]} amb client_id, client_name, scope, resource, granted_at, expires_at. Capçalera Cache-Control: no-store.
    +
    2
    🧑 Usuari🛡️ AS
    POST /oauth/consents amb consent_key=… (form-urlencoded) → revoca aquell consentiment i la família de refresh associada.
    +
    3
    🛡️ AS🧑 Usuari
    200. consent_key desconegut → 404. Usuari anònim → 401.
    +
    +
    Els endpoints són públics a nivell de permís (guillotina.Public) però comproven manualment que l'usuari no sigui anònim; cada usuari només veu i revoca els seus propis consentiments (filtrats per user_id).
    +
    + + +
    +

    6 · Validació de token i accés a recursos RFC 6750 RFC 8707

    +

    OAuthJWTValidator només accepta tokens OAuth via Authorization: Bearer. Decodifica el JWT amb un sol algorisme (evita confusió d'algorismes) i amb la subclau dedicada d'access-token (derive_key("access-token"), no el jwt.secret genèric), i aplica confinament d'audiència.

    +
    +
    Bearer access_token → API o MCPauth/validators.py · OAuthJWTValidator
    +
    1
    📱 Client🔌 RS
    Authorization: Bearer <jwt>
    +
    2
    🔌 RS
    Verifica signatura amb la subclau d'access-token (access_token_key() = derive_key("access-token")) + token_type=oauth_access_token + iss == container_url.
    +
    3
    🔌 RS
    Calcula oauth_required_audience(request, container) i exigeix que aquest valor sigui dins aud. Per defecte és el container_url; integracions com MCP registren resolvers d'audiència propis.
    +
    4
    🔌 RS
    Requereix scope guillotina:access; resol l'usuari i adjunta request.oauth (client_id, scopes, resources).
    +
    +

    Política d'autorització MCP RFC 9728

    +
    +

    🔒 Repte d'autenticació

    Sense token vàlid → 401 amb WWW-Authenticate: Bearer … resource_metadata="…/.well-known/oauth-protected-resource/…".

    +

    🎯 Confinament d'audiència

    MCP registra un resolver que fa que els requests /@mcp/protocol exigeixin aud = container_url + "/@mcp/protocol". Un token només per a l'API (aud=container) NO val per a MCP i viceversa.

    +
    +
    El permís efectiu del servei MCP passa de guillotina.MCPExecute a guillotina.Public + comprovació manual: 401 si anònim, 403 si sense permís, 401 amb repte si l'audiència no és vàlida.
    +
    Separació de tipus de token (defensa en profunditat): els validadors JWT del core (JWTValidator i JWTSessionValidator) ignoren qualsevol token amb token_type == "oauth_access_token". Així un access token OAuth només pot ser validat per OAuthJWTValidator i mai pel camí d'autenticació genèric de Guillotina (ni com a sessió). guillotina/auth/validators.py
    +
    + + +
    +

    🎯 Resource vs audience RFC 8707

    +

    resource i aud representen el mateix confinament vist en dos moments diferents: resource és el que el client demana al flux OAuth; aud és el que el servidor grava dins l'access token i el que el recurs protegit exigeix quan rep el token.

    + +
    +
    resource → aud → required audienceRFC 8707
    +
    1
    📱 Client🛡️ AS
    El client pot enviar resource=https://api.example.com/db/guillotina/@mcp/protocol a /oauth/authorize o /oauth/token.
    +
    2
    🛡️ AS
    validate_resource() comprova que el valor sigui dins el conjunt de recursos permesos. Aquest conjunt surt de register_oauth_resource_resolver().
    +
    3
    🛡️ AS📱 Client
    issue_access_token() emet el JWT amb aud=[resource]. Si no s'havia demanat cap resource, el valor per defecte és el container_url.
    +
    4
    📱 Client🔌 RS
    Quan arriba el Bearer token, OAuthJWTValidator calcula oauth_required_audience(request, container) i rebutja el token si aquesta audience no és dins aud.
    +
    + +
    +

    Sense resource

    El token surt amb aud=[container_url]. Serveix per APIs genèriques del container, com /@addons, però no per recursos especialitzats.

    +

    Amb resource MCP

    El token surt amb aud=[container_url + "/@mcp/protocol"]. Serveix per MCP, però no per l'API genèrica del container.

    +

    Carregar MCP no substitueix el container

    OAuth core sempre registra el container_url com a recurs base. MCP només afegeix un recurs addicional; no bloqueja clients OAuth normals.

    +

    Desacoblament de protocols

    El core OAuth no coneix MCP. Les integracions declaren recursos amb register_oauth_resource_resolver() i audiences requerides amb register_oauth_audience_resolver().

    +
    + +
    Regla operativa: no n'hi ha prou que el token tingui una audience globalment permesa. El token ha de portar explícitament l'audience requerida pel request concret. Això evita usar un token MCP contra l'API genèrica o un token genèric contra MCP.
    +
    + + +
    +

    7 · Descobriment / metadata RFC 8414 RFC 9728

    +
    +

    oauth-authorization-server

    Endpoints, response_types, grant_types, code_challenge_methods_supported=[S256], resource_indicators_supported, authorization_response_iss_parameter_supported.

    +

    oauth-protected-resource

    Registrat per guillotina.contrib.mcp quan l'addon està actiu: resource, authorization_servers, scopes_supported.

    +
    +
    Derivació de l'issuer: oauth.issuer fixat > (si trust_proxy_headers) capçaleres de proxy > transport scheme+Host. Per defecte no es confia en capçaleres spoofables.
    +
    + + +
    +

    ⚠️ Control d'errors (resum global)

    + + + + + + + + + + + + + + + +
    EndpointCondicióRespostaFormat
    registerrate limit429 temporarily_unavailableJSON
    registermetadades / client_id propi400 invalid_request · invalid_client_metadataJSON
    authorizeclient / redirect desconeguts400 pàgina HTML (no redirigeix)HTML
    authorizeresponse_type / client302 unsupported_response_type · unauthorized_clientredirect
    authorizePKCE absent/invàlid302 invalid_requestredirect
    authorizescope / resource302 invalid_scope · invalid_targetredirect
    authorizelogin fallit / rate limit401 · 429 pàgina HTMLHTML
    authorizedeny / CSRF invàlid302 access_denied · invalid_requestredirect
    tokencontent-type / grant400 invalid_request · unsupported_grant_typeJSON
    tokencodi/PKCE/redirect/refresh400 invalid_grantJSON
    tokengrant no permès al client400 unauthorized_clientJSON
    revokequalsevol token200 {}JSON
    well-knownaction/container desconegut404JSON
    +
    + + +
    +

    ⚙️ Configuració (app_settings["oauth"])

    + + + + + + + + + + + + + + + +
    ClauPer defecteEfecte
    issuerNoneURL canònica de l'issuer/audiència. Recomanat fixar-lo en producció.
    trust_proxy_headersFalseHonora X-Forwarded-* darrere proxy de confiança.
    allowed_code_challenge_methods["S256"]Mètodes PKCE permesos.
    scopes_supported["guillotina:access"]Scopes admesos.
    authorization_code_ttl600sVida del codi d'autorització.
    access_token_ttl3600sVida de l'access token JWT.
    refresh_token_ttl2592000sVida del refresh token (30 dies).
    consent_ttl2592000sNou: caducitat dels consentiments (30 dies). 0 = mai caduca.
    authorize_csrf_ttl600sValidesa del token CSRF de consentiment.
    registration_rate_limit / _window20 / 600sThrottle de registre per IP (0 = desactivat).
    login_rate_limit / _window10 / 300sNou: throttle de logins fallits per IP+username.
    token_rate_limit / _window120 / 60sThrottle del token endpoint per IP de transport.
    cleanup_interval / cleanup_batch_size900s / 5000Neteja periòdica de codis/refresh expirats (task d'OAuthStorageUtility).
    +
    + + +
    +

    🛡️ Mecanismes de seguretat

    +
    +
      +
    • PKCE S256 obligatori; plain rebutjat
    • +
    • Codi d'un sol ús atòmic (DELETE…RETURNING)
    • +
    • Anti-replay: reús de codi revoca família de refresh
    • +
    • Rotació de refresh + detecció de reús
    • +
    • PKCE no desactivable per clients públics
    • +
    • Codis i refresh desats com a hash (HMAC clau derivada)
    • +
    • Separació de claus per propòsit (derive_key): access-token, token-hash i csrf usen subclaus distintes
    • +
    +
      +
    • Match exacte de redirect_uri; sense open-redirect
    • +
    • Confinament d'audiència (resource indicators)
    • +
    • CSRF de consentiment HMAC signat amb TTL
    • +
    • Paràmetre iss a la resposta (anti mix-up)
    • +
    • Rate limit a registre i a logins fallits
    • +
    • Rebuig de paràmetres duplicats (anti param-pollution)
    • +
    • 302 (no 307) → credencials no es reenvien; cookie HttpOnly/SameSite/Secure
    • +
    • Aïllament multi-tenant per container_db_key
    • +
    • Validadors core ignoren token_type=oauth_access_token (sense confusió de camins d'auth)
    • +
    +
    +
    + + +
    +

    📜 RFCs i compliment

    +
    + ✓ Complet + ◑ Parcial / per disseny + ℹ️ Suport amb nota +
    +
    +
    RFC 6749✓ Complet
    OAuth 2.0 Authorization Framework
    +
    • Grants authorization_code i refresh_token
    • Errors estàndard (invalid_grant, invalid_scope…)
    • Preservació de state · Cache-Control: no-store
    +
    RFC 6750✓ Complet
    Bearer Token Usage
    +
    • Authorization: Bearer
    • WWW-Authenticate amb error/scope
    +
    RFC 7636✓ Complet
    PKCE
    +
    • Només S256; verificador 43–128
    • Verificació al token endpoint
    • Defensa downgrade (RFC 9700 §4.8.2)
    +
    RFC 7591✓ Complet
    Dynamic Client Registration
    +
    • Registre obert (rate-limited)
    • client_id generat pel servidor
    • Validació de metadades
    +
    RFC 7009✓ Complet
    Token Revocation
    +
    • 200 per a tokens desconeguts
    • Revocació de família · verificació de propietari
    +
    RFC 8414✓ Complet
    Authorization Server Metadata
    +
    • .well-known/oauth-authorization-server
    • Publica code_challenge_methods_supported
    +
    RFC 8707✓ Complet
    Resource Indicators
    +
    • resource a authorize/token
    • aud confinada · resolvers extensibles
    +
    RFC 9207✓ Complet
    Issuer Identification
    +
    • iss a totes les respostes d'autorització
    • Anunciat a la metadata
    +
    RFC 9728ℹ️ MCP
    Protected Resource Metadata
    +
    • .well-known/oauth-protected-resource
    • Repte WWW-Authenticate amb resource_metadata
    +
    RFC 9700✓ Seguit
    Security BCP (gener 2025)
    +
    • PKCE obligatori, sense implicit, sense ROPC
    • Rotació + reús, match exacte, anti open-redirect
    • Throttle de login, defensa downgrade, iss
    +
    RFC 9068◑ Inspirat
    JWT Access Token Profile
    +
    • Access token és JWT amb iss/sub/aud/exp/scope
    • Nota: usa token_type custom, no la capçalera typ: at+jwt
    +
    OAuth 2.0 profile✓ Alineat
    Authorization Code + PKCE public clients
    +
    • Clients públics + PKCE, sense implicit/ROPC
    • Rotació de refresh obligatòria
    +
    +
    + + +
    +

    ✅ Matriu de requisits MUST (RFC 9700)

    + + + + + + + + + + + + + + +
    Requisit normatiuEstatOn
    Match exacte de redirect URI (excepte port loopback)clients.py:validate_redirect_uri
    Cap open redirectorpàgina d'error abans de validar redirect
    Clients públics MUST usar PKCEPKCE obligatori, sense opció de desactivar-lo
    AS MUST suportar PKCE i enforce verifierverify_s256
    Mitigar PKCE downgrade_authorization_code (verifier sense challenge → reject)
    Refresh de clients públics: rotació o sender-constrainedrotate_refresh_token + reús
    Restricció d'audiència de l'access tokenaud = resources · validació MCP
    No usar implicit grantnomés response_type=code
    No usar ROPClogin a l'AS, no al client
    Respostes d'autorització no per HTTP sense xifrarredirect https (excepte loopback natiu)
    Publicar AS Metadata (RFC 8414).well-known
    Defensa mix-up (iss)RFC 9207 a totes les respostes
    +
    Aquest document reflecteix la implementació actual de guillotina.contrib.oauth. Per als detalls de codi, vegeu guillotina/contrib/oauth/ i els tests a guillotina/tests/oauth/.
    +
    +
    +
    + + + + From 5cf426a20785da3d4ddfa865fd47890b739165be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 31 May 2026 06:05:03 +0200 Subject: [PATCH 05/27] feat: Several improves following RFCs --- docs/source/_static/oauth-flow.svg | 228 +++++++++++++ docs/source/contrib/oauth.md | 24 +- guillotina/contrib/mcp/interfaces.py | 3 + guillotina/contrib/mcp/permissions.py | 2 - guillotina/contrib/oauth/__init__.py | 8 + guillotina/contrib/oauth/api/request.py | 48 ++- guillotina/contrib/oauth/api/services.py | 303 ++++++++---------- guillotina/contrib/oauth/api/urls.py | 10 +- guillotina/contrib/oauth/api/views.py | 142 ++++++++ guillotina/contrib/oauth/auth/validators.py | 6 +- guillotina/contrib/oauth/flow/clients.py | 30 +- guillotina/contrib/oauth/flow/csrf.py | 68 ++++ guillotina/contrib/oauth/flow/pkce.py | 9 + guillotina/contrib/oauth/flow/ratelimit.py | 58 ++++ guillotina/contrib/oauth/flow/tokens.py | 7 +- guillotina/contrib/oauth/integrations/mcp.py | 28 +- .../contrib/oauth/storage/interfaces.py | 9 +- .../contrib/oauth/storage/pg/repository.py | 26 +- guillotina/contrib/oauth/storage/utility.py | 18 ++ guillotina/tests/oauth/conftest.py | 26 +- guillotina/tests/oauth/test_mcp_oauth.py | 64 ++-- .../tests/oauth/test_oauth_authorize.py | 164 +++++++++- guillotina/tests/oauth/test_oauth_metadata.py | 37 +++ guillotina/tests/oauth/test_oauth_register.py | 31 ++ guillotina/tests/oauth/test_oauth_revoke.py | 93 +++++- .../tests/oauth/test_oauth_store_contract.py | 6 +- guillotina/tests/oauth/test_oauth_token.py | 84 ++++- 27 files changed, 1273 insertions(+), 259 deletions(-) create mode 100644 docs/source/_static/oauth-flow.svg create mode 100644 guillotina/contrib/oauth/api/views.py create mode 100644 guillotina/contrib/oauth/flow/csrf.py create mode 100644 guillotina/contrib/oauth/flow/ratelimit.py diff --git a/docs/source/_static/oauth-flow.svg b/docs/source/_static/oauth-flow.svg new file mode 100644 index 000000000..4d40c9e93 --- /dev/null +++ b/docs/source/_static/oauth-flow.svg @@ -0,0 +1,228 @@ + + Guillotina OAuth requests and discovery paths + Sequence diagram showing every request a client can use to discover Guillotina OAuth metadata, register, authorize with PKCE, exchange tokens, call REST or MCP resources, refresh, and revoke. + + + + + + + + + + + + Guillotina OAuth: discovery, registration, authorization and token requests + The diagram shows the implemented request paths, including REST discovery, MCP cold-start discovery, PKCE, token refresh, and revoke. + + + Client app + Cursor, CLI, SPA, native app + + + Browser + user + login and consent UI + + + Guillotina OAuth + authorization server in container + + + Protected resource + REST API or /@mcp/protocol + + + + + + + + 0. Cold-start discovery: how the client learns the OAuth URLs + + + + 0A. REST/client already knows the container URL + GET /db/container/.well-known/oauth-authorization-server + Alternative RFC 8414 root path: /.well-known/oauth-authorization-server/db/container + Compatibility alias: /.well-known/openid-configuration/db/container + + + + 200 authorization server metadata + issuer, authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint + + + + 0B. MCP client knows only the MCP endpoint + POST /db/container/@mcp/protocol without Authorization header + This is the initial MCP cold start when no access token is available yet. + + + + 401 WWW-Authenticate + Bearer resource_metadata= + "/.well-known/oauth-protected-resource/db/container/@mcp/protocol" + + + + 0C. Client follows resource_metadata + GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol + Container-scoped equivalent: /db/container/.well-known/oauth-protected-resource + + + + 200 protected resource metadata + resource, authorization_servers, scopes_supported + + + Implemented discovery variants + OAuth AS metadata: oauth-authorization-server and openid-configuration alias. + MCP protected-resource metadata: oauth-protected-resource. + The OpenID URL returns OAuth metadata only; this is not full OIDC. + + + 1. Dynamic client registration + + + + 1. Register public client + POST /db/container/oauth/register + JSON: client_name, redirect_uris[], token_endpoint_auth_method="none" + + + + 200 registration response + server-issued client_id; client-supplied client_id is rejected + + + Security behavior + Registration is create-only: no upsert and no redirect_uri merge. + Clients needing several callbacks must register all redirect_uris at once. + + + 2. Authorization code with PKCE + + + + 2A. Local PKCE step + No HTTP request. + Generate code_verifier and S256 code_challenge. + Keep code_verifier on the client. + + + + 2B. Open browser + Navigate user to authorization URL + + + + 2C. GET authorize + GET /db/container/oauth/authorize + response_type=code, client_id, redirect_uri, state + code_challenge, code_challenge_method=S256 + scope optional; resource optional + + + + HTML login/consent page + shown when authentication or consent is needed + + + + 2D. POST consent decision + POST /db/container/oauth/authorize + decision=allow plus original authorize parameters + + + + 302 redirect + Location: redirect_uri?code=goc_...&state=... + + + + 2E. Callback to client + Client receives code and validates state + + + Implemented authorize variants + resource omitted: defaults to the container issuer URL. + resource = /@mcp/protocol: token audience is the MCP endpoint. + scope omitted: empty scope claim; otherwise use guillotina:access. + PKCE is required by default; tests can disable it via configuration. + + + 3. Token endpoint grants + + + + 3A. Exchange authorization code + POST /db/container/oauth/token + grant_type=authorization_code, client_id, redirect_uri, code + code_verifier, optional resource subset + + + + 200 token response + access_token = JWT, refresh_token = opaque token + JWT aud equals container URL or MCP protocol URL. + + + + 3B. Refresh token grant + POST /db/container/oauth/token + grant_type=refresh_token, client_id, refresh_token + + + + 200 rotated token response + new access_token and new refresh_token + Reusing an old refresh token revokes its rotation family. + + + 4. Use and revoke + + + + 4A. Call protected resource + REST: GET /db/container/... or MCP: POST /db/container/@mcp/protocol + Header: Authorization: Bearer ACCESS_TOKEN + Guillotina then checks normal user permissions. + + + + 200 / 401 / 403 response + Depends on token validity, audience/resource, and Guillotina permissions. + + + + 4B. Optional revoke + POST /db/container/oauth/revoke + client_id, token=REFRESH_TOKEN, token_type_hint=refresh_token + + + + 200 revoke response + diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index 84cd7f0e4..f8eecd63e 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -99,12 +99,14 @@ register_oauth_resource_resolver(my_resolver) ## Dynamic client registration and redirect URIs -`/oauth/authorize` accepts only redirect URIs that are already present on the client record. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. +`/oauth/authorize` accepts only redirect URIs that are already present on the client record. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. HTTPS redirect URIs are accepted for web clients. Plain HTTP is accepted only for loopback/native redirects (`localhost`, `127.0.0.1`, `::1`). Redirect URIs with fragments are rejected. ## Supported flow The contrib implements public-client OAuth 2.1 Authorization Code with PKCE (`S256`), dynamic client registration, opaque refresh tokens, revocation, and JWT access tokens signed with Guillotina's configured JWT secret. +![OAuth 2.1 authorization code flow with PKCE in Guillotina](../_static/oauth-flow.svg) + Endpoints are container scoped: ```text @@ -224,7 +226,7 @@ For an **MCP client**, include the MCP protocol endpoint as `resource`: http://localhost:8080/db/container/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=http://127.0.0.1:12345/callback&scope=guillotina:access&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256&state=some_random_state&resource=http://localhost:8080/db/container/@mcp/protocol ``` -The `scope` parameter is optional. When omitted, the token is issued with an empty `scope` claim. When present, use `guillotina:access`. +The `scope` parameter is required and must include `guillotina:access`. Once the user logs in and consents, they will be redirected back to your `redirect_uri` with an authorization code parameter: `http://127.0.0.1:12345/callback?code=goc_XYZ123&state=some_random_state` @@ -250,7 +252,21 @@ curl -X POST http://localhost:8080/db/container/oauth/token \ -d 'grant_type=refresh_token&client_id=CLIENT_ID&refresh_token=YOUR_REFRESH_TOKEN' ``` -Refresh tokens are rotated on every successful refresh. Reusing an older refresh token fails and revokes the token family created from the same authorization code. +Guillotina rotates refresh tokens on every successful refresh. The response contains a new `access_token` and a new `refresh_token`: + +```json +{ + "access_token": "NEW_ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "NEW_REFRESH_TOKEN", + "scope": "guillotina:access" +} +``` + +Clients must persist the new `refresh_token` and discard the old one immediately. The previous refresh token is revoked as soon as the rotation succeeds. + +If an older refresh token is reused, Guillotina treats it as a replay signal and revokes the refresh-token family created from the same authorization code. OAuth clients should serialize refresh operations so two concurrent requests do not try to use the same refresh token at the same time. To revoke an active refresh token: @@ -270,7 +286,7 @@ OAuth provides **authentication** and **resource binding**. **Authorization** is | Which resource? | Token audience (`aud`) — container URL or MCP endpoint | | What can they do? | Guillotina roles and ACLs (`AddContent`, `ModifyContent`, `MCPExecute`, …) | -The OAuth scope `guillotina:access` is **protocol metadata only**. It appears in discovery, consent screens, and token claims, but it is **not checked** when calling the REST API or MCP tools. +OAuth access tokens must include the `guillotina:access` scope. Authorization is still enforced with native Guillotina permissions on the authenticated user. ### REST API clients diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index b1307dc5b..f42ed7ae8 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -39,5 +39,8 @@ def is_enabled(request, context): def unauthorized_headers(request, context): """Return extra headers for an unauthenticated MCP protocol response.""" + def forbidden_headers(request, context): + """Return extra headers for an authenticated but unauthorized MCP protocol response.""" + def is_authorized(request, context): """Return whether the current authenticated request may use this MCP endpoint.""" diff --git a/guillotina/contrib/mcp/permissions.py b/guillotina/contrib/mcp/permissions.py index 97935cce1..9e0d23880 100644 --- a/guillotina/contrib/mcp/permissions.py +++ b/guillotina/contrib/mcp/permissions.py @@ -7,5 +7,3 @@ configure.grant(permission="guillotina.MCPView", role="guillotina.Manager") configure.grant(permission="guillotina.MCPView", role="guillotina.Owner") configure.grant(permission="guillotina.MCPExecute", role="guillotina.Manager") -configure.grant(permission="guillotina.MCPExecute", role="guillotina.Owner") -configure.grant(permission="guillotina.MCPExecute", role="guillotina.Editor") diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index b69f31c66..df82faad9 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -5,12 +5,20 @@ "oauth": { "enabled": True, "issuer": None, + # When False (default) the issuer is derived only from the transport + # scheme and Host header. Enable behind a trusted reverse proxy so that + # X-Forwarded-Proto / X-VirtualHost-* headers are honored. + "trust_proxy_headers": False, "authorization_code_ttl": 600, "access_token_ttl": 3600, "refresh_token_ttl": 2592000, "require_pkce": True, "allowed_code_challenge_methods": ["S256"], "scopes_supported": ["guillotina:access"], + # Dynamic client registration throttling (per client IP, sliding window). + # Set ``registration_rate_limit`` to 0 to disable. + "registration_rate_limit": 20, + "registration_rate_window": 600, }, "check_writable_request": "guillotina.contrib.oauth.api.request.check_writable_request", "auth_token_validators": [ diff --git a/guillotina/contrib/oauth/api/request.py b/guillotina/contrib/oauth/api/request.py index 15c61d246..ff4976a6c 100644 --- a/guillotina/contrib/oauth/api/request.py +++ b/guillotina/contrib/oauth/api/request.py @@ -21,8 +21,54 @@ def normalize_list(value): return [item for item in str(value).split() if item] -def parse_form_encoded(body): +def form_content_type_valid(request): + content_type = request.headers.get("content-type", "") + return content_type.split(";", 1)[0].strip().lower() == "application/x-www-form-urlencoded" + + +def client_identifier(request): + """Return a stable identifier for the connecting peer. + + Uses the direct transport peer address (ASGI ``scope['client']``) rather + than ``X-Forwarded-For`` so the value cannot be spoofed by the caller to + bypass throttling. Behind a trusted reverse proxy every request shares the + proxy address; operators wanting per-client limits there should terminate + rate limiting at the proxy. + """ + scope = getattr(request, "scope", None) or {} + client = scope.get("client") + if client: + return str(client[0]) + return "unknown" + + +def duplicate_param_names(params, singleton_fields): + duplicates = [] + for field in singleton_fields: + getall = getattr(params, "getall", None) + if callable(getall): + try: + values = getall(field, []) + except TypeError: + values = getall(field) + if len(values) > 1: + duplicates.append(field) + continue + value = params.get(field) if hasattr(params, "get") else None + if isinstance(value, (list, tuple)) and len(value) > 1: + duplicates.append(field) + return duplicates + + +def reject_duplicate_params(params, singleton_fields): + duplicates = duplicate_param_names(params, singleton_fields) + if duplicates: + oauth_error("invalid_request", f"duplicate parameter: {duplicates[0]}") + + +def parse_form_encoded(body, *, singleton_fields=()): parsed = parse_qs(body, keep_blank_values=True) + reject_duplicate_params(parsed, singleton_fields) return {key: values if len(values) > 1 else values[0] for key, values in parsed.items()} diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 86cbeea57..2cf68a0f7 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -1,14 +1,15 @@ -from base64 import b64encode -from functools import lru_cache -from html import escape as html_escape -from pathlib import Path -from string import Template - from guillotina import app_settings, configure from guillotina.api.service import Service from guillotina.auth.utils import set_authenticated_user -from guillotina.contrib.oauth.api.request import normalize_list, parse_form_encoded +from guillotina.contrib.oauth.api.request import ( + client_identifier, + form_content_type_valid, + normalize_list, + parse_form_encoded, + reject_duplicate_params, +) from guillotina.contrib.oauth.api.urls import container_url, validate_resource +from guillotina.contrib.oauth.api.views import consent_form, login_form, oauth_error_page from guillotina.contrib.oauth.api.well_known import rfc_well_known_response from guillotina.contrib.oauth.flow.clients import ( consent_key, @@ -16,18 +17,41 @@ redirect_uri_registered_for_client, redirect_with_params, ) -from guillotina.contrib.oauth.flow.pkce import verify_s256 -from guillotina.contrib.oauth.flow.scopes import OAUTH_SCOPE_DESCRIPTIONS, oauth_scopes_supported +from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid +from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid, verify_s256 +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash from guillotina.contrib.oauth.storage.access import get_oauth_store from guillotina.interfaces import IApplication, IContainer -from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, Response +from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, HTTPTooManyRequests, Response from guillotina.utils import get_authenticated_user WELL_KNOWN_HANDLERS = {} -TEMPLATE_DIR = Path(__file__).parent / "templates" -BRAND_LOGO_PATH = Path(__file__).parents[3] / "static" / "assets" / "brand" / "guillotina-logo-horizontal.svg" +AUTHORIZE_SINGLETON_PARAMS = { + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "decision", + "username", + "password", + OAUTH_CSRF_FIELD, +} +TOKEN_SINGLETON_PARAMS = { + "grant_type", + "client_id", + "redirect_uri", + "code", + "code_verifier", + "refresh_token", + "scope", +} +REVOKE_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} def register_well_known_handler(name, handler): @@ -46,6 +70,8 @@ def _metadata(request, container): "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], + "revocation_endpoint_auth_methods_supported": ["none"], + "resource_indicators_supported": True, "scopes_supported": oauth_scopes_supported(), } @@ -133,6 +159,18 @@ async def __call__(self): async def _register(service, store): + oauth_settings = app_settings.get("oauth", {}) + if rate_limit_exceeded( + f"oauth-register:{client_identifier(service.request)}", + limit=oauth_settings.get("registration_rate_limit", 20), + window=oauth_settings.get("registration_rate_window", 600), + ): + return HTTPTooManyRequests( + content={ + "error": "temporarily_unavailable", + "error_description": "client registration rate limit exceeded", + } + ) data = await service.request.json() try: client = make_client(data) @@ -163,147 +201,40 @@ async def _authenticate_basic(username, password): return user -def _html(body, status=200): - return Response(body=body.encode("utf-8"), status=status, content_type="text/html") - - -@lru_cache(maxsize=None) -def _template(name): - return Template((TEMPLATE_DIR / name).read_text(encoding="utf-8")) - - -@lru_cache(maxsize=None) -def _template_text(name): - return (TEMPLATE_DIR / name).read_text(encoding="utf-8") - - -@lru_cache(maxsize=None) -def _logo_data_uri(): - encoded = b64encode(BRAND_LOGO_PATH.read_bytes()).decode("ascii") - return f"data:image/svg+xml;base64,{encoded}" - - -def _render_template(template_name, **context): - return _template(template_name).substitute(context) - - -def _oauth_page(title, heading, body, *, status=200, tone="default"): - return _html( - _render_template( - "base.html", - title=html_escape(title), - logo_src=_logo_data_uri(), - style=_template_text("oauth.css"), - tone=html_escape(tone), - heading=html_escape(heading), - body=body, - ), - status=status, - ) - - -def _hidden_inputs(params): - fields = ( - "response_type", - "client_id", - "redirect_uri", - "scope", - "state", - "code_challenge", - "code_challenge_method", - "resource", - ) - html = [] - for field in fields: - value = params.get(field) - if value is None: - continue - values = value if isinstance(value, list) else [value] - for item in values: - html.append( - _render_template( - "hidden_input.html", - name=html_escape(field, quote=True), - value=html_escape(str(item), quote=True), - ) - ) - return "\n".join(html) - - -def _oauth_error_page(title, message, *, status): - return _oauth_page( - title, - title, - _render_template("error.html", message=html_escape(message)), - status=status, - tone="error", - ) - - -def _login_form(params, client): - client_name = html_escape(client.get("client_name") or client["client_id"]) - body = _render_template( - "login.html", - client_name=client_name, - client_id=html_escape(client["client_id"]), - redirect_uri=html_escape(params.get("redirect_uri", "")), - hidden_inputs=_hidden_inputs(params), - ) - return _oauth_page("Login to Guillotina", "Login required", body) - - -def _list_items(values, *, empty): - if not values: - return _render_template("plain_item.html", value=html_escape(empty)) - return "".join(_render_template("list_item.html", value=html_escape(str(value))) for value in values) - - -def _scope_items(scopes): - if not scopes: - return _render_template("plain_item.html", value="No extra scopes were requested.") - return "".join( - _render_template( - "scope_item.html", - scope=html_escape(str(scope)), - description=html_escape( - OAUTH_SCOPE_DESCRIPTIONS.get(scope, "Access requested by this OAuth client.") - ), - ) - for scope in scopes - ) - - -def _consent_form(params, client, scopes, resources, user): - raw_client_name = client.get("client_name") or client["client_id"] - client_name = html_escape(raw_client_name) - body = _render_template( - "consent.html", - client_name=client_name, - user_id=html_escape(str(user.id)), - client_id=html_escape(client["client_id"]), - redirect_uri=html_escape(params.get("redirect_uri", "")), - scope_items=_scope_items(scopes), - resource_items=_list_items(resources, empty="Default Guillotina container"), - hidden_inputs=_hidden_inputs(params), +def _token_response(content): + return Response( + content=content, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, ) - return _oauth_page("Authorize OAuth Client", f"Allow {raw_client_name}?", body) async def _authorize(service, store): params = dict(service.request.query) + try: + reject_duplicate_params(service.request.query, AUTHORIZE_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc if service.request.method == "POST": content_type = service.request.headers.get("content-type", "") if "application/json" in content_type: data = await service.request.json() else: - data = parse_form_encoded(await service.request.text()) + try: + data = parse_form_encoded( + await service.request.text(), singleton_fields=AUTHORIZE_SINGLETON_PARAMS + ) + except HTTPBadRequest as exc: + return exc params.update(data) client = await store.get_client(params.get("client_id")) if client is None: - return _oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) + return oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) redirect_uri = params.get("redirect_uri") if not redirect_uri_registered_for_client(client, redirect_uri): - return _oauth_error_page( + return oauth_error_page( "Invalid redirect URI", "The requested redirect URI is not allowed for this OAuth client.", status=400, @@ -314,6 +245,10 @@ async def _authorize(service, store): redirect_uri, {"error": "unsupported_response_type", "state": params.get("state")} ) ) + if "code" not in set(client.get("response_types") or []): + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "unauthorized_client", "state": params.get("state")}) + ) require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) code_challenge = params.get("code_challenge") @@ -323,13 +258,17 @@ async def _authorize(service, store): return HTTPFound( redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) ) + if code_challenge and not pkce_challenge_valid(code_challenge): + return HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) if code_challenge and code_challenge_method not in allowed_methods: return HTTPFound( redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) ) scopes = normalize_list(params.get("scope")) supported_scopes = set(oauth_scopes_supported()) - if scopes and not set(scopes).issubset(supported_scopes): + if not scopes or OAUTH_DEFAULT_SCOPE not in scopes or not set(scopes).issubset(supported_scopes): return HTTPFound( redirect_with_params(redirect_uri, {"error": "invalid_scope", "state": params.get("state")}) ) @@ -341,11 +280,12 @@ async def _authorize(service, store): ) user = get_authenticated_user() newly_authenticated_token = None + authenticated_on_this_request = False if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": if service.request.method == "POST" and params.get("username"): user = await _authenticate_basic(params.get("username"), params.get("password", "")) if user is None: - return _oauth_error_page( + return oauth_error_page( "Login failed", "The username or password could not be verified.", status=401, @@ -353,19 +293,32 @@ async def _authorize(service, store): from guillotina.auth import authenticate_user newly_authenticated_token, _ = authenticate_user(user.id) + authenticated_on_this_request = True else: - return _login_form(params, client) + return login_form(params, client) response_obj = None ckey = consent_key(user.id, client["client_id"], scopes, resources) - if not await store.has_consent(ckey) and params.get("decision") != "allow": - if params.get("decision") == "deny": + existing_consent = await store.has_consent(ckey) + decision = ( + params.get("decision") + if service.request.method == "POST" and not authenticated_on_this_request + else None + ) + if decision in ("allow", "deny") and not csrf_valid( + params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources + ): + response_obj = HTTPFound( + redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) + ) + elif not existing_consent and decision != "allow": + if decision == "deny": response_obj = HTTPFound( redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")}) ) else: - response_obj = _consent_form(params, client, scopes, resources, user) + response_obj = consent_form(params, client, scopes, resources, user) else: - if not await store.has_consent(ckey): + if not existing_consent: await store.create_consent( ckey, user_id=user.id, @@ -398,7 +351,14 @@ async def _authorize(service, store): async def _token(service, store): - data = parse_form_encoded(await service.request.text()) + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=TOKEN_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc grant_type = data.get("grant_type") if grant_type == "authorization_code": return await _authorization_code(service, store, data) @@ -417,6 +377,8 @@ async def _authorization_code(service, store, data): return HTTPBadRequest(content={"error": "invalid_grant"}) if client is None or record["client_id"] != client["client_id"]: return HTTPBadRequest(content={"error": "invalid_grant"}) + if "authorization_code" not in set(client.get("grant_types") or []): + return HTTPBadRequest(content={"error": "unauthorized_client"}) if record["redirect_uri"] != data.get("redirect_uri"): return HTTPBadRequest(content={"error": "invalid_grant"}) require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) @@ -451,13 +413,15 @@ async def _authorization_code(service, store, data): resource=resources, auth_code_hash=record["code_hash"], ) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": refresh_token, - "scope": " ".join(record["scope"]), - } + return _token_response( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": refresh_token, + "scope": " ".join(record["scope"]), + } + ) async def _refresh_token(service, store, data): @@ -475,6 +439,8 @@ async def _refresh_token(service, store, data): return HTTPBadRequest(content={"error": "invalid_grant"}) if client is None or record["client_id"] != client["client_id"]: return HTTPBadRequest(content={"error": "invalid_grant"}) + if "refresh_token" not in set(client.get("grant_types") or []): + return HTTPBadRequest(content={"error": "unauthorized_client"}) scopes = normalize_list(data.get("scope")) or record["scope"] resources = normalize_list(data.get("resource")) or record["resource"] if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): @@ -496,18 +462,31 @@ async def _refresh_token(service, store, data): client_id=client["client_id"], scope=scopes, ) - return { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": new_refresh, - "scope": " ".join(scopes), - } + return _token_response( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": new_refresh, + "scope": " ".join(scopes), + } + ) async def _revoke(service, store): - data = parse_form_encoded(await service.request.text()) + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc record = await store.get_refresh_token(data.get("token", "")) if record is not None and record.get("client_id") == data.get("client_id"): - await store.delete_refresh_token(data.get("token", "")) + await store.revoke_refresh_family( + client_id=record["client_id"], + user_id=record["user_id"], + auth_code_hash=record.get("auth_code_hash"), + ) return {} diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/api/urls.py index d096b500e..bc249b974 100644 --- a/guillotina/contrib/oauth/api/urls.py +++ b/guillotina/contrib/oauth/api/urls.py @@ -3,6 +3,7 @@ from guillotina import app_settings from guillotina.interfaces import IContainer from guillotina.utils import get_current_container, get_full_content_path, get_url +from guillotina.utils.misc import build_url def container_url(request, container): @@ -16,7 +17,14 @@ def container_url(request, container): pass if not IContainer.providedBy(container): raise RuntimeError("OAuth container URL requires a container context") - return get_url(request, get_full_content_path(container)).rstrip("/") + path = get_full_content_path(container) + if app_settings.get("oauth", {}).get("trust_proxy_headers", False): + return get_url(request, path).rstrip("/") + # Secure default: do not honor client-spoofable forwarding/virtualhost headers + # (X-Forwarded-Proto, X-VirtualHost-*) when deriving the OAuth issuer. Only the + # transport scheme and the Host header are used. For HTTPS deployments behind a + # trusted reverse proxy set oauth.trust_proxy_headers=True, or pin oauth.issuer. + return build_url(scheme=request.scheme, host=request.host, path=path, query="").rstrip("/") def mcp_resource(request, container): diff --git a/guillotina/contrib/oauth/api/views.py b/guillotina/contrib/oauth/api/views.py new file mode 100644 index 000000000..d710ab11f --- /dev/null +++ b/guillotina/contrib/oauth/api/views.py @@ -0,0 +1,142 @@ +from base64 import b64encode +from functools import lru_cache +from html import escape as html_escape +from pathlib import Path +from string import Template + +from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_token +from guillotina.contrib.oauth.flow.scopes import OAUTH_SCOPE_DESCRIPTIONS +from guillotina.response import Response + + +TEMPLATE_DIR = Path(__file__).parent / "templates" +BRAND_LOGO_PATH = Path(__file__).parents[3] / "static" / "assets" / "brand" / "guillotina-logo-horizontal.svg" + + +def _html(body, status=200): + return Response(body=body.encode("utf-8"), status=status, content_type="text/html") + + +@lru_cache(maxsize=None) +def _template(name): + return Template((TEMPLATE_DIR / name).read_text(encoding="utf-8")) + + +@lru_cache(maxsize=None) +def _template_text(name): + return (TEMPLATE_DIR / name).read_text(encoding="utf-8") + + +@lru_cache(maxsize=None) +def _logo_data_uri(): + encoded = b64encode(BRAND_LOGO_PATH.read_bytes()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def _render_template(template_name, **context): + return _template(template_name).substitute(context) + + +def _oauth_page(title, heading, body, *, status=200, tone="default"): + return _html( + _render_template( + "base.html", + title=html_escape(title), + logo_src=_logo_data_uri(), + style=_template_text("oauth.css"), + tone=html_escape(tone), + heading=html_escape(heading), + body=body, + ), + status=status, + ) + + +def _hidden_inputs(params): + fields = ( + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "resource", + OAUTH_CSRF_FIELD, + ) + html = [] + for field in fields: + value = params.get(field) + if value is None: + continue + values = value if isinstance(value, list) else [value] + for item in values: + html.append( + _render_template( + "hidden_input.html", + name=html_escape(field, quote=True), + value=html_escape(str(item), quote=True), + ) + ) + return "\n".join(html) + + +def oauth_error_page(title, message, *, status): + return _oauth_page( + title, + title, + _render_template("error.html", message=html_escape(message)), + status=status, + tone="error", + ) + + +def login_form(params, client): + client_name = html_escape(client.get("client_name") or client["client_id"]) + body = _render_template( + "login.html", + client_name=client_name, + client_id=html_escape(client["client_id"]), + redirect_uri=html_escape(params.get("redirect_uri", "")), + hidden_inputs=_hidden_inputs(params), + ) + return _oauth_page("Login to Guillotina", "Login required", body) + + +def _list_items(values, *, empty): + if not values: + return _render_template("plain_item.html", value=html_escape(empty)) + return "".join(_render_template("list_item.html", value=html_escape(str(value))) for value in values) + + +def _scope_items(scopes): + if not scopes: + return _render_template("plain_item.html", value="No extra scopes were requested.") + return "".join( + _render_template( + "scope_item.html", + scope=html_escape(str(scope)), + description=html_escape( + OAUTH_SCOPE_DESCRIPTIONS.get(scope, "Access requested by this OAuth client.") + ), + ) + for scope in scopes + ) + + +def consent_form(params, client, scopes, resources, user): + raw_client_name = client.get("client_name") or client["client_id"] + client_name = html_escape(raw_client_name) + consent_params = dict(params) + consent_params[OAUTH_CSRF_FIELD] = csrf_token(consent_params, user.id, scopes, resources) + body = _render_template( + "consent.html", + client_name=client_name, + user_id=html_escape(str(user.id)), + client_id=html_escape(client["client_id"]), + redirect_uri=html_escape(consent_params.get("redirect_uri", "")), + scope_items=_scope_items(scopes), + resource_items=_list_items(resources, empty="Default Guillotina container"), + hidden_inputs=_hidden_inputs(consent_params), + ) + return _oauth_page("Authorize OAuth Client", f"Allow {raw_client_name}?", body) diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py index b7a19310e..24033515c 100644 --- a/guillotina/contrib/oauth/auth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -3,6 +3,7 @@ from guillotina import app_settings, task_vars from guillotina.auth import find_user from guillotina.contrib.oauth.api.urls import container_url +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE class OAuthJWTValidator: @@ -37,6 +38,9 @@ async def validate(self, token): return if not claims.get("client_id"): return + scopes = set((claims.get("scope") or "").split()) + if OAUTH_DEFAULT_SCOPE not in scopes: + return token["id"] = claims.get("id", claims.get("sub")) token["decoded"] = claims user = await find_user(token) @@ -44,7 +48,7 @@ async def validate(self, token): if request is not None: request.oauth = { "client_id": claims.get("client_id"), - "scopes": set((claims.get("scope") or "").split()), + "scopes": scopes, "resources": set(claims.get("aud") or []), "claims": claims, } diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index 0b75cc9e9..3a9bc1b2f 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -5,19 +5,27 @@ from guillotina.contrib.oauth.flow.tokens import utcnow +SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} +SUPPORTED_RESPONSE_TYPES = {"code"} + + def validate_redirect_uri(uri): if not uri: return False if "*" in uri: return False parsed = urlparse(uri) - if parsed.scheme in ("javascript", "data"): + if parsed.fragment: return False - if not parsed.netloc or not parsed.path.startswith("/"): + if parsed.scheme in ("javascript", "data"): return False - if parsed.scheme in ("http", "https"): + if parsed.scheme == "https": + return bool(parsed.netloc and parsed.path.startswith("/")) + if parsed.scheme == "http": + return parsed.hostname in {"localhost", "127.0.0.1", "::1"} and parsed.path.startswith("/") + if parsed.scheme.isalpha() and parsed.netloc and parsed.path.startswith("/"): return True - return parsed.scheme.isalpha() + return False def is_native_redirect_uri(uri): @@ -45,13 +53,23 @@ def make_client(data): method = data.get("token_endpoint_auth_method", "none") if method != "none": oauth_error("unsupported_token_endpoint_auth_method") + grant_types = data.get("grant_types") or ["authorization_code", "refresh_token"] + response_types = data.get("response_types") or ["code"] + if not isinstance(grant_types, list) or not grant_types: + oauth_error("invalid_client_metadata", "grant_types must be a non-empty array") + if not isinstance(response_types, list) or not response_types: + oauth_error("invalid_client_metadata", "response_types must be a non-empty array") + if any(grant_type not in SUPPORTED_GRANT_TYPES for grant_type in grant_types): + oauth_error("invalid_client_metadata", "unsupported grant_type") + if any(response_type not in SUPPORTED_RESPONSE_TYPES for response_type in response_types): + oauth_error("invalid_client_metadata", "unsupported response_type") now = utcnow().isoformat() return { "client_id": uuid4().hex, "client_name": data.get("client_name") or "OAuth Client", "redirect_uris": redirect_uris, - "grant_types": data.get("grant_types") or ["authorization_code", "refresh_token"], - "response_types": data.get("response_types") or ["code"], + "grant_types": grant_types, + "response_types": response_types, "token_endpoint_auth_method": "none", "scope": " ".join(normalize_list(data.get("scope"))), "created_at": now, diff --git a/guillotina/contrib/oauth/flow/csrf.py b/guillotina/contrib/oauth/flow/csrf.py new file mode 100644 index 000000000..25ed1c5fe --- /dev/null +++ b/guillotina/contrib/oauth/flow/csrf.py @@ -0,0 +1,68 @@ +from base64 import b64decode, b64encode +from binascii import Error as BinasciiError +import hashlib +import hmac +import json +import time + +from guillotina import app_settings + + +OAUTH_CSRF_FIELD = "oauth_csrf" + + +def _csrf_base_payload(params, user_id, scopes, resources): + return { + "user_id": str(user_id), + "client_id": str(params.get("client_id") or ""), + "redirect_uri": str(params.get("redirect_uri") or ""), + "response_type": str(params.get("response_type") or ""), + "scope": list(scopes), + "state": str(params.get("state") or ""), + "code_challenge": str(params.get("code_challenge") or ""), + "code_challenge_method": str(params.get("code_challenge_method") or ""), + "resource": list(resources), + } + + +def _b64url_encode(raw): + return b64encode(raw).rstrip(b"=").decode("ascii").replace("+", "-").replace("/", "_") + + +def _b64url_decode(value): + padded = value.replace("-", "+").replace("_", "/") + padded += "=" * (-len(padded) % 4) + return b64decode(padded.encode("ascii")) + + +def _csrf_signature(body): + secret = app_settings["jwt"]["secret"].encode("utf-8") + return _b64url_encode(hmac.new(secret, body.encode("ascii"), hashlib.sha256).digest()) + + +def csrf_token(params, user_id, scopes, resources): + payload = _csrf_base_payload(params, user_id, scopes, resources) + payload["iat"] = int(time.time()) + body = _b64url_encode(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")) + return f"{body}.{_csrf_signature(body)}" + + +def csrf_valid(token, params, user_id, scopes, resources): + if not token or not isinstance(token, str) or "." not in token: + return False + body, _, signature = token.partition(".") + try: + if not hmac.compare_digest(_csrf_signature(body), signature): + return False + payload = json.loads(_b64url_decode(body).decode("utf-8")) + except (BinasciiError, UnicodeDecodeError, UnicodeEncodeError, ValueError, TypeError): + return False + issued_at = payload.get("iat") + if not isinstance(issued_at, int): + return False + ttl = app_settings.get("oauth", {}).get("authorize_csrf_ttl", 600) + now = int(time.time()) + if issued_at > now + 60 or now - issued_at > ttl: + return False + expected = _csrf_base_payload(params, user_id, scopes, resources) + return all(payload.get(key) == value for key, value in expected.items()) diff --git a/guillotina/contrib/oauth/flow/pkce.py b/guillotina/contrib/oauth/flow/pkce.py index 5d5cabdc7..a82508950 100644 --- a/guillotina/contrib/oauth/flow/pkce.py +++ b/guillotina/contrib/oauth/flow/pkce.py @@ -5,6 +5,7 @@ _VERIFIER_CHARS = re.compile(r"^[A-Za-z0-9\-._~]{43,128}$") +_CHALLENGE_CHARS = re.compile(r"^[A-Za-z0-9\-._~]{43,128}$") def pkce_verifier_valid(verifier: Optional[str]) -> bool: @@ -15,6 +16,14 @@ def pkce_verifier_valid(verifier: Optional[str]) -> bool: return _VERIFIER_CHARS.fullmatch(verifier) is not None +def pkce_challenge_valid(challenge: Optional[str]) -> bool: + """Return True when ``code_challenge`` conforms to RFC 7636 syntax.""" + + if not challenge or not isinstance(challenge, str): + return False + return _CHALLENGE_CHARS.fullmatch(challenge) is not None + + def s256_challenge_from_bytes(verifier: bytes) -> str: digest = hashlib.sha256(verifier).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") diff --git a/guillotina/contrib/oauth/flow/ratelimit.py b/guillotina/contrib/oauth/flow/ratelimit.py new file mode 100644 index 000000000..e3efc5429 --- /dev/null +++ b/guillotina/contrib/oauth/flow/ratelimit.py @@ -0,0 +1,58 @@ +"""Best-effort, in-memory sliding-window rate limiter. + +Used to throttle anonymous OAuth dynamic client registration (RFC 7591) so an +open registration endpoint cannot be trivially abused to flood the store. + +Notes / limitations: +- State is per-process. Behind multiple workers each process keeps its own + window; the effective global limit is ``limit * workers``. For stricter + guarantees use a shared backend (e.g. Redis), but this provides a cheap and + effective first line of defense without extra infrastructure. +- Keys are bounded to avoid unbounded memory growth from many distinct callers. +""" + +import time +from collections import deque + + +_MAX_TRACKED_KEYS = 50000 +_buckets: "dict[str, deque]" = {} + + +def reset_rate_limits(): + """Clear all tracked windows (used by tests).""" + _buckets.clear() + + +def _prune_if_needed(): + if len(_buckets) <= _MAX_TRACKED_KEYS: + return + # Drop the oldest-tracked half. ``dict`` preserves insertion order, which is + # a good enough approximation of staleness for eviction purposes. + for key in list(_buckets.keys())[: len(_buckets) // 2]: + _buckets.pop(key, None) + + +def rate_limit_exceeded(key, *, limit, window, now=None): + """Register a hit for ``key`` and report whether it exceeds the window limit. + + ``limit <= 0`` disables the limiter (always allowed). When the call would + exceed ``limit`` events within ``window`` seconds it returns ``True`` and + does **not** record the hit, so a blocked caller cannot extend its own + window indefinitely. + """ + if not limit or limit <= 0: + return False + now = time.monotonic() if now is None else now + cutoff = now - window + bucket = _buckets.get(key) + if bucket is None: + bucket = deque() + _buckets[key] = bucket + _prune_if_needed() + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if len(bucket) >= limit: + return True + bucket.append(now) + return False diff --git a/guillotina/contrib/oauth/flow/tokens.py b/guillotina/contrib/oauth/flow/tokens.py index 859941102..9fade3e8d 100644 --- a/guillotina/contrib/oauth/flow/tokens.py +++ b/guillotina/contrib/oauth/flow/tokens.py @@ -23,7 +23,12 @@ def opaque_token(prefix=""): def token_hash(token: str) -> str: - secret = app_settings.get("jwt", {}).get("secret", "") or "guillotina-oauth-dev-secret" + secret = app_settings.get("jwt", {}).get("secret") + if not secret: + raise RuntimeError( + "OAuth token hashing requires `jwt.secret` to be configured; " + "refusing to fall back to an insecure default secret." + ) return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index 407db280e..763b88ff5 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -38,14 +38,28 @@ def is_enabled(self, request, context): return "guillotina.contrib.oauth" in applications and "guillotina.contrib.mcp" in applications def unauthorized_headers(self, request, context): + return self._challenge_headers(request, context) + + def forbidden_headers(self, request, context): + return self._challenge_headers( + request, + context, + error="invalid_token", + error_description="OAuth access token is not valid for this protected resource", + ) + + def _challenge_headers(self, request, context, *, error=None, error_description=None): metadata = well_known_protected_resource_url(request, context) - return { - "WWW-Authenticate": ( - 'Bearer realm="guillotina-mcp", ' - f'resource_metadata="{metadata}", ' - f'scope="{OAUTH_DEFAULT_SCOPE}"' - ) - } + parts = [ + 'Bearer realm="guillotina-mcp"', + f'resource_metadata="{metadata}"', + f'scope="{OAUTH_DEFAULT_SCOPE}"', + ] + if error: + parts.append(f'error="{error}"') + if error_description: + parts.append(f'error_description="{error_description}"') + return {"WWW-Authenticate": ", ".join(parts)} def is_authorized(self, request, context): oauth = getattr(request, "oauth", None) diff --git a/guillotina/contrib/oauth/storage/interfaces.py b/guillotina/contrib/oauth/storage/interfaces.py index 5d003ac6a..e91001094 100644 --- a/guillotina/contrib/oauth/storage/interfaces.py +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -44,7 +44,7 @@ def delete_code(self, code_hash_val): """Remove an authorization code after use or cleanup.""" def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): - """Revoke refresh tokens issued from a code; return ``True`` if any were removed.""" + """Revoke refresh tokens issued from a code; return ``True`` if any were changed.""" def create_refresh_token( self, @@ -64,14 +64,17 @@ def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client_id, s def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): """Revoke all refresh tokens in the reuse-compromise rotation family.""" + def revoke_refresh_family(self, *, client_id, user_id, auth_code_hash): + """Revoke all refresh tokens in one authorization grant family.""" + def get_valid_refresh(self, token): """Return a valid, unexpired refresh token record or ``None``.""" def get_refresh_token(self, token): """Return a refresh token record regardless of expiry, or ``None``.""" - def delete_refresh_token(self, token): - """Remove a refresh token.""" + def revoke_refresh_token(self, token): + """Revoke a refresh token without removing its replay-detection record.""" def delete_container_data(self): """Remove all OAuth state for this container (addon uninstall).""" diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index f127225e1..4ea4ecdf7 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -281,8 +281,11 @@ async def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): async with txn.lock: result = await conn.execute( """ - DELETE FROM oauth_refresh_tokens - WHERE container_db_key = $1 AND auth_code_hash = $2 + UPDATE oauth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, now()) + WHERE container_db_key = $1 + AND auth_code_hash = $2 + AND revoked_at IS NULL """, self.container_db_key, auth_code_hash, @@ -290,11 +293,19 @@ async def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): return int(result.split()[-1]) > 0 async def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): + return await self.revoke_refresh_family( + client_id=client_id, + user_id=user_id, + auth_code_hash=auth_code_hash, + ) + + async def revoke_refresh_family(self, *, client_id, user_id, auth_code_hash): txn, conn = await self._connection() async with txn.lock: - await conn.execute( + result = await conn.execute( """ - DELETE FROM oauth_refresh_tokens + UPDATE oauth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, now()) WHERE container_db_key = $1 AND client_id = $2 AND user_id = $3 @@ -302,12 +313,14 @@ async def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code ($4::text IS NULL AND auth_code_hash IS NULL) OR auth_code_hash = $4 ) + AND revoked_at IS NULL """, self.container_db_key, client_id, user_id, auth_code_hash, ) + return int(result.split()[-1]) > 0 async def create_refresh_token( self, @@ -431,12 +444,13 @@ async def get_refresh_token(self, token): ) return _row_to_refresh(row) - async def delete_refresh_token(self, token): + async def revoke_refresh_token(self, token): txn, conn = await self._connection() async with txn.lock: await conn.execute( """ - DELETE FROM oauth_refresh_tokens + UPDATE oauth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, now()) WHERE container_db_key = $1 AND token_hash = $2 """, self.container_db_key, diff --git a/guillotina/contrib/oauth/storage/utility.py b/guillotina/contrib/oauth/storage/utility.py index b4b018189..c28f351fb 100644 --- a/guillotina/contrib/oauth/storage/utility.py +++ b/guillotina/contrib/oauth/storage/utility.py @@ -67,7 +67,25 @@ def __init__(self, settings=None): self._task = None self._closing = False + def _warn_issuer_not_pinned(self): + oauth = app_settings.get("oauth") or {} + if oauth.get("issuer") or app_settings.get("debug"): + return + if oauth.get("trust_proxy_headers"): + logger.warning( + "oauth.issuer is not configured and oauth.trust_proxy_headers is enabled: " + "the OAuth issuer/audience will be derived from client-supplied forwarding " + "headers. Pin oauth.issuer to your canonical public URL to prevent spoofing." + ) + else: + logger.warning( + "oauth.issuer is not configured: the OAuth issuer/audience will be derived " + "from the request Host header. Pin oauth.issuer to your canonical public URL " + "(or set oauth.trust_proxy_headers=True behind a trusted reverse proxy)." + ) + async def initialize(self, app=None): + self._warn_issuer_not_pinned() initialized = False root = get_utility(IApplication, name="root") for _id, db in root: diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index c5cd5abec..7f64e524f 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -1,6 +1,8 @@ import base64 import hashlib +from html import unescape import json +import re from urllib.parse import parse_qs, urlencode, urlparse import pytest @@ -17,6 +19,7 @@ OAUTH_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"registration_rate_limit": 0}, "auth_extractors": [ "guillotina.auth.extractors.BearerAuthPolicy", "guillotina.auth.extractors.BasicAuthPolicy", @@ -26,6 +29,7 @@ } OAUTH_MCP_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], + "oauth": {"registration_rate_limit": 0}, "auth_extractors": [ "guillotina.auth.extractors.BearerAuthPolicy", "guillotina.auth.extractors.BasicAuthPolicy", @@ -41,6 +45,13 @@ def verifier_pair(verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ return verifier, challenge +def oauth_csrf_from_body(value): + body = value.decode("utf-8") if isinstance(value, bytes) else value + match = re.search(r'name="oauth_csrf" value="([^"]+)"', body) + assert match is not None, body + return unescape(match.group(1)) + + async def register_client(requester, redirect_uri="http://127.0.0.1:12345/callback"): response, status = await requester( "POST", @@ -74,10 +85,23 @@ async def authorize_code( "state": "abc", "code_challenge": challenge, "code_challenge_method": "S256", - "decision": "allow", } if resource: data["resource"] = resource + + value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=data, + allow_redirects=False, + ) + if status == 302: + query = parse_qs(urlparse(headers["Location"]).query) + return query["code"][0], verifier + assert status == 200 + + data["oauth_csrf"] = oauth_csrf_from_body(value) + data["decision"] = "allow" value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index fe1dbd359..d718d694b 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -6,6 +6,7 @@ from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, authorize_code, + oauth_csrf_from_body, register_client, requires_pg, token_from_code, @@ -50,6 +51,20 @@ async def test_mcp_without_token_challenges(container_install_requester): assert 'scope="guillotina:access"' in www_authenticate +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_mcp_allows_non_oauth_guillotina_authentication(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/@mcp/protocol", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), + headers=PROTOCOL_HEADERS, + ) + _skip_if_protocol_unavailable(response, status) + assert status == 200 + + @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) async def test_mcp_with_oauth_token(container_install_requester): @@ -78,7 +93,7 @@ async def test_mcp_rejects_missing_mcp_audience(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") token = await token_from_code(requester, client, code, verifier) - _response, status = await requester( + _response, status, headers = await requester.make_request( "POST", "/db/guillotina/@mcp/protocol", data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), @@ -87,6 +102,7 @@ async def test_mcp_rejects_missing_mcp_audience(container_install_requester): token=token["access_token"], ) assert status == 401 + assert 'error="invalid_token"' in headers["WWW-Authenticate"] @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @@ -152,42 +168,6 @@ async def test_mcp_serialized_content_with_oauth_token(container_install_request assert status == 200 -@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) -@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) -async def test_mcp_create_content(container_install_requester): - async with container_install_requester as requester: - client = await register_client(requester) - code, verifier = await authorize_code( - requester, - client, - resource="http://localhost/db/guillotina/@mcp/protocol", - ) - token = await token_from_code(requester, client, code, verifier) - response, status = await requester( - "POST", - "/db/guillotina/@mcp/protocol", - data=json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "create_content", - "arguments": { - "data": {"@type": "Folder", "id": "oauth-mcp-folder", "title": "OAuth MCP Folder"} - }, - }, - } - ), - headers=PROTOCOL_HEADERS, - auth_type="Bearer", - token=token["access_token"], - ) - _skip_if_protocol_unavailable(response, status) - assert status == 200 - assert "oauth-mcp-folder" in response["result"]["content"][0]["text"] - - NO_PKCE_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], "auth_extractors": [ @@ -268,8 +248,16 @@ async def test_authorize_without_pkce(container_install_requester): "redirect_uri": client["redirect_uris"][0], "scope": "guillotina:access", "state": "abc", - "decision": "allow", } + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=data, + allow_redirects=False, + ) + assert status == 200 + data["oauth_csrf"] = oauth_csrf_from_body(value) + data["decision"] = "allow" _value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index bec84e246..33dd7eaee 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -1,11 +1,12 @@ import json -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlencode, urlparse import pytest from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, + oauth_csrf_from_body, register_client, requires_pg, token_from_code, @@ -150,24 +151,79 @@ async def test_authorize_pkce_required(challenge_method, container_install_reque assert "error=invalid_request" in headers["Location"] +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_rejects_invalid_code_challenge(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "scope": "guillotina:access", + "code_challenge": "short", + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_request" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_rejects_duplicate_singleton_parameter(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=[ + ("client_id", client["client_id"]), + ("client_id", client["client_id"]), + ("redirect_uri", client["redirect_uris"][0]), + ("response_type", "code"), + ("scope", "guillotina:access"), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ], + allow_redirects=False, + ) + assert status == 400 + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_allow_and_remember_consent(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) _verifier, challenge = verifier_pair() - body = ( - "response_type=code" - f"&client_id={client['client_id']}" - f"&redirect_uri={client['redirect_uris'][0]}" - "&scope=guillotina:access&state=s" - f"&code_challenge={challenge}" - "&code_challenge_method=S256&decision=allow" + params = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "state": "s", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=params, + allow_redirects=False, ) + assert status == 200 + params["oauth_csrf"] = oauth_csrf_from_body(value) + params["decision"] = "allow" _value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", - data=body, + data=urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False, ) @@ -194,6 +250,67 @@ async def test_authorize_allow_and_remember_consent(container_install_requester) @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_deny(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + params = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=params, + allow_redirects=False, + ) + assert status == 200 + params["oauth_csrf"] = oauth_csrf_from_body(value) + params["decision"] = "deny" + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=urlencode(params), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + assert "error=access_denied" in headers["Location"] + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_get_decision_allow_does_not_create_code(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "state": "s", + "code_challenge": challenge, + "code_challenge_method": "S256", + "decision": "allow", + }, + allow_redirects=False, + ) + assert status == 200 + body = value.decode("utf-8") if isinstance(value, bytes) else value + assert "Allow Test" in body + assert "code=" not in body + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_post_decision_requires_csrf(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) _verifier, challenge = verifier_pair() @@ -201,9 +318,9 @@ async def test_authorize_deny(container_install_requester): "response_type=code" f"&client_id={client['client_id']}" f"&redirect_uri={client['redirect_uris'][0]}" - "&scope=guillotina:access" + "&scope=guillotina:access&state=s" f"&code_challenge={challenge}" - "&code_challenge_method=S256&decision=deny" + "&code_challenge_method=S256&decision=allow" ) _value, status, headers = await requester.make_request( "POST", @@ -213,7 +330,7 @@ async def test_authorize_deny(container_install_requester): allow_redirects=False, ) assert status == 302 - assert "error=access_denied" in headers["Location"] + assert "error=invalid_request" in headers["Location"] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -263,9 +380,21 @@ async def test_authorize_invalid_scope_redirects(container_install_requester): async def test_authorize_without_scope(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) - code, verifier = await authorize_code(requester, client, scope="") - token = await token_from_code(requester, client, code, verifier) - assert token["access_token"] + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_scope" in headers["Location"] @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -281,6 +410,7 @@ async def test_authorize_invalid_target_redirects(container_install_requester): "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "response_type": "code", + "scope": "guillotina:access", "resource": "http://invalid-target.com", "code_challenge": challenge, "code_challenge_method": "S256", @@ -304,6 +434,7 @@ async def test_authorize_oauth_only_rejects_mcp_protocol_resource(container_inst "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "response_type": "code", + "scope": "guillotina:access", "resource": "http://localhost/db/guillotina/@mcp/protocol", "code_challenge": challenge, "code_challenge_method": "S256", @@ -337,9 +468,10 @@ async def test_authorize_sets_auth_token_cookie(container_install_requester): authenticated=False, allow_redirects=False, ) - assert status == 302 + assert status == 200 assert "Set-Cookie" in headers assert "auth_token=" in headers["Set-Cookie"] + assert b"Allow Test" in _value @pytest.mark.app_settings(OAUTH_SETTINGS) diff --git a/guillotina/tests/oauth/test_oauth_metadata.py b/guillotina/tests/oauth/test_oauth_metadata.py index df5969439..d89f60107 100644 --- a/guillotina/tests/oauth/test_oauth_metadata.py +++ b/guillotina/tests/oauth/test_oauth_metadata.py @@ -1,3 +1,5 @@ +import copy + import pytest from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, requires_pg @@ -6,6 +8,10 @@ pytestmark = [pytest.mark.asyncio, requires_pg] +OAUTH_SETTINGS_TRUST_PROXY = copy.deepcopy(OAUTH_SETTINGS) +OAUTH_SETTINGS_TRUST_PROXY["oauth"]["trust_proxy_headers"] = True + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_metadata(container_install_requester): @@ -15,6 +21,8 @@ async def test_metadata(container_install_requester): assert response["issuer"].endswith("/db/guillotina") assert response["authorization_endpoint"].endswith("/oauth/authorize") assert response["registration_endpoint"].endswith("/oauth/register") + assert response["revocation_endpoint_auth_methods_supported"] == ["none"] + assert response["resource_indicators_supported"] is True @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -36,3 +44,32 @@ async def test_metadata_requires_addon(container_requester): async with container_requester as requester: _response, status = await requester("GET", "/db/guillotina/.well-known/oauth-authorization-server") assert status == 412 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_metadata_ignores_forwarded_proto_by_default(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "GET", + "/db/guillotina/.well-known/oauth-authorization-server", + headers={"X-Forwarded-Proto": "https"}, + ) + assert status == 200 + # Secure default: spoofable forwarding header must not promote issuer to https + assert response["issuer"].startswith("http://") + assert response["authorization_endpoint"].startswith("http://") + + +@pytest.mark.app_settings(OAUTH_SETTINGS_TRUST_PROXY) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_metadata_trusts_forwarded_proto_when_enabled(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "GET", + "/db/guillotina/.well-known/oauth-authorization-server", + headers={"X-Forwarded-Proto": "https"}, + ) + assert status == 200 + # Opt-in: behind a trusted reverse proxy the forwarded scheme is honored + assert response["issuer"].startswith("https://") diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index e651052e3..42bf3a45a 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -8,6 +8,12 @@ pytestmark = [pytest.mark.asyncio, requires_pg] +RATE_LIMITED_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {"registration_rate_limit": 2, "registration_rate_window": 600}, +} + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_client(container_install_requester): @@ -30,7 +36,11 @@ async def test_register_client(container_install_requester): {"redirect_uris": []}, {"redirect_uris": ["javascript:alert(1)"]}, {"redirect_uris": ["https://example.com/*"]}, + {"redirect_uris": ["https://example.com/cb#fragment"]}, + {"redirect_uris": ["http://example.com/callback"]}, {"redirect_uris": ["http://localhost/cb"], "token_endpoint_auth_method": "client_secret_basic"}, + {"redirect_uris": ["http://localhost/cb"], "grant_types": ["implicit"]}, + {"redirect_uris": ["http://localhost/cb"], "response_types": ["token"]}, ], ) @pytest.mark.parametrize("install_addons", [["oauth"]]) @@ -117,3 +127,24 @@ async def test_register_rejects_client_supplied_client_id(container_install_requ assert status == 400 assert response["error"] == "invalid_request" assert response["error_description"] == "client_id is server-issued" + + +@pytest.mark.app_settings(RATE_LIMITED_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_rate_limited(container_install_requester): + from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits + + reset_rate_limits() + payload = json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}) + async with container_install_requester as requester: + for _ in range(2): + _response, status = await requester( + "POST", "/db/guillotina/oauth/register", data=payload, authenticated=False + ) + assert status == 200 + response, status = await requester( + "POST", "/db/guillotina/oauth/register", data=payload, authenticated=False + ) + assert status == 429 + assert response["error"] == "temporarily_unavailable" + reset_rate_limits() diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index 86bb4f59a..dc4cc4d74 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -23,22 +23,113 @@ async def test_revoke_refresh_token(container_install_requester): "POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token={token['refresh_token']}&token_type_hint=refresh_token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 200 _response, status = await requester( "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_rotated_refresh_token_revokes_family(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token = await token_from_code(requester, client, code, verifier) + rotated, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + + _response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token={token['refresh_token']}&token_type_hint=refresh_token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={rotated['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_does_not_cross_authorization_grants(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code_a, verifier_a = await authorize_code(requester, client) + token_a = await token_from_code(requester, client, code_a, verifier_a) + code_b, verifier_b = await authorize_code(requester, client) + token_b = await token_from_code(requester, client, code_b, verifier_b) + + _response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token={token_a['refresh_token']}&token_type_hint=refresh_token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + + refreshed_b, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token_b['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + assert refreshed_b["refresh_token"] != token_b["refresh_token"] + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_revoke_unknown_token(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) _response, status = await requester( - "POST", "/db/guillotina/oauth/revoke", data=f"client_id={client['client_id']}&token=unknown" + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token=unknown", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 200 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_requires_form_content_type(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data="client_id=client&token=token", + headers={"Content-Type": "text/plain"}, + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_rejects_duplicate_singleton_parameter(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data="client_id=client&client_id=client&token=token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 diff --git a/guillotina/tests/oauth/test_oauth_store_contract.py b/guillotina/tests/oauth/test_oauth_store_contract.py index 4f46dbd17..674c4b761 100644 --- a/guillotina/tests/oauth/test_oauth_store_contract.py +++ b/guillotina/tests/oauth/test_oauth_store_contract.py @@ -62,8 +62,9 @@ async def run_oauth_store_contract(store): resource=resources, ) assert await store.get_valid_refresh(standalone_refresh) is not None - await store.delete_refresh_token(standalone_refresh) - assert await store.get_refresh_token(standalone_refresh) is None + await store.revoke_refresh_token(standalone_refresh) + assert await store.get_valid_refresh(standalone_refresh) is None + assert (await store.get_refresh_token(standalone_refresh))["revoked_at"] is not None linked_refresh = opaque_token("gor_") await store.create_refresh_token( @@ -78,6 +79,7 @@ async def run_oauth_store_contract(store): assert await store.get_active_code(raw_code) is None assert await store.revoke_refresh_tokens_by_auth_code(code_record["code_hash"]) is True assert await store.get_valid_refresh(linked_refresh) is None + assert (await store.get_refresh_token(linked_refresh))["revoked_at"] is not None await store.delete_container_data() assert await store.get_client("contract-client") is None diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index c3415f69e..8eb1b029d 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -5,6 +5,7 @@ from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, + oauth_csrf_from_body, register_client, requires_pg, token_from_code, @@ -29,7 +30,21 @@ async def test_code_token_and_refresh_rotation(container_install_requester): async with container_install_requester as requester: client = await register_client(requester) code, verifier = await authorize_code(requester, client, resource="http://localhost/db/guillotina") - token = await token_from_code(requester, client, code, verifier) + token, status, token_headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/token", + data=( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + f"&code={code}&code_verifier={verifier}" + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 200 + assert token_headers["Cache-Control"] == "no-store" + assert token_headers["Pragma"] == "no-cache" claims = jwt.decode( token["access_token"], app_settings["jwt"]["secret"], @@ -45,13 +60,24 @@ async def test_code_token_and_refresh_rotation(container_install_requester): "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 200 + _refreshed_again, _status, refresh_headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/token", + data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={refreshed['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert refresh_headers["Cache-Control"] == "no-store" + assert refresh_headers["Pragma"] == "no-cache" assert refreshed["refresh_token"] != token["refresh_token"] _response, status = await requester( "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -71,6 +97,7 @@ async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): f"&redirect_uri={client['redirect_uris'][0]}" f"&code={code}&code_verifier=bad" ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 code, verifier = await authorize_code(requester, client, scope="guillotina:access") @@ -83,6 +110,33 @@ async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): "&redirect_uri=http://127.0.0.1:9999/cb" f"&code={code}&code_verifier={verifier}" ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_requires_form_content_type(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data="grant_type=refresh_token&client_id=client&refresh_token=token", + headers={"Content-Type": "text/plain"}, + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_rejects_duplicate_singleton_parameter(container_install_requester): + async with container_install_requester as requester: + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data="grant_type=refresh_token&grant_type=refresh_token&client_id=client&refresh_token=token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -90,7 +144,7 @@ async def test_token_rejects_bad_pkce_and_redirect(container_install_requester): @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_token_rejects_pkce_verifier_below_min_length(container_install_requester): - from urllib.parse import parse_qs, urlparse + from urllib.parse import parse_qs, urlencode, urlparse from guillotina.contrib.oauth.flow.pkce import s256_challenge @@ -98,15 +152,27 @@ async def test_token_rejects_pkce_verifier_below_min_length(container_install_re client = await register_client(requester) verifier_42 = "a" * 42 challenge = s256_challenge(verifier_42) - body = ( - "response_type=code&decision=allow&" - f"client_id={client['client_id']}&redirect_uri={client['redirect_uris'][0]}&" - f"scope=guillotina:access&code_challenge={challenge}&code_challenge_method=S256" + data = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=data, + allow_redirects=False, ) + assert status == 200 + data["oauth_csrf"] = oauth_csrf_from_body(value) + data["decision"] = "allow" _value, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/authorize", - data=body, + data=urlencode(data), headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False, ) @@ -174,6 +240,7 @@ async def test_expired_authorization_code_fails(container_install_requester): f"grant_type=authorization_code&client_id={client['client_id']}" f"&redirect_uri={client['redirect_uris'][0]}&code={code}&code_verifier={verifier}" ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -189,6 +256,7 @@ async def test_expired_refresh_token_fails(container_install_requester): "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -211,6 +279,7 @@ async def test_code_reuse_revokes_tokens(container_install_requester): f"&redirect_uri={client['redirect_uris'][0]}" f"&code={code}&code_verifier={verifier}" ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 @@ -218,5 +287,6 @@ async def test_code_reuse_revokes_tokens(container_install_requester): "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 From 534b87b6f4335972a07889b5c9ac3ea33806f94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 31 May 2026 18:14:11 +0200 Subject: [PATCH 06/27] feat: Harden OAuth security compliance --- CHANGELOG.rst | 9 + guillotina/auth/validators.py | 4 + guillotina/contrib/oauth/__init__.py | 11 +- guillotina/contrib/oauth/api/services.py | 120 ++++++++---- guillotina/contrib/oauth/api/urls.py | 20 +- guillotina/contrib/oauth/api/views.py | 10 +- guillotina/contrib/oauth/auth/validators.py | 9 +- guillotina/contrib/oauth/flow/clients.py | 27 ++- guillotina/contrib/oauth/flow/csrf.py | 3 +- guillotina/contrib/oauth/flow/keys.py | 31 +++ guillotina/contrib/oauth/flow/ratelimit.py | 144 +++++++++++++- guillotina/contrib/oauth/flow/resources.py | 34 +++- guillotina/contrib/oauth/flow/tokens.py | 16 +- guillotina/contrib/oauth/integrations/mcp.py | 21 +- guillotina/tests/oauth/conftest.py | 7 +- guillotina/tests/oauth/test_mcp_oauth.py | 68 ------- .../tests/oauth/test_oauth_authorize.py | 113 ++++++++++- guillotina/tests/oauth/test_oauth_register.py | 64 ++++++- guillotina/tests/oauth/test_oauth_revoke.py | 33 ++++ guillotina/tests/oauth/test_oauth_token.py | 36 +++- .../tests/oauth/test_oauth_validator.py | 1 - guillotina/tests/test_auth.py | 180 ++++++++++++++++++ 22 files changed, 800 insertions(+), 161 deletions(-) create mode 100644 guillotina/contrib/oauth/flow/keys.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6adda7f32..09e244ff4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,15 @@ CHANGELOG [rboixaderg] - OAuth: drop in-memory and Redis storage backends; PostgreSQL is the only store. [rboixaderg] +- OAuth: harden per RFC 9700 security BCP: throttle failed credential logins at the authorization endpoint, + reject PKCE downgrade (``code_verifier`` without a bound ``code_challenge``), emit the ``iss`` authorization + response parameter (RFC 9207) and advertise it in metadata, and derive purpose-specific keys for token + hashing and CSRF signing instead of reusing the raw ``jwt.secret``. + [rboixaderg] +- OAuth: require PKCE for all public-client authorization-code flows, enforce registered client scopes, + add Redis-backed rate limiting when Redis is configured, and tighten dynamic client registration responses + with ``201 Created``, no-store cache headers and ``client_id_issued_at``. + [rboixaderg] 7.1.2 (2026-05-22) diff --git a/guillotina/auth/validators.py b/guillotina/auth/validators.py index 7ac547159..be1046138 100644 --- a/guillotina/auth/validators.py +++ b/guillotina/auth/validators.py @@ -127,6 +127,8 @@ async def validate(self, token): validated_jwt = jwt.decode( token["token"], app_settings["jwt"]["secret"], algorithms=[app_settings["jwt"]["algorithm"]] ) + if validated_jwt.get("token_type") == "oauth_access_token": + return token["id"] = validated_jwt.get("id", validated_jwt.get("sub")) token["decoded"] = validated_jwt user = await find_user(token) @@ -153,6 +155,8 @@ async def validate(self, token): validated_jwt = jwt.decode( token["token"], app_settings["jwt"]["secret"], algorithms=[app_settings["jwt"]["algorithm"]] ) + if validated_jwt.get("token_type") == "oauth_access_token": + return session_manager = query_utility(ISessionManagerUtility) if session_manager is not None: diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index df82faad9..5dc7a6862 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -12,13 +12,22 @@ "authorization_code_ttl": 600, "access_token_ttl": 3600, "refresh_token_ttl": 2592000, - "require_pkce": True, "allowed_code_challenge_methods": ["S256"], "scopes_supported": ["guillotina:access"], # Dynamic client registration throttling (per client IP, sliding window). # Set ``registration_rate_limit`` to 0 to disable. "registration_rate_limit": 20, "registration_rate_window": 600, + # Failed-login throttling at the authorization endpoint (per client IP + + # username, sliding window). Set ``login_rate_limit`` to 0 to disable. + "login_rate_limit": 10, + "login_rate_window": 300, + # Token and revocation endpoint throttling (per client IP). + # Set the limit to 0 to disable. + "token_rate_limit": 120, + "token_rate_window": 60, + "revoke_rate_limit": 120, + "revoke_rate_window": 60, }, "check_writable_request": "guillotina.contrib.oauth.api.request.check_writable_request", "auth_token_validators": [ diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 2cf68a0f7..ff7a5646c 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -16,10 +16,11 @@ make_client, redirect_uri_registered_for_client, redirect_with_params, + scopes_registered_for_client, ) from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid, verify_s256 -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash from guillotina.contrib.oauth.storage.access import get_oauth_store @@ -72,6 +73,7 @@ def _metadata(request, container): "token_endpoint_auth_methods_supported": ["none"], "revocation_endpoint_auth_methods_supported": ["none"], "resource_indicators_supported": True, + "authorization_response_iss_parameter_supported": True, "scopes_supported": oauth_scopes_supported(), } @@ -160,7 +162,7 @@ async def __call__(self): async def _register(service, store): oauth_settings = app_settings.get("oauth", {}) - if rate_limit_exceeded( + if await rate_limit_exceeded( f"oauth-register:{client_identifier(service.request)}", limit=oauth_settings.get("registration_rate_limit", 20), window=oauth_settings.get("registration_rate_window", 600), @@ -171,13 +173,18 @@ async def _register(service, store): "error_description": "client registration rate limit exceeded", } ) + content_type = service.request.headers.get("content-type", "") + if content_type.split(";", 1)[0].strip().lower() != "application/json": + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) data = await service.request.json() try: client = make_client(data) except HTTPBadRequest as exc: return exc await store.create_client(client) - return { + content = { key: client[key] for key in ( "client_id", @@ -185,9 +192,19 @@ async def _register(service, store): "redirect_uris", "grant_types", "response_types", + "scope", "token_endpoint_auth_method", ) } + content["client_id_issued_at"] = client["client_id_issued_at"] + return Response( + content=content, + status=201, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) async def _authenticate_basic(username, password): @@ -239,52 +256,60 @@ async def _authorize(service, store): "The requested redirect URI is not allowed for this OAuth client.", status=400, ) + # Mix-up defense (RFC 9207): include the issuer identifier in every + # authorization response so the client can verify which AS responded. + issuer = container_url(service.request, service.context) + + def _authz_redirect(extra): + payload = {"state": params.get("state"), "iss": issuer} + payload.update(extra) + return HTTPFound(redirect_with_params(redirect_uri, payload)) + if params.get("response_type") != "code": - return HTTPFound( - redirect_with_params( - redirect_uri, {"error": "unsupported_response_type", "state": params.get("state")} - ) - ) + return _authz_redirect({"error": "unsupported_response_type"}) if "code" not in set(client.get("response_types") or []): - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "unauthorized_client", "state": params.get("state")}) - ) - require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) + return _authz_redirect({"error": "unauthorized_client"}) allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) code_challenge = params.get("code_challenge") code_challenge_method = params.get("code_challenge_method") - if require_pkce and not code_challenge: - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) + if not code_challenge: + return _authz_redirect({"error": "invalid_request"}) if code_challenge and not pkce_challenge_valid(code_challenge): - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) + return _authz_redirect({"error": "invalid_request"}) if code_challenge and code_challenge_method not in allowed_methods: - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) + return _authz_redirect({"error": "invalid_request"}) scopes = normalize_list(params.get("scope")) supported_scopes = set(oauth_scopes_supported()) - if not scopes or OAUTH_DEFAULT_SCOPE not in scopes or not set(scopes).issubset(supported_scopes): - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_scope", "state": params.get("state")}) - ) + if ( + not scopes + or OAUTH_DEFAULT_SCOPE not in scopes + or not set(scopes).issubset(supported_scopes) + or not scopes_registered_for_client(client, scopes) + ): + return _authz_redirect({"error": "invalid_scope"}) try: resources = validate_resource(service.request, service.context, params.get("resource")) except HTTPBadRequest: - return HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_target", "state": params.get("state")}) - ) + return _authz_redirect({"error": "invalid_target"}) user = get_authenticated_user() newly_authenticated_token = None authenticated_on_this_request = False if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": if service.request.method == "POST" and params.get("username"): + oauth_settings = app_settings.get("oauth", {}) + login_limit = oauth_settings.get("login_rate_limit", 10) + login_window = oauth_settings.get("login_rate_window", 300) + login_key = f"oauth-login:{client_identifier(service.request)}:{params.get('username')}" + if await rate_limit_check(login_key, limit=login_limit, window=login_window): + return oauth_error_page( + "Too many attempts", + "Too many failed login attempts. Please wait and try again.", + status=429, + ) user = await _authenticate_basic(params.get("username"), params.get("password", "")) if user is None: + await rate_limit_exceeded(login_key, limit=login_limit, window=login_window) return oauth_error_page( "Login failed", "The username or password could not be verified.", @@ -307,14 +332,10 @@ async def _authorize(service, store): if decision in ("allow", "deny") and not csrf_valid( params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources ): - response_obj = HTTPFound( - redirect_with_params(redirect_uri, {"error": "invalid_request", "state": params.get("state")}) - ) + response_obj = _authz_redirect({"error": "invalid_request"}) elif not existing_consent and decision != "allow": if decision == "deny": - response_obj = HTTPFound( - redirect_with_params(redirect_uri, {"error": "access_denied", "state": params.get("state")}) - ) + response_obj = _authz_redirect({"error": "access_denied"}) else: response_obj = consent_form(params, client, scopes, resources, user) else: @@ -336,9 +357,7 @@ async def _authorize(service, store): resource=resources, code_challenge=params.get("code_challenge"), ) - response_obj = HTTPFound( - redirect_with_params(redirect_uri, {"code": raw_code, "state": params.get("state")}) - ) + response_obj = _authz_redirect({"code": raw_code}) if newly_authenticated_token is not None: secure = "" @@ -360,6 +379,15 @@ async def _token(service, store): except HTTPBadRequest as exc: return exc grant_type = data.get("grant_type") + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-token:{client_identifier(service.request)}", + limit=oauth_settings.get("token_rate_limit", 120), + window=oauth_settings.get("token_rate_window", 60), + ): + return HTTPTooManyRequests( + content={"error": "temporarily_unavailable", "error_description": "token rate limit exceeded"} + ) if grant_type == "authorization_code": return await _authorization_code(service, store, data) if grant_type == "refresh_token": @@ -381,11 +409,11 @@ async def _authorization_code(service, store, data): return HTTPBadRequest(content={"error": "unauthorized_client"}) if record["redirect_uri"] != data.get("redirect_uri"): return HTTPBadRequest(content={"error": "invalid_grant"}) - require_pkce = app_settings.get("oauth", {}).get("require_pkce", True) if record.get("code_challenge"): if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): return HTTPBadRequest(content={"error": "invalid_grant"}) - elif require_pkce: + else: + # PKCE is mandatory for public clients. A code without a bound challenge is invalid. return HTTPBadRequest(content={"error": "invalid_grant"}) requested_resources = normalize_list(data.get("resource")) if requested_resources and not set(requested_resources).issubset(set(record["resource"])): @@ -482,6 +510,18 @@ async def _revoke(service, store): data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) except HTTPBadRequest as exc: return exc + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-revoke:{client_identifier(service.request)}", + limit=oauth_settings.get("revoke_rate_limit", 120), + window=oauth_settings.get("revoke_rate_window", 60), + ): + return HTTPTooManyRequests( + content={ + "error": "temporarily_unavailable", + "error_description": "revocation rate limit exceeded", + } + ) record = await store.get_refresh_token(data.get("token", "")) if record is not None and record.get("client_id") == data.get("client_id"): await store.revoke_refresh_family( diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/api/urls.py index bc249b974..3673ec56a 100644 --- a/guillotina/contrib/oauth/api/urls.py +++ b/guillotina/contrib/oauth/api/urls.py @@ -9,7 +9,7 @@ def container_url(request, container): issuer = app_settings.get("oauth", {}).get("issuer") if issuer: - return issuer.rstrip("/") + return validate_issuer(issuer) if not IContainer.providedBy(container): try: container = get_current_container() @@ -27,8 +27,18 @@ def container_url(request, container): return build_url(scheme=request.scheme, host=request.host, path=path, query="").rstrip("/") -def mcp_resource(request, container): - return f"{container_url(request, container)}/@mcp/protocol" +def validate_issuer(issuer): + issuer = str(issuer).rstrip("/") + parsed = urlparse(issuer) + if parsed.scheme not in ("https", "http") or not parsed.netloc: + raise RuntimeError("oauth.issuer must be an absolute HTTP(S) URL") + if parsed.query or parsed.fragment: + raise RuntimeError("oauth.issuer must not include query or fragment components") + if parsed.username or parsed.password: + raise RuntimeError("oauth.issuer must not include userinfo") + if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: + raise RuntimeError("oauth.issuer must use https except for localhost development") + return issuer def issuer_path(request, container): @@ -50,7 +60,9 @@ def well_known_openid_configuration_url(request, container): def well_known_protected_resource_url(request, container): - parsed = urlparse(mcp_resource(request, container)) + from guillotina.contrib.oauth.flow.resources import oauth_required_audience + + parsed = urlparse(oauth_required_audience(request, container)) return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource/{parsed.path.lstrip('/')}" diff --git a/guillotina/contrib/oauth/api/views.py b/guillotina/contrib/oauth/api/views.py index d710ab11f..1cbc3f32e 100644 --- a/guillotina/contrib/oauth/api/views.py +++ b/guillotina/contrib/oauth/api/views.py @@ -14,7 +14,15 @@ def _html(body, status=200): - return Response(body=body.encode("utf-8"), status=status, content_type="text/html") + return Response( + body=body.encode("utf-8"), + status=status, + content_type="text/html", + headers={ + "Content-Security-Policy": "frame-ancestors 'none'", + "X-Frame-Options": "DENY", + }, + ) @lru_cache(maxsize=None) diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py index 24033515c..705c5ff85 100644 --- a/guillotina/contrib/oauth/auth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -3,11 +3,13 @@ from guillotina import app_settings, task_vars from guillotina.auth import find_user from guillotina.contrib.oauth.api.urls import container_url +from guillotina.contrib.oauth.flow.resources import oauth_required_audience from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE +from guillotina.contrib.oauth.flow.tokens import access_token_key class OAuthJWTValidator: - for_validators = ("bearer", "wstoken", "cookie") + for_validators = ("bearer",) async def validate(self, token): if token.get("type") not in self.for_validators: @@ -18,7 +20,7 @@ async def validate(self, token): try: claims = jwt.decode( raw, - app_settings["jwt"]["secret"], + access_token_key(), algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) @@ -33,8 +35,7 @@ async def validate(self, token): if claims.get("iss") != issuer: return aud = set(claims.get("aud") or []) - # Generic API accepts the container audience. MCP performs stricter audience checks. - if issuer not in aud and not request.path.endswith("/@mcp/protocol"): + if oauth_required_audience(request, container) not in aud: return if not claims.get("client_id"): return diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index 3a9bc1b2f..8af32929e 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -2,7 +2,8 @@ from uuid import uuid4 from guillotina.contrib.oauth.api.request import normalize_list, oauth_error -from guillotina.contrib.oauth.flow.tokens import utcnow +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported +from guillotina.contrib.oauth.flow.tokens import timestamp, utcnow SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} @@ -53,8 +54,8 @@ def make_client(data): method = data.get("token_endpoint_auth_method", "none") if method != "none": oauth_error("unsupported_token_endpoint_auth_method") - grant_types = data.get("grant_types") or ["authorization_code", "refresh_token"] - response_types = data.get("response_types") or ["code"] + grant_types = data["grant_types"] if "grant_types" in data else ["authorization_code", "refresh_token"] + response_types = data["response_types"] if "response_types" in data else ["code"] if not isinstance(grant_types, list) or not grant_types: oauth_error("invalid_client_metadata", "grant_types must be a non-empty array") if not isinstance(response_types, list) or not response_types: @@ -63,7 +64,17 @@ def make_client(data): oauth_error("invalid_client_metadata", "unsupported grant_type") if any(response_type not in SUPPORTED_RESPONSE_TYPES for response_type in response_types): oauth_error("invalid_client_metadata", "unsupported response_type") - now = utcnow().isoformat() + if "authorization_code" in grant_types and "code" not in response_types: + oauth_error("invalid_client_metadata", "authorization_code grant requires code response_type") + if "code" in response_types and "authorization_code" not in grant_types: + oauth_error("invalid_client_metadata", "code response_type requires authorization_code grant") + scope = normalize_list(data.get("scope")) or [OAUTH_DEFAULT_SCOPE] + if OAUTH_DEFAULT_SCOPE not in scope: + oauth_error("invalid_client_metadata", f"{OAUTH_DEFAULT_SCOPE} scope is required") + if not set(scope).issubset(set(oauth_scopes_supported())): + oauth_error("invalid_client_metadata", "unsupported scope") + now_dt = utcnow() + now = now_dt.isoformat() return { "client_id": uuid4().hex, "client_name": data.get("client_name") or "OAuth Client", @@ -71,12 +82,18 @@ def make_client(data): "grant_types": grant_types, "response_types": response_types, "token_endpoint_auth_method": "none", - "scope": " ".join(normalize_list(data.get("scope"))), + "scope": " ".join(scope), + "client_id_issued_at": timestamp(now_dt), "created_at": now, "updated_at": now, } +def scopes_registered_for_client(client, scopes): + allowed = normalize_list(client.get("scope")) or [OAUTH_DEFAULT_SCOPE] + return set(scopes).issubset(set(allowed)) + + def consent_key(user_id, client_id, scopes, resources): return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) diff --git a/guillotina/contrib/oauth/flow/csrf.py b/guillotina/contrib/oauth/flow/csrf.py index 25ed1c5fe..538057c8c 100644 --- a/guillotina/contrib/oauth/flow/csrf.py +++ b/guillotina/contrib/oauth/flow/csrf.py @@ -6,6 +6,7 @@ import time from guillotina import app_settings +from guillotina.contrib.oauth.flow.keys import derive_key OAUTH_CSRF_FIELD = "oauth_csrf" @@ -36,7 +37,7 @@ def _b64url_decode(value): def _csrf_signature(body): - secret = app_settings["jwt"]["secret"].encode("utf-8") + secret = derive_key("csrf") return _b64url_encode(hmac.new(secret, body.encode("ascii"), hashlib.sha256).digest()) diff --git a/guillotina/contrib/oauth/flow/keys.py b/guillotina/contrib/oauth/flow/keys.py new file mode 100644 index 000000000..3eeaf9558 --- /dev/null +++ b/guillotina/contrib/oauth/flow/keys.py @@ -0,0 +1,31 @@ +"""Purpose-specific key derivation for OAuth secrets. + +All OAuth HMAC operations are keyed from the single configured ``jwt.secret``. +To avoid using the same raw key for unrelated purposes (token hashing, CSRF +signing, ...), every consumer derives a distinct subkey bound to a stable +purpose label. This provides cryptographic domain separation: compromising or +analysing one usage does not weaken the others. + +Access-token JWTs, token hashes and CSRF signatures each use a separate derived +key so an OAuth token cannot be validated by Guillotina's generic JWT validator. +""" + +import hashlib +import hmac + +from guillotina import app_settings + + +def _base_secret() -> bytes: + secret = app_settings.get("jwt", {}).get("secret") + if not secret: + raise RuntimeError( + "OAuth key derivation requires `jwt.secret` to be configured; " + "refusing to fall back to an insecure default secret." + ) + return secret.encode("utf-8") + + +def derive_key(purpose: str) -> bytes: + """Return a 32-byte key bound to ``purpose``, derived from ``jwt.secret``.""" + return hmac.new(_base_secret(), f"guillotina.oauth:{purpose}".encode("utf-8"), hashlib.sha256).digest() diff --git a/guillotina/contrib/oauth/flow/ratelimit.py b/guillotina/contrib/oauth/flow/ratelimit.py index e3efc5429..00e22d33b 100644 --- a/guillotina/contrib/oauth/flow/ratelimit.py +++ b/guillotina/contrib/oauth/flow/ratelimit.py @@ -1,26 +1,35 @@ -"""Best-effort, in-memory sliding-window rate limiter. +"""Best-effort sliding-window rate limiter. Used to throttle anonymous OAuth dynamic client registration (RFC 7591) so an open registration endpoint cannot be trivially abused to flood the store. -Notes / limitations: -- State is per-process. Behind multiple workers each process keeps its own - window; the effective global limit is ``limit * workers``. For stricter - guarantees use a shared backend (e.g. Redis), but this provides a cheap and - effective first line of defense without extra infrastructure. -- Keys are bounded to avoid unbounded memory growth from many distinct callers. +When Redis is configured via ``guillotina.contrib.redis`` this module stores +windows in Redis so limits are shared across workers. Without Redis it falls back +to a bounded in-memory store, which is still useful for development and simple +single-process deployments. """ +import logging import time from collections import deque +from json import dumps, loads + +from guillotina import app_settings _MAX_TRACKED_KEYS = 50000 _buckets: "dict[str, deque]" = {} +logger = logging.getLogger("guillotina.contrib.oauth") + +_redis_driver = None +_redis_unavailable = False +_REDIS_PREFIX = "oauth-rate-limit:v1" def reset_rate_limits(): """Clear all tracked windows (used by tests).""" + global _redis_unavailable + _redis_unavailable = False _buckets.clear() @@ -33,7 +42,7 @@ def _prune_if_needed(): _buckets.pop(key, None) -def rate_limit_exceeded(key, *, limit, window, now=None): +def _memory_rate_limit_exceeded(key, *, limit, window, now=None): """Register a hit for ``key`` and report whether it exceeds the window limit. ``limit <= 0`` disables the limiter (always allowed). When the call would @@ -56,3 +65,122 @@ def rate_limit_exceeded(key, *, limit, window, now=None): return True bucket.append(now) return False + + +def _memory_rate_limit_check(key, *, limit, window, now=None): + """Report whether ``key`` is already at/over the window limit without recording a hit. + + Useful to throttle expensive operations (such as password verification) by + counting only failures: check first with this function, then record an + actual failure with :func:`rate_limit_exceeded`. + """ + if not limit or limit <= 0: + return False + now = time.monotonic() if now is None else now + cutoff = now - window + bucket = _buckets.get(key) + if bucket is None: + return False + while bucket and bucket[0] <= cutoff: + bucket.popleft() + return len(bucket) >= limit + + +def _redis_enabled(): + return "guillotina.contrib.redis" in set(app_settings.get("applications") or []) and bool( + app_settings.get("redis") + ) + + +async def _get_redis_driver(): + global _redis_driver, _redis_unavailable + if _redis_unavailable or not _redis_enabled(): + return None + try: + from guillotina.contrib.redis import get_driver + + _redis_driver = await get_driver() + return _redis_driver + except Exception: + _redis_unavailable = True + logger.warning( + "OAuth rate limiter falling back to in-memory storage; Redis unavailable", exc_info=True + ) + return None + + +def _redis_key(key): + return f"{_REDIS_PREFIX}:{key}" + + +def _decode_redis_bucket(raw): + if not raw: + return [] + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + try: + data = loads(raw) + except Exception: + return [] + return [float(item) for item in data if isinstance(item, (int, float))] + + +async def _redis_bucket(driver, redis_key, *, window, now): + cutoff = now - window + bucket = _decode_redis_bucket(await driver.get(redis_key)) + return [item for item in bucket if item > cutoff] + + +async def _save_redis_bucket(driver, redis_key, bucket, *, window): + await driver.set(redis_key, dumps(bucket), expire=max(int(window) + 1, 1)) + + +async def _redis_rate_limit_exceeded(driver, key, *, limit, window, now=None): + now = time.time() if now is None else now + redis_key = _redis_key(key) + bucket = await _redis_bucket(driver, redis_key, window=window, now=now) + if len(bucket) >= limit: + await _save_redis_bucket(driver, redis_key, bucket, window=window) + return True + bucket.append(now) + await _save_redis_bucket(driver, redis_key, bucket, window=window) + return False + + +async def _redis_rate_limit_check(driver, key, *, limit, window, now=None): + now = time.time() if now is None else now + redis_key = _redis_key(key) + bucket = await _redis_bucket(driver, redis_key, window=window, now=now) + await _save_redis_bucket(driver, redis_key, bucket, window=window) + return len(bucket) >= limit + + +async def rate_limit_exceeded(key, *, limit, window, now=None): + """Register a hit for ``key`` and report whether it exceeds the window limit. + + ``limit <= 0`` disables the limiter. When the call would exceed ``limit`` + events within ``window`` seconds it returns ``True`` and does not record the + hit, so a blocked caller cannot extend its own window indefinitely. + """ + if not limit or limit <= 0: + return False + driver = await _get_redis_driver() + if driver is not None: + try: + return await _redis_rate_limit_exceeded(driver, key, limit=limit, window=window, now=now) + except Exception: + logger.warning("OAuth Redis rate limit check failed; using in-memory fallback", exc_info=True) + return _memory_rate_limit_exceeded(key, limit=limit, window=window, now=now) + + +async def rate_limit_check(key, *, limit, window, now=None): + """Report whether ``key`` is already at/over the window limit without recording a hit.""" + if not limit or limit <= 0: + return False + driver = await _get_redis_driver() + if driver is not None: + try: + return await _redis_rate_limit_check(driver, key, limit=limit, window=window, now=now) + except Exception: + logger.warning("OAuth Redis rate limit check failed; using in-memory fallback", exc_info=True) + return _memory_rate_limit_check(key, limit=limit, window=window, now=now) diff --git a/guillotina/contrib/oauth/flow/resources.py b/guillotina/contrib/oauth/flow/resources.py index cc0e15835..28dc460a8 100644 --- a/guillotina/contrib/oauth/flow/resources.py +++ b/guillotina/contrib/oauth/flow/resources.py @@ -1,7 +1,11 @@ """Extensible OAuth `resource` identifiers (RFC 8707 style) for this authorization server. -Each resolver is a callable ``(request, container) -> Iterable[str]`` of absolute -resource URIs allowed in authorize/token requests. +Resource resolvers are callables ``(request, container) -> Iterable[str]`` of +absolute resource URIs allowed in authorize/token requests. + +Audience resolvers are callables ``(request, container) -> str | None``. They +allow protocol integrations to declare the exact audience required for the +current request without coupling the OAuth validator to protocol-specific paths. The oauth contrib registers the container issuer URL by default. Other packages (for example MCP) register additional URIs via :func:`register_oauth_resource_resolver`. @@ -9,12 +13,14 @@ from __future__ import annotations -from typing import Callable, FrozenSet, Iterable, List +from typing import Callable, FrozenSet, Iterable, List, Optional ResourceResolver = Callable[..., Iterable[str]] +AudienceResolver = Callable[..., Optional[str]] _resource_resolvers: List[ResourceResolver] = [] +_audience_resolvers: List[AudienceResolver] = [] _default_registered = False @@ -23,6 +29,11 @@ def register_oauth_resource_resolver(resolver: ResourceResolver) -> None: _resource_resolvers.append(resolver) +def register_oauth_audience_resolver(resolver: AudienceResolver) -> None: + if resolver not in _audience_resolvers: + _audience_resolvers.append(resolver) + + def _default_container_resolver(request, container): from guillotina.contrib.oauth.api.urls import container_url @@ -53,3 +64,20 @@ def oauth_allowed_resources(request, container) -> FrozenSet[str]: if urls: out.update(urls) return frozenset(out) + + +def oauth_required_audience(request, container) -> str: + from guillotina import app_settings as _apps + from guillotina.contrib.oauth.api.urls import container_url + + applications = set(_apps.get("applications") or []) + for resolver in _audience_resolvers: + if ( + getattr(resolver, "_oauth_resource_source", None) == "mcp" + and "guillotina.contrib.mcp" not in applications + ): + continue + resource = resolver(request, container) + if resource: + return resource + return container_url(request, container) diff --git a/guillotina/contrib/oauth/flow/tokens.py b/guillotina/contrib/oauth/flow/tokens.py index 9fade3e8d..15ff05f1c 100644 --- a/guillotina/contrib/oauth/flow/tokens.py +++ b/guillotina/contrib/oauth/flow/tokens.py @@ -7,6 +7,7 @@ import jwt from guillotina import app_settings +from guillotina.contrib.oauth.flow.keys import derive_key def utcnow(): @@ -23,13 +24,12 @@ def opaque_token(prefix=""): def token_hash(token: str) -> str: - secret = app_settings.get("jwt", {}).get("secret") - if not secret: - raise RuntimeError( - "OAuth token hashing requires `jwt.secret` to be configured; " - "refusing to fall back to an insecure default secret." - ) - return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() + key = derive_key("token-hash") + return hmac.new(key, token.encode("utf-8"), hashlib.sha256).hexdigest() + + +def access_token_key() -> bytes: + return derive_key("access-token") def issue_access_token(*, issuer, subject, audience, client_id, scope): @@ -46,5 +46,5 @@ def issue_access_token(*, issuer, subject, audience, client_id, scope): "exp": timestamp(now + timedelta(seconds=ttl)), "token_type": "oauth_access_token", } - token = jwt.encode(claims, app_settings["jwt"]["secret"], algorithm=app_settings["jwt"]["algorithm"]) + token = jwt.encode(claims, access_token_key(), algorithm=app_settings["jwt"]["algorithm"]) return token, claims diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index 763b88ff5..6cec8902b 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -3,17 +3,31 @@ from guillotina import app_settings, configure from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy from guillotina.contrib.oauth.api.services import register_well_known_handler -from guillotina.contrib.oauth.api.urls import container_url, mcp_resource, well_known_protected_resource_url -from guillotina.contrib.oauth.flow.resources import register_oauth_resource_resolver +from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url +from guillotina.contrib.oauth.flow.resources import ( + register_oauth_audience_resolver, + register_oauth_resource_resolver, +) from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported +def mcp_resource(request, container): + return f"{container_url(request, container)}/@mcp/protocol" + + def _mcp_protocol_resource_resolver(request, container): return {mcp_resource(request, container)} +def _mcp_protocol_audience_resolver(request, container): + if str(getattr(request, "path", "") or "").endswith("/@mcp/protocol"): + return mcp_resource(request, container) + + _mcp_protocol_resource_resolver._oauth_resource_source = "mcp" +_mcp_protocol_audience_resolver._oauth_resource_source = "mcp" register_oauth_resource_resolver(_mcp_protocol_resource_resolver) +register_oauth_audience_resolver(_mcp_protocol_audience_resolver) def _protected_resource_metadata(request, context): @@ -38,6 +52,9 @@ def is_enabled(self, request, context): return "guillotina.contrib.oauth" in applications and "guillotina.contrib.mcp" in applications def unauthorized_headers(self, request, context): + authz = request.headers.get("AUTHORIZATION", "") or request.headers.get("Authorization", "") + if authz.lower().startswith("bearer "): + return self.forbidden_headers(request, context) return self._challenge_headers(request, context) def forbidden_headers(self, request, context): diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index 7f64e524f..184b74fff 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -19,7 +19,7 @@ OAUTH_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth"], - "oauth": {"registration_rate_limit": 0}, + "oauth": {"registration_rate_limit": 0, "token_rate_limit": 0, "revoke_rate_limit": 0}, "auth_extractors": [ "guillotina.auth.extractors.BearerAuthPolicy", "guillotina.auth.extractors.BasicAuthPolicy", @@ -29,7 +29,7 @@ } OAUTH_MCP_SETTINGS = { "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], - "oauth": {"registration_rate_limit": 0}, + "oauth": {"registration_rate_limit": 0, "token_rate_limit": 0, "revoke_rate_limit": 0}, "auth_extractors": [ "guillotina.auth.extractors.BearerAuthPolicy", "guillotina.auth.extractors.BasicAuthPolicy", @@ -63,8 +63,9 @@ async def register_client(requester, redirect_uri="http://127.0.0.1:12345/callba "scope": "guillotina:access", } ), + headers={"Content-Type": "application/json"}, ) - assert status == 200 + assert status == 201 return response diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index d718d694b..37a0139cb 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -6,7 +6,6 @@ from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, authorize_code, - oauth_csrf_from_body, register_client, requires_pg, token_from_code, @@ -168,20 +167,6 @@ async def test_mcp_serialized_content_with_oauth_token(container_install_request assert status == 200 -NO_PKCE_SETTINGS = { - "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], - "auth_extractors": [ - "guillotina.auth.extractors.BearerAuthPolicy", - "guillotina.auth.extractors.BasicAuthPolicy", - "guillotina.auth.extractors.WSTokenAuthPolicy", - "guillotina.auth.extractors.CookiePolicy", - ], - "oauth": { - "require_pkce": False, - }, -} - - @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) async def test_subresource_mcp_unauthorized(container_install_requester): @@ -233,56 +218,3 @@ async def test_subresource_mcp_authorized(container_install_requester): ) _skip_if_protocol_unavailable(response, status) assert status == 200 - - -@pytest.mark.app_settings(NO_PKCE_SETTINGS) -@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) -async def test_authorize_without_pkce(container_install_requester): - from urllib.parse import parse_qs, urlencode, urlparse - - async with container_install_requester as requester: - client = await register_client(requester) - data = { - "response_type": "code", - "client_id": client["client_id"], - "redirect_uri": client["redirect_uris"][0], - "scope": "guillotina:access", - "state": "abc", - } - value, status, _headers = await requester.make_request( - "GET", - "/db/guillotina/oauth/authorize", - params=data, - allow_redirects=False, - ) - assert status == 200 - data["oauth_csrf"] = oauth_csrf_from_body(value) - data["decision"] = "allow" - _value, status, headers = await requester.make_request( - "POST", - "/db/guillotina/oauth/authorize", - data=urlencode(data), - headers={"Content-Type": "application/x-www-form-urlencoded"}, - allow_redirects=False, - ) - assert status == 302 - query = parse_qs(urlparse(headers["Location"]).query) - code = query["code"][0] - - body = urlencode( - { - "grant_type": "authorization_code", - "client_id": client["client_id"], - "redirect_uri": client["redirect_uris"][0], - "code": code, - } - ) - response, status = await requester( - "POST", - "/db/guillotina/oauth/token", - data=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert status == 200 - assert "access_token" in response - assert "refresh_token" in response diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index 33dd7eaee..6e12c6b01 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -43,9 +43,10 @@ async def test_authorize_accepts_cursor_redirect_registered_with_client(containe ], } ), + headers={"Content-Type": "application/json"}, authenticated=False, ) - assert status == 200 + assert status == 201 client = response _response, status = await requester( "GET", @@ -107,6 +108,7 @@ async def test_register_rejects_client_supplied_client_id(container_install_requ "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], } ), + headers={"Content-Type": "application/json"}, authenticated=False, ) assert status == 400 @@ -525,3 +527,112 @@ async def test_authorize_cookie_authenticates_get_request(container_install_requ ) assert status == 200 assert b"Allow Test" in value + + +OAUTH_LOGIN_LIMIT_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {**OAUTH_SETTINGS["oauth"], "login_rate_limit": 2, "login_rate_window": 300}, +} +OAUTH_EXTRA_SCOPE_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {**OAUTH_SETTINGS["oauth"], "scopes_supported": ["guillotina:access", "guillotina:extra"]}, +} + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_response_includes_iss(container_install_requester): + """RFC 9207: the authorization response must carry the issuer identifier.""" + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + params = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "state": "s", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=params, + allow_redirects=False, + ) + assert status == 200 + params["oauth_csrf"] = oauth_csrf_from_body(value) + params["decision"] = "allow" + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=urlencode(params), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + query = parse_qs(urlparse(headers["Location"]).query) + assert query["code"][0] + assert query["iss"][0].endswith("/db/guillotina") + + +@pytest.mark.app_settings(OAUTH_LOGIN_LIMIT_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_login_rate_limited_after_failures(container_install_requester): + """Failed credential logins at the authorization endpoint are throttled.""" + from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits + + reset_rate_limits() + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + body = ( + "response_type=code" + f"&client_id={client['client_id']}" + f"&redirect_uri={client['redirect_uris'][0]}" + "&scope=guillotina:access" + f"&code_challenge={challenge}" + "&code_challenge_method=S256" + "&username=root&password=wrong-password" + ) + + async def _attempt(): + _value, status, _headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + authenticated=False, + allow_redirects=False, + ) + return status + + assert await _attempt() == 401 + assert await _attempt() == 401 + # Third failed attempt is blocked by the sliding-window limiter. + assert await _attempt() == 429 + reset_rate_limits() + + +@pytest.mark.app_settings(OAUTH_EXTRA_SCOPE_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_rejects_scope_not_registered_for_client(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + _verifier, challenge = verifier_pair() + _value, status, headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "response_type": "code", + "scope": "guillotina:access guillotina:extra", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + assert status == 302 + assert "error=invalid_scope" in headers["Location"] diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index 42bf3a45a..18eba2de1 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -18,15 +18,20 @@ @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_client(container_install_requester): async with container_install_requester as requester: - response, status = await requester( + response, status, headers = await requester.make_request( "POST", "/db/guillotina/oauth/register", data=json.dumps({"client_name": "Example", "redirect_uris": ["http://127.0.0.1:12345/callback"]}), + headers={"Content-Type": "application/json"}, authenticated=False, ) - assert status == 200 + assert status == 201 assert response["client_id"] + assert response["client_id_issued_at"] > 0 + assert response["scope"] == "guillotina:access" assert response["token_endpoint_auth_method"] == "none" + assert headers["Cache-Control"] == "no-store" + assert headers["Pragma"] == "no-cache" @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -41,13 +46,42 @@ async def test_register_client(container_install_requester): {"redirect_uris": ["http://localhost/cb"], "token_endpoint_auth_method": "client_secret_basic"}, {"redirect_uris": ["http://localhost/cb"], "grant_types": ["implicit"]}, {"redirect_uris": ["http://localhost/cb"], "response_types": ["token"]}, + { + "redirect_uris": ["http://localhost/cb"], + "grant_types": ["authorization_code"], + "response_types": [], + }, + { + "redirect_uris": ["http://localhost/cb"], + "grant_types": ["refresh_token"], + "response_types": ["code"], + }, ], ) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_rejects_invalid(payload, container_install_requester): async with container_install_requester as requester: - _response, status = await requester("POST", "/db/guillotina/oauth/register", data=json.dumps(payload)) + _response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + assert status == 400 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_requires_json_content_type(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}), + headers={"Content-Type": "text/plain"}, + ) assert status == 400 + assert response["error"] == "invalid_request" @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -58,8 +92,9 @@ async def test_register_accepts_loopback(container_install_requester): "POST", "/db/guillotina/oauth/register", data=json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}), + headers={"Content-Type": "application/json"}, ) - assert status == 200 + assert status == 201 @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -75,9 +110,10 @@ async def test_register_accepts_cursor_native_redirect(container_install_request "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], } ), + headers={"Content-Type": "application/json"}, authenticated=False, ) - assert status == 200 + assert status == 201 assert response["redirect_uris"] == ["cursor://anysphere.cursor-mcp/oauth/callback"] @@ -98,9 +134,10 @@ async def test_register_accepts_multiple_redirect_uris(container_install_request ], } ), + headers={"Content-Type": "application/json"}, authenticated=False, ) - assert status == 200 + assert status == 201 assert response["redirect_uris"] == [ "cursor://anysphere.cursor-mcp/oauth/callback", "https://www.cursor.com/agents/mcp/oauth/callback", @@ -122,6 +159,7 @@ async def test_register_rejects_client_supplied_client_id(container_install_requ "redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"], } ), + headers={"Content-Type": "application/json"}, authenticated=False, ) assert status == 400 @@ -139,11 +177,19 @@ async def test_register_rate_limited(container_install_requester): async with container_install_requester as requester: for _ in range(2): _response, status = await requester( - "POST", "/db/guillotina/oauth/register", data=payload, authenticated=False + "POST", + "/db/guillotina/oauth/register", + data=payload, + headers={"Content-Type": "application/json"}, + authenticated=False, ) - assert status == 200 + assert status == 201 response, status = await requester( - "POST", "/db/guillotina/oauth/register", data=payload, authenticated=False + "POST", + "/db/guillotina/oauth/register", + data=payload, + headers={"Content-Type": "application/json"}, + authenticated=False, ) assert status == 429 assert response["error"] == "temporarily_unavailable" diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index dc4cc4d74..995218a89 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -1,5 +1,6 @@ import pytest +from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, @@ -11,6 +12,11 @@ pytestmark = [pytest.mark.asyncio, requires_pg] +REVOKE_RATE_LIMIT_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {**OAUTH_SETTINGS["oauth"], "revoke_rate_limit": 2, "revoke_rate_window": 300}, +} + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) @@ -133,3 +139,30 @@ async def test_revoke_rejects_duplicate_singleton_parameter(container_install_re headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 + + +@pytest.mark.app_settings(REVOKE_RATE_LIMIT_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_endpoint_rate_limited(container_install_requester): + reset_rate_limits() + async with container_install_requester as requester: + client = await register_client(requester) + body = f"client_id={client['client_id']}&token=unknown" + + async def _attempt(): + response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + return response, status + + _response, status = await _attempt() + assert status == 200 + _response, status = await _attempt() + assert status == 200 + response, status = await _attempt() + assert status == 429 + assert response["error"] == "temporarily_unavailable" + reset_rate_limits() diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index 8eb1b029d..f23cfc372 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -1,7 +1,8 @@ import jwt import pytest - from guillotina import app_settings +from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits +from guillotina.contrib.oauth.flow.tokens import access_token_key from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, @@ -22,6 +23,10 @@ "applications": ["guillotina", "guillotina.contrib.oauth"], "oauth": {"refresh_token_ttl": 0}, } +TOKEN_RATE_LIMIT_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {**OAUTH_SETTINGS["oauth"], "token_rate_limit": 2, "token_rate_window": 300}, +} @pytest.mark.app_settings(OAUTH_SETTINGS) @@ -47,7 +52,7 @@ async def test_code_token_and_refresh_rotation(container_install_requester): assert token_headers["Pragma"] == "no-cache" claims = jwt.decode( token["access_token"], - app_settings["jwt"]["secret"], + access_token_key(), algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) @@ -290,3 +295,30 @@ async def test_code_reuse_revokes_tokens(container_install_requester): headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert status == 400 + + +@pytest.mark.app_settings(TOKEN_RATE_LIMIT_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_endpoint_rate_limited(container_install_requester): + reset_rate_limits() + async with container_install_requester as requester: + client = await register_client(requester) + body = f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token=missing" + + async def _attempt(): + response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + return response, status + + _response, status = await _attempt() + assert status == 400 + _response, status = await _attempt() + assert status == 400 + response, status = await _attempt() + assert status == 429 + assert response["error"] == "temporarily_unavailable" + reset_rate_limits() diff --git a/guillotina/tests/oauth/test_oauth_validator.py b/guillotina/tests/oauth/test_oauth_validator.py index d4b29cc05..8e545cd23 100644 --- a/guillotina/tests/oauth/test_oauth_validator.py +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -1,5 +1,4 @@ import pytest - from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, OAUTH_SETTINGS, diff --git a/guillotina/tests/test_auth.py b/guillotina/tests/test_auth.py index 5d1f51bda..1ec1d7d99 100644 --- a/guillotina/tests/test_auth.py +++ b/guillotina/tests/test_auth.py @@ -2,9 +2,22 @@ import jwt import pytest +from guillotina.response import HTTPBadRequest from guillotina._settings import app_settings from guillotina.auth import validators +from guillotina.content import Container +from guillotina.contrib.oauth.api.urls import container_url, validate_issuer +from guillotina.contrib.oauth.api.views import oauth_error_page +from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator +from guillotina.contrib.oauth.flow.clients import make_client, scopes_registered_for_client +from guillotina.contrib.oauth.flow.resources import ( + oauth_required_audience, + register_oauth_audience_resolver, +) +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits +from guillotina.contrib.oauth.flow.tokens import issue_access_token +from guillotina.tests.utils import make_mocked_request pytestmark = pytest.mark.asyncio @@ -51,6 +64,173 @@ async def test_cookie_auth(container_requester): assert status == 200 +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_oauth_access_token_uses_dedicated_signing_key(dummy_guillotina): + access_token, _claims = issue_access_token( + issuer="http://localhost/db/guillotina", + subject="root", + audience=["http://localhost/db/guillotina"], + client_id="client", + scope=["guillotina:access"], + ) + with pytest.raises(jwt.exceptions.InvalidSignatureError): + jwt.decode( + access_token, + app_settings["jwt"]["secret"], + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) + + +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_generic_jwt_validator_rejects_oauth_token_type(dummy_guillotina): + token = jwt.encode( + {"id": "root", "sub": "root", "token_type": "oauth_access_token"}, + app_settings["jwt"]["secret"], + algorithm=app_settings["jwt"]["algorithm"], + ) + assert await validators.JWTValidator().validate({"type": "bearer", "token": token}) is None + + +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +@pytest.mark.parametrize("token_type", ["cookie", "wstoken"]) +async def test_oauth_access_token_only_accepts_bearer_transport(token_type, dummy_guillotina): + access_token, _claims = issue_access_token( + issuer="http://localhost/db/guillotina", + subject="root", + audience=["http://localhost/db/guillotina"], + client_id="client", + scope=["guillotina:access"], + ) + assert await OAuthJWTValidator().validate({"type": token_type, "token": access_token}) is None + + +async def test_oauth_html_pages_deny_framing(dummy_guillotina): + response = oauth_error_page("Error", "Message", status=400) + assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'" + assert response.headers["X-Frame-Options"] == "DENY" + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, + } +) +async def test_oauth_client_scope_registration_limits_requested_scopes(dummy_guillotina): + client = make_client({"redirect_uris": ["http://localhost/callback"]}) + assert client["scope"] == "guillotina:access" + assert scopes_registered_for_client(client, ["guillotina:access"]) + assert not scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) + + client = make_client( + {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:access guillotina:extra"} + ) + assert scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, + } +) +async def test_oauth_client_registration_rejects_unusable_scope(dummy_guillotina): + with pytest.raises(HTTPBadRequest): + make_client({"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:extra"}) + + +async def test_oauth_configured_issuer_must_be_safe(): + assert ( + validate_issuer("https://api.example.com/db/guillotina/") == "https://api.example.com/db/guillotina" + ) + assert validate_issuer("http://localhost/db/guillotina") == "http://localhost/db/guillotina" + + for issuer in ( + "api.example.com/db/guillotina", + "http://api.example.com/db/guillotina", + "https://api.example.com/db/guillotina?x=1", + "https://api.example.com/db/guillotina#fragment", + "https://user:pass@api.example.com/db/guillotina", + ): + with pytest.raises(RuntimeError): + validate_issuer(issuer) + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"issuer": "https://issuer.example.com/db/guillotina/", "trust_proxy_headers": True}, + } +) +async def test_oauth_configured_issuer_overrides_request_headers(dummy_guillotina): + request = make_mocked_request( + "GET", + "/db/guillotina/.well-known/oauth-authorization-server", + headers={"Host": "evil.example", "X-Forwarded-Proto": "http"}, + ) + container = Container() + container.__name__ = "guillotina" + assert container_url(request, container) == "https://issuer.example.com/db/guillotina" + + +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_oauth_required_audience_defaults_to_container(dummy_guillotina): + request = make_mocked_request("GET", "/db/guillotina/@addons") + container = Container() + container.__name__ = "guillotina" + assert oauth_required_audience(request, container) == "http://localhost/guillotina" + + +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_oauth_required_audience_can_be_extended(dummy_guillotina): + def resolver(request, container): + if request.path.endswith("/@custom-protocol"): + return f"{container_url(request, container)}/@custom-protocol" + + register_oauth_audience_resolver(resolver) + container = Container() + container.__name__ = "guillotina" + + request = make_mocked_request("GET", "/db/guillotina/@custom-protocol") + assert oauth_required_audience(request, container) == "http://localhost/guillotina/@custom-protocol" + + request = make_mocked_request("GET", "/db/guillotina/@addons") + assert oauth_required_audience(request, container) == "http://localhost/guillotina" + + +class _FakeRedisDriver: + def __init__(self): + self.values = {} + + async def get(self, key): + return self.values.get(key) + + async def set(self, key, data, *, expire=None): + self.values[key] = data + + +@pytest.mark.app_settings( + {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.redis"], "redis": {}} +) +async def test_oauth_rate_limit_uses_redis_when_configured(monkeypatch, dummy_guillotina): + from guillotina.contrib.oauth.flow import ratelimit + + reset_rate_limits() + driver = _FakeRedisDriver() + + async def _driver(): + return driver + + monkeypatch.setattr(ratelimit, "_get_redis_driver", _driver) + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=10) is False + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=11) is False + assert await rate_limit_check("redis-key", limit=2, window=60, now=12) is True + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=12) is True + assert "oauth-rate-limit:v1:redis-key" in driver.values + reset_rate_limits() + + async def test_argon_hashing(dummy_guillotina): hashed = validators.hash_password("foobar", algorithm="argon2") assert validators.check_password(hashed, "foobar") From 78c3e601753ec2cedd545957c2b87d59908a4983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 31 May 2026 18:31:52 +0200 Subject: [PATCH 07/27] feat: Manage OAuth consents with TTL and revocation - Add configurable consent_ttl (default 30 days, 0 disables expiry) with an expires_at column on oauth_consents, purged by oauth_cleanup_expired. - Expose GET/POST /oauth/consents for authenticated users to list and revoke their own grants (401 for anonymous, 404 for unknown consent_key). - Drop the stored consent on revocation (consent endpoint or refresh-token /oauth/revoke) and clear all refresh tokens for the (user, client) pair so a grant can no longer be re-issued silently. - Add IOAuthStore methods list_consents, delete_consent and revoke_user_client_refresh_tokens; renew expires_at on consent re-grant. - Add oauth consents tests and visual documentation (oauth-overview.html). --- CHANGELOG.rst | 6 + docs/source/contrib/oauth-overview.html | 663 ++++++++++++++++++ guillotina/contrib/oauth/__init__.py | 3 + guillotina/contrib/oauth/api/services.py | 79 ++- .../contrib/oauth/storage/interfaces.py | 13 +- .../contrib/oauth/storage/pg/repository.py | 93 ++- guillotina/contrib/oauth/storage/pg/schema.py | 23 + guillotina/tests/oauth/test_oauth_consents.py | 185 +++++ 8 files changed, 1059 insertions(+), 6 deletions(-) create mode 100644 docs/source/contrib/oauth-overview.html create mode 100644 guillotina/tests/oauth/test_oauth_consents.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 09e244ff4..fc3cf66f1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,12 @@ CHANGELOG add Redis-backed rate limiting when Redis is configured, and tighten dynamic client registration responses with ``201 Created``, no-store cache headers and ``client_id_issued_at``. [rboixaderg] +- OAuth: manage stored consents — add a configurable ``consent_ttl`` (default 30 days, ``0`` disables + expiry) with an ``expires_at`` column purged by ``oauth_cleanup_expired``, expose + ``GET``/``POST /oauth/consents`` for an authenticated user to list and revoke their grants, and make + revocation (via the consent endpoint or refresh-token ``/oauth/revoke``) drop the consent so a new + authorization can no longer be re-issued silently. + [rboixaderg] 7.1.2 (2026-05-22) diff --git a/docs/source/contrib/oauth-overview.html b/docs/source/contrib/oauth-overview.html new file mode 100644 index 000000000..edef8dcf9 --- /dev/null +++ b/docs/source/contrib/oauth-overview.html @@ -0,0 +1,663 @@ + + + + + +Guillotina OAuth + MCP — Mapa visual d'implementació + + + +
    + + +
    +
    +

    Guillotina OAuth 2.1 + MCP

    +

    Servidor d'autorització OAuth integrat a Guillotina (perfil OAuth 2.1 de facto): només clients públics amb PKCE obligatori, grants authorization_code i refresh_token, confinament d'audiència (resource indicators) i integració amb el protocol MCP com a recurs protegit.

    +

    Aquest document és un mapa visual de tot el que s'ha implementat a la branca feature/oauth-mcp: fluxos de dades, camins alternatius, control d'errors, opcions de configuració i els RFC que es compleixen.

    +
    + RFC 6749RFC 6750RFC 7636 · PKCE + RFC 7591 · DCRRFC 7009 · RevokeRFC 8414 · Metadata + RFC 8707 · ResourcesRFC 9207 · issRFC 9700 · Security BCP + RFC 9728 · Protected Resource +
    +
    + + +
    +

    🗺️ Visió general

    +

    L'AS viu dins de cada container de Guillotina. Tot l'estat (clients, codis, refresh tokens, consentiments) es desa a PostgreSQL, aïllat per container_db_key = db_id/container.id. Els access tokens són JWT sense estat, signats amb una subclau dedicada derivada del secret de l'app (derive_key("access-token")), separada de la clau de signatura JWT genèrica.

    +
    +
    4

    Endpoints OAuth: register, authorize, token, revoke

    +
    3

    Endpoints de descobriment .well-known (AS metadata, OIDC alias, protected-resource)

    +
    PKCE

    S256 obligatori per defecte · plain rebutjat

    +
    PG

    PostgreSQL és l'únic backend · neteja periòdica d'expirats

    +
    + +

    Arquitectura a vista d'ocell

    +
    + + + + + + + + 🧑 Usuari + Navegador + + + + 📱 Client + App / MCP client + + + + 🛡️ Guillotina AS + contrib.oauth · per-container + + authorize · token · register · revoke + PKCE · CSRF · rate-limit · keys + JWT validator (bearer/cookie/ws) + .well-known metadata + + + + 🗄️ PostgreSQL + hashed store + + + + 🔌 Recurs + API · MCP protocol + + + + register / token + + authorize (login+consent) + + desa hash + + valida JWT / aud + + Bearer access_token → recurs + +
    +
    + + +
    +

    🎭 Actors i components

    +
    + 🧑 Usuari (navegador) + 📱 Client OAuth públic + 🛡️ AS · Authorization Server + 🗄️ Store · PostgreSQL + 🔌 RS · Recurs (API/MCP) +
    +
    +

    🛡️ Servidor d'autorització

    Serveis a api/services.py. Emet codis, tokens i gestiona consentiment. Un per container.

    +

    🔑 Mòdul de claus

    flow/keys.py · derive_key() deriva subclaus HMAC distintes per propòsit (access-token, token-hash, csrf) des de jwt.secret.

    +

    🗄️ Repositori PG

    storage/pg/repository.py · operacions atòmiques (DELETE…RETURNING) i rotació amb detecció de reús.

    +

    🧩 PKCE / CSRF

    flow/pkce.py (S256) i flow/csrf.py (token HMAC signat amb TTL).

    +

    🚦 Rate limiter

    flow/ratelimit.py · finestra lliscant en memòria per registre i per login fallit.

    +

    🔌 Integració MCP

    integrations/mcp.py · política IMCPAuthPolicy + metadata de recurs protegit (RFC 9728).

    +
    +
    + + +
    +

    🔗 Endpoints

    + + + + + + + + + +
    MètodeRutaFuncióAuthDescripció
    POST/{container}/oauth/register_registercap (públic)Registre dinàmic de client (RFC 7591). Rate-limited.
    GET POST/{container}/oauth/authorize_authorizelogin/cookieAutorització: login, consentiment i emissió de codi.
    POST/{container}/oauth/token_tokenPKCEBescanvi de codi i rotació de refresh.
    POST/{container}/oauth/revoke_revokecap (públic)Revocació de refresh token (RFC 7009).
    GET POST/{container}/oauth/consents_list_consents · _revoke_consentusuari (no anònim)Llistar i revocar consentiments propis.
    GET/{container}/.well-known/{action}OAuthWellKnownpúblicMetadata AS / alias OIDC (relatiu al container).
    GET/.well-known/{action}/{path}OAuthRFCWellKnownpúblicMetadata a l'arrel (RFC 8414/9728), resol container des del path.
    +

    {action}oauth-authorization-server, openid-configuration (alias), oauth-protected-resource (només amb MCP).

    +
    + + +
    +

    🔄 Cicle de vida del client (extrem a extrem)

    +

    Què ha de fer un client públic (app nativa, SPA o client MCP) de principi a fi per integrar-se: des del descobriment fins a la revocació. Tot el flux assumeix client públic amb PKCE S256 i scope guillotina:access.

    + +
    +
    Cicle complet: descobriment → ús del recurs → refresc → revocaciópúblic · PKCE S256
    + +
    0
    📱 Client🛡️ AS
    +
    Descobriment. GET /{container}/.well-known/oauth-authorization-server per obtenir authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, code_challenge_methods_supported.MCP: parteix del WWW-Authenticate 401 → oauth-protected-resourceauthorization_servers.
    + +
    1
    📱 Client🛡️ AS
    +
    Registre dinàmic (un cop). POST /oauth/register amb client_name + redirect_uris[] → desa el client_id retornat. El client no tria el client_id; el genera el servidor.
    + +
    2
    📱 Client
    +
    Genera PKCE (per petició). code_verifier = aleatori 43–128 chars; code_challenge = BASE64URL(SHA256(verifier)). Genera també un state aleatori i guarda'l.
    + +
    3
    📱 Client🧑 Usuari🛡️ AS
    +
    Sol·licitud d'autorització. Obre al navegador GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=guillotina:access&state=…&code_challenge=…&code_challenge_method=S256 (opcional resource=… si vol un recurs concret com MCP).
    + +
    4
    🧑 Usuari🛡️ AS
    +
    Login + consentiment a l'AS. L'usuari s'autentica i aprova els scopes/recursos. El client no veu mai les credencials.
    + +
    5
    🛡️ AS📱 Client
    +
    Recepció del codi. Redirecció a redirect_uri?code=…&state=…&iss=…. El client HA de verificar que state coincideix amb el desat i que iss és l'issuer esperat (anti mix-up).
    + +
    6
    📱 Client🛡️ AS
    +
    Bescanvi del codi. POST /oauth/token (application/x-www-form-urlencoded) amb grant_type=authorization_code, code, client_id, redirect_uri, code_verifier → rep access_token (JWT), refresh_token, expires_in, scope.
    + +
    7
    📱 Client🔌 RS
    +
    Ús del recurs. Cada petició a l'API o al servidor MCP amb Authorization: Bearer <access_token>. El token ha de portar dins aud l'audiència requerida pel request concret.
    + +
    8
    📱 Client🛡️ AS
    +
    Refresc (en expirar). POST /oauth/token amb grant_type=refresh_token, refresh_token, client_id → nou access_token i nou refresh_token. El client ha de substituir el refresh token desat (rotació).
    + +
    9
    📱 Client🛡️ AS
    +
    Tancament de sessió. POST /oauth/revoke amb token=<refresh_token> + client_id per revocar tota la família.
    +
    + +
    Re-autorització silenciosa: mentre el consentiment segueixi vàlid, repetir el pas 3 amb els mateixos paràmetres salta login/consentiment i retorna el codi directament. Quan el refresh token caduca o es revoca, cal tornar al pas 2.
    + +

    Checklist d'obligacions del client

    +
    +
      +
    • Generar un code_verifier nou i aleatori per cada autorització
    • +
    • Generar i verificar state a la tornada
    • +
    • Verificar que iss retornat == issuer esperat
    • +
    • Usar el mateix redirect_uri a authorize i a token
    • +
    • Enviar token com a x-www-form-urlencoded, no JSON
    • +
    +
      +
    • Desar access_token i refresh_token de forma segura
    • +
    • Substituir el refresh token a cada rotació (mai reusar l'antic)
    • +
    • Refrescar abans/quan expires_in s'esgoti
    • +
    • Per recursos especialitzats: demanar el resource correcte perquè entri a aud
    • +
    • Revocar el refresh token en tancar sessió
    • +
    +
    + +
    +
    0–1 descobr.+registre
    +
    2–3 PKCE+authorize
    +
    4–5 login+codi
    +
    6 token
    +
    7–9 ús/refresh/revoke
    +
    +
    + + +
    +

    1 · Registre dinàmic de client RFC 7591

    +

    El client s'auto-registra abans d'autoritzar. Només clients públics (token_endpoint_auth_method=none). El servidor genera el client_id; mai l'accepta del client.

    +
    +
    Registre dinàmicPOST /oauth/register
    +
    1
    📱 Client🛡️ AS
    +
    JSON amb client_name, redirect_uris[], opcional scope.Comprova rate limit per IP de transport (client_identifier).
    +
    2
    🛡️ AS
    +
    make_client() valida: redirect_uris no buit, cada URI segura, auth_method=none, grants/response_types suportats.clients.py · validate_redirect_uri()
    +
    3
    🛡️ AS🗄️ Store
    +
    Genera client_id = uuid4().hex i persisteix.
    +
    4
    🛡️ AS📱 Client
    +
    201 amb client_id, client_id_issued_at, redirect_uris, grant_types, response_types, scope, token_endpoint_auth_method i headers Cache-Control: no-store + Pragma: no-cache.
    +
    +
    Camí d'error: rate limit superat → 429 temporarily_unavailable. Metadades invàlides → 400 invalid_request / invalid_client_metadata. Enviar client_id propi → 400 «client_id is server-issued».
    +

    Validació de redirect_uri (anti open-redirect)

    +
    +

    ✓ Acceptat

    +
      +
    • https://host/path amb netloc i path
    • +
    • http://localhost|127.0.0.1|::1/path (loopback natiu)
    • +
    • esquema custom natiu app.scheme://host/path
    • +
    +

    ✗ Rebutjat

    +
      +
    • comodins *, fragments #…
    • +
    • javascript: / data:
    • +
    • http:// no-loopback
    • +
    +
    +
    + + +
    +

    2 · Autorització + PKCE RFC 6749 RFC 7636 RFC 9207

    +

    El cor del sistema. Tres sub-camins segons l'estat de sessió i consentiment. Tot abans de validar el redirect_uri que mostra pàgina d'error (mai redirigeix); tot després redirigeix amb error + state + iss.

    + +

    Validacions prèvies (en ordre)

    + + + + + + + + + + +
    #ComprovacióSi falla
    0Paràmetres singleton duplicats (reject_duplicate_params)400
    1Client existeix400 pàgina d'error
    2redirect_uri registrat (match exacte)400 pàgina d'error
    3response_type == code302 unsupported_response_type
    4Client té code a response_types302 unauthorized_client
    5PKCE obligatori: code_challenge present, sintaxi vàlida i mètode ∈ S256302 invalid_request
    6Scope: conté guillotina:access, és subconjunt dels scopes suportats i també dels scopes registrats pel client302 invalid_scope
    7Resource ∈ recursos permesos (RFC 8707). Si no s'envia resource, s'usa per defecte el container_url.302 invalid_target
    + +

    Camí A — Usuari NO autenticat → login al propi AS

    +
    +
    Sub-flux de login (credencials a l'AS, no és ROPC)GET/POST /oauth/authorize
    +
    1
    🧑 Usuari🛡️ AS
    GET sense sessió → es renderitza login.html amb tots els paràmetres com a camps ocults.
    +
    2
    🧑 Usuari🛡️ AS
    POST amb username/password.Comprova rate_limit_check abans d'autenticar.
    +
    3
    🛡️ AS
    _authenticate_basic() via validators tipus «basic». Èxit → emet cookie auth_token (HttpOnly, SameSite=Lax, Secure si HTTPS) i força pantalla de consentiment.
    +
    +
    Errors: credencials incorrectes → registra fallada + 401. Massa fallades a la finestra → 429 «Too many attempts». (login_rate_limit / login_rate_window)
    +
    El login ocorre a l'AS, no al client → no és el grant «password» (prohibit per RFC 9700 §2.4). A més, en autenticar-se en aquesta petició, la decision es força a None: mai s'auto-aprova consentiment en el mateix POST que les credencials.
    + +

    Camí B — Autenticat, sense consentiment previ → pantalla de consentiment

    +
    +
    Sub-flux de consentiment (protegit amb CSRF)GET → POST /oauth/authorize
    +
    1
    🛡️ AS🧑 Usuari
    consent.html amb scopes, recursos i un token CSRF HMAC signat (lligat a user, client, redirect, scope, state, challenge, resource + iat).
    +
    2
    🧑 Usuari🛡️ AS
    POST decision=allow|deny + oauth_csrf.
    +
    3
    🛡️ AS
    csrf_valid(): compara HMAC (constant-time), TTL i tots els camps. Si allow → desa consentiment.
    +
    4
    🛡️ AS🗄️ Store
    Crea codi opac goc_… (hash desat), lligat a client, user, redirect, scope, resource, code_challenge.
    +
    5
    🛡️ AS🧑 Usuari
    302 a redirect_uri?code=…&state=…&iss=…
    +
    +
    decision=deny302 access_denied.  |  CSRF invàlid302 invalid_request.  |  decision=allow per GET (sense POST) → mostra consentiment, no crea codi.
    + +

    Camí C — Consentiment ja existent → emissió silenciosa

    +
    Re-autorització silenciosa: si has_consent(ckey) és cert, un GET amb els mateixos paràmetres salta consentiment i redirigeix directament amb el codi. La clau de consentiment és user|client|scopes|resources.
    +
    +
    🧑 GET authorize
    +
    ¿sessió?
    +
    ¿consentiment?
    +
    crea codi
    +
    302 + code+iss
    +
    +
    + + +
    +

    3 · Bescanvi de codi per token RFC 6749 RFC 7636 RFC 9700

    +
    +
    grant_type = authorization_codePOST /oauth/token
    +
    1
    📱 Client🛡️ AS
    application/x-www-form-urlencoded amb code, client_id, redirect_uri, code_verifier.
    +
    2
    🛡️ AS🗄️ Store
    get_active_code() per hash + no expirat. Si no existeix → revoca família del codi (anti-replay) i invalid_grant.
    +
    3
    🛡️ AS
    Comprova: client coincideix, grant permès, redirect_uri idèntic, PKCE verify_s256(verifier, challenge).
    +
    4
    🛡️ AS🗄️ Store
    consume_code() = DELETE … RETURNING atòmic (un sol ús garantit fins i tot en concurrència).
    +
    5
    🛡️ AS
    Emet access_token JWT (iss, sub, aud=recursos, client_id, scope, exp) signat amb la subclau derive_key("access-token") + refresh_token opac gor_… lligat al hash del codi.
    +
    6
    🛡️ AS📱 Client
    200 Cache-Control: no-store amb access_token, token_type=Bearer, expires_in, refresh_token, scope.
    +
    +
    Matriu d'errors: content-type incorrecte → 400 invalid_request · grant desconegut → unsupported_grant_type · codi/redirect/PKCE incorrectes → invalid_grant · grant no permès al client → unauthorized_client · recurs no subconjunt → invalid_target.
    +
    PKCE no és desactivable: si el codi nocode_challenge registrat, el bescanvi retorna invalid_grant. Aquest perfil només suporta clients públics i requereix PKCE sempre.
    +
    + + +
    +

    4 · Refresh amb rotació i detecció de reús RFC 9700 §4.14

    +

    Els refresh tokens per a clients públics es roten sempre. Reutilitzar un token ja rotat indica compromís → es revoca tota la família de l'autorització.

    +
    +
    grant_type = refresh_tokenPOST /oauth/token
    +
    1
    📱 Client🛡️ AS
    refresh_token + client_id (opcional scope/resource per reduir).
    +
    2
    🛡️ AS🗄️ Store
    get_valid_refresh(): no expirat i revoked_at IS NULL.
    +
    3
    🛡️ AS
    Scope/resource han de ser subconjunt dels originals. Client coincident i grant permès.
    +
    4
    🛡️ AS🗄️ Store
    rotate_refresh_token(): revoca l'antic (replaced_by) i crea el nou en una operació condicional. Si ja estava rotat → fallida.
    +
    5
    🛡️ AS📱 Client
    200 nou access_token + nou refresh_token.
    +
    +
    Detecció de reús: si arriba un refresh token que ja té revoked_at (reutilitzat) → revoke_refresh_family_for_reuse() revoca tots els tokens de la mateixa auth_code_hash i retorna invalid_grant.
    +
    + + +
    +

    5 · Revocació RFC 7009

    +
    +
    Revocació de refresh tokenPOST /oauth/revoke
    +
    1
    📱 Client🛡️ AS
    token + client_id (opcional token_type_hint), form-urlencoded.
    +
    2
    🛡️ AS🗄️ Store
    Busca per hash; si el client_id és el propietari → revoke_refresh_family() (revoca tota la família) i esborra el consentiment associat (consent_key del registre) perquè no es torni a emetre en silenci.
    +
    3
    🛡️ AS📱 Client
    200 {} sempre (també per tokens desconeguts, per RFC 7009).
    +
    +
    Límit conegut: els access token són JWT sense estat → no es poden revocar individualment abans del seu exp (TTL 1h). La revocació afecta la cadena de refresh. (decisió de disseny documentada)
    +
    + + +
    +

    8 · Gestió de consentiments

    +

    Els consentiments es desen a oauth_consents (per container_db_key + consent_key = user|client|scopes|resources) i permeten la re-autorització silenciosa. Ara tenen caducitat configurable i l'usuari els pot llistar i revocar ell mateix.

    +
    +

    ⏳ Caducitat (TTL)

    El setting consent_ttl (per defecte 2592000s = 30 dies) fixa expires_at en crear/renovar el consentiment. has_consent() ignora els caducats i oauth_cleanup_expired els purga. consent_ttl=0expires_at NULL (mai caduca). Renovar un consentiment existent (ON CONFLICT DO UPDATE) refresca l'expires_at.

    +

    🗑️ Revocació completa

    Revocar un consentiment fa una deautorització total del parell (usuari, client): esborra el registre de consentiment i revoca tots els refresh tokens vius via revoke_user_client_refresh_tokens(). La següent autorització tornarà a mostrar la pantalla de consentiment.

    +
    +
    +
    Llistar i revocar consentiments propisGET · POST /oauth/consents
    +
    1
    🧑 Usuari🛡️ AS
    GET /oauth/consents (usuari autenticat, no anònim) → 200 {"consents":[…]} amb client_id, client_name, scope, resource, granted_at, expires_at. Capçalera Cache-Control: no-store.
    +
    2
    🧑 Usuari🛡️ AS
    POST /oauth/consents amb consent_key=… (form-urlencoded) → revoca aquell consentiment i la família de refresh associada.
    +
    3
    🛡️ AS🧑 Usuari
    200. consent_key desconegut → 404. Usuari anònim → 401.
    +
    +
    Els endpoints són públics a nivell de permís (guillotina.Public) però comproven manualment que l'usuari no sigui anònim; cada usuari només veu i revoca els seus propis consentiments (filtrats per user_id).
    +
    + + +
    +

    6 · Validació de token i accés a recursos RFC 6750 RFC 8707

    +

    OAuthJWTValidator només accepta tokens OAuth via Authorization: Bearer. Decodifica el JWT amb un sol algorisme (evita confusió d'algorismes) i amb la subclau dedicada d'access-token (derive_key("access-token"), no el jwt.secret genèric), i aplica confinament d'audiència.

    +
    +
    Bearer access_token → API o MCPauth/validators.py · OAuthJWTValidator
    +
    1
    📱 Client🔌 RS
    Authorization: Bearer <jwt>
    +
    2
    🔌 RS
    Verifica signatura amb la subclau d'access-token (access_token_key() = derive_key("access-token")) + token_type=oauth_access_token + iss == container_url.
    +
    3
    🔌 RS
    Calcula oauth_required_audience(request, container) i exigeix que aquest valor sigui dins aud. Per defecte és el container_url; integracions com MCP registren resolvers d'audiència propis.
    +
    4
    🔌 RS
    Requereix scope guillotina:access; resol l'usuari i adjunta request.oauth (client_id, scopes, resources).
    +
    +

    Política d'autorització MCP RFC 9728

    +
    +

    🔒 Repte d'autenticació

    Sense token vàlid → 401 amb WWW-Authenticate: Bearer … resource_metadata="…/.well-known/oauth-protected-resource/…".

    +

    🎯 Confinament d'audiència

    MCP registra un resolver que fa que els requests /@mcp/protocol exigeixin aud = container_url + "/@mcp/protocol". Un token només per a l'API (aud=container) NO val per a MCP i viceversa.

    +
    +
    El permís efectiu del servei MCP passa de guillotina.MCPExecute a guillotina.Public + comprovació manual: 401 si anònim, 403 si sense permís, 401 amb repte si l'audiència no és vàlida.
    +
    Separació de tipus de token (defensa en profunditat): els validadors JWT del core (JWTValidator i JWTSessionValidator) ignoren qualsevol token amb token_type == "oauth_access_token". Així un access token OAuth només pot ser validat per OAuthJWTValidator i mai pel camí d'autenticació genèric de Guillotina (ni com a sessió). guillotina/auth/validators.py
    +
    + + +
    +

    🎯 Resource vs audience RFC 8707

    +

    resource i aud representen el mateix confinament vist en dos moments diferents: resource és el que el client demana al flux OAuth; aud és el que el servidor grava dins l'access token i el que el recurs protegit exigeix quan rep el token.

    + +
    +
    resource → aud → required audienceRFC 8707
    +
    1
    📱 Client🛡️ AS
    El client pot enviar resource=https://api.example.com/db/guillotina/@mcp/protocol a /oauth/authorize o /oauth/token.
    +
    2
    🛡️ AS
    validate_resource() comprova que el valor sigui dins el conjunt de recursos permesos. Aquest conjunt surt de register_oauth_resource_resolver().
    +
    3
    🛡️ AS📱 Client
    issue_access_token() emet el JWT amb aud=[resource]. Si no s'havia demanat cap resource, el valor per defecte és el container_url.
    +
    4
    📱 Client🔌 RS
    Quan arriba el Bearer token, OAuthJWTValidator calcula oauth_required_audience(request, container) i rebutja el token si aquesta audience no és dins aud.
    +
    + +
    +

    Sense resource

    El token surt amb aud=[container_url]. Serveix per APIs genèriques del container, com /@addons, però no per recursos especialitzats.

    +

    Amb resource MCP

    El token surt amb aud=[container_url + "/@mcp/protocol"]. Serveix per MCP, però no per l'API genèrica del container.

    +

    Carregar MCP no substitueix el container

    OAuth core sempre registra el container_url com a recurs base. MCP només afegeix un recurs addicional; no bloqueja clients OAuth normals.

    +

    Desacoblament de protocols

    El core OAuth no coneix MCP. Les integracions declaren recursos amb register_oauth_resource_resolver() i audiences requerides amb register_oauth_audience_resolver().

    +
    + +
    Regla operativa: no n'hi ha prou que el token tingui una audience globalment permesa. El token ha de portar explícitament l'audience requerida pel request concret. Això evita usar un token MCP contra l'API genèrica o un token genèric contra MCP.
    +
    + + +
    +

    7 · Descobriment / metadata RFC 8414 RFC 9728

    +
    +

    oauth-authorization-server

    Endpoints, response_types, grant_types, code_challenge_methods_supported=[S256], resource_indicators_supported, authorization_response_iss_parameter_supported.

    +

    openid-configuration

    Àlies de compatibilitat amb la mateixa payload OAuth (no és OIDC complet: sense jwks_uri/userinfo).

    +

    oauth-protected-resource

    Només amb MCP actiu: resource, authorization_servers, scopes_supported.

    +
    +
    Derivació de l'issuer: oauth.issuer fixat > (si trust_proxy_headers) capçaleres de proxy > transport scheme+Host. Per defecte no es confia en capçaleres spoofables.
    +
    + + +
    +

    ⚠️ Control d'errors (resum global)

    + + + + + + + + + + + + + + + +
    EndpointCondicióRespostaFormat
    registerrate limit429 temporarily_unavailableJSON
    registermetadades / client_id propi400 invalid_request · invalid_client_metadataJSON
    authorizeclient / redirect desconeguts400 pàgina HTML (no redirigeix)HTML
    authorizeresponse_type / client302 unsupported_response_type · unauthorized_clientredirect
    authorizePKCE absent/invàlid302 invalid_requestredirect
    authorizescope / resource302 invalid_scope · invalid_targetredirect
    authorizelogin fallit / rate limit401 · 429 pàgina HTMLHTML
    authorizedeny / CSRF invàlid302 access_denied · invalid_requestredirect
    tokencontent-type / grant400 invalid_request · unsupported_grant_typeJSON
    tokencodi/PKCE/redirect/refresh400 invalid_grantJSON
    tokengrant no permès al client400 unauthorized_clientJSON
    revokequalsevol token200 {}JSON
    well-knownaction/container desconegut404JSON
    +
    + + +
    +

    ⚙️ Configuració (app_settings["oauth"])

    + + + + + + + + + + + + + + +
    ClauPer defecteEfecte
    issuerNoneURL canònica de l'issuer/audiència. Recomanat fixar-lo en producció.
    trust_proxy_headersFalseHonora X-Forwarded-* darrere proxy de confiança.
    allowed_code_challenge_methods["S256"]Mètodes PKCE permesos.
    scopes_supported["guillotina:access"]Scopes admesos.
    authorization_code_ttl600sVida del codi d'autorització.
    access_token_ttl3600sVida de l'access token JWT.
    refresh_token_ttl2592000sVida del refresh token (30 dies).
    consent_ttl2592000sNou: caducitat dels consentiments (30 dies). 0 = mai caduca.
    authorize_csrf_ttl600sValidesa del token CSRF de consentiment.
    registration_rate_limit / _window20 / 600sThrottle de registre per IP (0 = desactivat).
    login_rate_limit / _window10 / 300sNou: throttle de logins fallits per IP+username.
    cleanup_interval / cleanup_batch_size900s / 5000Neteja periòdica de codis/refresh expirats.
    +
    + + +
    +

    🛡️ Mecanismes de seguretat

    +
    +
      +
    • PKCE S256 obligatori; plain rebutjat
    • +
    • Codi d'un sol ús atòmic (DELETE…RETURNING)
    • +
    • Anti-replay: reús de codi revoca família de refresh
    • +
    • Rotació de refresh + detecció de reús
    • +
    • PKCE no desactivable per clients públics
    • +
    • Codis i refresh desats com a hash (HMAC clau derivada)
    • +
    • Separació de claus per propòsit (derive_key): access-token, token-hash i csrf usen subclaus distintes
    • +
    +
      +
    • Match exacte de redirect_uri; sense open-redirect
    • +
    • Confinament d'audiència (resource indicators)
    • +
    • CSRF de consentiment HMAC signat amb TTL
    • +
    • Paràmetre iss a la resposta (anti mix-up)
    • +
    • Rate limit a registre i a logins fallits
    • +
    • Rebuig de paràmetres duplicats (anti param-pollution)
    • +
    • 302 (no 307) → credencials no es reenvien; cookie HttpOnly/SameSite/Secure
    • +
    • Aïllament multi-tenant per container_db_key
    • +
    • Validadors core ignoren token_type=oauth_access_token (sense confusió de camins d'auth)
    • +
    +
    +
    + + +
    +

    📜 RFCs i compliment

    +
    + ✓ Complet + ◑ Parcial / per disseny + ℹ️ Suport amb nota +
    +
    +
    RFC 6749✓ Complet
    OAuth 2.0 Authorization Framework
    +
    • Grants authorization_code i refresh_token
    • Errors estàndard (invalid_grant, invalid_scope…)
    • Preservació de state · Cache-Control: no-store
    +
    RFC 6750✓ Complet
    Bearer Token Usage
    +
    • Authorization: Bearer
    • WWW-Authenticate amb error/scope
    +
    RFC 7636✓ Complet
    PKCE
    +
    • Només S256; verificador 43–128
    • Verificació al token endpoint
    • Defensa downgrade (RFC 9700 §4.8.2)
    +
    RFC 7591✓ Complet
    Dynamic Client Registration
    +
    • Registre obert (rate-limited)
    • client_id generat pel servidor
    • Validació de metadades
    +
    RFC 7009✓ Complet
    Token Revocation
    +
    • 200 per a tokens desconeguts
    • Revocació de família · verificació de propietari
    +
    RFC 8414✓ Complet
    Authorization Server Metadata
    +
    • .well-known/oauth-authorization-server
    • Publica code_challenge_methods_supported
    +
    RFC 8707✓ Complet
    Resource Indicators
    +
    • resource a authorize/token
    • aud confinada · resolvers extensibles
    +
    RFC 9207✓ Complet
    Issuer Identification
    +
    • iss a totes les respostes d'autorització
    • Anunciat a la metadata
    +
    RFC 9728ℹ️ MCP
    Protected Resource Metadata
    +
    • .well-known/oauth-protected-resource
    • Repte WWW-Authenticate amb resource_metadata
    +
    RFC 9700✓ Seguit
    Security BCP (gener 2025)
    +
    • PKCE obligatori, sense implicit, sense ROPC
    • Rotació + reús, match exacte, anti open-redirect
    • Throttle de login, defensa downgrade, iss
    +
    RFC 9068◑ Inspirat
    JWT Access Token Profile
    +
    • Access token és JWT amb iss/sub/aud/exp/scope
    • Nota: usa token_type custom, no la capçalera typ: at+jwt
    +
    OAuth 2.1✓ Alineat
    draft (perfil de facto)
    +
    • Clients públics + PKCE, sense implicit/ROPC
    • Rotació de refresh obligatòria
    +
    +
    + + +
    +

    ✅ Matriu de requisits MUST (RFC 9700)

    + + + + + + + + + + + + + + +
    Requisit normatiuEstatOn
    Match exacte de redirect URI (excepte port loopback)clients.py:validate_redirect_uri
    Cap open redirectorpàgina d'error abans de validar redirect
    Clients públics MUST usar PKCEPKCE obligatori, sense opció de desactivar-lo
    AS MUST suportar PKCE i enforce verifierverify_s256
    Mitigar PKCE downgrade_authorization_code (verifier sense challenge → reject)
    Refresh de clients públics: rotació o sender-constrainedrotate_refresh_token + reús
    Restricció d'audiència de l'access tokenaud = resources · validació MCP
    No usar implicit grantnomés response_type=code
    No usar ROPClogin a l'AS, no al client
    Respostes d'autorització no per HTTP sense xifrarredirect https (excepte loopback natiu)
    Publicar AS Metadata (RFC 8414).well-known
    Defensa mix-up (iss)RFC 9207 a totes les respostes
    +
    Aquest document reflecteix la branca feature/oauth-mcp. Per als detalls de codi, vegeu guillotina/contrib/oauth/ i els tests a guillotina/tests/oauth/.
    +
    +
    +
    + + + + diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index 5dc7a6862..3de31955e 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -14,6 +14,9 @@ "refresh_token_ttl": 2592000, "allowed_code_challenge_methods": ["S256"], "scopes_supported": ["guillotina:access"], + # Lifetime of a remembered consent (seconds). After it expires the user + # is prompted to consent again. Set to 0 to keep consents indefinitely. + "consent_ttl": 2592000, # Dynamic client registration throttling (per client IP, sliding window). # Set ``registration_rate_limit`` to 0 to disable. "registration_rate_limit": 20, diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index ff7a5646c..958c6a2ab 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -25,7 +25,14 @@ from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash from guillotina.contrib.oauth.storage.access import get_oauth_store from guillotina.interfaces import IApplication, IContainer -from guillotina.response import HTTPBadRequest, HTTPFound, HTTPNotFound, HTTPTooManyRequests, Response +from guillotina.response import ( + HTTPBadRequest, + HTTPFound, + HTTPNotFound, + HTTPTooManyRequests, + HTTPUnauthorized, + Response, +) from guillotina.utils import get_authenticated_user @@ -53,6 +60,7 @@ "scope", } REVOKE_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} +CONSENT_SINGLETON_PARAMS = {"consent_key", "client_id"} def register_well_known_handler(name, handler): @@ -135,6 +143,8 @@ async def __call__(self): action = self.request.matchdict.get("action", "") if action == "authorize": return await _authorize(self, self.oauth_store()) + if action == "consents": + return await _list_consents(self, self.oauth_store()) return HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) @@ -157,6 +167,8 @@ async def __call__(self): return await _token(self, store) if action == "revoke": return await _revoke(self, store) + if action == "consents": + return await _revoke_consent(self, store) return HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) @@ -529,4 +541,69 @@ async def _revoke(service, store): user_id=record["user_id"], auth_code_hash=record.get("auth_code_hash"), ) + # Drop the remembered consent so the grant cannot be silently re-issued + # after the user revoked their tokens (RFC 9700 deauthorization hygiene). + await store.delete_consent( + consent_key( + record["user_id"], + record["client_id"], + record.get("scope") or [], + record.get("resource") or [], + ), + user_id=record["user_id"], + ) + return {} + + +async def _list_consents(service, store): + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + return HTTPUnauthorized(content={"error": "invalid_token"}) + consents = await store.list_consents(user.id) + clients = {} + items = [] + for consent in consents: + client_id = consent["client_id"] + if client_id not in clients: + clients[client_id] = await store.get_client(client_id) + client = clients[client_id] or {} + items.append( + { + "consent_key": consent["consent_key"], + "client_id": client_id, + "client_name": client.get("client_name"), + "scope": consent["scope"], + "resource": consent["resource"], + "granted_at": consent["granted_at"], + "expires_at": consent["expires_at"], + } + ) + return Response(content={"consents": items}, headers={"Cache-Control": "no-store"}) + + +async def _revoke_consent(service, store): + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + return HTTPUnauthorized(content={"error": "invalid_token"}) + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=CONSENT_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc + ckey = data.get("consent_key") + if not ckey: + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "consent_key is required"} + ) + consents = {c["consent_key"]: c for c in await store.list_consents(user.id)} + consent = consents.get(ckey) + if consent is None: + return HTTPNotFound(content={"error": "not_found", "error_description": "unknown consent"}) + await store.delete_consent(ckey, user_id=user.id) + # Complete deauthorization: revoke every refresh token this user holds for + # the client so revoking consent also kills active sessions. + await store.revoke_user_client_refresh_tokens(user_id=user.id, client_id=consent["client_id"]) return {} diff --git a/guillotina/contrib/oauth/storage/interfaces.py b/guillotina/contrib/oauth/storage/interfaces.py index e91001094..19c1fa44a 100644 --- a/guillotina/contrib/oauth/storage/interfaces.py +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -26,10 +26,19 @@ def create_client(self, client): """Create a dynamically registered client.""" def has_consent(self, consent_key): - """Return whether the user already granted consent for this key.""" + """Return whether the user already granted (and not-yet-expired) consent for this key.""" def create_consent(self, consent_key, user_id, client_id, scope, resource): - """Persist a consent decision.""" + """Persist a consent decision, refreshing its expiry on re-grant.""" + + def list_consents(self, user_id): + """Return the user's active (unexpired) consent records (newest first).""" + + def delete_consent(self, consent_key, *, user_id=None): + """Delete a consent (optionally scoped to ``user_id``). Return ``True`` if removed.""" + + def revoke_user_client_refresh_tokens(self, *, user_id, client_id): + """Revoke every refresh token a user holds for a client. Return ``True`` if any changed.""" def create_code(self, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): """Store a new authorization code and return its record.""" diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index 4ea4ecdf7..69412c55e 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -100,6 +100,20 @@ def _row_to_refresh(row): } +def _row_to_consent(row): + if row is None: + return None + return { + "consent_key": row["consent_key"], + "user_id": row["user_id"], + "client_id": row["client_id"], + "scope": _load_jsonb(row["scope"]), + "resource": _load_jsonb(row["resource"]), + "granted_at": _iso(row["granted_at"]), + "expires_at": _iso(row["expires_at"]), + } + + @implementer(IOAuthStore) class OAuthRepository: def __init__(self, container_db_key: str): @@ -155,21 +169,33 @@ async def has_consent(self, consent_key): """ SELECT 1 FROM oauth_consents WHERE container_db_key = $1 AND consent_key = $2 + AND (expires_at IS NULL OR expires_at > $3) """, self.container_db_key, consent_key, + _aware(utcnow()), ) return row is not None async def create_consent(self, consent_key, *, user_id, client_id, scope, resource): + now = utcnow() + ttl = app_settings.get("oauth", {}).get("consent_ttl", 2592000) + # ttl == 0 means the consent never expires; any other value (including a + # negative one, used by tests to force expiry) yields an explicit timestamp. + expires_at = None if ttl == 0 else _aware(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( """ INSERT INTO oauth_consents ( - container_db_key, consent_key, user_id, client_id, scope, resource - ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) - ON CONFLICT (container_db_key, consent_key) DO NOTHING + container_db_key, consent_key, user_id, client_id, scope, resource, + granted_at, expires_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8) + ON CONFLICT (container_db_key, consent_key) DO UPDATE + SET scope = EXCLUDED.scope, + resource = EXCLUDED.resource, + granted_at = EXCLUDED.granted_at, + expires_at = EXCLUDED.expires_at """, self.container_db_key, consent_key, @@ -177,7 +203,68 @@ async def create_consent(self, consent_key, *, user_id, client_id, scope, resour client_id, _jsonb(list(scope)), _jsonb(list(resource)), + _aware(now), + expires_at, + ) + + async def list_consents(self, user_id): + txn, conn = await self._connection() + async with txn.lock: + rows = await conn.fetch( + """ + SELECT consent_key, user_id, client_id, scope, resource, granted_at, expires_at + FROM oauth_consents + WHERE container_db_key = $1 AND user_id = $2 + AND (expires_at IS NULL OR expires_at > $3) + ORDER BY granted_at DESC + """, + self.container_db_key, + user_id, + _aware(utcnow()), + ) + return [_row_to_consent(row) for row in rows] + + async def delete_consent(self, consent_key, *, user_id=None): + txn, conn = await self._connection() + async with txn.lock: + if user_id is None: + result = await conn.execute( + """ + DELETE FROM oauth_consents + WHERE container_db_key = $1 AND consent_key = $2 + """, + self.container_db_key, + consent_key, + ) + else: + result = await conn.execute( + """ + DELETE FROM oauth_consents + WHERE container_db_key = $1 AND consent_key = $2 AND user_id = $3 + """, + self.container_db_key, + consent_key, + user_id, + ) + return int(result.split()[-1]) > 0 + + async def revoke_user_client_refresh_tokens(self, *, user_id, client_id): + txn, conn = await self._connection() + async with txn.lock: + result = await conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, now()) + WHERE container_db_key = $1 + AND user_id = $2 + AND client_id = $3 + AND revoked_at IS NULL + """, + self.container_db_key, + user_id, + client_id, ) + return int(result.split()[-1]) > 0 async def create_code( self, diff --git a/guillotina/contrib/oauth/storage/pg/schema.py b/guillotina/contrib/oauth/storage/pg/schema.py index 18646507f..878402edf 100644 --- a/guillotina/contrib/oauth/storage/pg/schema.py +++ b/guillotina/contrib/oauth/storage/pg/schema.py @@ -74,8 +74,21 @@ scope jsonb NOT NULL DEFAULT '[]', resource jsonb NOT NULL DEFAULT '[]', granted_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, PRIMARY KEY (container_db_key, consent_key) ) +""", + """ +ALTER TABLE oauth_consents ADD COLUMN IF NOT EXISTS expires_at timestamptz +""", + """ +CREATE INDEX IF NOT EXISTS oauth_consents_user_idx + ON oauth_consents (container_db_key, user_id) +""", + """ +CREATE INDEX IF NOT EXISTS oauth_consents_expires_idx + ON oauth_consents (expires_at) + WHERE expires_at IS NOT NULL """, """ CREATE OR REPLACE FUNCTION oauth_cleanup_expired(batch_size int DEFAULT 5000) @@ -104,6 +117,16 @@ GET DIAGNOSTICS batch = ROW_COUNT; deleted := deleted + batch; + WITH doomed AS ( + SELECT ctid FROM oauth_consents + WHERE expires_at IS NOT NULL AND expires_at < now() + LIMIT batch_size + ) + DELETE FROM oauth_consents o + USING doomed d WHERE o.ctid = d.ctid; + GET DIAGNOSTICS batch = ROW_COUNT; + deleted := deleted + batch; + RETURN deleted; END; $$ LANGUAGE plpgsql diff --git a/guillotina/tests/oauth/test_oauth_consents.py b/guillotina/tests/oauth/test_oauth_consents.py new file mode 100644 index 000000000..ae0e4e627 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_consents.py @@ -0,0 +1,185 @@ +import pytest +from guillotina.contrib.oauth.flow.clients import consent_key +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, + verifier_pair, +) + + +pytestmark = [pytest.mark.asyncio, requires_pg] + + +async def _authorize_get(requester, client, *, verifier=None): + """Issue a bare GET /authorize and return the raw (value, status, headers).""" + verifier = verifier or "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + _verifier, challenge = verifier_pair(verifier) + params = { + "response_type": "code", + "client_id": client["client_id"], + "redirect_uri": client["redirect_uris"][0], + "scope": "guillotina:access", + "state": "abc", + "code_challenge": challenge, + "code_challenge_method": "S256", + } + return await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=params, + allow_redirects=False, + ) + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_token_deletes_consent(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + token = await token_from_code(requester, client, code, verifier) + + # Consent is remembered: a fresh authorize redirects silently (302). + _value, status, _headers = await _authorize_get(requester, client) + assert status == 302 + + # Revoking the refresh token must also drop the remembered consent. + _resp, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token={token['refresh_token']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + + # The grant can no longer be silently re-issued: consent is required again. + _value, status, _headers = await _authorize_get(requester, client) + assert status == 200 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_list_and_revoke_consents_endpoint(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + await token_from_code(requester, client, code, verifier) + + listing, status = await requester("GET", "/db/guillotina/oauth/consents") + assert status == 200 + # The shared test database may carry consents from other tests, so scope + # the assertions to the client registered here. + ours = [c for c in listing["consents"] if c["client_id"] == client["client_id"]] + assert len(ours) == 1 + entry = ours[0] + assert entry["client_name"] == client["client_name"] + assert entry["scope"] == ["guillotina:access"] + ckey = entry["consent_key"] + + revoked, status = await requester( + "POST", + "/db/guillotina/oauth/consents", + data=f"consent_key={ckey}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200 + + listing, status = await requester("GET", "/db/guillotina/oauth/consents") + assert status == 200 + ours = [c for c in listing["consents"] if c["client_id"] == client["client_id"]] + assert ours == [] + + # Revoking consent forces the consent screen on the next authorize. + _value, status, _headers = await _authorize_get(requester, client) + assert status == 200 + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_unknown_consent_returns_404(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + code, verifier = await authorize_code(requester, client) + await token_from_code(requester, client, code, verifier) + + _resp, status = await requester( + "POST", + "/db/guillotina/oauth/consents", + data="consent_key=does-not-exist", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 404 + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"consent_ttl": -1}, + } +) +async def test_consent_ttl_expires(guillotina_main): + from guillotina.component import get_utility + from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables + from guillotina.interfaces import IApplication + from guillotina.transactions import transaction + + root = get_utility(IApplication, name="root") + await ensure_oauth_tables(root["db"].storage) + + async with transaction(db=root["db"]): + store = OAuthRepository("db/consent-ttl") + scopes = ["guillotina:access"] + resources = ["http://localhost/db/guillotina"] + ckey = consent_key("root", "ttl-client", scopes, resources) + await store.create_consent( + ckey, + user_id="root", + client_id="ttl-client", + scope=scopes, + resource=resources, + ) + # A negative TTL produces an already-expired consent. + assert await store.has_consent(ckey) is False + assert await store.list_consents("root") == [] + await store.delete_container_data() + + +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"consent_ttl": 0}, + } +) +async def test_consent_ttl_zero_never_expires(guillotina_main): + from guillotina.component import get_utility + from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables + from guillotina.interfaces import IApplication + from guillotina.transactions import transaction + + root = get_utility(IApplication, name="root") + await ensure_oauth_tables(root["db"].storage) + + async with transaction(db=root["db"]): + store = OAuthRepository("db/consent-ttl-zero") + scopes = ["guillotina:access"] + resources = ["http://localhost/db/guillotina"] + ckey = consent_key("root", "zero-client", scopes, resources) + await store.create_consent( + ckey, + user_id="root", + client_id="zero-client", + scope=scopes, + resource=resources, + ) + assert await store.has_consent(ckey) is True + records = await store.list_consents("root") + assert len(records) == 1 + assert records[0]["expires_at"] is None + assert await store.delete_consent(ckey, user_id="root") is True + assert await store.has_consent(ckey) is False + await store.delete_container_data() From c6623c4a9eb69c9ad3f798abb037ab76affe20d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 1 Jun 2026 08:24:39 +0200 Subject: [PATCH 08/27] feat: Enhance OAuth request validation and MCP resource handling - Added validation for required parameters in OAuth token, authorization code, refresh token, and revoke endpoints, returning appropriate HTTPBadRequest responses for missing or invalid requests. - Improved handling of protected resource paths in MCP integration, ensuring correct URL generation and validation. - Updated tests to cover new validation scenarios and ensure compliance with the updated OAuth flow. --- guillotina/contrib/oauth/api/services.py | 11 +++++ guillotina/contrib/oauth/api/well_known.py | 12 ++++-- guillotina/contrib/oauth/integrations/mcp.py | 42 +++++++++++++++++++- guillotina/tests/oauth/test_mcp_oauth.py | 12 +++++- guillotina/tests/oauth/test_oauth_revoke.py | 30 ++++++++++++++ guillotina/tests/oauth/test_oauth_token.py | 15 +++++++ 6 files changed, 115 insertions(+), 7 deletions(-) diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 958c6a2ab..04244f260 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -258,6 +258,7 @@ async def _authorize(service, store): except HTTPBadRequest as exc: return exc params.update(data) + service.request.oauth_request_params = params client = await store.get_client(params.get("client_id")) if client is None: return oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) @@ -391,6 +392,8 @@ async def _token(service, store): except HTTPBadRequest as exc: return exc grant_type = data.get("grant_type") + if not grant_type: + return HTTPBadRequest(content={"error": "invalid_request"}) oauth_settings = app_settings.get("oauth", {}) if await rate_limit_exceeded( f"oauth-token:{client_identifier(service.request)}", @@ -408,6 +411,8 @@ async def _token(service, store): async def _authorization_code(service, store, data): + if not data.get("client_id") or not data.get("code") or not data.get("redirect_uri"): + return HTTPBadRequest(content={"error": "invalid_request"}) client = await store.get_client(data.get("client_id")) code_raw = data.get("code", "") code_hash_val = token_hash(code_raw) @@ -465,6 +470,8 @@ async def _authorization_code(service, store, data): async def _refresh_token(service, store, data): + if not data.get("client_id") or not data.get("refresh_token"): + return HTTPBadRequest(content={"error": "invalid_request"}) refresh_raw = data.get("refresh_token", "") client = await store.get_client(data.get("client_id")) record = await store.get_valid_refresh(refresh_raw) @@ -522,6 +529,10 @@ async def _revoke(service, store): data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) except HTTPBadRequest as exc: return exc + if not data.get("client_id") or not data.get("token"): + return HTTPBadRequest(content={"error": "invalid_request"}) + if data.get("token_type_hint") == "access_token": + return HTTPBadRequest(content={"error": "unsupported_token_type"}) oauth_settings = app_settings.get("oauth", {}) if await rate_limit_exceeded( f"oauth-revoke:{client_identifier(service.request)}", diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py index b3a4df326..d13a35d00 100644 --- a/guillotina/contrib/oauth/api/well_known.py +++ b/guillotina/contrib/oauth/api/well_known.py @@ -12,18 +12,20 @@ def _container_path_parts(path_value, *, allow_mcp_suffix=False): raise HTTPNotFound(content={"reason": "Invalid path"}) suffix = parts[2:] if allow_mcp_suffix: - if suffix and suffix != ["@mcp", "protocol"]: + if suffix and suffix[-2:] != ["@mcp", "protocol"]: raise HTTPNotFound(content={"reason": "Invalid resource path"}) elif suffix: raise HTTPNotFound(content={"reason": "Invalid issuer path"}) - return parts[0], parts[1] + return parts[0], parts[1], "/" + "/".join(parts) async def rfc_well_known_response(request, action, target_path, handlers): if action == "oauth-protected-resource": - db_id, container_id = _container_path_parts(target_path, allow_mcp_suffix=True) + db_id, container_id, protected_resource_path = _container_path_parts( + target_path, allow_mcp_suffix=True + ) else: - db_id, container_id = _container_path_parts(target_path) + db_id, container_id, protected_resource_path = _container_path_parts(target_path) db = await get_database(db_id) async with transaction(db=db): root = await db.get_transaction_manager().get_root() @@ -37,4 +39,6 @@ async def rfc_well_known_response(request, action, target_path, handlers): task_vars.registry.set(None) await get_registry(container) get_oauth_store(container, require_installed=True) + if action == "oauth-protected-resource": + request.oauth_protected_resource_path = protected_resource_path return handlers[action](request, container) diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index 6cec8902b..b8557f3f0 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -1,7 +1,10 @@ +from urllib.parse import urlparse + from zope.interface import implementer from guillotina import app_settings, configure from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy +from guillotina.contrib.oauth.api.request import normalize_list from guillotina.contrib.oauth.api.services import register_well_known_handler from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url from guillotina.contrib.oauth.flow.resources import ( @@ -11,12 +14,49 @@ from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported +def _mcp_resource_url_from_path(request, container, path): + issuer = urlparse(container_url(request, container)) + target_path = "/" + str(path or "").strip("/") + container_path = issuer.path.rstrip("/") + if not target_path.endswith("/@mcp/protocol"): + return None + if target_path != f"{container_path}/@mcp/protocol" and not target_path.startswith(f"{container_path}/"): + return None + return f"{issuer.scheme}://{issuer.netloc}{target_path}" + + +def _mcp_resource_url_from_value(request, container, value): + issuer = urlparse(container_url(request, container)) + parsed = urlparse(value) + if parsed.scheme != issuer.scheme or parsed.netloc != issuer.netloc: + return None + if parsed.query or parsed.fragment: + return None + return _mcp_resource_url_from_path(request, container, parsed.path) + + def mcp_resource(request, container): + protected_path = getattr(request, "oauth_protected_resource_path", None) + if protected_path: + resource = _mcp_resource_url_from_path(request, container, protected_path) + if resource: + return resource + request_path = str(getattr(request, "path", "") or "") + if request_path.endswith("/@mcp/protocol"): + resource = _mcp_resource_url_from_path(request, container, request_path) + if resource: + return resource return f"{container_url(request, container)}/@mcp/protocol" def _mcp_protocol_resource_resolver(request, container): - return {mcp_resource(request, container)} + resources = {f"{container_url(request, container)}/@mcp/protocol"} + params = getattr(request, "oauth_request_params", {}) or {} + for value in normalize_list(params.get("resource")): + resource = _mcp_resource_url_from_value(request, container, value) + if resource: + resources.add(resource) + return resources def _mcp_protocol_audience_resolver(request, container): diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index 37a0139cb..14b8b795b 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -188,7 +188,15 @@ async def test_subresource_mcp_unauthorized(container_install_requester): assert status == 401 www_authenticate = headers["WWW-Authenticate"] assert "resource_metadata" in www_authenticate - assert "/.well-known/oauth-protected-resource/db/guillotina/@mcp/protocol" in www_authenticate + assert ( + "/.well-known/oauth-protected-resource/db/guillotina/subfolder/@mcp/protocol" in www_authenticate + ) + + response, status = await requester( + "GET", "/.well-known/oauth-protected-resource/db/guillotina/subfolder/@mcp/protocol" + ) + assert status == 200 + assert response["resource"].endswith("/db/guillotina/subfolder/@mcp/protocol") @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @@ -204,7 +212,7 @@ async def test_subresource_mcp_authorized(container_install_requester): client = await register_client(requester) code, verifier = await authorize_code( - requester, client, resource="http://localhost/db/guillotina/@mcp/protocol" + requester, client, resource="http://localhost/db/guillotina/subfolder/@mcp/protocol" ) token = await token_from_code(requester, client, code, verifier) diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index 995218a89..b3b11b49a 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -128,6 +128,36 @@ async def test_revoke_requires_form_content_type(container_install_requester): assert status == 400 +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_requires_token(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + assert response["error"] == "invalid_request" + + +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_revoke_reports_unsupported_access_token_revocation(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + response, status = await requester( + "POST", + "/db/guillotina/oauth/revoke", + data=f"client_id={client['client_id']}&token=token&token_type_hint=access_token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + assert response["error"] == "unsupported_token_type" + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_revoke_rejects_duplicate_singleton_parameter(container_install_requester): diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index f23cfc372..91089553f 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -1,5 +1,6 @@ import jwt import pytest + from guillotina import app_settings from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits from guillotina.contrib.oauth.flow.tokens import access_token_key @@ -133,6 +134,20 @@ async def test_token_requires_form_content_type(container_install_requester): assert status == 400 +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_token_requires_grant_type(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + data="client_id=client&refresh_token=token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + assert response["error"] == "invalid_request" + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_token_rejects_duplicate_singleton_parameter(container_install_requester): From c262a954341dd8b308ab1a93ff1ec67b7973ec03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 1 Jun 2026 08:32:57 +0200 Subject: [PATCH 09/27] feat: Refactor OAuth handling and enhance documentation - Removed the outdated oauth-overview.html file to streamline documentation. - Updated oauth.md to reflect changes in OAuth 2.0 Authorization Code + PKCE configuration. - Improved request parameter handling in the OAuth API to preserve multiple resource parameters. - Enhanced validation for redirect URIs, including support for loopback and private-use native redirects. - Added tests for new functionality, ensuring compliance with updated OAuth flows and parameter handling. --- docs/source/contrib/oauth-overview.html | 663 ------------------ docs/source/contrib/oauth.md | 10 +- guillotina/contrib/oauth/api/request.py | 21 + guillotina/contrib/oauth/api/services.py | 3 +- guillotina/contrib/oauth/flow/clients.py | 43 +- guillotina/tests/oauth/test_mcp_oauth.py | 60 ++ .../tests/oauth/test_oauth_authorize.py | 22 +- guillotina/tests/oauth/test_oauth_register.py | 35 + 8 files changed, 179 insertions(+), 678 deletions(-) delete mode 100644 docs/source/contrib/oauth-overview.html diff --git a/docs/source/contrib/oauth-overview.html b/docs/source/contrib/oauth-overview.html deleted file mode 100644 index edef8dcf9..000000000 --- a/docs/source/contrib/oauth-overview.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - -Guillotina OAuth + MCP — Mapa visual d'implementació - - - -
    - - -
    -
    -

    Guillotina OAuth 2.1 + MCP

    -

    Servidor d'autorització OAuth integrat a Guillotina (perfil OAuth 2.1 de facto): només clients públics amb PKCE obligatori, grants authorization_code i refresh_token, confinament d'audiència (resource indicators) i integració amb el protocol MCP com a recurs protegit.

    -

    Aquest document és un mapa visual de tot el que s'ha implementat a la branca feature/oauth-mcp: fluxos de dades, camins alternatius, control d'errors, opcions de configuració i els RFC que es compleixen.

    -
    - RFC 6749RFC 6750RFC 7636 · PKCE - RFC 7591 · DCRRFC 7009 · RevokeRFC 8414 · Metadata - RFC 8707 · ResourcesRFC 9207 · issRFC 9700 · Security BCP - RFC 9728 · Protected Resource -
    -
    - - -
    -

    🗺️ Visió general

    -

    L'AS viu dins de cada container de Guillotina. Tot l'estat (clients, codis, refresh tokens, consentiments) es desa a PostgreSQL, aïllat per container_db_key = db_id/container.id. Els access tokens són JWT sense estat, signats amb una subclau dedicada derivada del secret de l'app (derive_key("access-token")), separada de la clau de signatura JWT genèrica.

    -
    -
    4

    Endpoints OAuth: register, authorize, token, revoke

    -
    3

    Endpoints de descobriment .well-known (AS metadata, OIDC alias, protected-resource)

    -
    PKCE

    S256 obligatori per defecte · plain rebutjat

    -
    PG

    PostgreSQL és l'únic backend · neteja periòdica d'expirats

    -
    - -

    Arquitectura a vista d'ocell

    -
    - - - - - - - - 🧑 Usuari - Navegador - - - - 📱 Client - App / MCP client - - - - 🛡️ Guillotina AS - contrib.oauth · per-container - - authorize · token · register · revoke - PKCE · CSRF · rate-limit · keys - JWT validator (bearer/cookie/ws) - .well-known metadata - - - - 🗄️ PostgreSQL - hashed store - - - - 🔌 Recurs - API · MCP protocol - - - - register / token - - authorize (login+consent) - - desa hash - - valida JWT / aud - - Bearer access_token → recurs - -
    -
    - - -
    -

    🎭 Actors i components

    -
    - 🧑 Usuari (navegador) - 📱 Client OAuth públic - 🛡️ AS · Authorization Server - 🗄️ Store · PostgreSQL - 🔌 RS · Recurs (API/MCP) -
    -
    -

    🛡️ Servidor d'autorització

    Serveis a api/services.py. Emet codis, tokens i gestiona consentiment. Un per container.

    -

    🔑 Mòdul de claus

    flow/keys.py · derive_key() deriva subclaus HMAC distintes per propòsit (access-token, token-hash, csrf) des de jwt.secret.

    -

    🗄️ Repositori PG

    storage/pg/repository.py · operacions atòmiques (DELETE…RETURNING) i rotació amb detecció de reús.

    -

    🧩 PKCE / CSRF

    flow/pkce.py (S256) i flow/csrf.py (token HMAC signat amb TTL).

    -

    🚦 Rate limiter

    flow/ratelimit.py · finestra lliscant en memòria per registre i per login fallit.

    -

    🔌 Integració MCP

    integrations/mcp.py · política IMCPAuthPolicy + metadata de recurs protegit (RFC 9728).

    -
    -
    - - -
    -

    🔗 Endpoints

    - - - - - - - - - -
    MètodeRutaFuncióAuthDescripció
    POST/{container}/oauth/register_registercap (públic)Registre dinàmic de client (RFC 7591). Rate-limited.
    GET POST/{container}/oauth/authorize_authorizelogin/cookieAutorització: login, consentiment i emissió de codi.
    POST/{container}/oauth/token_tokenPKCEBescanvi de codi i rotació de refresh.
    POST/{container}/oauth/revoke_revokecap (públic)Revocació de refresh token (RFC 7009).
    GET POST/{container}/oauth/consents_list_consents · _revoke_consentusuari (no anònim)Llistar i revocar consentiments propis.
    GET/{container}/.well-known/{action}OAuthWellKnownpúblicMetadata AS / alias OIDC (relatiu al container).
    GET/.well-known/{action}/{path}OAuthRFCWellKnownpúblicMetadata a l'arrel (RFC 8414/9728), resol container des del path.
    -

    {action}oauth-authorization-server, openid-configuration (alias), oauth-protected-resource (només amb MCP).

    -
    - - -
    -

    🔄 Cicle de vida del client (extrem a extrem)

    -

    Què ha de fer un client públic (app nativa, SPA o client MCP) de principi a fi per integrar-se: des del descobriment fins a la revocació. Tot el flux assumeix client públic amb PKCE S256 i scope guillotina:access.

    - -
    -
    Cicle complet: descobriment → ús del recurs → refresc → revocaciópúblic · PKCE S256
    - -
    0
    📱 Client🛡️ AS
    -
    Descobriment. GET /{container}/.well-known/oauth-authorization-server per obtenir authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, code_challenge_methods_supported.MCP: parteix del WWW-Authenticate 401 → oauth-protected-resourceauthorization_servers.
    - -
    1
    📱 Client🛡️ AS
    -
    Registre dinàmic (un cop). POST /oauth/register amb client_name + redirect_uris[] → desa el client_id retornat. El client no tria el client_id; el genera el servidor.
    - -
    2
    📱 Client
    -
    Genera PKCE (per petició). code_verifier = aleatori 43–128 chars; code_challenge = BASE64URL(SHA256(verifier)). Genera també un state aleatori i guarda'l.
    - -
    3
    📱 Client🧑 Usuari🛡️ AS
    -
    Sol·licitud d'autorització. Obre al navegador GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=guillotina:access&state=…&code_challenge=…&code_challenge_method=S256 (opcional resource=… si vol un recurs concret com MCP).
    - -
    4
    🧑 Usuari🛡️ AS
    -
    Login + consentiment a l'AS. L'usuari s'autentica i aprova els scopes/recursos. El client no veu mai les credencials.
    - -
    5
    🛡️ AS📱 Client
    -
    Recepció del codi. Redirecció a redirect_uri?code=…&state=…&iss=…. El client HA de verificar que state coincideix amb el desat i que iss és l'issuer esperat (anti mix-up).
    - -
    6
    📱 Client🛡️ AS
    -
    Bescanvi del codi. POST /oauth/token (application/x-www-form-urlencoded) amb grant_type=authorization_code, code, client_id, redirect_uri, code_verifier → rep access_token (JWT), refresh_token, expires_in, scope.
    - -
    7
    📱 Client🔌 RS
    -
    Ús del recurs. Cada petició a l'API o al servidor MCP amb Authorization: Bearer <access_token>. El token ha de portar dins aud l'audiència requerida pel request concret.
    - -
    8
    📱 Client🛡️ AS
    -
    Refresc (en expirar). POST /oauth/token amb grant_type=refresh_token, refresh_token, client_id → nou access_token i nou refresh_token. El client ha de substituir el refresh token desat (rotació).
    - -
    9
    📱 Client🛡️ AS
    -
    Tancament de sessió. POST /oauth/revoke amb token=<refresh_token> + client_id per revocar tota la família.
    -
    - -
    Re-autorització silenciosa: mentre el consentiment segueixi vàlid, repetir el pas 3 amb els mateixos paràmetres salta login/consentiment i retorna el codi directament. Quan el refresh token caduca o es revoca, cal tornar al pas 2.
    - -

    Checklist d'obligacions del client

    -
    -
      -
    • Generar un code_verifier nou i aleatori per cada autorització
    • -
    • Generar i verificar state a la tornada
    • -
    • Verificar que iss retornat == issuer esperat
    • -
    • Usar el mateix redirect_uri a authorize i a token
    • -
    • Enviar token com a x-www-form-urlencoded, no JSON
    • -
    -
      -
    • Desar access_token i refresh_token de forma segura
    • -
    • Substituir el refresh token a cada rotació (mai reusar l'antic)
    • -
    • Refrescar abans/quan expires_in s'esgoti
    • -
    • Per recursos especialitzats: demanar el resource correcte perquè entri a aud
    • -
    • Revocar el refresh token en tancar sessió
    • -
    -
    - -
    -
    0–1 descobr.+registre
    -
    2–3 PKCE+authorize
    -
    4–5 login+codi
    -
    6 token
    -
    7–9 ús/refresh/revoke
    -
    -
    - - -
    -

    1 · Registre dinàmic de client RFC 7591

    -

    El client s'auto-registra abans d'autoritzar. Només clients públics (token_endpoint_auth_method=none). El servidor genera el client_id; mai l'accepta del client.

    -
    -
    Registre dinàmicPOST /oauth/register
    -
    1
    📱 Client🛡️ AS
    -
    JSON amb client_name, redirect_uris[], opcional scope.Comprova rate limit per IP de transport (client_identifier).
    -
    2
    🛡️ AS
    -
    make_client() valida: redirect_uris no buit, cada URI segura, auth_method=none, grants/response_types suportats.clients.py · validate_redirect_uri()
    -
    3
    🛡️ AS🗄️ Store
    -
    Genera client_id = uuid4().hex i persisteix.
    -
    4
    🛡️ AS📱 Client
    -
    201 amb client_id, client_id_issued_at, redirect_uris, grant_types, response_types, scope, token_endpoint_auth_method i headers Cache-Control: no-store + Pragma: no-cache.
    -
    -
    Camí d'error: rate limit superat → 429 temporarily_unavailable. Metadades invàlides → 400 invalid_request / invalid_client_metadata. Enviar client_id propi → 400 «client_id is server-issued».
    -

    Validació de redirect_uri (anti open-redirect)

    -
    -

    ✓ Acceptat

    -
      -
    • https://host/path amb netloc i path
    • -
    • http://localhost|127.0.0.1|::1/path (loopback natiu)
    • -
    • esquema custom natiu app.scheme://host/path
    • -
    -

    ✗ Rebutjat

    -
      -
    • comodins *, fragments #…
    • -
    • javascript: / data:
    • -
    • http:// no-loopback
    • -
    -
    -
    - - -
    -

    2 · Autorització + PKCE RFC 6749 RFC 7636 RFC 9207

    -

    El cor del sistema. Tres sub-camins segons l'estat de sessió i consentiment. Tot abans de validar el redirect_uri que mostra pàgina d'error (mai redirigeix); tot després redirigeix amb error + state + iss.

    - -

    Validacions prèvies (en ordre)

    - - - - - - - - - - -
    #ComprovacióSi falla
    0Paràmetres singleton duplicats (reject_duplicate_params)400
    1Client existeix400 pàgina d'error
    2redirect_uri registrat (match exacte)400 pàgina d'error
    3response_type == code302 unsupported_response_type
    4Client té code a response_types302 unauthorized_client
    5PKCE obligatori: code_challenge present, sintaxi vàlida i mètode ∈ S256302 invalid_request
    6Scope: conté guillotina:access, és subconjunt dels scopes suportats i també dels scopes registrats pel client302 invalid_scope
    7Resource ∈ recursos permesos (RFC 8707). Si no s'envia resource, s'usa per defecte el container_url.302 invalid_target
    - -

    Camí A — Usuari NO autenticat → login al propi AS

    -
    -
    Sub-flux de login (credencials a l'AS, no és ROPC)GET/POST /oauth/authorize
    -
    1
    🧑 Usuari🛡️ AS
    GET sense sessió → es renderitza login.html amb tots els paràmetres com a camps ocults.
    -
    2
    🧑 Usuari🛡️ AS
    POST amb username/password.Comprova rate_limit_check abans d'autenticar.
    -
    3
    🛡️ AS
    _authenticate_basic() via validators tipus «basic». Èxit → emet cookie auth_token (HttpOnly, SameSite=Lax, Secure si HTTPS) i força pantalla de consentiment.
    -
    -
    Errors: credencials incorrectes → registra fallada + 401. Massa fallades a la finestra → 429 «Too many attempts». (login_rate_limit / login_rate_window)
    -
    El login ocorre a l'AS, no al client → no és el grant «password» (prohibit per RFC 9700 §2.4). A més, en autenticar-se en aquesta petició, la decision es força a None: mai s'auto-aprova consentiment en el mateix POST que les credencials.
    - -

    Camí B — Autenticat, sense consentiment previ → pantalla de consentiment

    -
    -
    Sub-flux de consentiment (protegit amb CSRF)GET → POST /oauth/authorize
    -
    1
    🛡️ AS🧑 Usuari
    consent.html amb scopes, recursos i un token CSRF HMAC signat (lligat a user, client, redirect, scope, state, challenge, resource + iat).
    -
    2
    🧑 Usuari🛡️ AS
    POST decision=allow|deny + oauth_csrf.
    -
    3
    🛡️ AS
    csrf_valid(): compara HMAC (constant-time), TTL i tots els camps. Si allow → desa consentiment.
    -
    4
    🛡️ AS🗄️ Store
    Crea codi opac goc_… (hash desat), lligat a client, user, redirect, scope, resource, code_challenge.
    -
    5
    🛡️ AS🧑 Usuari
    302 a redirect_uri?code=…&state=…&iss=…
    -
    -
    decision=deny302 access_denied.  |  CSRF invàlid302 invalid_request.  |  decision=allow per GET (sense POST) → mostra consentiment, no crea codi.
    - -

    Camí C — Consentiment ja existent → emissió silenciosa

    -
    Re-autorització silenciosa: si has_consent(ckey) és cert, un GET amb els mateixos paràmetres salta consentiment i redirigeix directament amb el codi. La clau de consentiment és user|client|scopes|resources.
    -
    -
    🧑 GET authorize
    -
    ¿sessió?
    -
    ¿consentiment?
    -
    crea codi
    -
    302 + code+iss
    -
    -
    - - -
    -

    3 · Bescanvi de codi per token RFC 6749 RFC 7636 RFC 9700

    -
    -
    grant_type = authorization_codePOST /oauth/token
    -
    1
    📱 Client🛡️ AS
    application/x-www-form-urlencoded amb code, client_id, redirect_uri, code_verifier.
    -
    2
    🛡️ AS🗄️ Store
    get_active_code() per hash + no expirat. Si no existeix → revoca família del codi (anti-replay) i invalid_grant.
    -
    3
    🛡️ AS
    Comprova: client coincideix, grant permès, redirect_uri idèntic, PKCE verify_s256(verifier, challenge).
    -
    4
    🛡️ AS🗄️ Store
    consume_code() = DELETE … RETURNING atòmic (un sol ús garantit fins i tot en concurrència).
    -
    5
    🛡️ AS
    Emet access_token JWT (iss, sub, aud=recursos, client_id, scope, exp) signat amb la subclau derive_key("access-token") + refresh_token opac gor_… lligat al hash del codi.
    -
    6
    🛡️ AS📱 Client
    200 Cache-Control: no-store amb access_token, token_type=Bearer, expires_in, refresh_token, scope.
    -
    -
    Matriu d'errors: content-type incorrecte → 400 invalid_request · grant desconegut → unsupported_grant_type · codi/redirect/PKCE incorrectes → invalid_grant · grant no permès al client → unauthorized_client · recurs no subconjunt → invalid_target.
    -
    PKCE no és desactivable: si el codi nocode_challenge registrat, el bescanvi retorna invalid_grant. Aquest perfil només suporta clients públics i requereix PKCE sempre.
    -
    - - -
    -

    4 · Refresh amb rotació i detecció de reús RFC 9700 §4.14

    -

    Els refresh tokens per a clients públics es roten sempre. Reutilitzar un token ja rotat indica compromís → es revoca tota la família de l'autorització.

    -
    -
    grant_type = refresh_tokenPOST /oauth/token
    -
    1
    📱 Client🛡️ AS
    refresh_token + client_id (opcional scope/resource per reduir).
    -
    2
    🛡️ AS🗄️ Store
    get_valid_refresh(): no expirat i revoked_at IS NULL.
    -
    3
    🛡️ AS
    Scope/resource han de ser subconjunt dels originals. Client coincident i grant permès.
    -
    4
    🛡️ AS🗄️ Store
    rotate_refresh_token(): revoca l'antic (replaced_by) i crea el nou en una operació condicional. Si ja estava rotat → fallida.
    -
    5
    🛡️ AS📱 Client
    200 nou access_token + nou refresh_token.
    -
    -
    Detecció de reús: si arriba un refresh token que ja té revoked_at (reutilitzat) → revoke_refresh_family_for_reuse() revoca tots els tokens de la mateixa auth_code_hash i retorna invalid_grant.
    -
    - - -
    -

    5 · Revocació RFC 7009

    -
    -
    Revocació de refresh tokenPOST /oauth/revoke
    -
    1
    📱 Client🛡️ AS
    token + client_id (opcional token_type_hint), form-urlencoded.
    -
    2
    🛡️ AS🗄️ Store
    Busca per hash; si el client_id és el propietari → revoke_refresh_family() (revoca tota la família) i esborra el consentiment associat (consent_key del registre) perquè no es torni a emetre en silenci.
    -
    3
    🛡️ AS📱 Client
    200 {} sempre (també per tokens desconeguts, per RFC 7009).
    -
    -
    Límit conegut: els access token són JWT sense estat → no es poden revocar individualment abans del seu exp (TTL 1h). La revocació afecta la cadena de refresh. (decisió de disseny documentada)
    -
    - - -
    -

    8 · Gestió de consentiments

    -

    Els consentiments es desen a oauth_consents (per container_db_key + consent_key = user|client|scopes|resources) i permeten la re-autorització silenciosa. Ara tenen caducitat configurable i l'usuari els pot llistar i revocar ell mateix.

    -
    -

    ⏳ Caducitat (TTL)

    El setting consent_ttl (per defecte 2592000s = 30 dies) fixa expires_at en crear/renovar el consentiment. has_consent() ignora els caducats i oauth_cleanup_expired els purga. consent_ttl=0expires_at NULL (mai caduca). Renovar un consentiment existent (ON CONFLICT DO UPDATE) refresca l'expires_at.

    -

    🗑️ Revocació completa

    Revocar un consentiment fa una deautorització total del parell (usuari, client): esborra el registre de consentiment i revoca tots els refresh tokens vius via revoke_user_client_refresh_tokens(). La següent autorització tornarà a mostrar la pantalla de consentiment.

    -
    -
    -
    Llistar i revocar consentiments propisGET · POST /oauth/consents
    -
    1
    🧑 Usuari🛡️ AS
    GET /oauth/consents (usuari autenticat, no anònim) → 200 {"consents":[…]} amb client_id, client_name, scope, resource, granted_at, expires_at. Capçalera Cache-Control: no-store.
    -
    2
    🧑 Usuari🛡️ AS
    POST /oauth/consents amb consent_key=… (form-urlencoded) → revoca aquell consentiment i la família de refresh associada.
    -
    3
    🛡️ AS🧑 Usuari
    200. consent_key desconegut → 404. Usuari anònim → 401.
    -
    -
    Els endpoints són públics a nivell de permís (guillotina.Public) però comproven manualment que l'usuari no sigui anònim; cada usuari només veu i revoca els seus propis consentiments (filtrats per user_id).
    -
    - - -
    -

    6 · Validació de token i accés a recursos RFC 6750 RFC 8707

    -

    OAuthJWTValidator només accepta tokens OAuth via Authorization: Bearer. Decodifica el JWT amb un sol algorisme (evita confusió d'algorismes) i amb la subclau dedicada d'access-token (derive_key("access-token"), no el jwt.secret genèric), i aplica confinament d'audiència.

    -
    -
    Bearer access_token → API o MCPauth/validators.py · OAuthJWTValidator
    -
    1
    📱 Client🔌 RS
    Authorization: Bearer <jwt>
    -
    2
    🔌 RS
    Verifica signatura amb la subclau d'access-token (access_token_key() = derive_key("access-token")) + token_type=oauth_access_token + iss == container_url.
    -
    3
    🔌 RS
    Calcula oauth_required_audience(request, container) i exigeix que aquest valor sigui dins aud. Per defecte és el container_url; integracions com MCP registren resolvers d'audiència propis.
    -
    4
    🔌 RS
    Requereix scope guillotina:access; resol l'usuari i adjunta request.oauth (client_id, scopes, resources).
    -
    -

    Política d'autorització MCP RFC 9728

    -
    -

    🔒 Repte d'autenticació

    Sense token vàlid → 401 amb WWW-Authenticate: Bearer … resource_metadata="…/.well-known/oauth-protected-resource/…".

    -

    🎯 Confinament d'audiència

    MCP registra un resolver que fa que els requests /@mcp/protocol exigeixin aud = container_url + "/@mcp/protocol". Un token només per a l'API (aud=container) NO val per a MCP i viceversa.

    -
    -
    El permís efectiu del servei MCP passa de guillotina.MCPExecute a guillotina.Public + comprovació manual: 401 si anònim, 403 si sense permís, 401 amb repte si l'audiència no és vàlida.
    -
    Separació de tipus de token (defensa en profunditat): els validadors JWT del core (JWTValidator i JWTSessionValidator) ignoren qualsevol token amb token_type == "oauth_access_token". Així un access token OAuth només pot ser validat per OAuthJWTValidator i mai pel camí d'autenticació genèric de Guillotina (ni com a sessió). guillotina/auth/validators.py
    -
    - - -
    -

    🎯 Resource vs audience RFC 8707

    -

    resource i aud representen el mateix confinament vist en dos moments diferents: resource és el que el client demana al flux OAuth; aud és el que el servidor grava dins l'access token i el que el recurs protegit exigeix quan rep el token.

    - -
    -
    resource → aud → required audienceRFC 8707
    -
    1
    📱 Client🛡️ AS
    El client pot enviar resource=https://api.example.com/db/guillotina/@mcp/protocol a /oauth/authorize o /oauth/token.
    -
    2
    🛡️ AS
    validate_resource() comprova que el valor sigui dins el conjunt de recursos permesos. Aquest conjunt surt de register_oauth_resource_resolver().
    -
    3
    🛡️ AS📱 Client
    issue_access_token() emet el JWT amb aud=[resource]. Si no s'havia demanat cap resource, el valor per defecte és el container_url.
    -
    4
    📱 Client🔌 RS
    Quan arriba el Bearer token, OAuthJWTValidator calcula oauth_required_audience(request, container) i rebutja el token si aquesta audience no és dins aud.
    -
    - -
    -

    Sense resource

    El token surt amb aud=[container_url]. Serveix per APIs genèriques del container, com /@addons, però no per recursos especialitzats.

    -

    Amb resource MCP

    El token surt amb aud=[container_url + "/@mcp/protocol"]. Serveix per MCP, però no per l'API genèrica del container.

    -

    Carregar MCP no substitueix el container

    OAuth core sempre registra el container_url com a recurs base. MCP només afegeix un recurs addicional; no bloqueja clients OAuth normals.

    -

    Desacoblament de protocols

    El core OAuth no coneix MCP. Les integracions declaren recursos amb register_oauth_resource_resolver() i audiences requerides amb register_oauth_audience_resolver().

    -
    - -
    Regla operativa: no n'hi ha prou que el token tingui una audience globalment permesa. El token ha de portar explícitament l'audience requerida pel request concret. Això evita usar un token MCP contra l'API genèrica o un token genèric contra MCP.
    -
    - - -
    -

    7 · Descobriment / metadata RFC 8414 RFC 9728

    -
    -

    oauth-authorization-server

    Endpoints, response_types, grant_types, code_challenge_methods_supported=[S256], resource_indicators_supported, authorization_response_iss_parameter_supported.

    -

    openid-configuration

    Àlies de compatibilitat amb la mateixa payload OAuth (no és OIDC complet: sense jwks_uri/userinfo).

    -

    oauth-protected-resource

    Només amb MCP actiu: resource, authorization_servers, scopes_supported.

    -
    -
    Derivació de l'issuer: oauth.issuer fixat > (si trust_proxy_headers) capçaleres de proxy > transport scheme+Host. Per defecte no es confia en capçaleres spoofables.
    -
    - - -
    -

    ⚠️ Control d'errors (resum global)

    - - - - - - - - - - - - - - - -
    EndpointCondicióRespostaFormat
    registerrate limit429 temporarily_unavailableJSON
    registermetadades / client_id propi400 invalid_request · invalid_client_metadataJSON
    authorizeclient / redirect desconeguts400 pàgina HTML (no redirigeix)HTML
    authorizeresponse_type / client302 unsupported_response_type · unauthorized_clientredirect
    authorizePKCE absent/invàlid302 invalid_requestredirect
    authorizescope / resource302 invalid_scope · invalid_targetredirect
    authorizelogin fallit / rate limit401 · 429 pàgina HTMLHTML
    authorizedeny / CSRF invàlid302 access_denied · invalid_requestredirect
    tokencontent-type / grant400 invalid_request · unsupported_grant_typeJSON
    tokencodi/PKCE/redirect/refresh400 invalid_grantJSON
    tokengrant no permès al client400 unauthorized_clientJSON
    revokequalsevol token200 {}JSON
    well-knownaction/container desconegut404JSON
    -
    - - -
    -

    ⚙️ Configuració (app_settings["oauth"])

    - - - - - - - - - - - - - - -
    ClauPer defecteEfecte
    issuerNoneURL canònica de l'issuer/audiència. Recomanat fixar-lo en producció.
    trust_proxy_headersFalseHonora X-Forwarded-* darrere proxy de confiança.
    allowed_code_challenge_methods["S256"]Mètodes PKCE permesos.
    scopes_supported["guillotina:access"]Scopes admesos.
    authorization_code_ttl600sVida del codi d'autorització.
    access_token_ttl3600sVida de l'access token JWT.
    refresh_token_ttl2592000sVida del refresh token (30 dies).
    consent_ttl2592000sNou: caducitat dels consentiments (30 dies). 0 = mai caduca.
    authorize_csrf_ttl600sValidesa del token CSRF de consentiment.
    registration_rate_limit / _window20 / 600sThrottle de registre per IP (0 = desactivat).
    login_rate_limit / _window10 / 300sNou: throttle de logins fallits per IP+username.
    cleanup_interval / cleanup_batch_size900s / 5000Neteja periòdica de codis/refresh expirats.
    -
    - - -
    -

    🛡️ Mecanismes de seguretat

    -
    -
      -
    • PKCE S256 obligatori; plain rebutjat
    • -
    • Codi d'un sol ús atòmic (DELETE…RETURNING)
    • -
    • Anti-replay: reús de codi revoca família de refresh
    • -
    • Rotació de refresh + detecció de reús
    • -
    • PKCE no desactivable per clients públics
    • -
    • Codis i refresh desats com a hash (HMAC clau derivada)
    • -
    • Separació de claus per propòsit (derive_key): access-token, token-hash i csrf usen subclaus distintes
    • -
    -
      -
    • Match exacte de redirect_uri; sense open-redirect
    • -
    • Confinament d'audiència (resource indicators)
    • -
    • CSRF de consentiment HMAC signat amb TTL
    • -
    • Paràmetre iss a la resposta (anti mix-up)
    • -
    • Rate limit a registre i a logins fallits
    • -
    • Rebuig de paràmetres duplicats (anti param-pollution)
    • -
    • 302 (no 307) → credencials no es reenvien; cookie HttpOnly/SameSite/Secure
    • -
    • Aïllament multi-tenant per container_db_key
    • -
    • Validadors core ignoren token_type=oauth_access_token (sense confusió de camins d'auth)
    • -
    -
    -
    - - -
    -

    📜 RFCs i compliment

    -
    - ✓ Complet - ◑ Parcial / per disseny - ℹ️ Suport amb nota -
    -
    -
    RFC 6749✓ Complet
    OAuth 2.0 Authorization Framework
    -
    • Grants authorization_code i refresh_token
    • Errors estàndard (invalid_grant, invalid_scope…)
    • Preservació de state · Cache-Control: no-store
    -
    RFC 6750✓ Complet
    Bearer Token Usage
    -
    • Authorization: Bearer
    • WWW-Authenticate amb error/scope
    -
    RFC 7636✓ Complet
    PKCE
    -
    • Només S256; verificador 43–128
    • Verificació al token endpoint
    • Defensa downgrade (RFC 9700 §4.8.2)
    -
    RFC 7591✓ Complet
    Dynamic Client Registration
    -
    • Registre obert (rate-limited)
    • client_id generat pel servidor
    • Validació de metadades
    -
    RFC 7009✓ Complet
    Token Revocation
    -
    • 200 per a tokens desconeguts
    • Revocació de família · verificació de propietari
    -
    RFC 8414✓ Complet
    Authorization Server Metadata
    -
    • .well-known/oauth-authorization-server
    • Publica code_challenge_methods_supported
    -
    RFC 8707✓ Complet
    Resource Indicators
    -
    • resource a authorize/token
    • aud confinada · resolvers extensibles
    -
    RFC 9207✓ Complet
    Issuer Identification
    -
    • iss a totes les respostes d'autorització
    • Anunciat a la metadata
    -
    RFC 9728ℹ️ MCP
    Protected Resource Metadata
    -
    • .well-known/oauth-protected-resource
    • Repte WWW-Authenticate amb resource_metadata
    -
    RFC 9700✓ Seguit
    Security BCP (gener 2025)
    -
    • PKCE obligatori, sense implicit, sense ROPC
    • Rotació + reús, match exacte, anti open-redirect
    • Throttle de login, defensa downgrade, iss
    -
    RFC 9068◑ Inspirat
    JWT Access Token Profile
    -
    • Access token és JWT amb iss/sub/aud/exp/scope
    • Nota: usa token_type custom, no la capçalera typ: at+jwt
    -
    OAuth 2.1✓ Alineat
    draft (perfil de facto)
    -
    • Clients públics + PKCE, sense implicit/ROPC
    • Rotació de refresh obligatòria
    -
    -
    - - -
    -

    ✅ Matriu de requisits MUST (RFC 9700)

    - - - - - - - - - - - - - - -
    Requisit normatiuEstatOn
    Match exacte de redirect URI (excepte port loopback)clients.py:validate_redirect_uri
    Cap open redirectorpàgina d'error abans de validar redirect
    Clients públics MUST usar PKCEPKCE obligatori, sense opció de desactivar-lo
    AS MUST suportar PKCE i enforce verifierverify_s256
    Mitigar PKCE downgrade_authorization_code (verifier sense challenge → reject)
    Refresh de clients públics: rotació o sender-constrainedrotate_refresh_token + reús
    Restricció d'audiència de l'access tokenaud = resources · validació MCP
    No usar implicit grantnomés response_type=code
    No usar ROPClogin a l'AS, no al client
    Respostes d'autorització no per HTTP sense xifrarredirect https (excepte loopback natiu)
    Publicar AS Metadata (RFC 8414).well-known
    Defensa mix-up (iss)RFC 9207 a totes les respostes
    -
    Aquest document reflecteix la branca feature/oauth-mcp. Per als detalls de codi, vegeu guillotina/contrib/oauth/ i els tests a guillotina/tests/oauth/.
    -
    -
    -
    - - - - diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index f8eecd63e..a77c4e16c 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -4,7 +4,7 @@ Install `guillotina.contrib.oauth` as an application and install the `oauth` add ## Configuration -To enable and configure the OAuth 2.1 authorization server, the following settings must be defined in your Guillotina configuration (e.g., `config.yaml`). +To enable and configure the OAuth 2.0 Authorization Code + PKCE public-client profile, the following settings must be defined in your Guillotina configuration (e.g., `config.yaml`). ### 1. Enable the Application @@ -61,7 +61,7 @@ oauth: authorization_code_ttl: 600 # Time to live in seconds for Authorization Codes (default 10 min) access_token_ttl: 3600 # Time to live in seconds for Access Tokens (default 1 hour) refresh_token_ttl: 2592000 # Time to live in seconds for Refresh Tokens (default 30 days) - require_pkce: true # Whether PKCE is strictly required (always true for OAuth 2.1) + require_pkce: true # PKCE S256 is required for public clients scopes_supported: # Optional OAuth protocol label (not used for authorization) - guillotina:access @@ -99,13 +99,13 @@ register_oauth_resource_resolver(my_resolver) ## Dynamic client registration and redirect URIs -`/oauth/authorize` accepts only redirect URIs that are already present on the client record. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. HTTPS redirect URIs are accepted for web clients. Plain HTTP is accepted only for loopback/native redirects (`localhost`, `127.0.0.1`, `::1`). Redirect URIs with fragments are rejected. +`/oauth/authorize` accepts only redirect URIs that are already present on the client record. Loopback redirect URIs may use a different runtime port than the registered URI, as recommended for native apps. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients and does not issue client secrets. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. HTTPS redirect URIs are accepted for web clients. Plain HTTP is accepted only for loopback/native redirects (`localhost`, `127.0.0.1`, `::1`). Private-use native redirects using reverse-domain schemes such as `com.example.app:/oauth2redirect/provider` are accepted. Redirect URIs with fragments are rejected. ## Supported flow -The contrib implements public-client OAuth 2.1 Authorization Code with PKCE (`S256`), dynamic client registration, opaque refresh tokens, revocation, and JWT access tokens signed with Guillotina's configured JWT secret. +The contrib implements an OAuth 2.0 Authorization Code + PKCE (`S256`) public-client profile, aligned with RFC 9700 guidance and selected extensions including dynamic client registration, authorization server metadata, resource indicators, issuer identification, protected resource metadata, opaque refresh tokens, revocation, and JWT access tokens signed with Guillotina's configured JWT secret. -![OAuth 2.1 authorization code flow with PKCE in Guillotina](../_static/oauth-flow.svg) +![OAuth 2.0 authorization code flow with PKCE in Guillotina](../_static/oauth-flow.svg) Endpoints are container scoped: diff --git a/guillotina/contrib/oauth/api/request.py b/guillotina/contrib/oauth/api/request.py index ff4976a6c..60d921ca2 100644 --- a/guillotina/contrib/oauth/api/request.py +++ b/guillotina/contrib/oauth/api/request.py @@ -21,6 +21,27 @@ def normalize_list(value): return [item for item in str(value).split() if item] +def params_preserving_repeated(query): + params = {} + seen = set() + keys = query.keys() if hasattr(query, "keys") else [] + for key in keys: + if key in seen: + continue + seen.add(key) + getall = getattr(query, "getall", None) + if callable(getall): + try: + values = getall(key, []) + except TypeError: + values = getall(key) + else: + value = query.get(key) if hasattr(query, "get") else None + values = value if isinstance(value, (list, tuple)) else [value] + params[key] = values if len(values) > 1 else values[0] + return params + + def form_content_type_valid(request): content_type = request.headers.get("content-type", "") return content_type.split(";", 1)[0].strip().lower() == "application/x-www-form-urlencoded" diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 04244f260..5c3cbf61a 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -5,6 +5,7 @@ client_identifier, form_content_type_valid, normalize_list, + params_preserving_repeated, parse_form_encoded, reject_duplicate_params, ) @@ -241,7 +242,7 @@ def _token_response(content): async def _authorize(service, store): - params = dict(service.request.query) + params = params_preserving_repeated(service.request.query) try: reject_duplicate_params(service.request.query, AUTHORIZE_SINGLETON_PARAMS) except HTTPBadRequest as exc: diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index 8af32929e..c51311886 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -8,6 +8,21 @@ SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} SUPPORTED_RESPONSE_TYPES = {"code"} +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +def _is_loopback_http_redirect(parsed): + return parsed.scheme == "http" and parsed.hostname in LOOPBACK_HOSTS and parsed.path.startswith("/") + + +def _is_private_use_redirect(parsed): + if parsed.scheme in ("http", "https", "javascript", "data"): + return False + if not parsed.path.startswith("/"): + return False + if "." in parsed.scheme: + return True + return parsed.scheme.isalpha() and bool(parsed.netloc) def validate_redirect_uri(uri): @@ -22,11 +37,9 @@ def validate_redirect_uri(uri): return False if parsed.scheme == "https": return bool(parsed.netloc and parsed.path.startswith("/")) - if parsed.scheme == "http": - return parsed.hostname in {"localhost", "127.0.0.1", "::1"} and parsed.path.startswith("/") - if parsed.scheme.isalpha() and parsed.netloc and parsed.path.startswith("/"): + if _is_loopback_http_redirect(parsed): return True - return False + return _is_private_use_redirect(parsed) def is_native_redirect_uri(uri): @@ -40,7 +53,23 @@ def redirect_uri_registered_for_client(client, redirect_uri): Native redirects must be included in the client's dynamic registration request. """ redirect_uris = client.get("redirect_uris") or [] - return redirect_uri in redirect_uris + if redirect_uri in redirect_uris: + return True + requested = urlparse(redirect_uri or "") + if not _is_loopback_http_redirect(requested): + return False + for registered_uri in redirect_uris: + registered = urlparse(registered_uri) + if not _is_loopback_http_redirect(registered): + continue + if ( + requested.scheme == registered.scheme + and requested.hostname == registered.hostname + and requested.path == registered.path + and requested.query == registered.query + ): + return True + return False def make_client(data): @@ -48,9 +77,9 @@ def make_client(data): oauth_error("invalid_request", "client_id is server-issued") redirect_uris = data.get("redirect_uris") or [] if not redirect_uris or not isinstance(redirect_uris, list): - oauth_error("invalid_request", "redirect_uris is required") + oauth_error("invalid_client_metadata", "redirect_uris is required") if any(not validate_redirect_uri(uri) for uri in redirect_uris): - oauth_error("invalid_request", "unsafe redirect_uri") + oauth_error("invalid_redirect_uri", "unsafe redirect_uri") method = data.get("token_endpoint_auth_method", "none") if method != "none": oauth_error("unsupported_token_endpoint_auth_method") diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index 14b8b795b..21f6343ed 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -1,14 +1,20 @@ import json +from urllib.parse import parse_qs, urlencode, urlparse +import jwt import pytest +from guillotina import app_settings +from guillotina.contrib.oauth.flow.tokens import access_token_key from guillotina.tests.mcp.test_mcp import PROTOCOL_HEADERS, _skip_if_protocol_unavailable from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, authorize_code, + oauth_csrf_from_body, register_client, requires_pg, token_from_code, + verifier_pair, ) @@ -85,6 +91,60 @@ async def test_mcp_with_oauth_token(container_install_requester): assert status == 200 +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +async def test_authorize_get_preserves_multiple_resource_parameters(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester) + verifier, challenge = verifier_pair() + params = [ + ("response_type", "code"), + ("client_id", client["client_id"]), + ("redirect_uri", client["redirect_uris"][0]), + ("scope", "guillotina:access"), + ("state", "abc"), + ("resource", "http://localhost/db/guillotina"), + ("resource", "http://localhost/db/guillotina/@mcp/protocol"), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ] + value, status, _headers = await requester.make_request( + "GET", + "/db/guillotina/oauth/authorize", + params=params, + allow_redirects=False, + ) + assert status == 200 + body = value.decode("utf-8") if isinstance(value, bytes) else value + assert body.count('name="resource"') == 2 + + post_params = params + [ + ("oauth_csrf", oauth_csrf_from_body(value)), + ("decision", "allow"), + ] + _value, status, headers = await requester.make_request( + "POST", + "/db/guillotina/oauth/authorize", + data=urlencode(post_params), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + assert status == 302 + + code = parse_qs(urlparse(headers["Location"]).query)["code"][0] + token = await token_from_code(requester, client, code, verifier) + claims = jwt.decode( + token["access_token"], + access_token_key(), + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) + assert set(claims["aud"]) == { + "http://localhost/db/guillotina", + "http://localhost/db/guillotina/@mcp/protocol", + } + + @pytest.mark.app_settings(OAUTH_MCP_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) async def test_mcp_rejects_missing_mcp_audience(container_install_requester): diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index 6e12c6b01..abb876a28 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -5,11 +5,9 @@ from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, - authorize_code, oauth_csrf_from_body, register_client, requires_pg, - token_from_code, verifier_pair, ) @@ -65,6 +63,26 @@ async def test_authorize_accepts_cursor_redirect_registered_with_client(containe assert "Login" in body or "Allow" in body +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_authorize_accepts_loopback_redirect_with_different_port(container_install_requester): + async with container_install_requester as requester: + client = await register_client(requester, redirect_uri="http://127.0.0.1:12345/callback") + _response, status = await requester( + "GET", + "/db/guillotina/oauth/authorize", + params={ + "client_id": client["client_id"], + "redirect_uri": "http://127.0.0.1:54321/callback", + "response_type": "code", + "code_challenge": verifier_pair()[1], + "code_challenge_method": "S256", + "scope": "guillotina:access", + }, + ) + assert status == 200 + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_consent_page_describes_requested_access(container_install_requester): diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index 18eba2de1..29a7f5f0c 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -117,6 +117,26 @@ async def test_register_accepts_cursor_native_redirect(container_install_request assert response["redirect_uris"] == ["cursor://anysphere.cursor-mcp/oauth/callback"] +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_accepts_reverse_domain_native_redirect(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_name": "Native App", + "redirect_uris": ["com.example.app:/oauth2redirect/example-provider"], + } + ), + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 201 + assert response["redirect_uris"] == ["com.example.app:/oauth2redirect/example-provider"] + + @pytest.mark.app_settings(OAUTH_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_accepts_multiple_redirect_uris(container_install_requester): @@ -167,6 +187,21 @@ async def test_register_rejects_client_supplied_client_id(container_install_requ assert response["error_description"] == "client_id is server-issued" +@pytest.mark.app_settings(OAUTH_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth"]]) +async def test_register_reports_invalid_redirect_uri(container_install_requester): + async with container_install_requester as requester: + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=json.dumps({"redirect_uris": ["https://example.com/cb#fragment"]}), + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 400 + assert response["error"] == "invalid_redirect_uri" + + @pytest.mark.app_settings(RATE_LIMITED_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_rate_limited(container_install_requester): From bdfb61b5d52d09db1ee1bf54ad5c2d816e0e7cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 3 Jun 2026 08:32:41 +0200 Subject: [PATCH 10/27] refactor: Update OAuth documentation and remove OpenID compatibility --- docs/source/_static/oauth-flow.svg | 330 +++++++++--------- docs/source/contrib/oauth.md | 6 +- guillotina/contrib/oauth/api/services.py | 2 - guillotina/contrib/oauth/api/urls.py | 7 - guillotina/tests/oauth/test_oauth_metadata.py | 5 +- 5 files changed, 169 insertions(+), 181 deletions(-) diff --git a/docs/source/_static/oauth-flow.svg b/docs/source/_static/oauth-flow.svg index 4d40c9e93..f906ac9dd 100644 --- a/docs/source/_static/oauth-flow.svg +++ b/docs/source/_static/oauth-flow.svg @@ -1,4 +1,4 @@ - + Guillotina OAuth requests and discovery paths Sequence diagram showing every request a client can use to discover Guillotina OAuth metadata, register, authorize with PKCE, exchange tokens, call REST or MCP resources, refresh, and revoke. @@ -34,7 +34,7 @@ - + Guillotina OAuth: discovery, registration, authorization and token requests The diagram shows the implemented request paths, including REST discovery, MCP cold-start discovery, PKCE, token refresh, and revoke. @@ -54,175 +54,175 @@ Protected resource REST API or /@mcp/protocol - - - - + + + + 0. Cold-start discovery: how the client learns the OAuth URLs - + 0A. REST/client already knows the container URL GET /db/container/.well-known/oauth-authorization-server Alternative RFC 8414 root path: /.well-known/oauth-authorization-server/db/container - Compatibility alias: /.well-known/openid-configuration/db/container - - - - 200 authorization server metadata - issuer, authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint - - - - 0B. MCP client knows only the MCP endpoint - POST /db/container/@mcp/protocol without Authorization header - This is the initial MCP cold start when no access token is available yet. - - - - 401 WWW-Authenticate - Bearer resource_metadata= - "/.well-known/oauth-protected-resource/db/container/@mcp/protocol" - - - - 0C. Client follows resource_metadata - GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol - Container-scoped equivalent: /db/container/.well-known/oauth-protected-resource - - - - 200 protected resource metadata - resource, authorization_servers, scopes_supported - - - Implemented discovery variants - OAuth AS metadata: oauth-authorization-server and openid-configuration alias. - MCP protected-resource metadata: oauth-protected-resource. - The OpenID URL returns OAuth metadata only; this is not full OIDC. - - - 1. Dynamic client registration - - - - 1. Register public client - POST /db/container/oauth/register - JSON: client_name, redirect_uris[], token_endpoint_auth_method="none" - - - - 200 registration response - server-issued client_id; client-supplied client_id is rejected - - - Security behavior - Registration is create-only: no upsert and no redirect_uri merge. - Clients needing several callbacks must register all redirect_uris at once. - - - 2. Authorization code with PKCE - - - - 2A. Local PKCE step - No HTTP request. - Generate code_verifier and S256 code_challenge. - Keep code_verifier on the client. - - - - 2B. Open browser - Navigate user to authorization URL - - - - 2C. GET authorize - GET /db/container/oauth/authorize - response_type=code, client_id, redirect_uri, state - code_challenge, code_challenge_method=S256 - scope optional; resource optional - - - - HTML login/consent page - shown when authentication or consent is needed - - - - 2D. POST consent decision - POST /db/container/oauth/authorize - decision=allow plus original authorize parameters - - - - 302 redirect - Location: redirect_uri?code=goc_...&state=... - - - - 2E. Callback to client - Client receives code and validates state - - - Implemented authorize variants - resource omitted: defaults to the container issuer URL. - resource = /@mcp/protocol: token audience is the MCP endpoint. - scope omitted: empty scope claim; otherwise use guillotina:access. - PKCE is required by default; tests can disable it via configuration. - - - 3. Token endpoint grants - - - - 3A. Exchange authorization code - POST /db/container/oauth/token - grant_type=authorization_code, client_id, redirect_uri, code - code_verifier, optional resource subset - - - - 200 token response - access_token = JWT, refresh_token = opaque token - JWT aud equals container URL or MCP protocol URL. - - - - 3B. Refresh token grant - POST /db/container/oauth/token - grant_type=refresh_token, client_id, refresh_token - - - - 200 rotated token response - new access_token and new refresh_token - Reusing an old refresh token revokes its rotation family. - - - 4. Use and revoke - - - - 4A. Call protected resource - REST: GET /db/container/... or MCP: POST /db/container/@mcp/protocol - Header: Authorization: Bearer ACCESS_TOKEN - Guillotina then checks normal user permissions. - - - - 200 / 401 / 403 response - Depends on token validity, audience/resource, and Guillotina permissions. - - - - 4B. Optional revoke - POST /db/container/oauth/revoke - client_id, token=REFRESH_TOKEN, token_type_hint=refresh_token - - - - 200 revoke response + OpenID Connect discovery is not exposed. + + + + 200 authorization server metadata + issuer, authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint + + + + 0B. MCP client knows only the MCP endpoint + POST /db/container/@mcp/protocol without Authorization header + This is the initial MCP cold start when no access token is available yet. + + + + 401 WWW-Authenticate + Bearer resource_metadata= + "/.well-known/oauth-protected-resource/db/container/@mcp/protocol" + + + + 0C. Client follows resource_metadata + GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol + Container-scoped equivalent: /db/container/.well-known/oauth-protected-resource + + + + 200 protected resource metadata + resource, authorization_servers, scopes_supported + + + Implemented discovery variants + OAuth AS metadata: oauth-authorization-server. + MCP protected-resource metadata: oauth-protected-resource. + OpenID Connect discovery is intentionally not served. + + + 1. Dynamic client registration + + + + 1. Register public client + POST /db/container/oauth/register + JSON: client_name, redirect_uris[], token_endpoint_auth_method="none" + + + + 200 registration response + server-issued client_id; client-supplied client_id is rejected + + + Security behavior + Registration is create-only: no upsert and no redirect_uri merge. + Clients needing several callbacks must register all redirect_uris at once. + + + 2. Authorization code with PKCE + + + + 2A. Local PKCE step + No HTTP request. + Generate code_verifier and S256 code_challenge. + Keep code_verifier on the client. + + + + 2B. Open browser + Navigate user to authorization URL + + + + 2C. GET authorize + GET /db/container/oauth/authorize + response_type=code, client_id, redirect_uri, state + code_challenge, code_challenge_method=S256 + scope optional; resource optional + + + + HTML login/consent page + shown when authentication or consent is needed + + + + 2D. POST consent decision + POST /db/container/oauth/authorize + decision=allow plus original authorize parameters + + + + 302 redirect + Location: redirect_uri?code=goc_...&state=... + + + + 2E. Callback to client + Client receives code and validates state + + + Implemented authorize variants + resource omitted: defaults to the container issuer URL. + resource = /@mcp/protocol: token audience is the MCP endpoint. + scope omitted: empty scope claim; otherwise use guillotina:access. + PKCE is required by default; tests can disable it via configuration. + + + 3. Token endpoint grants + + + + 3A. Exchange authorization code + POST /db/container/oauth/token + grant_type=authorization_code, client_id, redirect_uri, code + code_verifier, optional resource subset + + + + 200 token response + access_token = JWT, refresh_token = opaque token + JWT aud equals container URL or MCP protocol URL. + + + + 3B. Refresh token grant + POST /db/container/oauth/token + grant_type=refresh_token, client_id, refresh_token + + + + 200 rotated token response + new access_token and new refresh_token + Reusing an old refresh token revokes its rotation family. + + + 4. Use and revoke + + + + 4A. Call protected resource + REST: GET /db/container/... or MCP: POST /db/container/@mcp/protocol + Header: Authorization: Bearer ACCESS_TOKEN + Guillotina then checks normal user permissions. + + + + 200 / 401 / 403 response + Depends on token validity, audience/resource, and Guillotina permissions. + + + + 4B. Optional revoke + POST /db/container/oauth/revoke + client_id, token=REFRESH_TOKEN, token_type_hint=refresh_token + + + + 200 revoke response diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index a77c4e16c..257433f01 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -76,9 +76,9 @@ The same cleanup keys may still be set under `oauth` for backward compatibility; OAuth state is always persisted in PostgreSQL tables (`oauth_clients`, `oauth_authorization_codes`, …). A PostgreSQL database storage is required. -## Discovery and OAuth vs OpenID (`openid-configuration`) +## Discovery and OpenID Connect -The primary metadata URL is `/.well-known/oauth-authorization-server` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)). The same JSON is also served at `/.well-known/openid-configuration` (container-scoped and RFC 8414 root variants) **as a compatibility alias** for clients that probe the OpenID path. This is still **OAuth authorization server metadata only**—not full OpenID Connect (no `id_token`, `userinfo`, OIDC JWKS, etc.). +The metadata URL is `/.well-known/oauth-authorization-server` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)). The OAuth contrib does not expose `/.well-known/openid-configuration` because that path identifies OpenID Connect provider metadata, and this contrib does not implement OpenID Connect (`id_token`, UserInfo, OIDC JWKS, subject types, etc.). ## Allowed `resource` values (RFC 8707) @@ -111,7 +111,6 @@ Endpoints are container scoped: ```text GET /db/container/.well-known/oauth-authorization-server -GET /db/container/.well-known/openid-configuration POST /db/container/oauth/register GET /db/container/oauth/authorize ``` @@ -120,7 +119,6 @@ RFC 8414 discovery for issuers with a path component (such as `/db/container`) i ```text GET /.well-known/oauth-authorization-server/db/container -GET /.well-known/openid-configuration/db/container ``` When using MCP, protected resource metadata follows [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728): diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 5c3cbf61a..ac2b42376 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -88,8 +88,6 @@ def _metadata(request, container): register_well_known_handler("oauth-authorization-server", _metadata) -# Compatibility alias: some clients probe `openid-configuration`; payload is OAuth AS metadata, not full OIDC. -register_well_known_handler("openid-configuration", _metadata) class OAuthService(Service): diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/api/urls.py index 3673ec56a..269b360cc 100644 --- a/guillotina/contrib/oauth/api/urls.py +++ b/guillotina/contrib/oauth/api/urls.py @@ -52,13 +52,6 @@ def well_known_authorization_server_url(request, container): ) -def well_known_openid_configuration_url(request, container): - return ( - f"{request.scheme}://{request.host}/.well-known/openid-configuration/" - f"{issuer_path(request, container)}" - ) - - def well_known_protected_resource_url(request, container): from guillotina.contrib.oauth.flow.resources import oauth_required_audience diff --git a/guillotina/tests/oauth/test_oauth_metadata.py b/guillotina/tests/oauth/test_oauth_metadata.py index d89f60107..78b2c2394 100644 --- a/guillotina/tests/oauth/test_oauth_metadata.py +++ b/guillotina/tests/oauth/test_oauth_metadata.py @@ -34,9 +34,8 @@ async def test_rfc_metadata(container_install_requester): assert response["issuer"].endswith("/db/guillotina") assert response["authorization_endpoint"].endswith("/oauth/authorize") - response, status = await requester("GET", "/.well-known/openid-configuration/db/guillotina") - assert status == 200 - assert response["issuer"].endswith("/db/guillotina") + _response, status = await requester("GET", "/.well-known/openid-configuration/db/guillotina") + assert status == 404 @pytest.mark.app_settings(OAUTH_SETTINGS) From 05c5775c7841d5894e31c6469a609e2233dd5288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 3 Jun 2026 08:53:41 +0200 Subject: [PATCH 11/27] fix(oauth): satisfy ci checks --- guillotina/contrib/oauth/flow/csrf.py | 4 ++-- guillotina/contrib/oauth/integrations/mcp.py | 4 ++-- guillotina/contrib/oauth/storage/interfaces.py | 5 +++-- guillotina/tests/oauth/conftest.py | 7 ++++--- guillotina/tests/oauth/test_oauth_consents.py | 1 + guillotina/tests/oauth/test_oauth_validator.py | 1 + guillotina/tests/test_auth.py | 7 ++----- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/guillotina/contrib/oauth/flow/csrf.py b/guillotina/contrib/oauth/flow/csrf.py index 538057c8c..f77aa7f16 100644 --- a/guillotina/contrib/oauth/flow/csrf.py +++ b/guillotina/contrib/oauth/flow/csrf.py @@ -1,9 +1,9 @@ -from base64 import b64decode, b64encode -from binascii import Error as BinasciiError import hashlib import hmac import json import time +from base64 import b64decode, b64encode +from binascii import Error as BinasciiError from guillotina import app_settings from guillotina.contrib.oauth.flow.keys import derive_key diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index b8557f3f0..d29c3e881 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -64,8 +64,8 @@ def _mcp_protocol_audience_resolver(request, container): return mcp_resource(request, container) -_mcp_protocol_resource_resolver._oauth_resource_source = "mcp" -_mcp_protocol_audience_resolver._oauth_resource_source = "mcp" +setattr(_mcp_protocol_resource_resolver, "_oauth_resource_source", "mcp") +setattr(_mcp_protocol_audience_resolver, "_oauth_resource_source", "mcp") register_oauth_resource_resolver(_mcp_protocol_resource_resolver) register_oauth_audience_resolver(_mcp_protocol_audience_resolver) diff --git a/guillotina/contrib/oauth/storage/interfaces.py b/guillotina/contrib/oauth/storage/interfaces.py index 19c1fa44a..e95d8ad75 100644 --- a/guillotina/contrib/oauth/storage/interfaces.py +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -28,7 +28,7 @@ def create_client(self, client): def has_consent(self, consent_key): """Return whether the user already granted (and not-yet-expired) consent for this key.""" - def create_consent(self, consent_key, user_id, client_id, scope, resource): + def create_consent(self, consent_key, *, user_id, client_id, scope, resource): """Persist a consent decision, refreshing its expiry on re-grant.""" def list_consents(self, user_id): @@ -40,7 +40,7 @@ def delete_consent(self, consent_key, *, user_id=None): def revoke_user_client_refresh_tokens(self, *, user_id, client_id): """Revoke every refresh token a user holds for a client. Return ``True`` if any changed.""" - def create_code(self, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): + def create_code(self, *, raw_code, client_id, user_id, redirect_uri, scope, resource, code_challenge): """Store a new authorization code and return its record.""" def get_active_code(self, code): @@ -57,6 +57,7 @@ def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): def create_refresh_token( self, + *, raw_token, client_id, user_id, diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index 184b74fff..6d3896871 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -1,8 +1,9 @@ import base64 import hashlib -from html import unescape import json import re +from html import unescape +from typing import Any from urllib.parse import parse_qs, urlencode, urlparse import pytest @@ -17,7 +18,7 @@ reason="requires PostgreSQL (set DATABASE=postgresql)", ) -OAUTH_SETTINGS = { +OAUTH_SETTINGS: dict[str, Any] = { "applications": ["guillotina", "guillotina.contrib.oauth"], "oauth": {"registration_rate_limit": 0, "token_rate_limit": 0, "revoke_rate_limit": 0}, "auth_extractors": [ @@ -27,7 +28,7 @@ "guillotina.auth.extractors.CookiePolicy", ], } -OAUTH_MCP_SETTINGS = { +OAUTH_MCP_SETTINGS: dict[str, Any] = { "applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.mcp"], "oauth": {"registration_rate_limit": 0, "token_rate_limit": 0, "revoke_rate_limit": 0}, "auth_extractors": [ diff --git a/guillotina/tests/oauth/test_oauth_consents.py b/guillotina/tests/oauth/test_oauth_consents.py index ae0e4e627..cc51c50bd 100644 --- a/guillotina/tests/oauth/test_oauth_consents.py +++ b/guillotina/tests/oauth/test_oauth_consents.py @@ -1,4 +1,5 @@ import pytest + from guillotina.contrib.oauth.flow.clients import consent_key from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, diff --git a/guillotina/tests/oauth/test_oauth_validator.py b/guillotina/tests/oauth/test_oauth_validator.py index 8e545cd23..d4b29cc05 100644 --- a/guillotina/tests/oauth/test_oauth_validator.py +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -1,4 +1,5 @@ import pytest + from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, OAUTH_SETTINGS, diff --git a/guillotina/tests/test_auth.py b/guillotina/tests/test_auth.py index 1ec1d7d99..4003831a1 100644 --- a/guillotina/tests/test_auth.py +++ b/guillotina/tests/test_auth.py @@ -2,7 +2,6 @@ import jwt import pytest -from guillotina.response import HTTPBadRequest from guillotina._settings import app_settings from guillotina.auth import validators @@ -11,12 +10,10 @@ from guillotina.contrib.oauth.api.views import oauth_error_page from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator from guillotina.contrib.oauth.flow.clients import make_client, scopes_registered_for_client -from guillotina.contrib.oauth.flow.resources import ( - oauth_required_audience, - register_oauth_audience_resolver, -) from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits +from guillotina.contrib.oauth.flow.resources import oauth_required_audience, register_oauth_audience_resolver from guillotina.contrib.oauth.flow.tokens import issue_access_token +from guillotina.response import HTTPBadRequest from guillotina.tests.utils import make_mocked_request From 3f4969a7586608231990e0cea19aa98376dd91d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 3 Jun 2026 09:35:07 +0200 Subject: [PATCH 12/27] fix(oauth): address reviewer robustness issues --- docs/source/contrib/oauth.md | 3 +- guillotina/contrib/oauth/auth/validators.py | 2 +- .../contrib/oauth/storage/pg/repository.py | 4 +- guillotina/contrib/oauth/storage/utility.py | 14 +++--- .../tests/oauth/test_oauth_storage_backend.py | 46 ++++++++++++++++++- guillotina/tests/test_auth.py | 8 ++++ 6 files changed, 66 insertions(+), 11 deletions(-) diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index 257433f01..2bcd64bc9 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -61,7 +61,8 @@ oauth: authorization_code_ttl: 600 # Time to live in seconds for Authorization Codes (default 10 min) access_token_ttl: 3600 # Time to live in seconds for Access Tokens (default 1 hour) refresh_token_ttl: 2592000 # Time to live in seconds for Refresh Tokens (default 30 days) - require_pkce: true # PKCE S256 is required for public clients + allowed_code_challenge_methods: # PKCE S256 is always required for public clients + - S256 scopes_supported: # Optional OAuth protocol label (not used for authorization) - guillotina:access diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py index 705c5ff85..5393a73a5 100644 --- a/guillotina/contrib/oauth/auth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -24,7 +24,7 @@ async def validate(self, token): algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError): + except (jwt.exceptions.PyJWTError, KeyError): return if claims.get("token_type") != "oauth_access_token": return diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index 69412c55e..65a75f46f 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -42,9 +42,9 @@ def _parse_dt(value): if value is None: return None if isinstance(value, datetime): - return value + return _aware(value) if isinstance(value, str): - return datetime.fromisoformat(value.replace("Z", "+00:00")) + return _aware(datetime.fromisoformat(value.replace("Z", "+00:00"))) return value diff --git a/guillotina/contrib/oauth/storage/utility.py b/guillotina/contrib/oauth/storage/utility.py index c28f351fb..ba24bfe98 100644 --- a/guillotina/contrib/oauth/storage/utility.py +++ b/guillotina/contrib/oauth/storage/utility.py @@ -14,8 +14,8 @@ logger = logging.getLogger("guillotina.contrib.oauth") -_ddl_lock = asyncio.Lock() -_ddl_initialized = False +_ddl_locks = {} +_ddl_initialized = set() OAUTH_STORAGE_DEFAULTS = { "cleanup_interval": 900, @@ -43,9 +43,11 @@ def get_oauth_storage_settings(): async def ensure_oauth_tables(storage): import asyncpg.exceptions - global _ddl_initialized - async with _ddl_lock: - if _ddl_initialized: + loop = asyncio.get_running_loop() + lock = _ddl_locks.setdefault(id(loop), asyncio.Lock()) + storage_key = id(storage.pool) + async with lock: + if storage_key in _ddl_initialized: return async with storage.pool.acquire() as conn: for ddl in OAUTH_DDL: @@ -57,7 +59,7 @@ async def ensure_oauth_tables(storage): if attempt == 2: raise await asyncio.sleep(0.05) - _ddl_initialized = True + _ddl_initialized.add(storage_key) @implementer(IOAuthStorageUtility) diff --git a/guillotina/tests/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py index f5f34414e..f933b851f 100644 --- a/guillotina/tests/oauth/test_oauth_storage_backend.py +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -1,11 +1,13 @@ import asyncio +from datetime import timezone import pytest from guillotina import task_vars +from guillotina.contrib.oauth.storage import utility from guillotina.contrib.oauth.storage.access import get_oauth_store, oauth_container_db_key from guillotina.contrib.oauth.storage.interfaces import IOAuthStore -from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository +from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository, _parse_dt from guillotina.contrib.oauth.storage.pg.schema import OAUTH_DDL @@ -39,3 +41,45 @@ def test_oauth_schema_uses_container_db_key(): def test_get_oauth_store_without_pg_raises(): with pytest.raises(RuntimeError, match="PostgreSQL"): get_oauth_store(type("Container", (), {"id": "guillotina"})(), require_installed=False) + + +def test_oauth_repository_parses_naive_datetimes_as_utc(): + parsed = _parse_dt("2026-01-01T00:00:00") + assert parsed.tzinfo == timezone.utc + + +@pytest.mark.asyncio +async def test_ensure_oauth_tables_tracks_initialization_per_pool(monkeypatch): + class FakeAcquire: + def __init__(self, pool): + self.pool = pool + + async def __aenter__(self): + return self.pool + + async def __aexit__(self, exc_type, exc, tb): + return False + + class FakePool: + def __init__(self): + self.executed = [] + + def acquire(self): + return FakeAcquire(self) + + async def execute(self, ddl): + self.executed.append(ddl) + + monkeypatch.setattr(utility, "OAUTH_DDL", ["SELECT 1"]) + monkeypatch.setattr(utility, "_ddl_locks", {}) + monkeypatch.setattr(utility, "_ddl_initialized", set()) + + first_storage = type("Storage", (), {"pool": FakePool()})() + second_storage = type("Storage", (), {"pool": FakePool()})() + + await utility.ensure_oauth_tables(first_storage) + await utility.ensure_oauth_tables(first_storage) + await utility.ensure_oauth_tables(second_storage) + + assert first_storage.pool.executed == ["SELECT 1"] + assert second_storage.pool.executed == ["SELECT 1"] diff --git a/guillotina/tests/test_auth.py b/guillotina/tests/test_auth.py index 4003831a1..1016ecd95 100644 --- a/guillotina/tests/test_auth.py +++ b/guillotina/tests/test_auth.py @@ -102,6 +102,14 @@ async def test_oauth_access_token_only_accepts_bearer_transport(token_type, dumm assert await OAuthJWTValidator().validate({"type": token_type, "token": access_token}) is None +async def test_oauth_access_token_rejects_all_pyjwt_errors(monkeypatch): + def raise_invalid_algorithm(*args, **kwargs): + raise jwt.exceptions.InvalidAlgorithmError() + + monkeypatch.setattr(jwt, "decode", raise_invalid_algorithm) + assert await OAuthJWTValidator().validate({"type": "bearer", "token": "bad.token.value"}) is None + + async def test_oauth_html_pages_deny_framing(dummy_guillotina): response = oauth_error_page("Error", "Message", status=400) assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'" From 710f7ff9d8f7a56351a214c091371898eb8324b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 3 Jun 2026 10:46:29 +0200 Subject: [PATCH 13/27] fix(oauth): make cleanup compatible with cockroachdb --- .../contrib/oauth/storage/pg/repository.py | 37 ++++++++++++++++- guillotina/contrib/oauth/storage/pg/schema.py | 41 ------------------- .../tests/oauth/test_oauth_storage_backend.py | 6 +++ 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index 65a75f46f..7770de566 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -560,4 +560,39 @@ async def delete_container_data(self): async def cleanup_expired(conn, batch_size=5000): - await conn.execute("SELECT oauth_cleanup_expired($1)", batch_size) + await conn.execute( + """ + DELETE FROM oauth_authorization_codes + WHERE (container_db_key, code_hash) IN ( + SELECT container_db_key, code_hash + FROM oauth_authorization_codes + WHERE expires_at < now() + LIMIT $1 + ) + """, + batch_size, + ) + await conn.execute( + """ + DELETE FROM oauth_refresh_tokens + WHERE (container_db_key, token_hash) IN ( + SELECT container_db_key, token_hash + FROM oauth_refresh_tokens + WHERE expires_at < now() + LIMIT $1 + ) + """, + batch_size, + ) + await conn.execute( + """ + DELETE FROM oauth_consents + WHERE (container_db_key, consent_key) IN ( + SELECT container_db_key, consent_key + FROM oauth_consents + WHERE expires_at IS NOT NULL AND expires_at < now() + LIMIT $1 + ) + """, + batch_size, + ) diff --git a/guillotina/contrib/oauth/storage/pg/schema.py b/guillotina/contrib/oauth/storage/pg/schema.py index 878402edf..3b7457e54 100644 --- a/guillotina/contrib/oauth/storage/pg/schema.py +++ b/guillotina/contrib/oauth/storage/pg/schema.py @@ -89,46 +89,5 @@ CREATE INDEX IF NOT EXISTS oauth_consents_expires_idx ON oauth_consents (expires_at) WHERE expires_at IS NOT NULL -""", - """ -CREATE OR REPLACE FUNCTION oauth_cleanup_expired(batch_size int DEFAULT 5000) -RETURNS int AS $$ -DECLARE - deleted int := 0; - batch int; -BEGIN - WITH doomed AS ( - SELECT ctid FROM oauth_authorization_codes - WHERE expires_at < now() - LIMIT batch_size - ) - DELETE FROM oauth_authorization_codes o - USING doomed d WHERE o.ctid = d.ctid; - GET DIAGNOSTICS batch = ROW_COUNT; - deleted := deleted + batch; - - WITH doomed AS ( - SELECT ctid FROM oauth_refresh_tokens - WHERE expires_at < now() - LIMIT batch_size - ) - DELETE FROM oauth_refresh_tokens o - USING doomed d WHERE o.ctid = d.ctid; - GET DIAGNOSTICS batch = ROW_COUNT; - deleted := deleted + batch; - - WITH doomed AS ( - SELECT ctid FROM oauth_consents - WHERE expires_at IS NOT NULL AND expires_at < now() - LIMIT batch_size - ) - DELETE FROM oauth_consents o - USING doomed d WHERE o.ctid = d.ctid; - GET DIAGNOSTICS batch = ROW_COUNT; - deleted := deleted + batch; - - RETURN deleted; -END; -$$ LANGUAGE plpgsql """, ] diff --git a/guillotina/tests/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py index f933b851f..1cf6ccde7 100644 --- a/guillotina/tests/oauth/test_oauth_storage_backend.py +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -38,6 +38,12 @@ def test_oauth_schema_uses_container_db_key(): assert "container_id text NOT NULL" not in ddl +def test_oauth_schema_avoids_postgresql_specific_cleanup_function(): + ddl = "\n".join(OAUTH_DDL).lower() + assert "create or replace function oauth_cleanup_expired" not in ddl + assert "ctid" not in ddl + + def test_get_oauth_store_without_pg_raises(): with pytest.raises(RuntimeError, match="PostgreSQL"): get_oauth_store(type("Container", (), {"id": "guillotina"})(), require_installed=False) From 5c68cbc8810e5c5042c089321dd053c784aa8f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 3 Jun 2026 15:56:31 +0200 Subject: [PATCH 14/27] fix(oauth): update MCP configuration in documentation --- docs/source/contrib/oauth.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index 2bcd64bc9..09bcb4f57 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -303,12 +303,14 @@ Authorize with `resource` set to the MCP protocol URL. MCP additionally verifies Example Cursor `mcp.json`: ```json -{ - "auth": { - "CLIENT_ID": "...", - "scopes": ["guillotina:access"] +"mcp-name": { + "url": "http://localhost:8080/db/container/@mcp/protocol", + "auth": { + "scopes": [ + "guillotina:access" + ] + } } -} ``` `@login` JWTs authenticate Guillotina sessions directly. OAuth access tokens include `token_type=oauth_access_token`, `client_id`, `scope` and audience/resource claims and are validated by the OAuth validator. MCP clients should use OAuth discovery and must not store manually copied bearer tokens in configuration. From 598a2f29cba7e353247b9f8ddae57150fcf90407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 17:18:03 +0200 Subject: [PATCH 15/27] refactor(oauth): restructure OAuth API endpoints and enhance request handling --- .../contrib/oauth/api/endpoints/__init__.py | 0 .../contrib/oauth/api/endpoints/authorize.py | 236 +++++++ .../contrib/oauth/api/endpoints/common.py | 60 ++ .../contrib/oauth/api/endpoints/consents.py | 65 ++ .../contrib/oauth/api/endpoints/register.py | 52 ++ .../contrib/oauth/api/endpoints/revoke.py | 56 ++ .../contrib/oauth/api/endpoints/token.py | 151 +++++ guillotina/contrib/oauth/api/services.py | 581 +----------------- guillotina/contrib/oauth/api/well_known.py | 33 + guillotina/contrib/oauth/integrations/mcp.py | 2 +- guillotina/contrib/oauth/storage/utility.py | 30 +- .../tests/oauth/test_oauth_storage_backend.py | 1 - 12 files changed, 694 insertions(+), 573 deletions(-) create mode 100644 guillotina/contrib/oauth/api/endpoints/__init__.py create mode 100644 guillotina/contrib/oauth/api/endpoints/authorize.py create mode 100644 guillotina/contrib/oauth/api/endpoints/common.py create mode 100644 guillotina/contrib/oauth/api/endpoints/consents.py create mode 100644 guillotina/contrib/oauth/api/endpoints/register.py create mode 100644 guillotina/contrib/oauth/api/endpoints/revoke.py create mode 100644 guillotina/contrib/oauth/api/endpoints/token.py diff --git a/guillotina/contrib/oauth/api/endpoints/__init__.py b/guillotina/contrib/oauth/api/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/api/endpoints/authorize.py b/guillotina/contrib/oauth/api/endpoints/authorize.py new file mode 100644 index 000000000..2f44ce373 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -0,0 +1,236 @@ +from guillotina import app_settings +from guillotina.contrib.oauth.api.endpoints.common import AUTHORIZE_SINGLETON_PARAMS, authenticate_basic +from guillotina.contrib.oauth.api.request import ( + client_identifier, + normalize_list, + params_preserving_repeated, + parse_form_encoded, + reject_duplicate_params, +) +from guillotina.contrib.oauth.api.urls import container_url, validate_resource +from guillotina.contrib.oauth.api.views import consent_form, login_form, oauth_error_page +from guillotina.contrib.oauth.flow.clients import ( + consent_key, + redirect_uri_registered_for_client, + redirect_with_params, + scopes_registered_for_client, +) +from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid +from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported +from guillotina.contrib.oauth.flow.tokens import opaque_token +from guillotina.response import HTTPBadRequest, HTTPFound +from guillotina.utils import get_authenticated_user + + +async def authorize(service, store): + params, error = await _collect_params(service) + if error is not None: + return error + service.request.oauth_request_params = params + + client = await store.get_client(params.get("client_id")) + if client is None: + return oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) + redirect_uri = params.get("redirect_uri") + if not redirect_uri_registered_for_client(client, redirect_uri): + return oauth_error_page( + "Invalid redirect URI", + "The requested redirect URI is not allowed for this OAuth client.", + status=400, + ) + + # Mix-up defense (RFC 9207): include the issuer identifier in every + # authorization response so the client can verify which AS responded. + issuer = container_url(service.request, service.context) + + def authz_redirect(extra): + payload = {"state": params.get("state"), "iss": issuer} + payload.update(extra) + return HTTPFound(redirect_with_params(redirect_uri, payload)) + + request_error = _validate_request(params, client) + if request_error is not None: + return authz_redirect({"error": request_error}) + try: + resources = validate_resource(service.request, service.context, params.get("resource")) + except HTTPBadRequest: + return authz_redirect({"error": "invalid_target"}) + scopes = normalize_list(params.get("scope")) + + user, new_token, authenticated_now, early_response = await _ensure_authenticated( + service, params, client + ) + if early_response is not None: + return early_response + + response_obj = await _issue_or_consent( + service, + store, + params=params, + client=client, + user=user, + scopes=scopes, + resources=resources, + redirect_uri=redirect_uri, + authz_redirect=authz_redirect, + authenticated_now=authenticated_now, + ) + + if new_token is not None: + secure = "" + if str(getattr(service.request, "scheme", "") or "").lower() == "https": + secure = "; Secure" + response_obj.headers["Set-Cookie"] = ( + f"auth_token={new_token}; Path=/; HttpOnly; SameSite=Lax{secure}" + ) + return response_obj + + +async def _collect_params(service): + """Merge query and (for POST) body params; returns ``(params, error)``.""" + params = params_preserving_repeated(service.request.query) + try: + reject_duplicate_params(service.request.query, AUTHORIZE_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return None, exc + if service.request.method == "POST": + content_type = service.request.headers.get("content-type", "") + if "application/json" in content_type: + data = await service.request.json() + else: + try: + data = parse_form_encoded( + await service.request.text(), singleton_fields=AUTHORIZE_SINGLETON_PARAMS + ) + except HTTPBadRequest as exc: + return None, exc + params.update(data) + return params, None + + +def _validate_request(params, client): + """Validate response_type, PKCE and scope. Returns an OAuth error code or None.""" + if params.get("response_type") != "code": + return "unsupported_response_type" + if "code" not in set(client.get("response_types") or []): + return "unauthorized_client" + allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) + code_challenge = params.get("code_challenge") + if not code_challenge: + return "invalid_request" + if not pkce_challenge_valid(code_challenge): + return "invalid_request" + if params.get("code_challenge_method") not in allowed_methods: + return "invalid_request" + scopes = normalize_list(params.get("scope")) + supported_scopes = set(oauth_scopes_supported()) + if ( + not scopes + or OAUTH_DEFAULT_SCOPE not in scopes + or not set(scopes).issubset(supported_scopes) + or not scopes_registered_for_client(client, scopes) + ): + return "invalid_scope" + return None + + +async def _ensure_authenticated(service, params, client): + """Resolve the end user, logging in via the form if needed. + + Returns ``(user, new_token, authenticated_now, early_response)``. When + ``early_response`` is not None the caller must return it immediately (login + form, rate-limit page or failed-login page). + """ + user = get_authenticated_user() + if user is not None and getattr(user, "id", "Anonymous User") != "Anonymous User": + return user, None, False, None + if not (service.request.method == "POST" and params.get("username")): + return None, None, False, login_form(params, client) + + oauth_settings = app_settings.get("oauth", {}) + login_limit = oauth_settings.get("login_rate_limit", 10) + login_window = oauth_settings.get("login_rate_window", 300) + login_key = f"oauth-login:{client_identifier(service.request)}:{params.get('username')}" + if await rate_limit_check(login_key, limit=login_limit, window=login_window): + return ( + None, + None, + False, + oauth_error_page( + "Too many attempts", + "Too many failed login attempts. Please wait and try again.", + status=429, + ), + ) + user = await authenticate_basic(params.get("username"), params.get("password", "")) + if user is None: + await rate_limit_exceeded(login_key, limit=login_limit, window=login_window) + return ( + None, + None, + False, + oauth_error_page( + "Login failed", + "The username or password could not be verified.", + status=401, + ), + ) + from guillotina.auth import authenticate_user + + new_token, _ = authenticate_user(user.id) + return user, new_token, True, None + + +async def _issue_or_consent( + service, + store, + *, + params, + client, + user, + scopes, + resources, + redirect_uri, + authz_redirect, + authenticated_now, +): + """Handle the consent decision and, when granted, issue the auth code.""" + ckey = consent_key(user.id, client["client_id"], scopes, resources) + existing_consent = await store.has_consent(ckey) + # A freshly logged-in request never carries a consent decision: the user + # only submitted credentials, so always render the consent screen next. + decision = ( + params.get("decision") + if service.request.method == "POST" and not authenticated_now + else None + ) + if decision in ("allow", "deny") and not csrf_valid( + params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources + ): + return authz_redirect({"error": "invalid_request"}) + if not existing_consent and decision != "allow": + if decision == "deny": + return authz_redirect({"error": "access_denied"}) + return consent_form(params, client, scopes, resources, user) + + if not existing_consent: + await store.create_consent( + ckey, + user_id=user.id, + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + raw_code = opaque_token("goc_") + await store.create_code( + raw_code=raw_code, + client_id=client["client_id"], + user_id=user.id, + redirect_uri=redirect_uri, + scope=scopes, + resource=resources, + code_challenge=params.get("code_challenge"), + ) + return authz_redirect({"code": raw_code}) diff --git a/guillotina/contrib/oauth/api/endpoints/common.py b/guillotina/contrib/oauth/api/endpoints/common.py new file mode 100644 index 000000000..accadd77f --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/common.py @@ -0,0 +1,60 @@ +from guillotina import app_settings +from guillotina.api.service import Service +from guillotina.auth.utils import set_authenticated_user +from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD +from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.response import Response + + +# Parameters that must appear at most once per request. Repeated occurrences are +# rejected to avoid OAuth parameter-pollution attacks. +AUTHORIZE_SINGLETON_PARAMS = { + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "decision", + "username", + "password", + OAUTH_CSRF_FIELD, +} +TOKEN_SINGLETON_PARAMS = { + "grant_type", + "client_id", + "redirect_uri", + "code", + "code_verifier", + "refresh_token", + "scope", +} +REVOKE_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} +CONSENT_SINGLETON_PARAMS = {"consent_key", "client_id"} + + +class OAuthService(Service): + def oauth_store(self): + return get_oauth_store(self.context) + + +async def authenticate_basic(username, password): + creds = {"type": "basic", "token": password, "id": username} + for validator in app_settings["auth_token_validators"]: + if validator.for_validators is not None and "basic" not in validator.for_validators: + continue + user = await validator().validate(creds) + if user is not None: + set_authenticated_user(user) + return user + + +def token_response(content): + return Response( + content=content, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) diff --git a/guillotina/contrib/oauth/api/endpoints/consents.py b/guillotina/contrib/oauth/api/endpoints/consents.py new file mode 100644 index 000000000..28614b038 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/consents.py @@ -0,0 +1,65 @@ +from guillotina.contrib.oauth.api.endpoints.common import CONSENT_SINGLETON_PARAMS +from guillotina.contrib.oauth.api.request import form_content_type_valid, parse_form_encoded +from guillotina.response import HTTPBadRequest, HTTPNotFound, HTTPUnauthorized, Response +from guillotina.utils import get_authenticated_user + + +def _authenticated_user(): + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + return None + return user + + +async def list_consents(service, store): + user = _authenticated_user() + if user is None: + return HTTPUnauthorized(content={"error": "invalid_token"}) + consents = await store.list_consents(user.id) + clients = {} + items = [] + for consent in consents: + client_id = consent["client_id"] + if client_id not in clients: + clients[client_id] = await store.get_client(client_id) + client = clients[client_id] or {} + items.append( + { + "consent_key": consent["consent_key"], + "client_id": client_id, + "client_name": client.get("client_name"), + "scope": consent["scope"], + "resource": consent["resource"], + "granted_at": consent["granted_at"], + "expires_at": consent["expires_at"], + } + ) + return Response(content={"consents": items}, headers={"Cache-Control": "no-store"}) + + +async def revoke_consent(service, store): + user = _authenticated_user() + if user is None: + return HTTPUnauthorized(content={"error": "invalid_token"}) + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=CONSENT_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc + ckey = data.get("consent_key") + if not ckey: + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "consent_key is required"} + ) + consents = {c["consent_key"]: c for c in await store.list_consents(user.id)} + consent = consents.get(ckey) + if consent is None: + return HTTPNotFound(content={"error": "not_found", "error_description": "unknown consent"}) + await store.delete_consent(ckey, user_id=user.id) + # Complete deauthorization: revoke every refresh token this user holds for + # the client so revoking consent also kills active sessions. + await store.revoke_user_client_refresh_tokens(user_id=user.id, client_id=consent["client_id"]) + return {} diff --git a/guillotina/contrib/oauth/api/endpoints/register.py b/guillotina/contrib/oauth/api/endpoints/register.py new file mode 100644 index 000000000..5bb136cde --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/register.py @@ -0,0 +1,52 @@ +from guillotina import app_settings +from guillotina.contrib.oauth.api.request import client_identifier +from guillotina.contrib.oauth.flow.clients import make_client +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.response import HTTPBadRequest, HTTPTooManyRequests, Response + + +async def register(service, store): + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-register:{client_identifier(service.request)}", + limit=oauth_settings.get("registration_rate_limit", 20), + window=oauth_settings.get("registration_rate_window", 600), + ): + return HTTPTooManyRequests( + content={ + "error": "temporarily_unavailable", + "error_description": "client registration rate limit exceeded", + } + ) + content_type = service.request.headers.get("content-type", "") + if content_type.split(";", 1)[0].strip().lower() != "application/json": + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + data = await service.request.json() + try: + client = make_client(data) + except HTTPBadRequest as exc: + return exc + await store.create_client(client) + content = { + key: client[key] + for key in ( + "client_id", + "client_name", + "redirect_uris", + "grant_types", + "response_types", + "scope", + "token_endpoint_auth_method", + ) + } + content["client_id_issued_at"] = client["client_id_issued_at"] + return Response( + content=content, + status=201, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) diff --git a/guillotina/contrib/oauth/api/endpoints/revoke.py b/guillotina/contrib/oauth/api/endpoints/revoke.py new file mode 100644 index 000000000..77a494204 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/revoke.py @@ -0,0 +1,56 @@ +from guillotina import app_settings +from guillotina.contrib.oauth.api.endpoints.common import REVOKE_SINGLETON_PARAMS +from guillotina.contrib.oauth.api.request import ( + client_identifier, + form_content_type_valid, + parse_form_encoded, +) +from guillotina.contrib.oauth.flow.clients import consent_key +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.response import HTTPBadRequest, HTTPTooManyRequests + + +async def revoke(service, store): + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc + if not data.get("client_id") or not data.get("token"): + return HTTPBadRequest(content={"error": "invalid_request"}) + if data.get("token_type_hint") == "access_token": + return HTTPBadRequest(content={"error": "unsupported_token_type"}) + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-revoke:{client_identifier(service.request)}", + limit=oauth_settings.get("revoke_rate_limit", 120), + window=oauth_settings.get("revoke_rate_window", 60), + ): + return HTTPTooManyRequests( + content={ + "error": "temporarily_unavailable", + "error_description": "revocation rate limit exceeded", + } + ) + record = await store.get_refresh_token(data.get("token", "")) + if record is not None and record.get("client_id") == data.get("client_id"): + await store.revoke_refresh_family( + client_id=record["client_id"], + user_id=record["user_id"], + auth_code_hash=record.get("auth_code_hash"), + ) + # Drop the remembered consent so the grant cannot be silently re-issued + # after the user revoked their tokens (RFC 9700 deauthorization hygiene). + await store.delete_consent( + consent_key( + record["user_id"], + record["client_id"], + record.get("scope") or [], + record.get("resource") or [], + ), + user_id=record["user_id"], + ) + return {} diff --git a/guillotina/contrib/oauth/api/endpoints/token.py b/guillotina/contrib/oauth/api/endpoints/token.py new file mode 100644 index 000000000..efc96d7a7 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/token.py @@ -0,0 +1,151 @@ +from guillotina import app_settings +from guillotina.contrib.oauth.api.endpoints.common import TOKEN_SINGLETON_PARAMS, token_response +from guillotina.contrib.oauth.api.request import ( + client_identifier, + form_content_type_valid, + normalize_list, + parse_form_encoded, +) +from guillotina.contrib.oauth.api.urls import container_url +from guillotina.contrib.oauth.flow.pkce import verify_s256 +from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash +from guillotina.response import HTTPBadRequest, HTTPTooManyRequests + + +async def token(service, store): + if not form_content_type_valid(service.request): + return HTTPBadRequest( + content={"error": "invalid_request", "error_description": "invalid content type"} + ) + try: + data = parse_form_encoded(await service.request.text(), singleton_fields=TOKEN_SINGLETON_PARAMS) + except HTTPBadRequest as exc: + return exc + grant_type = data.get("grant_type") + if not grant_type: + return HTTPBadRequest(content={"error": "invalid_request"}) + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-token:{client_identifier(service.request)}", + limit=oauth_settings.get("token_rate_limit", 120), + window=oauth_settings.get("token_rate_window", 60), + ): + return HTTPTooManyRequests( + content={"error": "temporarily_unavailable", "error_description": "token rate limit exceeded"} + ) + if grant_type == "authorization_code": + return await _authorization_code(service, store, data) + if grant_type == "refresh_token": + return await _refresh_token(service, store, data) + return HTTPBadRequest(content={"error": "unsupported_grant_type"}) + + +async def _authorization_code(service, store, data): + if not data.get("client_id") or not data.get("code") or not data.get("redirect_uri"): + return HTTPBadRequest(content={"error": "invalid_request"}) + client = await store.get_client(data.get("client_id")) + code_raw = data.get("code", "") + code_hash_val = token_hash(code_raw) + record = await store.get_active_code(code_raw) + if record is None: + await store.revoke_refresh_tokens_by_auth_code(code_hash_val) + return HTTPBadRequest(content={"error": "invalid_grant"}) + if client is None or record["client_id"] != client["client_id"]: + return HTTPBadRequest(content={"error": "invalid_grant"}) + if "authorization_code" not in set(client.get("grant_types") or []): + return HTTPBadRequest(content={"error": "unauthorized_client"}) + if record["redirect_uri"] != data.get("redirect_uri"): + return HTTPBadRequest(content={"error": "invalid_grant"}) + if record.get("code_challenge"): + if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): + return HTTPBadRequest(content={"error": "invalid_grant"}) + else: + # PKCE is mandatory for public clients. A code without a bound challenge is invalid. + return HTTPBadRequest(content={"error": "invalid_grant"}) + requested_resources = normalize_list(data.get("resource")) + if requested_resources and not set(requested_resources).issubset(set(record["resource"])): + return HTTPBadRequest(content={"error": "invalid_target"}) + resources = requested_resources or record["resource"] + + consumed = await store.consume_code(code_raw) + if consumed is None: + return HTTPBadRequest(content={"error": "invalid_grant"}) + record = consumed + + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=record["scope"], + ) + refresh_token = opaque_token("gor_") + await store.create_refresh_token( + raw_token=refresh_token, + client_id=client["client_id"], + user_id=record["user_id"], + scope=record["scope"], + resource=resources, + auth_code_hash=record["code_hash"], + ) + return token_response( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": refresh_token, + "scope": " ".join(record["scope"]), + } + ) + + +async def _refresh_token(service, store, data): + if not data.get("client_id") or not data.get("refresh_token"): + return HTTPBadRequest(content={"error": "invalid_request"}) + refresh_raw = data.get("refresh_token", "") + client = await store.get_client(data.get("client_id")) + record = await store.get_valid_refresh(refresh_raw) + if record is None: + cand = await store.get_refresh_token(refresh_raw) + if cand is not None and cand.get("revoked_at"): + await store.revoke_refresh_family_for_reuse( + client_id=cand["client_id"], + user_id=cand["user_id"], + auth_code_hash=cand.get("auth_code_hash"), + ) + return HTTPBadRequest(content={"error": "invalid_grant"}) + if client is None or record["client_id"] != client["client_id"]: + return HTTPBadRequest(content={"error": "invalid_grant"}) + if "refresh_token" not in set(client.get("grant_types") or []): + return HTTPBadRequest(content={"error": "unauthorized_client"}) + scopes = normalize_list(data.get("scope")) or record["scope"] + resources = normalize_list(data.get("resource")) or record["resource"] + if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): + return HTTPBadRequest(content={"error": "invalid_scope"}) + new_refresh = opaque_token("gor_") + rotated = await store.rotate_refresh_token( + old_refresh_raw=refresh_raw, + new_refresh_raw=new_refresh, + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + if not rotated: + return HTTPBadRequest(content={"error": "invalid_grant"}) + access_token, _claims = issue_access_token( + issuer=container_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=scopes, + ) + return token_response( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), + "refresh_token": new_refresh, + "scope": " ".join(scopes), + } + ) diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index ac2b42376..70464d4da 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -1,98 +1,28 @@ -from guillotina import app_settings, configure +from guillotina import configure from guillotina.api.service import Service -from guillotina.auth.utils import set_authenticated_user -from guillotina.contrib.oauth.api.request import ( - client_identifier, - form_content_type_valid, - normalize_list, - params_preserving_repeated, - parse_form_encoded, - reject_duplicate_params, -) -from guillotina.contrib.oauth.api.urls import container_url, validate_resource -from guillotina.contrib.oauth.api.views import consent_form, login_form, oauth_error_page -from guillotina.contrib.oauth.api.well_known import rfc_well_known_response -from guillotina.contrib.oauth.flow.clients import ( - consent_key, - make_client, - redirect_uri_registered_for_client, - redirect_with_params, - scopes_registered_for_client, -) -from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid -from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid, verify_s256 -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded -from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported -from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash -from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.contrib.oauth.api.endpoints.authorize import authorize +from guillotina.contrib.oauth.api.endpoints.common import OAuthService +from guillotina.contrib.oauth.api.endpoints.consents import list_consents, revoke_consent +from guillotina.contrib.oauth.api.endpoints.register import register +from guillotina.contrib.oauth.api.endpoints.revoke import revoke +from guillotina.contrib.oauth.api.endpoints.token import token +from guillotina.contrib.oauth.api.well_known import WELL_KNOWN_HANDLERS, rfc_well_known_response from guillotina.interfaces import IApplication, IContainer -from guillotina.response import ( - HTTPBadRequest, - HTTPFound, - HTTPNotFound, - HTTPTooManyRequests, - HTTPUnauthorized, - Response, -) -from guillotina.utils import get_authenticated_user +from guillotina.response import HTTPNotFound -WELL_KNOWN_HANDLERS = {} -AUTHORIZE_SINGLETON_PARAMS = { - "response_type", - "client_id", - "redirect_uri", - "scope", - "state", - "code_challenge", - "code_challenge_method", - "decision", - "username", - "password", - OAUTH_CSRF_FIELD, +# Dispatch tables mapping the ``oauth/{action}`` matchdict to its handler. +OAUTH_GET_ACTIONS = { + "authorize": authorize, + "consents": list_consents, } -TOKEN_SINGLETON_PARAMS = { - "grant_type", - "client_id", - "redirect_uri", - "code", - "code_verifier", - "refresh_token", - "scope", +OAUTH_POST_ACTIONS = { + "register": register, + "authorize": authorize, + "token": token, + "revoke": revoke, + "consents": revoke_consent, } -REVOKE_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} -CONSENT_SINGLETON_PARAMS = {"consent_key", "client_id"} - - -def register_well_known_handler(name, handler): - WELL_KNOWN_HANDLERS[name] = handler - - -def _metadata(request, container): - issuer = container_url(request, container) - return { - "issuer": issuer, - "authorization_endpoint": f"{issuer}/oauth/authorize", - "token_endpoint": f"{issuer}/oauth/token", - "registration_endpoint": f"{issuer}/oauth/register", - "revocation_endpoint": f"{issuer}/oauth/revoke", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["none"], - "revocation_endpoint_auth_methods_supported": ["none"], - "resource_indicators_supported": True, - "authorization_response_iss_parameter_supported": True, - "scopes_supported": oauth_scopes_supported(), - } - - -register_well_known_handler("oauth-authorization-server", _metadata) - - -class OAuthService(Service): - def oauth_store(self): - return get_oauth_store(self.context) @configure.service( @@ -140,11 +70,10 @@ async def __call__(self): class OAuthGet(OAuthService): async def __call__(self): action = self.request.matchdict.get("action", "") - if action == "authorize": - return await _authorize(self, self.oauth_store()) - if action == "consents": - return await _list_consents(self, self.oauth_store()) - return HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) + handler = OAUTH_GET_ACTIONS.get(action) + if handler is None: + return HTTPNotFound(content={"reason": f"Unknown OAuth GET action: {action}"}) + return await handler(self, self.oauth_store()) @configure.service( @@ -156,464 +85,8 @@ async def __call__(self): ) class OAuthPost(OAuthService): async def __call__(self): - store = self.oauth_store() action = self.request.matchdict.get("action", "") - if action == "register": - return await _register(self, store) - if action == "authorize": - return await _authorize(self, store) - if action == "token": - return await _token(self, store) - if action == "revoke": - return await _revoke(self, store) - if action == "consents": - return await _revoke_consent(self, store) - return HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) - - -async def _register(service, store): - oauth_settings = app_settings.get("oauth", {}) - if await rate_limit_exceeded( - f"oauth-register:{client_identifier(service.request)}", - limit=oauth_settings.get("registration_rate_limit", 20), - window=oauth_settings.get("registration_rate_window", 600), - ): - return HTTPTooManyRequests( - content={ - "error": "temporarily_unavailable", - "error_description": "client registration rate limit exceeded", - } - ) - content_type = service.request.headers.get("content-type", "") - if content_type.split(";", 1)[0].strip().lower() != "application/json": - return HTTPBadRequest( - content={"error": "invalid_request", "error_description": "invalid content type"} - ) - data = await service.request.json() - try: - client = make_client(data) - except HTTPBadRequest as exc: - return exc - await store.create_client(client) - content = { - key: client[key] - for key in ( - "client_id", - "client_name", - "redirect_uris", - "grant_types", - "response_types", - "scope", - "token_endpoint_auth_method", - ) - } - content["client_id_issued_at"] = client["client_id_issued_at"] - return Response( - content=content, - status=201, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - - -async def _authenticate_basic(username, password): - creds = {"type": "basic", "token": password, "id": username} - for validator in app_settings["auth_token_validators"]: - if validator.for_validators is not None and "basic" not in validator.for_validators: - continue - user = await validator().validate(creds) - if user is not None: - set_authenticated_user(user) - return user - - -def _token_response(content): - return Response( - content=content, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - - -async def _authorize(service, store): - params = params_preserving_repeated(service.request.query) - try: - reject_duplicate_params(service.request.query, AUTHORIZE_SINGLETON_PARAMS) - except HTTPBadRequest as exc: - return exc - if service.request.method == "POST": - content_type = service.request.headers.get("content-type", "") - if "application/json" in content_type: - data = await service.request.json() - else: - try: - data = parse_form_encoded( - await service.request.text(), singleton_fields=AUTHORIZE_SINGLETON_PARAMS - ) - except HTTPBadRequest as exc: - return exc - params.update(data) - service.request.oauth_request_params = params - client = await store.get_client(params.get("client_id")) - if client is None: - return oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) - redirect_uri = params.get("redirect_uri") - if not redirect_uri_registered_for_client(client, redirect_uri): - return oauth_error_page( - "Invalid redirect URI", - "The requested redirect URI is not allowed for this OAuth client.", - status=400, - ) - # Mix-up defense (RFC 9207): include the issuer identifier in every - # authorization response so the client can verify which AS responded. - issuer = container_url(service.request, service.context) - - def _authz_redirect(extra): - payload = {"state": params.get("state"), "iss": issuer} - payload.update(extra) - return HTTPFound(redirect_with_params(redirect_uri, payload)) - - if params.get("response_type") != "code": - return _authz_redirect({"error": "unsupported_response_type"}) - if "code" not in set(client.get("response_types") or []): - return _authz_redirect({"error": "unauthorized_client"}) - allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) - code_challenge = params.get("code_challenge") - code_challenge_method = params.get("code_challenge_method") - - if not code_challenge: - return _authz_redirect({"error": "invalid_request"}) - if code_challenge and not pkce_challenge_valid(code_challenge): - return _authz_redirect({"error": "invalid_request"}) - if code_challenge and code_challenge_method not in allowed_methods: - return _authz_redirect({"error": "invalid_request"}) - scopes = normalize_list(params.get("scope")) - supported_scopes = set(oauth_scopes_supported()) - if ( - not scopes - or OAUTH_DEFAULT_SCOPE not in scopes - or not set(scopes).issubset(supported_scopes) - or not scopes_registered_for_client(client, scopes) - ): - return _authz_redirect({"error": "invalid_scope"}) - try: - resources = validate_resource(service.request, service.context, params.get("resource")) - except HTTPBadRequest: - return _authz_redirect({"error": "invalid_target"}) - user = get_authenticated_user() - newly_authenticated_token = None - authenticated_on_this_request = False - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - if service.request.method == "POST" and params.get("username"): - oauth_settings = app_settings.get("oauth", {}) - login_limit = oauth_settings.get("login_rate_limit", 10) - login_window = oauth_settings.get("login_rate_window", 300) - login_key = f"oauth-login:{client_identifier(service.request)}:{params.get('username')}" - if await rate_limit_check(login_key, limit=login_limit, window=login_window): - return oauth_error_page( - "Too many attempts", - "Too many failed login attempts. Please wait and try again.", - status=429, - ) - user = await _authenticate_basic(params.get("username"), params.get("password", "")) - if user is None: - await rate_limit_exceeded(login_key, limit=login_limit, window=login_window) - return oauth_error_page( - "Login failed", - "The username or password could not be verified.", - status=401, - ) - from guillotina.auth import authenticate_user - - newly_authenticated_token, _ = authenticate_user(user.id) - authenticated_on_this_request = True - else: - return login_form(params, client) - response_obj = None - ckey = consent_key(user.id, client["client_id"], scopes, resources) - existing_consent = await store.has_consent(ckey) - decision = ( - params.get("decision") - if service.request.method == "POST" and not authenticated_on_this_request - else None - ) - if decision in ("allow", "deny") and not csrf_valid( - params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources - ): - response_obj = _authz_redirect({"error": "invalid_request"}) - elif not existing_consent and decision != "allow": - if decision == "deny": - response_obj = _authz_redirect({"error": "access_denied"}) - else: - response_obj = consent_form(params, client, scopes, resources, user) - else: - if not existing_consent: - await store.create_consent( - ckey, - user_id=user.id, - client_id=client["client_id"], - scope=scopes, - resource=resources, - ) - raw_code = opaque_token("goc_") - await store.create_code( - raw_code=raw_code, - client_id=client["client_id"], - user_id=user.id, - redirect_uri=redirect_uri, - scope=scopes, - resource=resources, - code_challenge=params.get("code_challenge"), - ) - response_obj = _authz_redirect({"code": raw_code}) - - if newly_authenticated_token is not None: - secure = "" - if str(getattr(service.request, "scheme", "") or "").lower() == "https": - secure = "; Secure" - response_obj.headers["Set-Cookie"] = ( - f"auth_token={newly_authenticated_token}; Path=/; HttpOnly; SameSite=Lax{secure}" - ) - return response_obj - - -async def _token(service, store): - if not form_content_type_valid(service.request): - return HTTPBadRequest( - content={"error": "invalid_request", "error_description": "invalid content type"} - ) - try: - data = parse_form_encoded(await service.request.text(), singleton_fields=TOKEN_SINGLETON_PARAMS) - except HTTPBadRequest as exc: - return exc - grant_type = data.get("grant_type") - if not grant_type: - return HTTPBadRequest(content={"error": "invalid_request"}) - oauth_settings = app_settings.get("oauth", {}) - if await rate_limit_exceeded( - f"oauth-token:{client_identifier(service.request)}", - limit=oauth_settings.get("token_rate_limit", 120), - window=oauth_settings.get("token_rate_window", 60), - ): - return HTTPTooManyRequests( - content={"error": "temporarily_unavailable", "error_description": "token rate limit exceeded"} - ) - if grant_type == "authorization_code": - return await _authorization_code(service, store, data) - if grant_type == "refresh_token": - return await _refresh_token(service, store, data) - return HTTPBadRequest(content={"error": "unsupported_grant_type"}) - - -async def _authorization_code(service, store, data): - if not data.get("client_id") or not data.get("code") or not data.get("redirect_uri"): - return HTTPBadRequest(content={"error": "invalid_request"}) - client = await store.get_client(data.get("client_id")) - code_raw = data.get("code", "") - code_hash_val = token_hash(code_raw) - record = await store.get_active_code(code_raw) - if record is None: - await store.revoke_refresh_tokens_by_auth_code(code_hash_val) - return HTTPBadRequest(content={"error": "invalid_grant"}) - if client is None or record["client_id"] != client["client_id"]: - return HTTPBadRequest(content={"error": "invalid_grant"}) - if "authorization_code" not in set(client.get("grant_types") or []): - return HTTPBadRequest(content={"error": "unauthorized_client"}) - if record["redirect_uri"] != data.get("redirect_uri"): - return HTTPBadRequest(content={"error": "invalid_grant"}) - if record.get("code_challenge"): - if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): - return HTTPBadRequest(content={"error": "invalid_grant"}) - else: - # PKCE is mandatory for public clients. A code without a bound challenge is invalid. - return HTTPBadRequest(content={"error": "invalid_grant"}) - requested_resources = normalize_list(data.get("resource")) - if requested_resources and not set(requested_resources).issubset(set(record["resource"])): - return HTTPBadRequest(content={"error": "invalid_target"}) - resources = requested_resources or record["resource"] - - consumed = await store.consume_code(code_raw) - if consumed is None: - return HTTPBadRequest(content={"error": "invalid_grant"}) - record = consumed - - access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=record["scope"], - ) - refresh_token = opaque_token("gor_") - await store.create_refresh_token( - raw_token=refresh_token, - client_id=client["client_id"], - user_id=record["user_id"], - scope=record["scope"], - resource=resources, - auth_code_hash=record["code_hash"], - ) - return _token_response( - { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": refresh_token, - "scope": " ".join(record["scope"]), - } - ) - - -async def _refresh_token(service, store, data): - if not data.get("client_id") or not data.get("refresh_token"): - return HTTPBadRequest(content={"error": "invalid_request"}) - refresh_raw = data.get("refresh_token", "") - client = await store.get_client(data.get("client_id")) - record = await store.get_valid_refresh(refresh_raw) - if record is None: - cand = await store.get_refresh_token(refresh_raw) - if cand is not None and cand.get("revoked_at"): - await store.revoke_refresh_family_for_reuse( - client_id=cand["client_id"], - user_id=cand["user_id"], - auth_code_hash=cand.get("auth_code_hash"), - ) - return HTTPBadRequest(content={"error": "invalid_grant"}) - if client is None or record["client_id"] != client["client_id"]: - return HTTPBadRequest(content={"error": "invalid_grant"}) - if "refresh_token" not in set(client.get("grant_types") or []): - return HTTPBadRequest(content={"error": "unauthorized_client"}) - scopes = normalize_list(data.get("scope")) or record["scope"] - resources = normalize_list(data.get("resource")) or record["resource"] - if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): - return HTTPBadRequest(content={"error": "invalid_scope"}) - new_refresh = opaque_token("gor_") - rotated = await store.rotate_refresh_token( - old_refresh_raw=refresh_raw, - new_refresh_raw=new_refresh, - client_id=client["client_id"], - scope=scopes, - resource=resources, - ) - if not rotated: - return HTTPBadRequest(content={"error": "invalid_grant"}) - access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), - subject=record["user_id"], - audience=resources, - client_id=client["client_id"], - scope=scopes, - ) - return _token_response( - { - "access_token": access_token, - "token_type": "Bearer", - "expires_in": app_settings["oauth"].get("access_token_ttl", 3600), - "refresh_token": new_refresh, - "scope": " ".join(scopes), - } - ) - - -async def _revoke(service, store): - if not form_content_type_valid(service.request): - return HTTPBadRequest( - content={"error": "invalid_request", "error_description": "invalid content type"} - ) - try: - data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) - except HTTPBadRequest as exc: - return exc - if not data.get("client_id") or not data.get("token"): - return HTTPBadRequest(content={"error": "invalid_request"}) - if data.get("token_type_hint") == "access_token": - return HTTPBadRequest(content={"error": "unsupported_token_type"}) - oauth_settings = app_settings.get("oauth", {}) - if await rate_limit_exceeded( - f"oauth-revoke:{client_identifier(service.request)}", - limit=oauth_settings.get("revoke_rate_limit", 120), - window=oauth_settings.get("revoke_rate_window", 60), - ): - return HTTPTooManyRequests( - content={ - "error": "temporarily_unavailable", - "error_description": "revocation rate limit exceeded", - } - ) - record = await store.get_refresh_token(data.get("token", "")) - if record is not None and record.get("client_id") == data.get("client_id"): - await store.revoke_refresh_family( - client_id=record["client_id"], - user_id=record["user_id"], - auth_code_hash=record.get("auth_code_hash"), - ) - # Drop the remembered consent so the grant cannot be silently re-issued - # after the user revoked their tokens (RFC 9700 deauthorization hygiene). - await store.delete_consent( - consent_key( - record["user_id"], - record["client_id"], - record.get("scope") or [], - record.get("resource") or [], - ), - user_id=record["user_id"], - ) - return {} - - -async def _list_consents(service, store): - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - return HTTPUnauthorized(content={"error": "invalid_token"}) - consents = await store.list_consents(user.id) - clients = {} - items = [] - for consent in consents: - client_id = consent["client_id"] - if client_id not in clients: - clients[client_id] = await store.get_client(client_id) - client = clients[client_id] or {} - items.append( - { - "consent_key": consent["consent_key"], - "client_id": client_id, - "client_name": client.get("client_name"), - "scope": consent["scope"], - "resource": consent["resource"], - "granted_at": consent["granted_at"], - "expires_at": consent["expires_at"], - } - ) - return Response(content={"consents": items}, headers={"Cache-Control": "no-store"}) - - -async def _revoke_consent(service, store): - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - return HTTPUnauthorized(content={"error": "invalid_token"}) - if not form_content_type_valid(service.request): - return HTTPBadRequest( - content={"error": "invalid_request", "error_description": "invalid content type"} - ) - try: - data = parse_form_encoded(await service.request.text(), singleton_fields=CONSENT_SINGLETON_PARAMS) - except HTTPBadRequest as exc: - return exc - ckey = data.get("consent_key") - if not ckey: - return HTTPBadRequest( - content={"error": "invalid_request", "error_description": "consent_key is required"} - ) - consents = {c["consent_key"]: c for c in await store.list_consents(user.id)} - consent = consents.get(ckey) - if consent is None: - return HTTPNotFound(content={"error": "not_found", "error_description": "unknown consent"}) - await store.delete_consent(ckey, user_id=user.id) - # Complete deauthorization: revoke every refresh token this user holds for - # the client so revoking consent also kills active sessions. - await store.revoke_user_client_refresh_tokens(user_id=user.id, client_id=consent["client_id"]) - return {} + handler = OAUTH_POST_ACTIONS.get(action) + if handler is None: + return HTTPNotFound(content={"reason": f"Unknown OAuth POST action: {action}"}) + return await handler(self, self.oauth_store()) diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py index d13a35d00..0db903f17 100644 --- a/guillotina/contrib/oauth/api/well_known.py +++ b/guillotina/contrib/oauth/api/well_known.py @@ -1,4 +1,6 @@ from guillotina import task_vars +from guillotina.contrib.oauth.api.urls import container_url +from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported from guillotina.contrib.oauth.storage.access import get_oauth_store from guillotina.interfaces import IContainer from guillotina.response import HTTPNotFound @@ -6,6 +8,37 @@ from guillotina.utils import get_database, get_registry +# Registry of ``.well-known`` metadata handlers keyed by document name. Other +# packages (e.g. the MCP integration) register additional documents here. +WELL_KNOWN_HANDLERS = {} + + +def register_well_known_handler(name, handler): + WELL_KNOWN_HANDLERS[name] = handler + + +def _authorization_server_metadata(request, container): + issuer = container_url(request, container) + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "registration_endpoint": f"{issuer}/oauth/register", + "revocation_endpoint": f"{issuer}/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "revocation_endpoint_auth_methods_supported": ["none"], + "resource_indicators_supported": True, + "authorization_response_iss_parameter_supported": True, + "scopes_supported": oauth_scopes_supported(), + } + + +register_well_known_handler("oauth-authorization-server", _authorization_server_metadata) + + def _container_path_parts(path_value, *, allow_mcp_suffix=False): parts = [part for part in path_value.strip("/").split("/") if part] if len(parts) < 2: diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index d29c3e881..f57e11df2 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -5,7 +5,7 @@ from guillotina import app_settings, configure from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy from guillotina.contrib.oauth.api.request import normalize_list -from guillotina.contrib.oauth.api.services import register_well_known_handler +from guillotina.contrib.oauth.api.well_known import register_well_known_handler from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url from guillotina.contrib.oauth.flow.resources import ( register_oauth_audience_resolver, diff --git a/guillotina/contrib/oauth/storage/utility.py b/guillotina/contrib/oauth/storage/utility.py index ba24bfe98..7324388c3 100644 --- a/guillotina/contrib/oauth/storage/utility.py +++ b/guillotina/contrib/oauth/storage/utility.py @@ -14,7 +14,6 @@ logger = logging.getLogger("guillotina.contrib.oauth") -_ddl_locks = {} _ddl_initialized = set() OAUTH_STORAGE_DEFAULTS = { @@ -43,23 +42,20 @@ def get_oauth_storage_settings(): async def ensure_oauth_tables(storage): import asyncpg.exceptions - loop = asyncio.get_running_loop() - lock = _ddl_locks.setdefault(id(loop), asyncio.Lock()) storage_key = id(storage.pool) - async with lock: - if storage_key in _ddl_initialized: - return - async with storage.pool.acquire() as conn: - for ddl in OAUTH_DDL: - for attempt in range(3): - try: - await conn.execute(ddl) - break - except asyncpg.exceptions.UniqueViolationError: - if attempt == 2: - raise - await asyncio.sleep(0.05) - _ddl_initialized.add(storage_key) + if storage_key in _ddl_initialized: + return + async with storage.pool.acquire() as conn: + for ddl in OAUTH_DDL: + for attempt in range(3): + try: + await conn.execute(ddl) + break + except asyncpg.exceptions.UniqueViolationError: + if attempt == 2: + raise + await asyncio.sleep(0.05) + _ddl_initialized.add(storage_key) @implementer(IOAuthStorageUtility) diff --git a/guillotina/tests/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py index 1cf6ccde7..7838d4ce3 100644 --- a/guillotina/tests/oauth/test_oauth_storage_backend.py +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -77,7 +77,6 @@ async def execute(self, ddl): self.executed.append(ddl) monkeypatch.setattr(utility, "OAUTH_DDL", ["SELECT 1"]) - monkeypatch.setattr(utility, "_ddl_locks", {}) monkeypatch.setattr(utility, "_ddl_initialized", set()) first_storage = type("Storage", (), {"pool": FakePool()})() From f82d7d151b8009c908f5e2ae4ac60c2a740090cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 19:39:16 +0200 Subject: [PATCH 16/27] style(oauth): fix black/isort formatting in authorize endpoint and mcp integration --- .../contrib/oauth/api/endpoints/authorize.py | 14 +++----------- guillotina/contrib/oauth/integrations/mcp.py | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/guillotina/contrib/oauth/api/endpoints/authorize.py b/guillotina/contrib/oauth/api/endpoints/authorize.py index 2f44ce373..826a54fb5 100644 --- a/guillotina/contrib/oauth/api/endpoints/authorize.py +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -59,9 +59,7 @@ def authz_redirect(extra): return authz_redirect({"error": "invalid_target"}) scopes = normalize_list(params.get("scope")) - user, new_token, authenticated_now, early_response = await _ensure_authenticated( - service, params, client - ) + user, new_token, authenticated_now, early_response = await _ensure_authenticated(service, params, client) if early_response is not None: return early_response @@ -82,9 +80,7 @@ def authz_redirect(extra): secure = "" if str(getattr(service.request, "scheme", "") or "").lower() == "https": secure = "; Secure" - response_obj.headers["Set-Cookie"] = ( - f"auth_token={new_token}; Path=/; HttpOnly; SameSite=Lax{secure}" - ) + response_obj.headers["Set-Cookie"] = f"auth_token={new_token}; Path=/; HttpOnly; SameSite=Lax{secure}" return response_obj @@ -201,11 +197,7 @@ async def _issue_or_consent( existing_consent = await store.has_consent(ckey) # A freshly logged-in request never carries a consent decision: the user # only submitted credentials, so always render the consent screen next. - decision = ( - params.get("decision") - if service.request.method == "POST" and not authenticated_now - else None - ) + decision = params.get("decision") if service.request.method == "POST" and not authenticated_now else None if decision in ("allow", "deny") and not csrf_valid( params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources ): diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index f57e11df2..539c8e777 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -5,8 +5,8 @@ from guillotina import app_settings, configure from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy from guillotina.contrib.oauth.api.request import normalize_list -from guillotina.contrib.oauth.api.well_known import register_well_known_handler from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url +from guillotina.contrib.oauth.api.well_known import register_well_known_handler from guillotina.contrib.oauth.flow.resources import ( register_oauth_audience_resolver, register_oauth_resource_resolver, From f0be0218931efc0ffd616cbb56b858656e6a2098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 19:49:47 +0200 Subject: [PATCH 17/27] fix(oauth): align well-known protected-resource metadata with tests and mcp integration --- guillotina/contrib/oauth/api/well_known.py | 41 +++++++++++++------- guillotina/contrib/oauth/integrations/mcp.py | 11 ++++-- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py index 0db903f17..df223b3e4 100644 --- a/guillotina/contrib/oauth/api/well_known.py +++ b/guillotina/contrib/oauth/api/well_known.py @@ -12,11 +12,20 @@ # packages (e.g. the MCP integration) register additional documents here. WELL_KNOWN_HANDLERS = {} +# Registry of protected-resource metadata providers (RFC 9728). Each provider +# receives (request, container, protected_path) and returns metadata dict or +# None when it does not handle the requested resource. +_PROTECTED_RESOURCE_PROVIDERS = [] + def register_well_known_handler(name, handler): WELL_KNOWN_HANDLERS[name] = handler +def register_protected_resource_provider(provider): + _PROTECTED_RESOURCE_PROVIDERS.append(provider) + + def _authorization_server_metadata(request, container): issuer = container_url(request, container) return { @@ -39,26 +48,32 @@ def _authorization_server_metadata(request, container): register_well_known_handler("oauth-authorization-server", _authorization_server_metadata) -def _container_path_parts(path_value, *, allow_mcp_suffix=False): +def _protected_resource_metadata(request, container): + protected_path = getattr(request, "oauth_protected_resource_path", None) + for provider in _PROTECTED_RESOURCE_PROVIDERS: + metadata = provider(request, container, protected_path) + if metadata is not None: + return metadata + raise HTTPNotFound(content={"reason": "Unknown protected resource"}) + + +register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) + + +def _container_path_parts(path_value, *, allow_resource_path=False): parts = [part for part in path_value.strip("/").split("/") if part] if len(parts) < 2: raise HTTPNotFound(content={"reason": "Invalid path"}) - suffix = parts[2:] - if allow_mcp_suffix: - if suffix and suffix[-2:] != ["@mcp", "protocol"]: - raise HTTPNotFound(content={"reason": "Invalid resource path"}) - elif suffix: + if not allow_resource_path and len(parts) > 2: raise HTTPNotFound(content={"reason": "Invalid issuer path"}) return parts[0], parts[1], "/" + "/".join(parts) async def rfc_well_known_response(request, action, target_path, handlers): - if action == "oauth-protected-resource": - db_id, container_id, protected_resource_path = _container_path_parts( - target_path, allow_mcp_suffix=True - ) - else: - db_id, container_id, protected_resource_path = _container_path_parts(target_path) + allow_resource_path = action == "oauth-protected-resource" + db_id, container_id, protected_resource_path = _container_path_parts( + target_path, allow_resource_path=allow_resource_path + ) db = await get_database(db_id) async with transaction(db=db): root = await db.get_transaction_manager().get_root() @@ -72,6 +87,6 @@ async def rfc_well_known_response(request, action, target_path, handlers): task_vars.registry.set(None) await get_registry(container) get_oauth_store(container, require_installed=True) - if action == "oauth-protected-resource": + if allow_resource_path: request.oauth_protected_resource_path = protected_resource_path return handlers[action](request, container) diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index 539c8e777..b1b1f6ba6 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -6,7 +6,7 @@ from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy from guillotina.contrib.oauth.api.request import normalize_list from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url -from guillotina.contrib.oauth.api.well_known import register_well_known_handler +from guillotina.contrib.oauth.api.well_known import register_protected_resource_provider from guillotina.contrib.oauth.flow.resources import ( register_oauth_audience_resolver, register_oauth_resource_resolver, @@ -70,16 +70,19 @@ def _mcp_protocol_audience_resolver(request, container): register_oauth_audience_resolver(_mcp_protocol_audience_resolver) -def _protected_resource_metadata(request, context): +def _mcp_protected_resource_provider(request, context, protected_path): + resource = _mcp_resource_url_from_path(request, context, protected_path) + if resource is None: + return None issuer = container_url(request, context) return { - "resource": mcp_resource(request, context), + "resource": resource, "authorization_servers": [issuer], "scopes_supported": oauth_scopes_supported(), } -register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) +register_protected_resource_provider(_mcp_protected_resource_provider) @configure.utility(provides=IMCPAuthPolicy) From 5938def68eecb5a14dc13f55ba4e1882678af1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 21:04:55 +0200 Subject: [PATCH 18/27] refactor(oauth): remove unused issuer path and redirect URI validation functions --- guillotina/contrib/oauth/api/urls.py | 11 ----------- guillotina/contrib/oauth/flow/clients.py | 5 ----- 2 files changed, 16 deletions(-) diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/api/urls.py index 269b360cc..16e71a999 100644 --- a/guillotina/contrib/oauth/api/urls.py +++ b/guillotina/contrib/oauth/api/urls.py @@ -41,17 +41,6 @@ def validate_issuer(issuer): return issuer -def issuer_path(request, container): - return urlparse(container_url(request, container)).path.lstrip("/") - - -def well_known_authorization_server_url(request, container): - return ( - f"{request.scheme}://{request.host}/.well-known/oauth-authorization-server/" - f"{issuer_path(request, container)}" - ) - - def well_known_protected_resource_url(request, container): from guillotina.contrib.oauth.flow.resources import oauth_required_audience diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index c51311886..d995e4de1 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -42,11 +42,6 @@ def validate_redirect_uri(uri): return _is_private_use_redirect(parsed) -def is_native_redirect_uri(uri): - parsed = urlparse(uri) - return parsed.scheme not in ("http", "https") - - def redirect_uri_registered_for_client(client, redirect_uri): """Return True only if redirect_uri was registered for this client (no side effects). From 9035ee21672d7f500f08ce8700846bd317533255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 21:40:17 +0200 Subject: [PATCH 19/27] delete: remove outdated OAuth documentation files --- documentacio_tasques_oauth.md | 30 -- oauth-rfc-theory.html | 649 --------------------------------- oauth_docs_html.html | 663 ---------------------------------- 3 files changed, 1342 deletions(-) delete mode 100644 documentacio_tasques_oauth.md delete mode 100644 oauth-rfc-theory.html delete mode 100644 oauth_docs_html.html diff --git a/documentacio_tasques_oauth.md b/documentacio_tasques_oauth.md deleted file mode 100644 index 495887218..000000000 --- a/documentacio_tasques_oauth.md +++ /dev/null @@ -1,30 +0,0 @@ -Tasques revisades: - -Implementacio well known: - -Documentacio: https://www.rfc-editor.org/info/rfc9728/ - -Explicacio principal: - -- Quan fem la peticio a la peticio de @mcp/protocol, si l'usuari no esta autenticat tornem unes capçaleres perque el client pugui autenticarse amb oauth. - -La reposta es : -```json -{ - "resource": "http://localhost:8080/db/container/@mcp/protocol", - "authorization_servers": [ - "http://localhost:8080/db/container" - ], - "scopes_supported": [ - "guillotina:access" - ] -} -``` - - - -Preguntes: - -Quin RFC ens diu que el validador del JWT normal, s'ha de bloquejar si el token es d'oauth? - -A parlar amb el ramon, estem fent un lock per la bd al crear les taules en la utility, realment pg no te problemes en diferens instancies intantant crear estructura, com a molt retornaria un error, es una mica de sobreenyinyeria o codi inecessari aquest lock? Realment amb les utilities no hauriem de tenir mai una mateixa instància dos processos a la vegada. diff --git a/oauth-rfc-theory.html b/oauth-rfc-theory.html deleted file mode 100644 index cd766f50f..000000000 --- a/oauth-rfc-theory.html +++ /dev/null @@ -1,649 +0,0 @@ - - - - - - Guia teòrica dels RFC d'OAuth 2.0 del PR #1218 - - - - -
    -

    Guia teòrica dels RFC d'OAuth 2.0

    -

    Referència per entendre els estàndards que cobreix el perfil OAuth 2.0 Authorization Code + PKCE implementat al PR #1218 de Guillotina, sense entrar en detalls de codi.

    -
    - - - -
    - -
    -

    Introducció: què és OAuth 2.0?

    -

    OAuth 2.0 és un framework d'autorització, no un protocol d'autenticació. Permet que una aplicació (el client) obtingui permisos limitats d'un usuari (el resource owner) per accedir a recursos protegits per un servidor, sense que l'usuari li hagi de donar la seva contrasenya.

    - -

    Actors principals

    -
      -
    • Resource Owner: l'usuari final que posseeix les dades i pot concedir accés.
    • -
    • Client: l'aplicació que vol accedir als recursos. Pot ser una aplicació web servidor, una app nativa, una SPA o un script.
    • -
    • Authorization Server (AS): emiteix els access tokens després d'autenticar l'usuari i obtenir el seu consentiment.
    • -
    • Resource Server (RS): l'API que protegeix els recursos i valida els access tokens.
    • -
    - -

    Token d'accés

    -

    El resultat del flux és un access token: una credencial que el client presenta al resource server per demanar dades. Els access tokens poden tenir temps de vida curt, i es poden renovar mitjançant refresh tokens.

    - -
    - Nota clau: El PR implementa un perfil molt concret: només clients públics, només Authorization Code amb PKCE S256, sense secrets de client i sense OpenID Connect. Això és una elecció de seguretat deliberada, no una omissió. -
    -
    - -
    -

    Resum dels RFC que cobreix el perfil

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    RFCTemaQuè aporta al perfil
    RFC 6749OAuth 2.0 Authorization FrameworkFlux Authorization Code, token response, errors, scopes, refresh tokens.
    RFC 6750Bearer Token UsageCom presentar i validar tokens Authorization: Bearer.
    RFC 7636PKCEProtecció del code exchange amb code_verifier i code_challenge.
    RFC 8252OAuth 2.0 for Native AppsRegles per a redirect URIs d'apps nadiues (loopback, private-use schemes).
    RFC 7591Dynamic Client RegistrationEndpoint per registrar clients de forma programàtica.
    RFC 8414Authorization Server MetadataDescobriment d'endpoints i capacitats de l'AS (.well-known).
    RFC 9207Issuer IdentificationParàmetre iss a la resposta d'autorització contra mix-up.
    RFC 8707Resource IndicatorsParàmetre resource per lligar tokens a audiències concretes.
    RFC 9728Protected Resource MetadataMetadades dels recursos protegits i WWW-Authenticate hints.
    RFC 7009Token RevocationEndpoint per revocar refresh tokens i invalidar families.
    RFC 9700OAuth 2.0 Security BCPRecomanacions de seguretat actuals (PKCE, rotació, exact match...).
    -
    - -
    -

    RFC 6749 OAuth 2.0 Authorization Framework

    -

    És el document fonamental d'OAuth 2.0. Defineix els grant types (formes d'obtenir un token), els endpoints, els paràmetres i els errors.

    - -

    Authorization Code Grant

    -

    El flux consta de dues crides:

    -
      -
    1. El client redirigeix l'usuari al authorization endpoint de l'AS. Si l'usuari accepta, l'AS retorna un authorization code via redirecció.
    2. -
    3. El client intercanvia el code pel access token (i opcionalment el refresh token) al token endpoint.
    4. -
    - -

    Paràmetres clau de l'endpoint d'autorització

    -
      -
    • response_type=code: indica que volem el Authorization Code grant.
    • -
    • client_id: identificador del client registrat.
    • -
    • redirect_uri: on s'ha de redirigir la resposta. Ha de coincidir amb un URI registrat.
    • -
    • scope: àmbits de permís sol·licitats.
    • -
    • state: valor opac que el client envia i rep de tornada; protegeix contra atacs CSRF.
    • -
    - -

    Paràmetres clau del token endpoint

    -
      -
    • grant_type=authorization_code
    • -
    • code: el codi rebut.
    • -
    • redirect_uri: el mateix URI usat a la primera crida.
    • -
    • client_id: per clients públics.
    • -
    - -

    Token response

    -

    La resposta és un JSON amb:

    -
    {
    -  "access_token": "...",
    -  "token_type": "Bearer",
    -  "expires_in": 3600,
    -  "refresh_token": "...",
    -  "scope": "read write"
    -}
    - -

    Errors OAuth

    -

    Els errors del token endpoint han de tornar 400 Bad Request (o 401 quan toqui) amb un JSON que inclogui error i opcionalment error_description. Exemples: invalid_request, invalid_client, invalid_grant, unsupported_grant_type, invalid_scope.

    - -
    - Què validar al codi: que l'authorize valida client_id, redirect_uri i scope; que el token requereix el code i el mateix redirect_uri; que els errors segueixen el format de l'RFC; que el state es retorna intacte. -
    -
    - -
    -

    RFC 6750 Bearer Token Usage

    -

    Defineix com utilitzar un token d'accés per accedir a recursos protegits. Els access tokens són bearer tokens: qualsevol que el posseeix pot usar-lo (com un bitllet).

    - -

    Formes de presentar el token

    -
      -
    • Authorization header (recomanada): Authorization: Bearer <token>
    • -
    • Form-encoded body parameter (menys recomanada).
    • -
    • Query parameter (desaconsellada perquè queda als logs).
    • -
    - -

    WWW-Authenticate

    -

    Quan una petició no porta token o és invàlid, el resource server ha de respondre 401 Unauthorized amb una capçalera WWW-Authenticate indicant l'esquema i l'error OAuth:

    -
    WWW-Authenticate: Bearer error="invalid_token",
    -                    error_description="The access token expired"
    - -
    - Què validar al codi: que l'API rebutja peticions sense token o amb token caducat; que es torna WWW-Authenticate; que el token conté o permet verificar issuer, audience i scope abans d'autoritzar. -
    -
    - -
    -

    RFC 7636 Proof Key for Code Exchange (PKCE)

    -

    PKCE va néixer per protegir el flux Authorization Code quan el client no pot mantenir un secret (apps mòbils, SPAs). Impedeix que un atacant que intercepti l'authorization code pugui intercanviar-lo per un token.

    - -

    Com funciona

    -
      -
    1. El client genera un code_verifier: string aleatori de 43-128 caràcters (A-Z, a-z, 0-9, -, ., _, ~).
    2. -
    3. En calcula el hash SHA-256 i el codifica en Base64URL sense padding. Això és el code_challenge.
    4. -
    5. A l'/authorize envia code_challenge i code_challenge_method=S256.
    6. -
    7. L'AS guarda el challenge.
    8. -
    9. Al /token el client envia el code_verifier.
    10. -
    11. L'AS en calcula el S256 i comprova que coincideix amb el challenge guardat.
    12. -
    - -

    Per què S256 obligatori

    -

    El mètode plain (enviar el verifier sense transformar) és més feble. El perfil del PR exigeix S256 per a tots els clients públics, tal com recomana el BCP de seguretat.

    - -
    - Atenció: Si un client envia code_challenge_method=plain o no en envia cap, l'AS ha de rebutjar la petició. -
    - -
    - Què validar al codi: que /authorize exigeix code_challenge i code_challenge_method=S256; que /token exigeix code_verifier; que la validació és estricta (no permet pla, no accepta challenges curts); que el verifier es descarta després de l'ús. -
    -
    - -
    -

    RFC 8252 OAuth 2.0 for Native Apps

    -

    Aquest RFC aplica el flux Authorization Code a aplicacions nadiues (mòbil, escriptori, CLI) que no poden emmagatzemar secrets de manera segura.

    - -

    Principis clau

    -
      -
    • No utilitzar secrets de client: les apps nadiues són public clients.
    • -
    • PKCE obligatori: per protegir l'intercanvi de codi.
    • -
    • Redirect URI controlat: ha de redirigir a una destinació que només l'app pugui capturar.
    • -
    - -

    Tipus de redirect URI permesos

    -
      -
    • Loopback: http://127.0.0.1:PORT o http://[::1]:PORT. El port pot ser dinàmic. És segur perquè només l'app local pot escoltar allà.
    • -
    • Private-use URI scheme: com.example.myapp:/callback. El sistema operatiu redirigeix aquest esquema a l'app corresponent.
    • -
    • HTTPS per a apps amb Universal Links / App Links: requereix validació addicional del domini.
    • -
    - -

    Què NO s'ha de permetre

    -
      -
    • http:// que no sigui loopback.
    • -
    • Wildcards (*) als dominis o paths.
    • -
    • Fragments (#) al redirect URI.
    • -
    • URI que no estiguin exactament registrats.
    • -
    - -
    - Què validar al codi: que només es permeten loopback amb port dinàmic, private-use schemes o https; que el redirect_uri de la peticiatoken coincideix exactament amb el registrat (tret del port loopback); que es rebutgen fragments, wildcards i HTTP no loopback. -
    -
    - -
    -

    RFC 7591 Dynamic Client Registration

    -

    Permet que els clients es registrin sols a l'AS enviant un JSON al registration endpoint, en lloc de necessitar un procés manual.

    - -

    Metadades de client rellevants

    -
      -
    • redirect_uris: llista d'URIs vàlids.
    • -
    • grant_types: ha de contenir authorization_code i, si escau, refresh_token.
    • -
    • response_types: ha de contenir code.
    • -
    • token_endpoint_auth_method: en aquest perfil sempre none (client públic).
    • -
    • scope: àmbits per defecte sol·licitats.
    • -
    • client_name, client_uri, etc.: metadades informatives.
    • -
    - -

    Errors específics

    -

    L'RFC defineix noms d'error concrets que han de tornar-se quan falla el registre:

    -
      -
    • invalid_redirect_uri: URI no vàlid o insegur.
    • -
    • invalid_client_metadata: altres metadades incorrectes.
    • -
    • unsupported_token_endpoint_auth_method: mètode d'autenticació no permès.
    • -
    - -
    - Què validar al codi: que només s'accepten clients públics (none); que client_id el genera l'AS i es rebutja quan el client l'envia; que es validen grant_types, response_types i scope; que els errors segueixen els noms de l'RFC. -
    -
    - -
    -

    RFC 8414 Authorization Server Metadata

    -

    Defineix un format estàndard perquè els clients descobreixin els endpoints i capacitats de l'AS sense haver-los configurats manualment.

    - -

    Endpoint de metadades

    -

    El client pot fer GET a:

    -
    /.well-known/oauth-authorization-server/<issuer-path>
    -

    o, si l'issuer no té path:

    -
    <issuer>/.well-known/oauth-authorization-server
    - -

    Contingut típic de la resposta

    -
      -
    • issuer: l'identificador canònic de l'AS.
    • -
    • authorization_endpoint, token_endpoint, revocation_endpoint, registration_endpoint.
    • -
    • grant_types_supported, response_types_supported.
    • -
    • scopes_supported.
    • -
    • code_challenge_methods_supported: ha de contenir S256.
    • -
    • token_endpoint_auth_methods_supported: en aquest perfil, ["none"].
    • -
    - -
    - Què validar al codi: que les URLs del metadata coincideixen amb les reals; que l'issuer és estable i no depèn de capçaleres insegures; que només s'anuncien els valors que realment es suporten; que els dos well-known paths funcionen segons l'RFC. -
    -
    - -
    -

    RFC 9207 Authorization Server Issuer Identification

    -

    Afegeix el paràmetre iss a la resposta del /authorize per evitar l'atac authorization server mix-up.

    - -

    El problema

    -

    Un client pot tenir configurats diversos AS. Si un atacant aconsegueix que el client enviï una petició a un AS maliciós però rebi una resposta que sembla d'un AS de confiança, el client podria enviar el code al AS equivocat.

    - -

    La solució

    -

    La resposta d'autorització inclou iss=<issuer URL>. El client ha de comprovar que coincideix amb l'issuer esperat abans d'anar al token endpoint.

    - -
    https://client.example/callback?code=abc&state=xyz&iss=https%3A%2F%2Fas.example
    - -
    - Què validar al codi: que la redirecció d'autorització inclou iss; que el seu valor és exactament l'URL de l'issuer del metadata; que els tests comproven la presència i el format. -
    -
    - -
    -

    RFC 8707 Resource Indicators for OAuth 2.0

    -

    Permet que el client indiqui a quin recurs vol accedir mitjançant el paràmetre resource. Això lliga l'access token a una audiència concreta.

    - -

    Funcionament

    -
      -
    • El paràmetre resource és una URL que identifica el recurs.
    • -
    • Es pot repetir diverses vegades si el token ha de servir per a múltiples recursos.
    • -
    • L'AS valida que els recursos sol·licitats estiguin permesos.
    • -
    • L'access token emès hauria de reflectir l'audiència (aud) del token.
    • -
    - -
    GET /authorize?response_type=code&client_id=...
    -    &resource=https%3A%2F%2Fapi.example%2Fcontainer
    -    &resource=https%3A%2F%2Fapi.example%2Fmcp
    - -
    - Què validar al codi: que resource es pot repetir i es preserven tots els valors; que es validen contra els resolvers registrats; que el token emès porta l'audiència correcta; que es rebutgen recursos desconeguts. -
    -
    - -
    -

    RFC 9728 OAuth 2.0 Protected Resource Metadata

    -

    Permet que un recurs protegit (una API) publiqui metadades sobre com els clients han de demanar tokens per accedir-hi.

    - -

    Endpoint i contingut

    -

    El resource server pot exposar:

    -
    /.well-known/oauth-protected-resource
    -

    Amb informació com:

    -
      -
    • resource: identificador del recurs.
    • -
    • authorization_servers: llista d'AS que poden emetre tokens per aquest recurs.
    • -
    • bearer_methods: per exemple ["header"].
    • -
    • scopes_supported: àmbits disponibles al recurs.
    • -
    - -

    WWW-Authenticate hints

    -

    Quan una petició arriba sense token o amb token insuficient, el RS pot respondre amb:

    -
    WWW-Authenticate: Bearer
    -  error="insufficient_user_authentication",
    -  error_description="...",
    -  issuer="https://as.example",
    -  authorization_uri="https://as.example/authorize"
    - -
    - Què validar al codi: que les metadades del recurs protegit són coherents amb les de l'AS; que els WWW-Authenticate hints inclouen issuer i authorization_uri; que es distingeix entre recursos OAuth i recursos MCP. -
    -
    - -
    -

    RFC 7009 Token Revocation

    -

    Defineix un endpoint perquè els clients informin l'AS que un token ja no s'ha d'acceptar.

    - -

    Revocació de refresh tokens

    -

    En aquest perfil el endpoint de revocació s'usa principalment per als refresh tokens. Quan un usuari revoca una aplicació, l'AS ha d'evitar que torni a obtenir tokens nous.

    - -

    Family invalidation

    -

    Si un refresh token es revoca o es detecta reús, tota la família de refresh tokens associada a aquella concessió s'ha d'invalidar. Això impedeix la reemissió silenciosa després de la revocació.

    - -

    Resposta

    -

    L'AS retorna 200 OK independentment de si el token existia o no (per no filtrar informació).

    - -
    - Què validar al codi: que /revoke accepta token i opcionalment token_type_hint; que revocar un refresh token invalida la seva família; que també esborra el consentiment recordat per aquell client; que la resposta és sempre 200. -
    -
    - -
    -

    RFC 9700 OAuth 2.0 Security Best Current Practice

    -

    No és un RFC de funcionalitat nova, sinó un recull de recomanacions de seguretat actualitzades. El perfil del PR l'aplica de forma integral.

    - -

    Recomanacions clau que cal exigir

    -
      -
    • Clients públics amb PKCE: tots els clients són públics i usen PKCE S256.
    • -
    • Exact redirect URI matching: el URI de la petició ha de coincidir exactament amb un URI registrat (tret del port loopback dinàmic).
    • -
    • State parameter: s'ha d'usar i validar per evitar CSRF.
    • -
    • Authorization code d'un sol ús: un cop canviat per token, s'ha d'invalidar.
    • -
    • Codi de curta durada: preferiblement menys d'un minut.
    • -
    • Refresh token rotation: cada cop que s'usa un refresh token, se n'emet un de nou i s'invalida l'anterior.
    • -
    • Reuse detection: si es rep un refresh token ja usat, s'invalida tota la família.
    • -
    • Issuer segur: l'issuer s'ha de derivar de fonts de confiança per evitar mix-up.
    • -
    • No enviar secrets: els clients públics no autentiquen el token endpoint amb secret.
    • -
    - -
    - Alineació amb el perfil: el fet que el PR només permeti clients públics, PKCE obligatori, rotació de refresh tokens i detecció de reús no és casualitat: és l'aplicació directa del BCP. -
    - -
    - Què validar al codi: que no hi ha camí que permeti un client confidencial; que PKCE és irrenunciable; que els redirect URIs es comparen exactament; que els refresh tokens roten; que un refresh token reutilitzat mata la família; que els auth codes caduquen ràpid i només serveixen una vegada. -
    -
    - -
    -

    Checklist per validar el codi del PR

    -

    Aquesta llista resumeix el que hauries de poder comprovar sense necessitat de conèixer la implementació interna.

    - -

    Flux Authorization Code + PKCE

    -
    - - - - - - - -
    - -

    Tokens i Bearer

    -
    - - - - -
    - -

    Registre i clients

    -
    - - - - -
    - -

    Metadata i recursos

    -
    - - - - -
    - -

    Revocació i consentiment

    -
    - - - -
    -
    - -
    -

    Què NO està implementat (i per què és correcte que no hi sigui)

    -

    El PR defineix un perfil intencionadament estret. Aquests estàndards són vàlids però no formen part de l'abast:

    -
      -
    • OpenID Connect (OIDC): no s'emeten id_token, no hi ha endpoint UserInfo ni descobriment OIDC (/.well-known/openid-configuration).
    • -
    • RFC 7662 Token Introspection: no hi ha endpoint d'introspecció; la validació es fa amb JWT.
    • -
    • RFC 7523 JWT Profile for Client Authentication: no s'usa private_key_jwt ni assertions JWT per autenticar el client.
    • -
    • Clients confidencials: no hi ha client_secret ni autenticació al token endpoint.
    • -
    • RFC 9068 JWT Access Token Profile: encara que els access tokens són JWT, el PR no reclama complir estrictament el perfil interoperable d'aquest RFC.
    • -
    -
    - Això és una decisió de disseny, no un error: el perfil és més segur i més senzill limitant-se a clients públics + PKCE, tal com recomana el BCP. -
    -
    - -
    -

    Flux resumit en 6 passos

    -
      -
    1. Registre (opcional per client): el client es registra a /oauth/register i obté un client_id públic.
    2. -
    3. Descobriment: el client llegeix .well-known/oauth-authorization-server per saber els endpoints.
    4. -
    5. Autorització: el client redirigeix l'usuari a /oauth/authorize amb PKCE, state i resource.
    6. -
    7. Consentiment: l'usuari inicia sessió i aprova els àmbits sol·licitats.
    8. -
    9. Intercanvi: el client envia el code i el code_verifier a /oauth/token i rep access_token i refresh_token.
    10. -
    11. Accés: el client usa l'access token com a Bearer per cridar l'API. Quan vol, pot revocar el refresh token o el consentiment.
    12. -
    -
    - -
    - -
    -

    Document generat per ajudar a la revisió teòrica del PR #1218 de Guillotina.

    -
    - - - diff --git a/oauth_docs_html.html b/oauth_docs_html.html deleted file mode 100644 index 8e2481076..000000000 --- a/oauth_docs_html.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - -Guillotina OAuth + MCP — Mapa visual d'implementació - - - -
    - - -
    -
    -

    Guillotina OAuth 2.0 + MCP

    -

    Servidor d'autorització OAuth integrat a Guillotina (perfil OAuth 2.0 Authorization Code + PKCE per a clients públics): PKCE obligatori, grants authorization_code i refresh_token, confinament d'audiència (resource indicators) i integració amb el protocol MCP com a recurs protegit.

    -

    Aquest document és un mapa visual de tot el que s'ha implementat a guillotina.contrib.oauth: fluxos de dades, camins alternatius, control d'errors, opcions de configuració i els RFC que es compleixen.

    -
    - RFC 6749RFC 6750RFC 7636 · PKCE - RFC 7591 · DCRRFC 7009 · RevokeRFC 8414 · Metadata - RFC 8707 · ResourcesRFC 9207 · issRFC 9700 · Security BCP - RFC 9728 · Protected Resource -
    -
    - - -
    -

    🗺️ Visió general

    -

    L'AS viu dins de cada container de Guillotina. Tot l'estat (clients, codis, refresh tokens, consentiments) es desa a PostgreSQL, aïllat per container_db_key = db_id/container.id. Els access tokens són JWT sense estat, signats amb una subclau dedicada derivada del secret de l'app (derive_key("access-token")), separada de la clau de signatura JWT genèrica.

    -
    -
    5

    Accions OAuth: register, authorize, token, revoke, consents

    -
    2

    Endpoints de descobriment .well-known (AS metadata; protected-resource només amb MCP)

    -
    PKCE

    S256 obligatori per defecte · plain rebutjat

    -
    PG

    PostgreSQL és l'únic backend · neteja periòdica d'expirats

    -
    - -

    Arquitectura a vista d'ocell

    -
    - - - - - - - - 🧑 Usuari - Navegador - - - - 📱 Client - App / MCP client - - - - 🛡️ Guillotina AS - contrib.oauth · per-container - - authorize · token · register · revoke - PKCE · CSRF · rate-limit · keys - JWT validator (bearer/cookie/ws) - .well-known metadata - - - - 🗄️ PostgreSQL - hashed store - - - - 🔌 Recurs - API · MCP protocol - - - - register / token - - authorize (login+consent) - - desa hash - - valida JWT / aud - - Bearer access_token → recurs - -
    -
    - - -
    -

    🎭 Actors i components

    -
    - 🧑 Usuari (navegador) - 📱 Client OAuth públic - 🛡️ AS · Authorization Server - 🗄️ Store · PostgreSQL - 🔌 RS · Recurs (API/MCP) -
    -
    -

    🛡️ Servidor d'autorització

    Serveis a api/services.py. Emet codis, tokens i gestiona consentiment. Un per container.

    -

    🔑 Mòdul de claus

    flow/keys.py · derive_key() deriva subclaus HMAC distintes per propòsit (access-token, token-hash, csrf) des de jwt.secret.

    -

    🗄️ Repositori PG

    storage/pg/repository.py · operacions atòmiques (DELETE…RETURNING) i rotació amb detecció de reús.

    -

    🧩 PKCE / CSRF

    flow/pkce.py (S256) i flow/csrf.py (token HMAC signat amb TTL).

    -

    🚦 Rate limiter

    flow/ratelimit.py · finestra lliscant en memòria per registre i per login fallit.

    -

    🔌 Integració MCP

    integrations/mcp.py · política IMCPAuthPolicy + metadata de recurs protegit (RFC 9728).

    -
    -
    - - -
    -

    🔗 Endpoints

    - - - - - - - - - -
    MètodeRutaFuncióAuthDescripció
    POST/{container}/oauth/register_registercap (públic)Registre dinàmic de client (RFC 7591). Rate-limited.
    GET POST/{container}/oauth/authorize_authorizelogin/cookieAutorització: login, consentiment i emissió de codi.
    POST/{container}/oauth/token_tokenPKCEBescanvi de codi i rotació de refresh.
    POST/{container}/oauth/revoke_revokecap (públic)Revocació de refresh token (RFC 7009).
    GET POST/{container}/oauth/consents_list_consents · _revoke_consentusuari (no anònim)Llistar i revocar consentiments propis.
    GET/{container}/.well-known/{action}OAuthWellKnownpúblicMetadata AS / alias OIDC (relatiu al container).
    GET/.well-known/{action}/{path}OAuthRFCWellKnownpúblicMetadata a l'arrel (RFC 8414/9728), resol container des del path.
    -

    {action}oauth-authorization-server, oauth-protected-resource (registrat per guillotina.contrib.mcp).

    -
    - - -
    -

    🔄 Cicle de vida del client (extrem a extrem)

    -

    Què ha de fer un client públic (app nativa, SPA o client MCP) de principi a fi per integrar-se: des del descobriment fins a la revocació. Tot el flux assumeix client públic amb PKCE S256 i scope guillotina:access.

    - -
    -
    Cicle complet: descobriment → ús del recurs → refresc → revocaciópúblic · PKCE S256
    - -
    0
    📱 Client🛡️ AS
    -
    Descobriment. GET /{container}/.well-known/oauth-authorization-server per obtenir authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, code_challenge_methods_supported.MCP: parteix del WWW-Authenticate 401 → oauth-protected-resourceauthorization_servers.
    - -
    1
    📱 Client🛡️ AS
    -
    Registre dinàmic (un cop). POST /oauth/register amb client_name + redirect_uris[] → desa el client_id retornat. El client no tria el client_id; el genera el servidor.
    - -
    2
    📱 Client
    -
    Genera PKCE (per petició). code_verifier = aleatori 43–128 chars; code_challenge = BASE64URL(SHA256(verifier)). Genera també un state aleatori i guarda'l.
    - -
    3
    📱 Client🧑 Usuari🛡️ AS
    -
    Sol·licitud d'autorització. Obre al navegador GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=guillotina:access&state=…&code_challenge=…&code_challenge_method=S256 (opcional resource=… si vol un recurs concret com MCP).
    - -
    4
    🧑 Usuari🛡️ AS
    -
    Login + consentiment a l'AS. L'usuari s'autentica i aprova els scopes/recursos. El client no veu mai les credencials.
    - -
    5
    🛡️ AS📱 Client
    -
    Recepció del codi. Redirecció a redirect_uri?code=…&state=…&iss=…. El client HA de verificar que state coincideix amb el desat i que iss és l'issuer esperat (anti mix-up).
    - -
    6
    📱 Client🛡️ AS
    -
    Bescanvi del codi. POST /oauth/token (application/x-www-form-urlencoded) amb grant_type=authorization_code, code, client_id, redirect_uri, code_verifier → rep access_token (JWT), refresh_token, expires_in, scope.
    - -
    7
    📱 Client🔌 RS
    -
    Ús del recurs. Cada petició a l'API o al servidor MCP amb Authorization: Bearer <access_token>. El token ha de portar dins aud l'audiència requerida pel request concret.
    - -
    8
    📱 Client🛡️ AS
    -
    Refresc (en expirar). POST /oauth/token amb grant_type=refresh_token, refresh_token, client_id → nou access_token i nou refresh_token. El client ha de substituir el refresh token desat (rotació).
    - -
    9
    📱 Client🛡️ AS
    -
    Tancament de sessió. POST /oauth/revoke amb token=<refresh_token> + client_id per revocar tota la família.
    -
    - -
    Re-autorització silenciosa: mentre el consentiment segueixi vàlid, repetir el pas 3 amb els mateixos paràmetres salta login/consentiment i retorna el codi directament. Quan el refresh token caduca o es revoca, cal tornar al pas 2.
    - -

    Checklist d'obligacions del client

    -
    -
      -
    • Generar un code_verifier nou i aleatori per cada autorització
    • -
    • Generar i verificar state a la tornada
    • -
    • Verificar que iss retornat == issuer esperat
    • -
    • Usar el mateix redirect_uri a authorize i a token
    • -
    • Enviar token com a x-www-form-urlencoded, no JSON
    • -
    -
      -
    • Desar access_token i refresh_token de forma segura
    • -
    • Substituir el refresh token a cada rotació (mai reusar l'antic)
    • -
    • Refrescar abans/quan expires_in s'esgoti
    • -
    • Per recursos especialitzats: demanar el resource correcte perquè entri a aud
    • -
    • Revocar el refresh token en tancar sessió
    • -
    -
    - -
    -
    0–1 descobr.+registre
    -
    2–3 PKCE+authorize
    -
    4–5 login+codi
    -
    6 token
    -
    7–9 ús/refresh/revoke
    -
    -
    - - -
    -

    1 · Registre dinàmic de client RFC 7591

    -

    El client s'auto-registra abans d'autoritzar. Només clients públics (token_endpoint_auth_method=none). El servidor genera el client_id; mai l'accepta del client.

    -
    -
    Registre dinàmicPOST /oauth/register
    -
    1
    📱 Client🛡️ AS
    -
    JSON amb client_name, redirect_uris[], opcional scope.Comprova rate limit per IP de transport (client_identifier).
    -
    2
    🛡️ AS
    -
    make_client() valida: redirect_uris no buit, cada URI segura, auth_method=none, grants/response_types suportats.clients.py · validate_redirect_uri()
    -
    3
    🛡️ AS🗄️ Store
    -
    Genera client_id = uuid4().hex i persisteix.
    -
    4
    🛡️ AS📱 Client
    -
    201 amb client_id, client_id_issued_at, redirect_uris, grant_types, response_types, scope, token_endpoint_auth_method i headers Cache-Control: no-store + Pragma: no-cache.
    -
    -
    Camí d'error: rate limit superat → 429 temporarily_unavailable. Metadades invàlides → 400 invalid_request / invalid_client_metadata. Enviar client_id propi → 400 «client_id is server-issued».
    -

    Validació de redirect_uri (anti open-redirect)

    -
    -

    ✓ Acceptat

    -
      -
    • https://host/path amb netloc i path
    • -
    • http://localhost|127.0.0.1|::1/path (loopback natiu)
    • -
    • esquema custom natiu app.scheme://host/path
    • -
    -

    ✗ Rebutjat

    -
      -
    • comodins *, fragments #…
    • -
    • javascript: / data:
    • -
    • http:// no-loopback
    • -
    -
    -
    - - -
    -

    2 · Autorització + PKCE RFC 6749 RFC 7636 RFC 9207

    -

    El cor del sistema. Tres sub-camins segons l'estat de sessió i consentiment. Tot abans de validar el redirect_uri que mostra pàgina d'error (mai redirigeix); tot després redirigeix amb error + state + iss.

    - -

    Validacions prèvies (en ordre)

    - - - - - - - - - - -
    #ComprovacióSi falla
    0Paràmetres singleton duplicats (reject_duplicate_params)400
    1Client existeix400 pàgina d'error
    2redirect_uri registrat (match exacte)400 pàgina d'error
    3response_type == code302 unsupported_response_type
    4Client té code a response_types302 unauthorized_client
    5PKCE obligatori: code_challenge present, sintaxi vàlida i mètode ∈ S256302 invalid_request
    6Scope: conté guillotina:access, és subconjunt dels scopes suportats i també dels scopes registrats pel client302 invalid_scope
    7Resource ∈ recursos permesos (RFC 8707). Si no s'envia resource, s'usa per defecte el container_url.302 invalid_target
    - -

    Camí A — Usuari NO autenticat → login al propi AS

    -
    -
    Sub-flux de login (credencials a l'AS, no és ROPC)GET/POST /oauth/authorize
    -
    1
    🧑 Usuari🛡️ AS
    GET sense sessió → es renderitza login.html amb tots els paràmetres com a camps ocults.
    -
    2
    🧑 Usuari🛡️ AS
    POST amb username/password.Comprova rate_limit_check abans d'autenticar.
    -
    3
    🛡️ AS
    _authenticate_basic() via validators tipus «basic». Èxit → emet cookie auth_token (HttpOnly, SameSite=Lax, Secure si HTTPS) i força pantalla de consentiment.
    -
    -
    Errors: credencials incorrectes → registra fallada + 401. Massa fallades a la finestra → 429 «Too many attempts». (login_rate_limit / login_rate_window)
    -
    El login ocorre a l'AS, no al client → no és el grant «password» (prohibit per RFC 9700 §2.4). A més, en autenticar-se en aquesta petició, la decision es força a None: mai s'auto-aprova consentiment en el mateix POST que les credencials.
    - -

    Camí B — Autenticat, sense consentiment previ → pantalla de consentiment

    -
    -
    Sub-flux de consentiment (protegit amb CSRF)GET → POST /oauth/authorize
    -
    1
    🛡️ AS🧑 Usuari
    consent.html amb scopes, recursos i un token CSRF HMAC signat (lligat a user, client, redirect, scope, state, challenge, resource + iat).
    -
    2
    🧑 Usuari🛡️ AS
    POST decision=allow|deny + oauth_csrf.
    -
    3
    🛡️ AS
    csrf_valid(): compara HMAC (constant-time), TTL i tots els camps. Si allow → desa consentiment.
    -
    4
    🛡️ AS🗄️ Store
    Crea codi opac goc_… (hash desat), lligat a client, user, redirect, scope, resource, code_challenge.
    -
    5
    🛡️ AS🧑 Usuari
    302 a redirect_uri?code=…&state=…&iss=…
    -
    -
    decision=deny302 access_denied.  |  CSRF invàlid302 invalid_request.  |  decision=allow per GET (sense POST) → mostra consentiment, no crea codi.
    - -

    Camí C — Consentiment ja existent → emissió silenciosa

    -
    Re-autorització silenciosa: si has_consent(ckey) és cert, un GET amb els mateixos paràmetres salta consentiment i redirigeix directament amb el codi. La clau de consentiment és user|client|scopes|resources.
    -
    -
    🧑 GET authorize
    -
    ¿sessió?
    -
    ¿consentiment?
    -
    crea codi
    -
    302 + code+iss
    -
    -
    - - -
    -

    3 · Bescanvi de codi per token RFC 6749 RFC 7636 RFC 9700

    -
    -
    grant_type = authorization_codePOST /oauth/token
    -
    1
    📱 Client🛡️ AS
    application/x-www-form-urlencoded amb code, client_id, redirect_uri, code_verifier.
    -
    2
    🛡️ AS🗄️ Store
    get_active_code() per hash + no expirat. Si no existeix → revoca família del codi (anti-replay) i invalid_grant.
    -
    3
    🛡️ AS
    Comprova: client coincideix, grant permès, redirect_uri idèntic, PKCE verify_s256(verifier, challenge).
    -
    4
    🛡️ AS🗄️ Store
    consume_code() = DELETE … RETURNING atòmic (un sol ús garantit fins i tot en concurrència).
    -
    5
    🛡️ AS
    Emet access_token JWT (iss, sub, aud=recursos, client_id, scope, exp) signat amb la subclau derive_key("access-token") + refresh_token opac gor_… lligat al hash del codi.
    -
    6
    🛡️ AS📱 Client
    200 Cache-Control: no-store amb access_token, token_type=Bearer, expires_in, refresh_token, scope.
    -
    -
    Matriu d'errors: content-type incorrecte → 400 invalid_request · grant desconegut → unsupported_grant_type · codi/redirect/PKCE incorrectes → invalid_grant · grant no permès al client → unauthorized_client · recurs no subconjunt → invalid_target.
    -
    PKCE no és desactivable: si el codi nocode_challenge registrat, el bescanvi retorna invalid_grant. Aquest perfil només suporta clients públics i requereix PKCE sempre.
    -
    - - -
    -

    4 · Refresh amb rotació i detecció de reús RFC 9700 §4.14

    -

    Els refresh tokens per a clients públics es roten sempre. Reutilitzar un token ja rotat indica compromís → es revoca tota la família de l'autorització.

    -
    -
    grant_type = refresh_tokenPOST /oauth/token
    -
    1
    📱 Client🛡️ AS
    refresh_token + client_id (opcional scope/resource per reduir).
    -
    2
    🛡️ AS🗄️ Store
    get_valid_refresh(): no expirat i revoked_at IS NULL.
    -
    3
    🛡️ AS
    Scope/resource han de ser subconjunt dels originals. Client coincident i grant permès.
    -
    4
    🛡️ AS🗄️ Store
    rotate_refresh_token(): revoca l'antic (replaced_by) i crea el nou en una operació condicional. Si ja estava rotat → fallida.
    -
    5
    🛡️ AS📱 Client
    200 nou access_token + nou refresh_token.
    -
    -
    Detecció de reús: si arriba un refresh token que ja té revoked_at (reutilitzat) → revoke_refresh_family_for_reuse() revoca tots els tokens de la mateixa auth_code_hash i retorna invalid_grant.
    -
    - - -
    -

    5 · Revocació RFC 7009

    -
    -
    Revocació de refresh tokenPOST /oauth/revoke
    -
    1
    📱 Client🛡️ AS
    token + client_id (opcional token_type_hint), form-urlencoded.
    -
    2
    🛡️ AS🗄️ Store
    Busca per hash; si el client_id és el propietari → revoke_refresh_family() (revoca tota la família) i esborra el consentiment associat (consent_key del registre) perquè no es torni a emetre en silenci.
    -
    3
    🛡️ AS📱 Client
    200 {} sempre (també per tokens desconeguts, per RFC 7009).
    -
    -
    Límit conegut: els access token són JWT sense estat → no es poden revocar individualment abans del seu exp (TTL 1h). La revocació afecta la cadena de refresh. (decisió de disseny documentada)
    -
    - - -
    -

    8 · Gestió de consentiments

    -

    Els consentiments es desen a oauth_consents (per container_db_key + consent_key = user|client|scopes|resources) i permeten la re-autorització silenciosa. Ara tenen caducitat configurable i l'usuari els pot llistar i revocar ell mateix.

    -
    -

    ⏳ Caducitat (TTL)

    El setting consent_ttl (per defecte 2592000s = 30 dies) fixa expires_at en crear/renovar el consentiment. has_consent() ignora els caducats i oauth_cleanup_expired els purga. consent_ttl=0expires_at NULL (mai caduca). Renovar un consentiment existent (ON CONFLICT DO UPDATE) refresca l'expires_at.

    -

    🗑️ Revocació completa

    Revocar un consentiment fa una deautorització total del parell (usuari, client): esborra el registre de consentiment i revoca tots els refresh tokens vius via revoke_user_client_refresh_tokens(). La següent autorització tornarà a mostrar la pantalla de consentiment.

    -
    -
    -
    Llistar i revocar consentiments propisGET · POST /oauth/consents
    -
    1
    🧑 Usuari🛡️ AS
    GET /oauth/consents (usuari autenticat, no anònim) → 200 {"consents":[…]} amb client_id, client_name, scope, resource, granted_at, expires_at. Capçalera Cache-Control: no-store.
    -
    2
    🧑 Usuari🛡️ AS
    POST /oauth/consents amb consent_key=… (form-urlencoded) → revoca aquell consentiment i la família de refresh associada.
    -
    3
    🛡️ AS🧑 Usuari
    200. consent_key desconegut → 404. Usuari anònim → 401.
    -
    -
    Els endpoints són públics a nivell de permís (guillotina.Public) però comproven manualment que l'usuari no sigui anònim; cada usuari només veu i revoca els seus propis consentiments (filtrats per user_id).
    -
    - - -
    -

    6 · Validació de token i accés a recursos RFC 6750 RFC 8707

    -

    OAuthJWTValidator només accepta tokens OAuth via Authorization: Bearer. Decodifica el JWT amb un sol algorisme (evita confusió d'algorismes) i amb la subclau dedicada d'access-token (derive_key("access-token"), no el jwt.secret genèric), i aplica confinament d'audiència.

    -
    -
    Bearer access_token → API o MCPauth/validators.py · OAuthJWTValidator
    -
    1
    📱 Client🔌 RS
    Authorization: Bearer <jwt>
    -
    2
    🔌 RS
    Verifica signatura amb la subclau d'access-token (access_token_key() = derive_key("access-token")) + token_type=oauth_access_token + iss == container_url.
    -
    3
    🔌 RS
    Calcula oauth_required_audience(request, container) i exigeix que aquest valor sigui dins aud. Per defecte és el container_url; integracions com MCP registren resolvers d'audiència propis.
    -
    4
    🔌 RS
    Requereix scope guillotina:access; resol l'usuari i adjunta request.oauth (client_id, scopes, resources).
    -
    -

    Política d'autorització MCP RFC 9728

    -
    -

    🔒 Repte d'autenticació

    Sense token vàlid → 401 amb WWW-Authenticate: Bearer … resource_metadata="…/.well-known/oauth-protected-resource/…".

    -

    🎯 Confinament d'audiència

    MCP registra un resolver que fa que els requests /@mcp/protocol exigeixin aud = container_url + "/@mcp/protocol". Un token només per a l'API (aud=container) NO val per a MCP i viceversa.

    -
    -
    El permís efectiu del servei MCP passa de guillotina.MCPExecute a guillotina.Public + comprovació manual: 401 si anònim, 403 si sense permís, 401 amb repte si l'audiència no és vàlida.
    -
    Separació de tipus de token (defensa en profunditat): els validadors JWT del core (JWTValidator i JWTSessionValidator) ignoren qualsevol token amb token_type == "oauth_access_token". Així un access token OAuth només pot ser validat per OAuthJWTValidator i mai pel camí d'autenticació genèric de Guillotina (ni com a sessió). guillotina/auth/validators.py
    -
    - - -
    -

    🎯 Resource vs audience RFC 8707

    -

    resource i aud representen el mateix confinament vist en dos moments diferents: resource és el que el client demana al flux OAuth; aud és el que el servidor grava dins l'access token i el que el recurs protegit exigeix quan rep el token.

    - -
    -
    resource → aud → required audienceRFC 8707
    -
    1
    📱 Client🛡️ AS
    El client pot enviar resource=https://api.example.com/db/guillotina/@mcp/protocol a /oauth/authorize o /oauth/token.
    -
    2
    🛡️ AS
    validate_resource() comprova que el valor sigui dins el conjunt de recursos permesos. Aquest conjunt surt de register_oauth_resource_resolver().
    -
    3
    🛡️ AS📱 Client
    issue_access_token() emet el JWT amb aud=[resource]. Si no s'havia demanat cap resource, el valor per defecte és el container_url.
    -
    4
    📱 Client🔌 RS
    Quan arriba el Bearer token, OAuthJWTValidator calcula oauth_required_audience(request, container) i rebutja el token si aquesta audience no és dins aud.
    -
    - -
    -

    Sense resource

    El token surt amb aud=[container_url]. Serveix per APIs genèriques del container, com /@addons, però no per recursos especialitzats.

    -

    Amb resource MCP

    El token surt amb aud=[container_url + "/@mcp/protocol"]. Serveix per MCP, però no per l'API genèrica del container.

    -

    Carregar MCP no substitueix el container

    OAuth core sempre registra el container_url com a recurs base. MCP només afegeix un recurs addicional; no bloqueja clients OAuth normals.

    -

    Desacoblament de protocols

    El core OAuth no coneix MCP. Les integracions declaren recursos amb register_oauth_resource_resolver() i audiences requerides amb register_oauth_audience_resolver().

    -
    - -
    Regla operativa: no n'hi ha prou que el token tingui una audience globalment permesa. El token ha de portar explícitament l'audience requerida pel request concret. Això evita usar un token MCP contra l'API genèrica o un token genèric contra MCP.
    -
    - - -
    -

    7 · Descobriment / metadata RFC 8414 RFC 9728

    -
    -

    oauth-authorization-server

    Endpoints, response_types, grant_types, code_challenge_methods_supported=[S256], resource_indicators_supported, authorization_response_iss_parameter_supported.

    -

    oauth-protected-resource

    Registrat per guillotina.contrib.mcp quan l'addon està actiu: resource, authorization_servers, scopes_supported.

    -
    -
    Derivació de l'issuer: oauth.issuer fixat > (si trust_proxy_headers) capçaleres de proxy > transport scheme+Host. Per defecte no es confia en capçaleres spoofables.
    -
    - - -
    -

    ⚠️ Control d'errors (resum global)

    - - - - - - - - - - - - - - - -
    EndpointCondicióRespostaFormat
    registerrate limit429 temporarily_unavailableJSON
    registermetadades / client_id propi400 invalid_request · invalid_client_metadataJSON
    authorizeclient / redirect desconeguts400 pàgina HTML (no redirigeix)HTML
    authorizeresponse_type / client302 unsupported_response_type · unauthorized_clientredirect
    authorizePKCE absent/invàlid302 invalid_requestredirect
    authorizescope / resource302 invalid_scope · invalid_targetredirect
    authorizelogin fallit / rate limit401 · 429 pàgina HTMLHTML
    authorizedeny / CSRF invàlid302 access_denied · invalid_requestredirect
    tokencontent-type / grant400 invalid_request · unsupported_grant_typeJSON
    tokencodi/PKCE/redirect/refresh400 invalid_grantJSON
    tokengrant no permès al client400 unauthorized_clientJSON
    revokequalsevol token200 {}JSON
    well-knownaction/container desconegut404JSON
    -
    - - -
    -

    ⚙️ Configuració (app_settings["oauth"])

    - - - - - - - - - - - - - - - -
    ClauPer defecteEfecte
    issuerNoneURL canònica de l'issuer/audiència. Recomanat fixar-lo en producció.
    trust_proxy_headersFalseHonora X-Forwarded-* darrere proxy de confiança.
    allowed_code_challenge_methods["S256"]Mètodes PKCE permesos.
    scopes_supported["guillotina:access"]Scopes admesos.
    authorization_code_ttl600sVida del codi d'autorització.
    access_token_ttl3600sVida de l'access token JWT.
    refresh_token_ttl2592000sVida del refresh token (30 dies).
    consent_ttl2592000sNou: caducitat dels consentiments (30 dies). 0 = mai caduca.
    authorize_csrf_ttl600sValidesa del token CSRF de consentiment.
    registration_rate_limit / _window20 / 600sThrottle de registre per IP (0 = desactivat).
    login_rate_limit / _window10 / 300sNou: throttle de logins fallits per IP+username.
    token_rate_limit / _window120 / 60sThrottle del token endpoint per IP de transport.
    cleanup_interval / cleanup_batch_size900s / 5000Neteja periòdica de codis/refresh expirats (task d'OAuthStorageUtility).
    -
    - - -
    -

    🛡️ Mecanismes de seguretat

    -
    -
      -
    • PKCE S256 obligatori; plain rebutjat
    • -
    • Codi d'un sol ús atòmic (DELETE…RETURNING)
    • -
    • Anti-replay: reús de codi revoca família de refresh
    • -
    • Rotació de refresh + detecció de reús
    • -
    • PKCE no desactivable per clients públics
    • -
    • Codis i refresh desats com a hash (HMAC clau derivada)
    • -
    • Separació de claus per propòsit (derive_key): access-token, token-hash i csrf usen subclaus distintes
    • -
    -
      -
    • Match exacte de redirect_uri; sense open-redirect
    • -
    • Confinament d'audiència (resource indicators)
    • -
    • CSRF de consentiment HMAC signat amb TTL
    • -
    • Paràmetre iss a la resposta (anti mix-up)
    • -
    • Rate limit a registre i a logins fallits
    • -
    • Rebuig de paràmetres duplicats (anti param-pollution)
    • -
    • 302 (no 307) → credencials no es reenvien; cookie HttpOnly/SameSite/Secure
    • -
    • Aïllament multi-tenant per container_db_key
    • -
    • Validadors core ignoren token_type=oauth_access_token (sense confusió de camins d'auth)
    • -
    -
    -
    - - -
    -

    📜 RFCs i compliment

    -
    - ✓ Complet - ◑ Parcial / per disseny - ℹ️ Suport amb nota -
    -
    -
    RFC 6749✓ Complet
    OAuth 2.0 Authorization Framework
    -
    • Grants authorization_code i refresh_token
    • Errors estàndard (invalid_grant, invalid_scope…)
    • Preservació de state · Cache-Control: no-store
    -
    RFC 6750✓ Complet
    Bearer Token Usage
    -
    • Authorization: Bearer
    • WWW-Authenticate amb error/scope
    -
    RFC 7636✓ Complet
    PKCE
    -
    • Només S256; verificador 43–128
    • Verificació al token endpoint
    • Defensa downgrade (RFC 9700 §4.8.2)
    -
    RFC 7591✓ Complet
    Dynamic Client Registration
    -
    • Registre obert (rate-limited)
    • client_id generat pel servidor
    • Validació de metadades
    -
    RFC 7009✓ Complet
    Token Revocation
    -
    • 200 per a tokens desconeguts
    • Revocació de família · verificació de propietari
    -
    RFC 8414✓ Complet
    Authorization Server Metadata
    -
    • .well-known/oauth-authorization-server
    • Publica code_challenge_methods_supported
    -
    RFC 8707✓ Complet
    Resource Indicators
    -
    • resource a authorize/token
    • aud confinada · resolvers extensibles
    -
    RFC 9207✓ Complet
    Issuer Identification
    -
    • iss a totes les respostes d'autorització
    • Anunciat a la metadata
    -
    RFC 9728ℹ️ MCP
    Protected Resource Metadata
    -
    • .well-known/oauth-protected-resource
    • Repte WWW-Authenticate amb resource_metadata
    -
    RFC 9700✓ Seguit
    Security BCP (gener 2025)
    -
    • PKCE obligatori, sense implicit, sense ROPC
    • Rotació + reús, match exacte, anti open-redirect
    • Throttle de login, defensa downgrade, iss
    -
    RFC 9068◑ Inspirat
    JWT Access Token Profile
    -
    • Access token és JWT amb iss/sub/aud/exp/scope
    • Nota: usa token_type custom, no la capçalera typ: at+jwt
    -
    OAuth 2.0 profile✓ Alineat
    Authorization Code + PKCE public clients
    -
    • Clients públics + PKCE, sense implicit/ROPC
    • Rotació de refresh obligatòria
    -
    -
    - - -
    -

    ✅ Matriu de requisits MUST (RFC 9700)

    - - - - - - - - - - - - - - -
    Requisit normatiuEstatOn
    Match exacte de redirect URI (excepte port loopback)clients.py:validate_redirect_uri
    Cap open redirectorpàgina d'error abans de validar redirect
    Clients públics MUST usar PKCEPKCE obligatori, sense opció de desactivar-lo
    AS MUST suportar PKCE i enforce verifierverify_s256
    Mitigar PKCE downgrade_authorization_code (verifier sense challenge → reject)
    Refresh de clients públics: rotació o sender-constrainedrotate_refresh_token + reús
    Restricció d'audiència de l'access tokenaud = resources · validació MCP
    No usar implicit grantnomés response_type=code
    No usar ROPClogin a l'AS, no al client
    Respostes d'autorització no per HTTP sense xifrarredirect https (excepte loopback natiu)
    Publicar AS Metadata (RFC 8414).well-known
    Defensa mix-up (iss)RFC 9207 a totes les respostes
    -
    Aquest document reflecteix la implementació actual de guillotina.contrib.oauth. Per als detalls de codi, vegeu guillotina/contrib/oauth/ i els tests a guillotina/tests/oauth/.
    -
    -
    -
    - - - - From ed8e6f0cc39462669eaae9836855c53aa5e4b944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 21:40:25 +0200 Subject: [PATCH 20/27] feat(mcp): enhance MCP action service with authentication and authorization checks --- guillotina/contrib/mcp/services.py | 25 ++++++++++++++++---- guillotina/contrib/oauth/integrations/mcp.py | 4 +++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 07d11f576..8c8a99032 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -1,10 +1,11 @@ from guillotina import configure from guillotina.api.service import Service from guillotina.component import query_utility -from guillotina.contrib.mcp.interfaces import IMCPToolRegistry +from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy, IMCPToolRegistry from guillotina.contrib.mcp.security import require_access_content from guillotina.interfaces import IResource -from guillotina.response import HTTPNotFound, HTTPServiceUnavailable, Response +from guillotina.response import HTTPForbidden, HTTPNotFound, HTTPServiceUnavailable, HTTPUnauthorized, Response +from guillotina.utils import get_authenticated_user, get_security_policy def _get_registry(): @@ -16,23 +17,39 @@ def _get_registry(): return registry +def _get_auth_policy(request, context): + policy = query_utility(IMCPAuthPolicy) + if policy is not None and policy.is_enabled(request, context): + return policy + + @configure.service( method="POST", context=IResource, name="@mcp/{action}", - permission="guillotina.MCPExecute", + permission="guillotina.Public", summary="MCP Streamable HTTP protocol endpoint (JSON-RPC 2.0)", allow_access=True, ) class MCPActionPostService(Service): async def __call__(self): - require_access_content(self.context) action = self.request.matchdict.get("action", "") if action == "protocol": return await self._handle_protocol() raise HTTPNotFound(content={"reason": f"Unknown MCP POST action: {action}"}) async def _handle_protocol(self): + auth_policy = _get_auth_policy(self.request, self.context) + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + headers = auth_policy.unauthorized_headers(self.request, self.context) if auth_policy else None + raise HTTPUnauthorized(headers=headers) + if not get_security_policy(user).check_permission("guillotina.MCPExecute", self.context): + raise HTTPForbidden() + require_access_content(self.context) + if auth_policy is not None and not auth_policy.is_authorized(self.request, self.context): + raise HTTPUnauthorized(headers=auth_policy.forbidden_headers(self.request, self.context)) + try: import anyio from mcp.server.streamable_http import StreamableHTTPServerTransport diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index b1b1f6ba6..0d756f481 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -71,7 +71,9 @@ def _mcp_protocol_audience_resolver(request, container): def _mcp_protected_resource_provider(request, context, protected_path): - resource = _mcp_resource_url_from_path(request, context, protected_path) + resource = mcp_resource(request, context) if protected_path is None else None + if resource is None: + resource = _mcp_resource_url_from_path(request, context, protected_path) if resource is None: return None issuer = container_url(request, context) From 561b183d22e79f8b8b704463745b3e3306a9f8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 14 Jun 2026 21:50:21 +0200 Subject: [PATCH 21/27] chore: isort --- guillotina/contrib/mcp/services.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 8c8a99032..3e3040efa 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -4,7 +4,13 @@ from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy, IMCPToolRegistry from guillotina.contrib.mcp.security import require_access_content from guillotina.interfaces import IResource -from guillotina.response import HTTPForbidden, HTTPNotFound, HTTPServiceUnavailable, HTTPUnauthorized, Response +from guillotina.response import ( + HTTPForbidden, + HTTPNotFound, + HTTPServiceUnavailable, + HTTPUnauthorized, + Response, +) from guillotina.utils import get_authenticated_user, get_security_policy From f6e489156d3e68edb08adc461d1a71b7c4479032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 15 Jun 2026 08:04:25 +0200 Subject: [PATCH 22/27] refactor: Refactor OAuth contrib, reorganize utils, clarify API flow and rename symbols to express intent --- AGENTS.md | 8 +- CHANGELOG.rst | 27 +-- guillotina/contrib/oauth/__init__.py | 2 +- guillotina/contrib/oauth/api/__init__.py | 4 + .../contrib/oauth/api/endpoints/authorize.py | 174 ++++++++++-------- .../contrib/oauth/api/endpoints/common.py | 24 +-- .../contrib/oauth/api/endpoints/consents.py | 38 ++-- .../contrib/oauth/api/endpoints/register.py | 12 +- .../contrib/oauth/api/endpoints/revoke.py | 23 ++- .../contrib/oauth/api/endpoints/token.py | 63 ++++--- .../contrib/oauth/api/{views.py => pages.py} | 24 +-- guillotina/contrib/oauth/api/services.py | 36 ++-- guillotina/contrib/oauth/api/well_known.py | 10 +- guillotina/contrib/oauth/auth/__init__.py | 5 + guillotina/contrib/oauth/auth/helpers.py | 23 +++ guillotina/contrib/oauth/auth/validators.py | 8 +- guillotina/contrib/oauth/flow/__init__.py | 29 +++ guillotina/contrib/oauth/flow/clients.py | 35 ++-- guillotina/contrib/oauth/flow/consent.py | 2 + guillotina/contrib/oauth/flow/resources.py | 24 ++- guillotina/contrib/oauth/flow/tokens.py | 29 +-- guillotina/contrib/oauth/integrations/mcp.py | 14 +- guillotina/contrib/oauth/storage/__init__.py | 5 + guillotina/contrib/oauth/storage/access.py | 4 +- .../contrib/oauth/storage/interfaces.py | 3 - .../contrib/oauth/storage/pg/repository.py | 116 ++++++------ guillotina/contrib/oauth/utils/__init__.py | 44 +++++ guillotina/contrib/oauth/utils/crypto.py | 13 ++ guillotina/contrib/oauth/utils/errors.py | 8 + .../oauth/{flow => utils}/ratelimit.py | 64 +++---- .../contrib/oauth/{api => utils}/request.py | 20 +- guillotina/contrib/oauth/utils/time.py | 10 + .../contrib/oauth/{api => utils}/urls.py | 17 +- guillotina/contrib/oauth/utils/writable.py | 12 ++ guillotina/tests/oauth/test_mcp_oauth.py | 4 +- .../tests/oauth/test_oauth_authorize.py | 2 +- guillotina/tests/oauth/test_oauth_consents.py | 14 +- guillotina/tests/oauth/test_oauth_register.py | 2 +- guillotina/tests/oauth/test_oauth_revoke.py | 2 +- .../tests/oauth/test_oauth_storage_backend.py | 4 +- .../tests/oauth/test_oauth_store_contract.py | 16 +- guillotina/tests/oauth/test_oauth_token.py | 6 +- .../tests/oauth/test_oauth_well_known.py | 8 +- guillotina/tests/test_auth.py | 22 ++- 44 files changed, 567 insertions(+), 443 deletions(-) rename guillotina/contrib/oauth/api/{views.py => pages.py} (87%) create mode 100644 guillotina/contrib/oauth/auth/helpers.py create mode 100644 guillotina/contrib/oauth/flow/consent.py create mode 100644 guillotina/contrib/oauth/utils/__init__.py create mode 100644 guillotina/contrib/oauth/utils/crypto.py create mode 100644 guillotina/contrib/oauth/utils/errors.py rename guillotina/contrib/oauth/{flow => utils}/ratelimit.py (76%) rename guillotina/contrib/oauth/{api => utils}/request.py (80%) create mode 100644 guillotina/contrib/oauth/utils/time.py rename guillotina/contrib/oauth/{api => utils}/urls.py (79%) create mode 100644 guillotina/contrib/oauth/utils/writable.py diff --git a/AGENTS.md b/AGENTS.md index 2bb39ab88..b8dda4a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,15 @@ - Run tests: - `.venv/bin/pytest guillotina/tests` - Targeted: `.venv/bin/pytest guillotina/tests/` +- Run GitHub Actions parity checks locally before finishing code changes: + - `.venv/bin/flake8 guillotina --config=setup.cfg` + - `.venv/bin/isort --check-only guillotina/` + - `.venv/bin/black --check --verbose guillotina` + - `.venv/bin/mypy --config-file setup.cfg guillotina/` + - `.venv/bin/pytest -rfE --reruns 2 --cov=guillotina -s --tb=native -v --cov-report xml --cov-append guillotina` ## Validation +- Always run the same local checks as GitHub Actions before marking code work complete. If any check cannot be run locally, state the exact command, the blocker, and whether the equivalent GitHub Actions check is expected to cover it. - For contrib changes, run focused tests under the touched contrib test folder. - For API/service changes, verify status codes and response payload contracts. - Keep docs updated under `docs/source/contrib/` when adding contrib features. @@ -38,4 +45,3 @@ ## Task Closeout Notes - Update `CHANGELOG.rst` for notable changes. - Record branch name, commit hash, validation output, and task evidence in Ops Tracker. - diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fc3cf66f1..06e69b8c2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,33 +4,16 @@ CHANGELOG 7.1.3 (unreleased) ------------------ +- OAuth: implement Guillotina OAuth 2.0 authorization server with authorization-code flow, + dynamic client registration, token refresh/revocation, consent management and well-known + metadata. Compliant with RFC 6749, RFC 7636, RFC 7591, RFC 8414, RFC 9207, RFC 9700 and + RFC 9728. + [rboixaderg] - MCP: enforce Guillotina content permissions for tools and resources, require ``ViewContent`` for full serialized JSON, isolate cached tool responses by principal/container/context, and invalidate MCP cache on permission changes. [rboixaderg] -- OAuth: add extensible ``resource`` resolvers (:mod:`guillotina.contrib.oauth.flow.resources`), apply MCP - protocol URLs only when ``guillotina.contrib.mcp`` is enabled, require redirect URIs to be merged via - ``POST /oauth/register`` before ``/oauth/authorize``, validate PKCE ``code_verifier`` (RFC 7636), - atomically finalize authorization-code exchange after checks, defend refresh-token rotation against reuse. - [rboixaderg] -- OAuth: drop in-memory and Redis storage backends; PostgreSQL is the only store. - [rboixaderg] -- OAuth: harden per RFC 9700 security BCP: throttle failed credential logins at the authorization endpoint, - reject PKCE downgrade (``code_verifier`` without a bound ``code_challenge``), emit the ``iss`` authorization - response parameter (RFC 9207) and advertise it in metadata, and derive purpose-specific keys for token - hashing and CSRF signing instead of reusing the raw ``jwt.secret``. - [rboixaderg] -- OAuth: require PKCE for all public-client authorization-code flows, enforce registered client scopes, - add Redis-backed rate limiting when Redis is configured, and tighten dynamic client registration responses - with ``201 Created``, no-store cache headers and ``client_id_issued_at``. - [rboixaderg] -- OAuth: manage stored consents — add a configurable ``consent_ttl`` (default 30 days, ``0`` disables - expiry) with an ``expires_at`` column purged by ``oauth_cleanup_expired``, expose - ``GET``/``POST /oauth/consents`` for an authenticated user to list and revoke their grants, and make - revocation (via the consent endpoint or refresh-token ``/oauth/revoke``) drop the consent so a new - authorization can no longer be re-issued silently. - [rboixaderg] 7.1.2 (2026-05-22) diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index 3de31955e..8865b205d 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -32,7 +32,7 @@ "revoke_rate_limit": 120, "revoke_rate_window": 60, }, - "check_writable_request": "guillotina.contrib.oauth.api.request.check_writable_request", + "check_writable_request": "guillotina.contrib.oauth.utils.writable.requires_writable_transaction", "auth_token_validators": [ "guillotina.contrib.oauth.auth.validators.OAuthJWTValidator", "guillotina.auth.validators.SaltedHashPasswordValidator", diff --git a/guillotina/contrib/oauth/api/__init__.py b/guillotina/contrib/oauth/api/__init__.py index e69de29bb..dbfe73f87 100644 --- a/guillotina/contrib/oauth/api/__init__.py +++ b/guillotina/contrib/oauth/api/__init__.py @@ -0,0 +1,4 @@ +from guillotina.contrib.oauth.api.endpoints.common import OAuthService, token_response + + +__all__ = ["OAuthService", "token_response"] diff --git a/guillotina/contrib/oauth/api/endpoints/authorize.py b/guillotina/contrib/oauth/api/endpoints/authorize.py index 826a54fb5..3c489cd96 100644 --- a/guillotina/contrib/oauth/api/endpoints/authorize.py +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -1,31 +1,33 @@ from guillotina import app_settings -from guillotina.contrib.oauth.api.endpoints.common import AUTHORIZE_SINGLETON_PARAMS, authenticate_basic -from guillotina.contrib.oauth.api.request import ( - client_identifier, - normalize_list, - params_preserving_repeated, - parse_form_encoded, - reject_duplicate_params, -) -from guillotina.contrib.oauth.api.urls import container_url, validate_resource -from guillotina.contrib.oauth.api.views import consent_form, login_form, oauth_error_page +from guillotina.auth import authenticate_user +from guillotina.contrib.oauth.api.endpoints.common import AUTHORIZATION_REQUEST_SINGLETON_PARAMS +from guillotina.contrib.oauth.api.pages import consent_form, login_form, oauth_error_page +from guillotina.contrib.oauth.auth.helpers import authenticate_user_credentials, current_user_or_none from guillotina.contrib.oauth.flow.clients import ( - consent_key, redirect_uri_registered_for_client, redirect_with_params, scopes_registered_for_client, ) +from guillotina.contrib.oauth.flow.consent import build_consent_key from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded +from guillotina.contrib.oauth.flow.resources import validate_resource from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported -from guillotina.contrib.oauth.flow.tokens import opaque_token +from guillotina.contrib.oauth.flow.tokens import generate_opaque_token +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded +from guillotina.contrib.oauth.utils.request import ( + normalize_list, + params_preserving_repeated, + parse_form_encoded, + peer_ip_address, + reject_duplicate_params, +) +from guillotina.contrib.oauth.utils.urls import container_issuer_url from guillotina.response import HTTPBadRequest, HTTPFound -from guillotina.utils import get_authenticated_user -async def authorize(service, store): - params, error = await _collect_params(service) +async def authorization_endpoint(service, store): + params, error = await _collect_authorization_params(service) if error is not None: return error service.request.oauth_request_params = params @@ -33,6 +35,7 @@ async def authorize(service, store): client = await store.get_client(params.get("client_id")) if client is None: return oauth_error_page("Unknown OAuth client", "The application is not registered.", status=400) + redirect_uri = params.get("redirect_uri") if not redirect_uri_registered_for_client(client, redirect_uri): return oauth_error_page( @@ -43,52 +46,46 @@ async def authorize(service, store): # Mix-up defense (RFC 9207): include the issuer identifier in every # authorization response so the client can verify which AS responded. - issuer = container_url(service.request, service.context) + issuer = container_issuer_url(service.request, service.context) - def authz_redirect(extra): - payload = {"state": params.get("state"), "iss": issuer} - payload.update(extra) - return HTTPFound(redirect_with_params(redirect_uri, payload)) + authorization_error = _validate_authorization_request(params, client) + if authorization_error is not None: + return _authorization_redirect(redirect_uri, params, issuer, {"error": authorization_error}) - request_error = _validate_request(params, client) - if request_error is not None: - return authz_redirect({"error": request_error}) try: resources = validate_resource(service.request, service.context, params.get("resource")) except HTTPBadRequest: - return authz_redirect({"error": "invalid_target"}) + return _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_target"}) + scopes = normalize_list(params.get("scope")) - user, new_token, authenticated_now, early_response = await _ensure_authenticated(service, params, client) - if early_response is not None: - return early_response + auth_result = await _authenticate_user_or_present_login(service, params, client) + if auth_result.early_response is not None: + return auth_result.early_response - response_obj = await _issue_or_consent( + response_obj = await _grant_or_request_consent( service, store, params=params, client=client, - user=user, + user=auth_result.user, scopes=scopes, resources=resources, redirect_uri=redirect_uri, - authz_redirect=authz_redirect, - authenticated_now=authenticated_now, + issuer=issuer, + authenticated_now=auth_result.authenticated_now, ) - if new_token is not None: - secure = "" - if str(getattr(service.request, "scheme", "") or "").lower() == "https": - secure = "; Secure" - response_obj.headers["Set-Cookie"] = f"auth_token={new_token}; Path=/; HttpOnly; SameSite=Lax{secure}" + if auth_result.session_token is not None: + _set_session_cookie(response_obj, auth_result.session_token, service.request) return response_obj -async def _collect_params(service): +async def _collect_authorization_params(service): """Merge query and (for POST) body params; returns ``(params, error)``.""" params = params_preserving_repeated(service.request.query) try: - reject_duplicate_params(service.request.query, AUTHORIZE_SINGLETON_PARAMS) + reject_duplicate_params(service.request.query, AUTHORIZATION_REQUEST_SINGLETON_PARAMS) except HTTPBadRequest as exc: return None, exc if service.request.method == "POST": @@ -98,7 +95,7 @@ async def _collect_params(service): else: try: data = parse_form_encoded( - await service.request.text(), singleton_fields=AUTHORIZE_SINGLETON_PARAMS + await service.request.text(), singleton_fields=AUTHORIZATION_REQUEST_SINGLETON_PARAMS ) except HTTPBadRequest as exc: return None, exc @@ -106,12 +103,13 @@ async def _collect_params(service): return params, None -def _validate_request(params, client): +def _validate_authorization_request(params, client): """Validate response_type, PKCE and scope. Returns an OAuth error code or None.""" if params.get("response_type") != "code": return "unsupported_response_type" if "code" not in set(client.get("response_types") or []): return "unauthorized_client" + allowed_methods = app_settings.get("oauth", {}).get("allowed_code_challenge_methods", ["S256"]) code_challenge = params.get("code_challenge") if not code_challenge: @@ -120,6 +118,7 @@ def _validate_request(params, client): return "invalid_request" if params.get("code_challenge_method") not in allowed_methods: return "invalid_request" + scopes = normalize_list(params.get("scope")) supported_scopes = set(oauth_scopes_supported()) if ( @@ -132,54 +131,62 @@ def _validate_request(params, client): return None -async def _ensure_authenticated(service, params, client): - """Resolve the end user, logging in via the form if needed. +class _AuthenticationResult: + __slots__ = ("user", "session_token", "authenticated_now", "early_response") + + def __init__(self, *, user=None, session_token=None, authenticated_now=False, early_response=None): + self.user = user + self.session_token = session_token + self.authenticated_now = authenticated_now + self.early_response = early_response + + +async def _authenticate_user_or_present_login(service, params, client): + """Resolve the end user, logging in via the form if needed.""" + user = current_user_or_none() + if user is not None: + return _AuthenticationResult(user=user) - Returns ``(user, new_token, authenticated_now, early_response)``. When - ``early_response`` is not None the caller must return it immediately (login - form, rate-limit page or failed-login page). - """ - user = get_authenticated_user() - if user is not None and getattr(user, "id", "Anonymous User") != "Anonymous User": - return user, None, False, None if not (service.request.method == "POST" and params.get("username")): - return None, None, False, login_form(params, client) + return _AuthenticationResult(early_response=login_form(params, client)) oauth_settings = app_settings.get("oauth", {}) login_limit = oauth_settings.get("login_rate_limit", 10) login_window = oauth_settings.get("login_rate_window", 300) - login_key = f"oauth-login:{client_identifier(service.request)}:{params.get('username')}" + login_key = f"oauth-login:{peer_ip_address(service.request)}:{params.get('username')}" + if await rate_limit_check(login_key, limit=login_limit, window=login_window): - return ( - None, - None, - False, - oauth_error_page( + return _AuthenticationResult( + early_response=oauth_error_page( "Too many attempts", "Too many failed login attempts. Please wait and try again.", status=429, - ), + ) ) - user = await authenticate_basic(params.get("username"), params.get("password", "")) + + user = await authenticate_user_credentials(params.get("username"), params.get("password", "")) if user is None: await rate_limit_exceeded(login_key, limit=login_limit, window=login_window) - return ( - None, - None, - False, - oauth_error_page( + return _AuthenticationResult( + early_response=oauth_error_page( "Login failed", "The username or password could not be verified.", status=401, - ), + ) ) - from guillotina.auth import authenticate_user - new_token, _ = authenticate_user(user.id) - return user, new_token, True, None + session_token, _ = authenticate_user(user.id) + return _AuthenticationResult(user=user, session_token=session_token, authenticated_now=True) + + +def _authorization_redirect(redirect_uri, params, issuer, extra): + """Build an authorization response redirect (RFC 9207 ``iss`` included).""" + payload = {"state": params.get("state"), "iss": issuer} + payload.update(extra) + return HTTPFound(redirect_with_params(redirect_uri, payload)) -async def _issue_or_consent( +async def _grant_or_request_consent( service, store, *, @@ -189,33 +196,37 @@ async def _issue_or_consent( scopes, resources, redirect_uri, - authz_redirect, + issuer, authenticated_now, ): - """Handle the consent decision and, when granted, issue the auth code.""" - ckey = consent_key(user.id, client["client_id"], scopes, resources) - existing_consent = await store.has_consent(ckey) + """Handle the consent decision and, when granted, issue the authorization code.""" + consent_key = build_consent_key(user.id, client["client_id"], scopes, resources) + existing_consent = await store.has_consent(consent_key) + # A freshly logged-in request never carries a consent decision: the user # only submitted credentials, so always render the consent screen next. decision = params.get("decision") if service.request.method == "POST" and not authenticated_now else None + if decision in ("allow", "deny") and not csrf_valid( params.get(OAUTH_CSRF_FIELD), params, user.id, scopes, resources ): - return authz_redirect({"error": "invalid_request"}) + return _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_request"}) + if not existing_consent and decision != "allow": if decision == "deny": - return authz_redirect({"error": "access_denied"}) + return _authorization_redirect(redirect_uri, params, issuer, {"error": "access_denied"}) return consent_form(params, client, scopes, resources, user) if not existing_consent: await store.create_consent( - ckey, + consent_key, user_id=user.id, client_id=client["client_id"], scope=scopes, resource=resources, ) - raw_code = opaque_token("goc_") + + raw_code = generate_opaque_token("goc_") await store.create_code( raw_code=raw_code, client_id=client["client_id"], @@ -225,4 +236,11 @@ async def _issue_or_consent( resource=resources, code_challenge=params.get("code_challenge"), ) - return authz_redirect({"code": raw_code}) + return _authorization_redirect(redirect_uri, params, issuer, {"code": raw_code}) + + +def _set_session_cookie(response, session_token, request): + secure = "" + if str(getattr(request, "scheme", "") or "").lower() == "https": + secure = "; Secure" + response.headers["Set-Cookie"] = f"auth_token={session_token}; Path=/; HttpOnly; SameSite=Lax{secure}" diff --git a/guillotina/contrib/oauth/api/endpoints/common.py b/guillotina/contrib/oauth/api/endpoints/common.py index accadd77f..f80020fcb 100644 --- a/guillotina/contrib/oauth/api/endpoints/common.py +++ b/guillotina/contrib/oauth/api/endpoints/common.py @@ -1,14 +1,11 @@ -from guillotina import app_settings from guillotina.api.service import Service -from guillotina.auth.utils import set_authenticated_user -from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD from guillotina.contrib.oauth.storage.access import get_oauth_store from guillotina.response import Response # Parameters that must appear at most once per request. Repeated occurrences are # rejected to avoid OAuth parameter-pollution attacks. -AUTHORIZE_SINGLETON_PARAMS = { +AUTHORIZATION_REQUEST_SINGLETON_PARAMS = { "response_type", "client_id", "redirect_uri", @@ -19,9 +16,9 @@ "decision", "username", "password", - OAUTH_CSRF_FIELD, + "oauth_csrf", } -TOKEN_SINGLETON_PARAMS = { +TOKEN_REQUEST_SINGLETON_PARAMS = { "grant_type", "client_id", "redirect_uri", @@ -30,8 +27,8 @@ "refresh_token", "scope", } -REVOKE_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} -CONSENT_SINGLETON_PARAMS = {"consent_key", "client_id"} +REVOCATION_REQUEST_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} +CONSENT_REQUEST_SINGLETON_PARAMS = {"consent_key", "client_id"} class OAuthService(Service): @@ -39,17 +36,6 @@ def oauth_store(self): return get_oauth_store(self.context) -async def authenticate_basic(username, password): - creds = {"type": "basic", "token": password, "id": username} - for validator in app_settings["auth_token_validators"]: - if validator.for_validators is not None and "basic" not in validator.for_validators: - continue - user = await validator().validate(creds) - if user is not None: - set_authenticated_user(user) - return user - - def token_response(content): return Response( content=content, diff --git a/guillotina/contrib/oauth/api/endpoints/consents.py b/guillotina/contrib/oauth/api/endpoints/consents.py index 28614b038..fad3765bc 100644 --- a/guillotina/contrib/oauth/api/endpoints/consents.py +++ b/guillotina/contrib/oauth/api/endpoints/consents.py @@ -1,20 +1,14 @@ -from guillotina.contrib.oauth.api.endpoints.common import CONSENT_SINGLETON_PARAMS -from guillotina.contrib.oauth.api.request import form_content_type_valid, parse_form_encoded +from guillotina.contrib.oauth.api.endpoints.common import CONSENT_REQUEST_SINGLETON_PARAMS +from guillotina.contrib.oauth.auth.helpers import current_user_or_none +from guillotina.contrib.oauth.utils.request import form_content_type_valid, parse_form_encoded from guillotina.response import HTTPBadRequest, HTTPNotFound, HTTPUnauthorized, Response -from guillotina.utils import get_authenticated_user -def _authenticated_user(): - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - return None - return user - - -async def list_consents(service, store): - user = _authenticated_user() +async def list_consents_endpoint(service, store): + user = current_user_or_none() if user is None: return HTTPUnauthorized(content={"error": "invalid_token"}) + consents = await store.list_consents(user.id) clients = {} items = [] @@ -37,28 +31,34 @@ async def list_consents(service, store): return Response(content={"consents": items}, headers={"Cache-Control": "no-store"}) -async def revoke_consent(service, store): - user = _authenticated_user() +async def revoke_consent_endpoint(service, store): + user = current_user_or_none() if user is None: return HTTPUnauthorized(content={"error": "invalid_token"}) + if not form_content_type_valid(service.request): return HTTPBadRequest( content={"error": "invalid_request", "error_description": "invalid content type"} ) try: - data = parse_form_encoded(await service.request.text(), singleton_fields=CONSENT_SINGLETON_PARAMS) + data = parse_form_encoded( + await service.request.text(), singleton_fields=CONSENT_REQUEST_SINGLETON_PARAMS + ) except HTTPBadRequest as exc: return exc - ckey = data.get("consent_key") - if not ckey: + + consent_key = data.get("consent_key") + if not consent_key: return HTTPBadRequest( content={"error": "invalid_request", "error_description": "consent_key is required"} ) + consents = {c["consent_key"]: c for c in await store.list_consents(user.id)} - consent = consents.get(ckey) + consent = consents.get(consent_key) if consent is None: return HTTPNotFound(content={"error": "not_found", "error_description": "unknown consent"}) - await store.delete_consent(ckey, user_id=user.id) + + await store.delete_consent(consent_key, user_id=user.id) # Complete deauthorization: revoke every refresh token this user holds for # the client so revoking consent also kills active sessions. await store.revoke_user_client_refresh_tokens(user_id=user.id, client_id=consent["client_id"]) diff --git a/guillotina/contrib/oauth/api/endpoints/register.py b/guillotina/contrib/oauth/api/endpoints/register.py index 5bb136cde..92d0b8ab4 100644 --- a/guillotina/contrib/oauth/api/endpoints/register.py +++ b/guillotina/contrib/oauth/api/endpoints/register.py @@ -1,14 +1,14 @@ from guillotina import app_settings -from guillotina.contrib.oauth.api.request import client_identifier -from guillotina.contrib.oauth.flow.clients import make_client -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.flow.clients import build_client_from_registration +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.utils.request import peer_ip_address from guillotina.response import HTTPBadRequest, HTTPTooManyRequests, Response -async def register(service, store): +async def client_registration_endpoint(service, store): oauth_settings = app_settings.get("oauth", {}) if await rate_limit_exceeded( - f"oauth-register:{client_identifier(service.request)}", + f"oauth-register:{peer_ip_address(service.request)}", limit=oauth_settings.get("registration_rate_limit", 20), window=oauth_settings.get("registration_rate_window", 600), ): @@ -25,7 +25,7 @@ async def register(service, store): ) data = await service.request.json() try: - client = make_client(data) + client = build_client_from_registration(data) except HTTPBadRequest as exc: return exc await store.create_client(client) diff --git a/guillotina/contrib/oauth/api/endpoints/revoke.py b/guillotina/contrib/oauth/api/endpoints/revoke.py index 77a494204..79f4e38ed 100644 --- a/guillotina/contrib/oauth/api/endpoints/revoke.py +++ b/guillotina/contrib/oauth/api/endpoints/revoke.py @@ -1,31 +1,35 @@ from guillotina import app_settings -from guillotina.contrib.oauth.api.endpoints.common import REVOKE_SINGLETON_PARAMS -from guillotina.contrib.oauth.api.request import ( - client_identifier, +from guillotina.contrib.oauth.api.endpoints.common import REVOCATION_REQUEST_SINGLETON_PARAMS +from guillotina.contrib.oauth.flow.consent import build_consent_key +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.utils.request import ( form_content_type_valid, parse_form_encoded, + peer_ip_address, ) -from guillotina.contrib.oauth.flow.clients import consent_key -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded from guillotina.response import HTTPBadRequest, HTTPTooManyRequests -async def revoke(service, store): +async def token_revocation_endpoint(service, store): if not form_content_type_valid(service.request): return HTTPBadRequest( content={"error": "invalid_request", "error_description": "invalid content type"} ) try: - data = parse_form_encoded(await service.request.text(), singleton_fields=REVOKE_SINGLETON_PARAMS) + data = parse_form_encoded( + await service.request.text(), singleton_fields=REVOCATION_REQUEST_SINGLETON_PARAMS + ) except HTTPBadRequest as exc: return exc + if not data.get("client_id") or not data.get("token"): return HTTPBadRequest(content={"error": "invalid_request"}) if data.get("token_type_hint") == "access_token": return HTTPBadRequest(content={"error": "unsupported_token_type"}) + oauth_settings = app_settings.get("oauth", {}) if await rate_limit_exceeded( - f"oauth-revoke:{client_identifier(service.request)}", + f"oauth-revoke:{peer_ip_address(service.request)}", limit=oauth_settings.get("revoke_rate_limit", 120), window=oauth_settings.get("revoke_rate_window", 60), ): @@ -35,6 +39,7 @@ async def revoke(service, store): "error_description": "revocation rate limit exceeded", } ) + record = await store.get_refresh_token(data.get("token", "")) if record is not None and record.get("client_id") == data.get("client_id"): await store.revoke_refresh_family( @@ -45,7 +50,7 @@ async def revoke(service, store): # Drop the remembered consent so the grant cannot be silently re-issued # after the user revoked their tokens (RFC 9700 deauthorization hygiene). await store.delete_consent( - consent_key( + build_consent_key( record["user_id"], record["client_id"], record.get("scope") or [], diff --git a/guillotina/contrib/oauth/api/endpoints/token.py b/guillotina/contrib/oauth/api/endpoints/token.py index efc96d7a7..afde1c26a 100644 --- a/guillotina/contrib/oauth/api/endpoints/token.py +++ b/guillotina/contrib/oauth/api/endpoints/token.py @@ -1,53 +1,61 @@ from guillotina import app_settings -from guillotina.contrib.oauth.api.endpoints.common import TOKEN_SINGLETON_PARAMS, token_response -from guillotina.contrib.oauth.api.request import ( - client_identifier, +from guillotina.contrib.oauth.api.endpoints.common import TOKEN_REQUEST_SINGLETON_PARAMS, token_response +from guillotina.contrib.oauth.flow.pkce import verify_s256 +from guillotina.contrib.oauth.flow.tokens import generate_opaque_token, issue_access_token +from guillotina.contrib.oauth.utils.crypto import token_hash +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_exceeded +from guillotina.contrib.oauth.utils.request import ( form_content_type_valid, normalize_list, parse_form_encoded, + peer_ip_address, ) -from guillotina.contrib.oauth.api.urls import container_url -from guillotina.contrib.oauth.flow.pkce import verify_s256 -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_exceeded -from guillotina.contrib.oauth.flow.tokens import issue_access_token, opaque_token, token_hash +from guillotina.contrib.oauth.utils.urls import container_issuer_url from guillotina.response import HTTPBadRequest, HTTPTooManyRequests -async def token(service, store): +async def token_endpoint(service, store): if not form_content_type_valid(service.request): return HTTPBadRequest( content={"error": "invalid_request", "error_description": "invalid content type"} ) try: - data = parse_form_encoded(await service.request.text(), singleton_fields=TOKEN_SINGLETON_PARAMS) + data = parse_form_encoded( + await service.request.text(), singleton_fields=TOKEN_REQUEST_SINGLETON_PARAMS + ) except HTTPBadRequest as exc: return exc + grant_type = data.get("grant_type") if not grant_type: return HTTPBadRequest(content={"error": "invalid_request"}) + oauth_settings = app_settings.get("oauth", {}) if await rate_limit_exceeded( - f"oauth-token:{client_identifier(service.request)}", + f"oauth-token:{peer_ip_address(service.request)}", limit=oauth_settings.get("token_rate_limit", 120), window=oauth_settings.get("token_rate_window", 60), ): return HTTPTooManyRequests( content={"error": "temporarily_unavailable", "error_description": "token rate limit exceeded"} ) + if grant_type == "authorization_code": - return await _authorization_code(service, store, data) + return await _exchange_authorization_code(service, store, data) if grant_type == "refresh_token": - return await _refresh_token(service, store, data) + return await _rotate_refresh_token(service, store, data) return HTTPBadRequest(content={"error": "unsupported_grant_type"}) -async def _authorization_code(service, store, data): +async def _exchange_authorization_code(service, store, data): if not data.get("client_id") or not data.get("code") or not data.get("redirect_uri"): return HTTPBadRequest(content={"error": "invalid_request"}) + client = await store.get_client(data.get("client_id")) code_raw = data.get("code", "") code_hash_val = token_hash(code_raw) record = await store.get_active_code(code_raw) + if record is None: await store.revoke_refresh_tokens_by_auth_code(code_hash_val) return HTTPBadRequest(content={"error": "invalid_grant"}) @@ -57,12 +65,14 @@ async def _authorization_code(service, store, data): return HTTPBadRequest(content={"error": "unauthorized_client"}) if record["redirect_uri"] != data.get("redirect_uri"): return HTTPBadRequest(content={"error": "invalid_grant"}) + if record.get("code_challenge"): if not verify_s256(data.get("code_verifier", ""), record["code_challenge"]): return HTTPBadRequest(content={"error": "invalid_grant"}) else: # PKCE is mandatory for public clients. A code without a bound challenge is invalid. return HTTPBadRequest(content={"error": "invalid_grant"}) + requested_resources = normalize_list(data.get("resource")) if requested_resources and not set(requested_resources).issubset(set(record["resource"])): return HTTPBadRequest(content={"error": "invalid_target"}) @@ -74,13 +84,13 @@ async def _authorization_code(service, store, data): record = consumed access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), + issuer=container_issuer_url(service.request, service.context), subject=record["user_id"], audience=resources, client_id=client["client_id"], scope=record["scope"], ) - refresh_token = opaque_token("gor_") + refresh_token = generate_opaque_token("gor_") await store.create_refresh_token( raw_token=refresh_token, client_id=client["client_id"], @@ -100,30 +110,34 @@ async def _authorization_code(service, store, data): ) -async def _refresh_token(service, store, data): +async def _rotate_refresh_token(service, store, data): if not data.get("client_id") or not data.get("refresh_token"): return HTTPBadRequest(content={"error": "invalid_request"}) + refresh_raw = data.get("refresh_token", "") client = await store.get_client(data.get("client_id")) record = await store.get_valid_refresh(refresh_raw) + if record is None: - cand = await store.get_refresh_token(refresh_raw) - if cand is not None and cand.get("revoked_at"): - await store.revoke_refresh_family_for_reuse( - client_id=cand["client_id"], - user_id=cand["user_id"], - auth_code_hash=cand.get("auth_code_hash"), + candidate = await store.get_refresh_token(refresh_raw) + if candidate is not None and candidate.get("revoked_at"): + await store.revoke_refresh_family( + client_id=candidate["client_id"], + user_id=candidate["user_id"], + auth_code_hash=candidate.get("auth_code_hash"), ) return HTTPBadRequest(content={"error": "invalid_grant"}) if client is None or record["client_id"] != client["client_id"]: return HTTPBadRequest(content={"error": "invalid_grant"}) if "refresh_token" not in set(client.get("grant_types") or []): return HTTPBadRequest(content={"error": "unauthorized_client"}) + scopes = normalize_list(data.get("scope")) or record["scope"] resources = normalize_list(data.get("resource")) or record["resource"] if not set(scopes).issubset(set(record["scope"])) or not set(resources).issubset(set(record["resource"])): return HTTPBadRequest(content={"error": "invalid_scope"}) - new_refresh = opaque_token("gor_") + + new_refresh = generate_opaque_token("gor_") rotated = await store.rotate_refresh_token( old_refresh_raw=refresh_raw, new_refresh_raw=new_refresh, @@ -133,8 +147,9 @@ async def _refresh_token(service, store, data): ) if not rotated: return HTTPBadRequest(content={"error": "invalid_grant"}) + access_token, _claims = issue_access_token( - issuer=container_url(service.request, service.context), + issuer=container_issuer_url(service.request, service.context), subject=record["user_id"], audience=resources, client_id=client["client_id"], diff --git a/guillotina/contrib/oauth/api/views.py b/guillotina/contrib/oauth/api/pages.py similarity index 87% rename from guillotina/contrib/oauth/api/views.py rename to guillotina/contrib/oauth/api/pages.py index 1cbc3f32e..b69566419 100644 --- a/guillotina/contrib/oauth/api/views.py +++ b/guillotina/contrib/oauth/api/pages.py @@ -13,7 +13,7 @@ BRAND_LOGO_PATH = Path(__file__).parents[3] / "static" / "assets" / "brand" / "guillotina-logo-horizontal.svg" -def _html(body, status=200): +def _html_response(body, status=200): return Response( body=body.encode("utf-8"), status=status, @@ -26,32 +26,32 @@ def _html(body, status=200): @lru_cache(maxsize=None) -def _template(name): +def _load_template(name): return Template((TEMPLATE_DIR / name).read_text(encoding="utf-8")) @lru_cache(maxsize=None) -def _template_text(name): +def _load_template_text(name): return (TEMPLATE_DIR / name).read_text(encoding="utf-8") @lru_cache(maxsize=None) -def _logo_data_uri(): +def _load_logo_data_uri(): encoded = b64encode(BRAND_LOGO_PATH.read_bytes()).decode("ascii") return f"data:image/svg+xml;base64,{encoded}" def _render_template(template_name, **context): - return _template(template_name).substitute(context) + return _load_template(template_name).substitute(context) -def _oauth_page(title, heading, body, *, status=200, tone="default"): - return _html( +def _render_oauth_page(title, heading, body, *, status=200, tone="default"): + return _html_response( _render_template( "base.html", title=html_escape(title), - logo_src=_logo_data_uri(), - style=_template_text("oauth.css"), + logo_src=_load_logo_data_uri(), + style=_load_template_text("oauth.css"), tone=html_escape(tone), heading=html_escape(heading), body=body, @@ -90,7 +90,7 @@ def _hidden_inputs(params): def oauth_error_page(title, message, *, status): - return _oauth_page( + return _render_oauth_page( title, title, _render_template("error.html", message=html_escape(message)), @@ -108,7 +108,7 @@ def login_form(params, client): redirect_uri=html_escape(params.get("redirect_uri", "")), hidden_inputs=_hidden_inputs(params), ) - return _oauth_page("Login to Guillotina", "Login required", body) + return _render_oauth_page("Login to Guillotina", "Login required", body) def _list_items(values, *, empty): @@ -147,4 +147,4 @@ def consent_form(params, client, scopes, resources, user): resource_items=_list_items(resources, empty="Default Guillotina container"), hidden_inputs=_hidden_inputs(consent_params), ) - return _oauth_page("Authorize OAuth Client", f"Allow {raw_client_name}?", body) + return _render_oauth_page("Authorize OAuth Client", f"Allow {raw_client_name}?", body) diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 70464d4da..1abb7a47b 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -1,27 +1,27 @@ from guillotina import configure from guillotina.api.service import Service -from guillotina.contrib.oauth.api.endpoints.authorize import authorize +from guillotina.contrib.oauth.api.endpoints.authorize import authorization_endpoint from guillotina.contrib.oauth.api.endpoints.common import OAuthService -from guillotina.contrib.oauth.api.endpoints.consents import list_consents, revoke_consent -from guillotina.contrib.oauth.api.endpoints.register import register -from guillotina.contrib.oauth.api.endpoints.revoke import revoke -from guillotina.contrib.oauth.api.endpoints.token import token -from guillotina.contrib.oauth.api.well_known import WELL_KNOWN_HANDLERS, rfc_well_known_response +from guillotina.contrib.oauth.api.endpoints.consents import list_consents_endpoint, revoke_consent_endpoint +from guillotina.contrib.oauth.api.endpoints.register import client_registration_endpoint +from guillotina.contrib.oauth.api.endpoints.revoke import token_revocation_endpoint +from guillotina.contrib.oauth.api.endpoints.token import token_endpoint +from guillotina.contrib.oauth.api.well_known import WELL_KNOWN_HANDLERS, serve_well_known_metadata from guillotina.interfaces import IApplication, IContainer from guillotina.response import HTTPNotFound # Dispatch tables mapping the ``oauth/{action}`` matchdict to its handler. OAUTH_GET_ACTIONS = { - "authorize": authorize, - "consents": list_consents, + "authorize": authorization_endpoint, + "consents": list_consents_endpoint, } OAUTH_POST_ACTIONS = { - "register": register, - "authorize": authorize, - "token": token, - "revoke": revoke, - "consents": revoke_consent, + "register": client_registration_endpoint, + "authorize": authorization_endpoint, + "token": token_endpoint, + "revoke": token_revocation_endpoint, + "consents": revoke_consent_endpoint, } @@ -32,7 +32,7 @@ name=".well-known/{action}", allow_access=True, ) -class OAuthWellKnown(OAuthService): +class ContainerOAuthWellKnownService(OAuthService): async def __call__(self): self.oauth_store() action = self.request.matchdict.get("action", "") @@ -48,14 +48,14 @@ async def __call__(self): name=".well-known/{action}/{target_path:path}", allow_access=True, ) -class OAuthRFCWellKnown(Service): +class ApplicationOAuthWellKnownService(Service): async def __call__(self): action = self.request.matchdict.get("action", "") if action not in WELL_KNOWN_HANDLERS: return HTTPNotFound(content={"reason": f"Unknown well-known endpoint: {action}"}) target_path = self.request.matchdict.get("target_path", "") try: - return await rfc_well_known_response(self.request, action, target_path, WELL_KNOWN_HANDLERS) + return await serve_well_known_metadata(self.request, action, target_path, WELL_KNOWN_HANDLERS) except HTTPNotFound as exc: return exc @@ -67,7 +67,7 @@ async def __call__(self): name="oauth/{action}", allow_access=True, ) -class OAuthGet(OAuthService): +class ContainerOAuthGetService(OAuthService): async def __call__(self): action = self.request.matchdict.get("action", "") handler = OAUTH_GET_ACTIONS.get(action) @@ -83,7 +83,7 @@ async def __call__(self): name="oauth/{action}", allow_access=True, ) -class OAuthPost(OAuthService): +class ContainerOAuthPostService(OAuthService): async def __call__(self): action = self.request.matchdict.get("action", "") handler = OAUTH_POST_ACTIONS.get(action) diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py index df223b3e4..6a7524933 100644 --- a/guillotina/contrib/oauth/api/well_known.py +++ b/guillotina/contrib/oauth/api/well_known.py @@ -1,7 +1,7 @@ from guillotina import task_vars -from guillotina.contrib.oauth.api.urls import container_url from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.contrib.oauth.utils.urls import container_issuer_url from guillotina.interfaces import IContainer from guillotina.response import HTTPNotFound from guillotina.transactions import transaction @@ -27,7 +27,7 @@ def register_protected_resource_provider(provider): def _authorization_server_metadata(request, container): - issuer = container_url(request, container) + issuer = container_issuer_url(request, container) return { "issuer": issuer, "authorization_endpoint": f"{issuer}/oauth/authorize", @@ -60,7 +60,7 @@ def _protected_resource_metadata(request, container): register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) -def _container_path_parts(path_value, *, allow_resource_path=False): +def _split_well_known_target_path(path_value, *, allow_resource_path=False): parts = [part for part in path_value.strip("/").split("/") if part] if len(parts) < 2: raise HTTPNotFound(content={"reason": "Invalid path"}) @@ -69,9 +69,9 @@ def _container_path_parts(path_value, *, allow_resource_path=False): return parts[0], parts[1], "/" + "/".join(parts) -async def rfc_well_known_response(request, action, target_path, handlers): +async def serve_well_known_metadata(request, action, target_path, handlers): allow_resource_path = action == "oauth-protected-resource" - db_id, container_id, protected_resource_path = _container_path_parts( + db_id, container_id, protected_resource_path = _split_well_known_target_path( target_path, allow_resource_path=allow_resource_path ) db = await get_database(db_id) diff --git a/guillotina/contrib/oauth/auth/__init__.py b/guillotina/contrib/oauth/auth/__init__.py index e69de29bb..745aca3cc 100644 --- a/guillotina/contrib/oauth/auth/__init__.py +++ b/guillotina/contrib/oauth/auth/__init__.py @@ -0,0 +1,5 @@ +from guillotina.contrib.oauth.auth.helpers import authenticate_user_credentials +from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator + + +__all__ = ["OAuthJWTValidator", "authenticate_user_credentials"] diff --git a/guillotina/contrib/oauth/auth/helpers.py b/guillotina/contrib/oauth/auth/helpers.py new file mode 100644 index 000000000..1aeedaaf7 --- /dev/null +++ b/guillotina/contrib/oauth/auth/helpers.py @@ -0,0 +1,23 @@ +from guillotina import app_settings +from guillotina.auth.utils import set_authenticated_user +from guillotina.utils import get_authenticated_user + + +async def authenticate_user_credentials(username, password): + """Validate username/password credentials through the configured validators.""" + creds = {"type": "basic", "token": password, "id": username} + for validator in app_settings["auth_token_validators"]: + if validator.for_validators is not None and "basic" not in validator.for_validators: + continue + user = await validator().validate(creds) + if user is not None: + set_authenticated_user(user) + return user + + +def current_user_or_none(): + """Return the authenticated user, or None when the request is anonymous.""" + user = get_authenticated_user() + if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + return None + return user diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py index 5393a73a5..086890fba 100644 --- a/guillotina/contrib/oauth/auth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -2,10 +2,10 @@ from guillotina import app_settings, task_vars from guillotina.auth import find_user -from guillotina.contrib.oauth.api.urls import container_url from guillotina.contrib.oauth.flow.resources import oauth_required_audience from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE -from guillotina.contrib.oauth.flow.tokens import access_token_key +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key +from guillotina.contrib.oauth.utils.urls import container_issuer_url class OAuthJWTValidator: @@ -20,7 +20,7 @@ async def validate(self, token): try: claims = jwt.decode( raw, - access_token_key(), + access_token_signing_key(), algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) @@ -31,7 +31,7 @@ async def validate(self, token): request = task_vars.request.get(None) container = task_vars.container.get(None) if request is not None and container is not None: - issuer = container_url(request, container) + issuer = container_issuer_url(request, container) if claims.get("iss") != issuer: return aud = set(claims.get("aud") or []) diff --git a/guillotina/contrib/oauth/flow/__init__.py b/guillotina/contrib/oauth/flow/__init__.py index e69de29bb..aa957f329 100644 --- a/guillotina/contrib/oauth/flow/__init__.py +++ b/guillotina/contrib/oauth/flow/__init__.py @@ -0,0 +1,29 @@ +from guillotina.contrib.oauth.flow.clients import ( + build_client_from_registration, + redirect_uri_registered_for_client, +) +from guillotina.contrib.oauth.flow.consent import build_consent_key +from guillotina.contrib.oauth.flow.resources import ( + ensure_default_oauth_resources_registered, + oauth_allowed_resources, + oauth_required_audience, + register_oauth_audience_resolver, + register_oauth_resource_resolver, + validate_resource, +) +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported + + +__all__ = [ + "build_client_from_registration", + "redirect_uri_registered_for_client", + "build_consent_key", + "ensure_default_oauth_resources_registered", + "oauth_allowed_resources", + "oauth_required_audience", + "register_oauth_audience_resolver", + "register_oauth_resource_resolver", + "validate_resource", + "OAUTH_DEFAULT_SCOPE", + "oauth_scopes_supported", +] diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index d995e4de1..e27a28ea4 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -1,9 +1,10 @@ from urllib.parse import urlencode, urlparse from uuid import uuid4 -from guillotina.contrib.oauth.api.request import normalize_list, oauth_error from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported -from guillotina.contrib.oauth.flow.tokens import timestamp, utcnow +from guillotina.contrib.oauth.utils.errors import raise_oauth_error +from guillotina.contrib.oauth.utils.request import normalize_list +from guillotina.contrib.oauth.utils.time import timestamp, utcnow SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} @@ -67,36 +68,36 @@ def redirect_uri_registered_for_client(client, redirect_uri): return False -def make_client(data): +def build_client_from_registration(data): if data.get("client_id"): - oauth_error("invalid_request", "client_id is server-issued") + raise_oauth_error("invalid_request", "client_id is server-issued") redirect_uris = data.get("redirect_uris") or [] if not redirect_uris or not isinstance(redirect_uris, list): - oauth_error("invalid_client_metadata", "redirect_uris is required") + raise_oauth_error("invalid_client_metadata", "redirect_uris is required") if any(not validate_redirect_uri(uri) for uri in redirect_uris): - oauth_error("invalid_redirect_uri", "unsafe redirect_uri") + raise_oauth_error("invalid_redirect_uri", "unsafe redirect_uri") method = data.get("token_endpoint_auth_method", "none") if method != "none": - oauth_error("unsupported_token_endpoint_auth_method") + raise_oauth_error("unsupported_token_endpoint_auth_method") grant_types = data["grant_types"] if "grant_types" in data else ["authorization_code", "refresh_token"] response_types = data["response_types"] if "response_types" in data else ["code"] if not isinstance(grant_types, list) or not grant_types: - oauth_error("invalid_client_metadata", "grant_types must be a non-empty array") + raise_oauth_error("invalid_client_metadata", "grant_types must be a non-empty array") if not isinstance(response_types, list) or not response_types: - oauth_error("invalid_client_metadata", "response_types must be a non-empty array") + raise_oauth_error("invalid_client_metadata", "response_types must be a non-empty array") if any(grant_type not in SUPPORTED_GRANT_TYPES for grant_type in grant_types): - oauth_error("invalid_client_metadata", "unsupported grant_type") + raise_oauth_error("invalid_client_metadata", "unsupported grant_type") if any(response_type not in SUPPORTED_RESPONSE_TYPES for response_type in response_types): - oauth_error("invalid_client_metadata", "unsupported response_type") + raise_oauth_error("invalid_client_metadata", "unsupported response_type") if "authorization_code" in grant_types and "code" not in response_types: - oauth_error("invalid_client_metadata", "authorization_code grant requires code response_type") + raise_oauth_error("invalid_client_metadata", "authorization_code grant requires code response_type") if "code" in response_types and "authorization_code" not in grant_types: - oauth_error("invalid_client_metadata", "code response_type requires authorization_code grant") + raise_oauth_error("invalid_client_metadata", "code response_type requires authorization_code grant") scope = normalize_list(data.get("scope")) or [OAUTH_DEFAULT_SCOPE] if OAUTH_DEFAULT_SCOPE not in scope: - oauth_error("invalid_client_metadata", f"{OAUTH_DEFAULT_SCOPE} scope is required") + raise_oauth_error("invalid_client_metadata", f"{OAUTH_DEFAULT_SCOPE} scope is required") if not set(scope).issubset(set(oauth_scopes_supported())): - oauth_error("invalid_client_metadata", "unsupported scope") + raise_oauth_error("invalid_client_metadata", "unsupported scope") now_dt = utcnow() now = now_dt.isoformat() return { @@ -118,10 +119,6 @@ def scopes_registered_for_client(client, scopes): return set(scopes).issubset(set(allowed)) -def consent_key(user_id, client_id, scopes, resources): - return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) - - def redirect_with_params(uri, params): sep = "&" if "?" in uri else "?" return f"{uri}{sep}{urlencode({k: v for k, v in params.items() if v is not None})}" diff --git a/guillotina/contrib/oauth/flow/consent.py b/guillotina/contrib/oauth/flow/consent.py new file mode 100644 index 000000000..730198424 --- /dev/null +++ b/guillotina/contrib/oauth/flow/consent.py @@ -0,0 +1,2 @@ +def build_consent_key(user_id, client_id, scopes, resources): + return "|".join([user_id, client_id, " ".join(sorted(scopes)), " ".join(sorted(resources))]) diff --git a/guillotina/contrib/oauth/flow/resources.py b/guillotina/contrib/oauth/flow/resources.py index 28dc460a8..511be759f 100644 --- a/guillotina/contrib/oauth/flow/resources.py +++ b/guillotina/contrib/oauth/flow/resources.py @@ -15,6 +15,8 @@ from typing import Callable, FrozenSet, Iterable, List, Optional +from guillotina.contrib.oauth.utils.urls import container_issuer_url + ResourceResolver = Callable[..., Iterable[str]] AudienceResolver = Callable[..., Optional[str]] @@ -35,9 +37,7 @@ def register_oauth_audience_resolver(resolver: AudienceResolver) -> None: def _default_container_resolver(request, container): - from guillotina.contrib.oauth.api.urls import container_url - - return {container_url(request, container)} + return {container_issuer_url(request, container)} def ensure_default_oauth_resources_registered() -> None: @@ -68,7 +68,6 @@ def oauth_allowed_resources(request, container) -> FrozenSet[str]: def oauth_required_audience(request, container) -> str: from guillotina import app_settings as _apps - from guillotina.contrib.oauth.api.urls import container_url applications = set(_apps.get("applications") or []) for resolver in _audience_resolvers: @@ -80,4 +79,19 @@ def oauth_required_audience(request, container) -> str: resource = resolver(request, container) if resource: return resource - return container_url(request, container) + return container_issuer_url(request, container) + + +def validate_resource(request, container, resources): + from guillotina.contrib.oauth.utils.errors import raise_oauth_error + from guillotina.contrib.oauth.utils.request import normalize_list + + base = container_issuer_url(request, container) + allowed = oauth_allowed_resources(request, container) + if not resources: + return [base] + resources = normalize_list(resources) + for resource in resources: + if resource not in allowed: + raise_oauth_error("invalid_target", "resource is not allowed") + return resources diff --git a/guillotina/contrib/oauth/flow/tokens.py b/guillotina/contrib/oauth/flow/tokens.py index 15ff05f1c..54003e668 100644 --- a/guillotina/contrib/oauth/flow/tokens.py +++ b/guillotina/contrib/oauth/flow/tokens.py @@ -1,37 +1,18 @@ -import calendar -import hashlib -import hmac import secrets -from datetime import datetime, timedelta +from datetime import timedelta import jwt from guillotina import app_settings -from guillotina.contrib.oauth.flow.keys import derive_key +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key +from guillotina.contrib.oauth.utils.time import timestamp, utcnow -def utcnow(): - return datetime.utcnow() - - -def timestamp(dt): - return int(calendar.timegm(dt.utctimetuple())) - - -def opaque_token(prefix=""): +def generate_opaque_token(prefix=""): value = secrets.token_urlsafe(48) return f"{prefix}{value}" if prefix else value -def token_hash(token: str) -> str: - key = derive_key("token-hash") - return hmac.new(key, token.encode("utf-8"), hashlib.sha256).hexdigest() - - -def access_token_key() -> bytes: - return derive_key("access-token") - - def issue_access_token(*, issuer, subject, audience, client_id, scope): now = utcnow() ttl = app_settings["oauth"].get("access_token_ttl", 3600) @@ -46,5 +27,5 @@ def issue_access_token(*, issuer, subject, audience, client_id, scope): "exp": timestamp(now + timedelta(seconds=ttl)), "token_type": "oauth_access_token", } - token = jwt.encode(claims, access_token_key(), algorithm=app_settings["jwt"]["algorithm"]) + token = jwt.encode(claims, access_token_signing_key(), algorithm=app_settings["jwt"]["algorithm"]) return token, claims diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py index 0d756f481..23d5081eb 100644 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ b/guillotina/contrib/oauth/integrations/mcp.py @@ -4,18 +4,18 @@ from guillotina import app_settings, configure from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy -from guillotina.contrib.oauth.api.request import normalize_list -from guillotina.contrib.oauth.api.urls import container_url, well_known_protected_resource_url from guillotina.contrib.oauth.api.well_known import register_protected_resource_provider from guillotina.contrib.oauth.flow.resources import ( register_oauth_audience_resolver, register_oauth_resource_resolver, ) from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported +from guillotina.contrib.oauth.utils.request import normalize_list +from guillotina.contrib.oauth.utils.urls import container_issuer_url, well_known_protected_resource_url def _mcp_resource_url_from_path(request, container, path): - issuer = urlparse(container_url(request, container)) + issuer = urlparse(container_issuer_url(request, container)) target_path = "/" + str(path or "").strip("/") container_path = issuer.path.rstrip("/") if not target_path.endswith("/@mcp/protocol"): @@ -26,7 +26,7 @@ def _mcp_resource_url_from_path(request, container, path): def _mcp_resource_url_from_value(request, container, value): - issuer = urlparse(container_url(request, container)) + issuer = urlparse(container_issuer_url(request, container)) parsed = urlparse(value) if parsed.scheme != issuer.scheme or parsed.netloc != issuer.netloc: return None @@ -46,11 +46,11 @@ def mcp_resource(request, container): resource = _mcp_resource_url_from_path(request, container, request_path) if resource: return resource - return f"{container_url(request, container)}/@mcp/protocol" + return f"{container_issuer_url(request, container)}/@mcp/protocol" def _mcp_protocol_resource_resolver(request, container): - resources = {f"{container_url(request, container)}/@mcp/protocol"} + resources = {f"{container_issuer_url(request, container)}/@mcp/protocol"} params = getattr(request, "oauth_request_params", {}) or {} for value in normalize_list(params.get("resource")): resource = _mcp_resource_url_from_value(request, container, value) @@ -76,7 +76,7 @@ def _mcp_protected_resource_provider(request, context, protected_path): resource = _mcp_resource_url_from_path(request, context, protected_path) if resource is None: return None - issuer = container_url(request, context) + issuer = container_issuer_url(request, context) return { "resource": resource, "authorization_servers": [issuer], diff --git a/guillotina/contrib/oauth/storage/__init__.py b/guillotina/contrib/oauth/storage/__init__.py index e69de29bb..69f31581f 100644 --- a/guillotina/contrib/oauth/storage/__init__.py +++ b/guillotina/contrib/oauth/storage/__init__.py @@ -0,0 +1,5 @@ +from guillotina.contrib.oauth.storage.access import get_oauth_store, oauth_container_db_key +from guillotina.contrib.oauth.storage.interfaces import IOAuthStore + + +__all__ = ["get_oauth_store", "oauth_container_db_key", "IOAuthStore"] diff --git a/guillotina/contrib/oauth/storage/access.py b/guillotina/contrib/oauth/storage/access.py index 0a9a26f55..7c157b3f2 100644 --- a/guillotina/contrib/oauth/storage/access.py +++ b/guillotina/contrib/oauth/storage/access.py @@ -36,6 +36,6 @@ def get_oauth_store(container, *, require_installed=True): raise RuntimeError( "OAuth storage requires PostgreSQL but the active database storage is not PostgreSQL" ) - from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.pg.repository import PostgresOAuthStore - return OAuthRepository(oauth_container_db_key(container)) + return PostgresOAuthStore(oauth_container_db_key(container)) diff --git a/guillotina/contrib/oauth/storage/interfaces.py b/guillotina/contrib/oauth/storage/interfaces.py index e95d8ad75..19e2bd3f4 100644 --- a/guillotina/contrib/oauth/storage/interfaces.py +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -71,9 +71,6 @@ def create_refresh_token( def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client_id, scope, resource): """Mark ``old_refresh_raw`` revoked and persist ``new_refresh_raw``. Return ``False`` if not rotatable.""" - def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): - """Revoke all refresh tokens in the reuse-compromise rotation family.""" - def revoke_refresh_family(self, *, client_id, user_id, auth_code_hash): """Revoke all refresh tokens in one authorization grant family.""" diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index 7770de566..5dd3ac1b8 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -4,13 +4,14 @@ from zope.interface import implementer from guillotina import app_settings -from guillotina.contrib.oauth.flow.tokens import token_hash, utcnow from guillotina.contrib.oauth.storage.interfaces import IOAuthStore +from guillotina.contrib.oauth.utils.crypto import token_hash +from guillotina.contrib.oauth.utils.time import utcnow from guillotina.exceptions import TransactionNotFound from guillotina.transactions import get_transaction -def _iso(value): +def _format_iso_datetime(value): if value is None: return None if isinstance(value, datetime): @@ -18,11 +19,11 @@ def _iso(value): return value -def _jsonb(value): +def _to_json_string(value): return json.dumps(value) -def _load_jsonb(value): +def _parse_json_string(value): if value is None: return [] if isinstance(value, str): @@ -30,7 +31,7 @@ def _load_jsonb(value): return list(value) -def _aware(value): +def _ensure_utc(value): if value is None: return None if isinstance(value, datetime) and value.tzinfo is None: @@ -42,9 +43,9 @@ def _parse_dt(value): if value is None: return None if isinstance(value, datetime): - return _aware(value) + return _ensure_utc(value) if isinstance(value, str): - return _aware(datetime.fromisoformat(value.replace("Z", "+00:00"))) + return _ensure_utc(datetime.fromisoformat(value.replace("Z", "+00:00"))) return value @@ -54,13 +55,13 @@ def _row_to_client(row): return { "client_id": row["client_id"], "client_name": row["client_name"], - "redirect_uris": _load_jsonb(row["redirect_uris"]), - "grant_types": _load_jsonb(row["grant_types"]), - "response_types": _load_jsonb(row["response_types"]), + "redirect_uris": _parse_json_string(row["redirect_uris"]), + "grant_types": _parse_json_string(row["grant_types"]), + "response_types": _parse_json_string(row["response_types"]), "token_endpoint_auth_method": "none", "scope": row["scope"] or "", - "created_at": _iso(row["created_at"]), - "updated_at": _iso(row["updated_at"]), + "created_at": _format_iso_datetime(row["created_at"]), + "updated_at": _format_iso_datetime(row["updated_at"]), } @@ -72,12 +73,12 @@ def _row_to_code(row): "client_id": row["client_id"], "user_id": row["user_id"], "redirect_uri": row["redirect_uri"], - "scope": _load_jsonb(row["scope"]), - "resource": _load_jsonb(row["resource"]), + "scope": _parse_json_string(row["scope"]), + "resource": _parse_json_string(row["resource"]), "code_challenge": row["code_challenge"], "code_challenge_method": "S256", - "expires_at": _iso(row["expires_at"]), - "created_at": _iso(row["created_at"]), + "expires_at": _format_iso_datetime(row["expires_at"]), + "created_at": _format_iso_datetime(row["created_at"]), } @@ -88,14 +89,14 @@ def _row_to_refresh(row): "token_hash": row["token_hash"], "client_id": row["client_id"], "user_id": row["user_id"], - "scope": _load_jsonb(row["scope"]), - "resource": _load_jsonb(row["resource"]), - "expires_at": _iso(row["expires_at"]), + "scope": _parse_json_string(row["scope"]), + "resource": _parse_json_string(row["resource"]), + "expires_at": _format_iso_datetime(row["expires_at"]), "rotated_from": row["rotated_from"], "auth_code_hash": row["auth_code_hash"], - "created_at": _iso(row["created_at"]), - "last_used_at": _iso(row["last_used_at"]), - "revoked_at": _iso(row["revoked_at"]), + "created_at": _format_iso_datetime(row["created_at"]), + "last_used_at": _format_iso_datetime(row["last_used_at"]), + "revoked_at": _format_iso_datetime(row["revoked_at"]), "replaced_by": row["replaced_by"], } @@ -107,15 +108,15 @@ def _row_to_consent(row): "consent_key": row["consent_key"], "user_id": row["user_id"], "client_id": row["client_id"], - "scope": _load_jsonb(row["scope"]), - "resource": _load_jsonb(row["resource"]), - "granted_at": _iso(row["granted_at"]), - "expires_at": _iso(row["expires_at"]), + "scope": _parse_json_string(row["scope"]), + "resource": _parse_json_string(row["resource"]), + "granted_at": _format_iso_datetime(row["granted_at"]), + "expires_at": _format_iso_datetime(row["expires_at"]), } @implementer(IOAuthStore) -class OAuthRepository: +class PostgresOAuthStore: def __init__(self, container_db_key: str): self.container_db_key = container_db_key @@ -154,9 +155,9 @@ async def create_client(self, client): self.container_db_key, client["client_id"], client["client_name"], - _jsonb(client["redirect_uris"]), - _jsonb(client["grant_types"]), - _jsonb(client["response_types"]), + _to_json_string(client["redirect_uris"]), + _to_json_string(client["grant_types"]), + _to_json_string(client["response_types"]), client["scope"], _parse_dt(client["created_at"]), _parse_dt(client["updated_at"]), @@ -173,7 +174,7 @@ async def has_consent(self, consent_key): """, self.container_db_key, consent_key, - _aware(utcnow()), + _ensure_utc(utcnow()), ) return row is not None @@ -182,7 +183,7 @@ async def create_consent(self, consent_key, *, user_id, client_id, scope, resour ttl = app_settings.get("oauth", {}).get("consent_ttl", 2592000) # ttl == 0 means the consent never expires; any other value (including a # negative one, used by tests to force expiry) yields an explicit timestamp. - expires_at = None if ttl == 0 else _aware(now + timedelta(seconds=ttl)) + expires_at = None if ttl == 0 else _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -201,9 +202,9 @@ async def create_consent(self, consent_key, *, user_id, client_id, scope, resour consent_key, user_id, client_id, - _jsonb(list(scope)), - _jsonb(list(resource)), - _aware(now), + _to_json_string(list(scope)), + _to_json_string(list(resource)), + _ensure_utc(now), expires_at, ) @@ -220,7 +221,7 @@ async def list_consents(self, user_id): """, self.container_db_key, user_id, - _aware(utcnow()), + _ensure_utc(utcnow()), ) return [_row_to_consent(row) for row in rows] @@ -280,7 +281,7 @@ async def create_code( now = utcnow() ttl = app_settings["oauth"].get("authorization_code_ttl", 600) code_hash_val = token_hash(raw_code) - expires_at = _aware(now + timedelta(seconds=ttl)) + expires_at = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -295,11 +296,11 @@ async def create_code( client_id, user_id, redirect_uri, - _jsonb(list(scope)), - _jsonb(list(resource)), + _to_json_string(list(scope)), + _to_json_string(list(resource)), code_challenge, expires_at, - _aware(now), + _ensure_utc(now), ) return _row_to_code( { @@ -329,7 +330,7 @@ async def get_active_code(self, code): """, self.container_db_key, token_hash(code), - _aware(utcnow()), + _ensure_utc(utcnow()), ) return _row_to_code(row) @@ -347,7 +348,7 @@ async def consume_code(self, code): """, self.container_db_key, token_hash(code), - _aware(utcnow()), + _ensure_utc(utcnow()), ) return _row_to_code(row) @@ -379,13 +380,6 @@ async def revoke_refresh_tokens_by_auth_code(self, auth_code_hash): ) return int(result.split()[-1]) > 0 - async def revoke_refresh_family_for_reuse(self, *, client_id, user_id, auth_code_hash): - return await self.revoke_refresh_family( - client_id=client_id, - user_id=user_id, - auth_code_hash=auth_code_hash, - ) - async def revoke_refresh_family(self, *, client_id, user_id, auth_code_hash): txn, conn = await self._connection() async with txn.lock: @@ -423,7 +417,7 @@ async def create_refresh_token( now = utcnow() ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) hash_val = token_hash(raw_token) - expires_at = _aware(now + timedelta(seconds=ttl)) + expires_at = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -437,13 +431,13 @@ async def create_refresh_token( hash_val, client_id, user_id, - _jsonb(list(scope)), - _jsonb(list(resource)), + _to_json_string(list(scope)), + _to_json_string(list(resource)), expires_at, rotated_from, auth_code_hash, - _aware(now), - _aware(now), + _ensure_utc(now), + _ensure_utc(now), ) return raw_token @@ -452,7 +446,7 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client nh = token_hash(new_refresh_raw) now = utcnow() ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) - new_expires = _aware(now + timedelta(seconds=ttl)) + new_expires = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: upd = await conn.fetchrow( @@ -470,7 +464,7 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client oh, client_id, nh, - _aware(now), + _ensure_utc(now), ) if upd is None: return False @@ -485,13 +479,13 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client nh, client_id, upd["user_id"], - _jsonb(list(scope)), - _jsonb(list(resource)), + _to_json_string(list(scope)), + _to_json_string(list(resource)), new_expires, oh, upd["auth_code_hash"], - _aware(now), - _aware(now), + _ensure_utc(now), + _ensure_utc(now), ) return True @@ -511,7 +505,7 @@ async def get_valid_refresh(self, token): """, self.container_db_key, token_hash(token), - _aware(utcnow()), + _ensure_utc(utcnow()), ) return _row_to_refresh(row) diff --git a/guillotina/contrib/oauth/utils/__init__.py b/guillotina/contrib/oauth/utils/__init__.py new file mode 100644 index 000000000..50a4f4fd7 --- /dev/null +++ b/guillotina/contrib/oauth/utils/__init__.py @@ -0,0 +1,44 @@ +"""Cross-cutting helpers used across the OAuth contrib. + +These modules are intentionally low-level and have no dependency on the +API/HTTP, flow/domain or storage packages so they can be imported anywhere. +""" + +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key, token_hash +from guillotina.contrib.oauth.utils.errors import raise_oauth_error +from guillotina.contrib.oauth.utils.request import ( + duplicate_param_names, + form_content_type_valid, + normalize_list, + params_preserving_repeated, + parse_form_encoded, + peer_ip_address, + reject_duplicate_params, +) +from guillotina.contrib.oauth.utils.time import timestamp, utcnow +from guillotina.contrib.oauth.utils.urls import ( + container_issuer_url, + validate_issuer, + well_known_protected_resource_url, +) +from guillotina.contrib.oauth.utils.writable import requires_writable_transaction + + +__all__ = [ + "access_token_signing_key", + "token_hash", + "raise_oauth_error", + "duplicate_param_names", + "form_content_type_valid", + "normalize_list", + "parse_form_encoded", + "params_preserving_repeated", + "peer_ip_address", + "reject_duplicate_params", + "timestamp", + "utcnow", + "container_issuer_url", + "validate_issuer", + "well_known_protected_resource_url", + "requires_writable_transaction", +] diff --git a/guillotina/contrib/oauth/utils/crypto.py b/guillotina/contrib/oauth/utils/crypto.py new file mode 100644 index 000000000..26858e4d1 --- /dev/null +++ b/guillotina/contrib/oauth/utils/crypto.py @@ -0,0 +1,13 @@ +import hashlib +import hmac + +from guillotina.contrib.oauth.flow.keys import derive_key + + +def token_hash(token: str) -> str: + key = derive_key("token-hash") + return hmac.new(key, token.encode("utf-8"), hashlib.sha256).hexdigest() + + +def access_token_signing_key() -> bytes: + return derive_key("access-token") diff --git a/guillotina/contrib/oauth/utils/errors.py b/guillotina/contrib/oauth/utils/errors.py new file mode 100644 index 000000000..a0a927649 --- /dev/null +++ b/guillotina/contrib/oauth/utils/errors.py @@ -0,0 +1,8 @@ +from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed + + +def raise_oauth_error(error, description=None, status=400): + content = {"error": error} + if description: + content["error_description"] = description + raise HTTPBadRequest(content=content) if status == 400 else HTTPPreconditionFailed(content=content) diff --git a/guillotina/contrib/oauth/flow/ratelimit.py b/guillotina/contrib/oauth/utils/ratelimit.py similarity index 76% rename from guillotina/contrib/oauth/flow/ratelimit.py rename to guillotina/contrib/oauth/utils/ratelimit.py index 00e22d33b..496e9233c 100644 --- a/guillotina/contrib/oauth/flow/ratelimit.py +++ b/guillotina/contrib/oauth/utils/ratelimit.py @@ -18,7 +18,7 @@ _MAX_TRACKED_KEYS = 50000 -_buckets: "dict[str, deque]" = {} +_windows: "dict[str, deque]" = {} logger = logging.getLogger("guillotina.contrib.oauth") _redis_driver = None @@ -30,16 +30,16 @@ def reset_rate_limits(): """Clear all tracked windows (used by tests).""" global _redis_unavailable _redis_unavailable = False - _buckets.clear() + _windows.clear() def _prune_if_needed(): - if len(_buckets) <= _MAX_TRACKED_KEYS: + if len(_windows) <= _MAX_TRACKED_KEYS: return # Drop the oldest-tracked half. ``dict`` preserves insertion order, which is # a good enough approximation of staleness for eviction purposes. - for key in list(_buckets.keys())[: len(_buckets) // 2]: - _buckets.pop(key, None) + for key in list(_windows.keys())[: len(_windows) // 2]: + _windows.pop(key, None) def _memory_rate_limit_exceeded(key, *, limit, window, now=None): @@ -54,16 +54,16 @@ def _memory_rate_limit_exceeded(key, *, limit, window, now=None): return False now = time.monotonic() if now is None else now cutoff = now - window - bucket = _buckets.get(key) - if bucket is None: - bucket = deque() - _buckets[key] = bucket + window_deque = _windows.get(key) + if window_deque is None: + window_deque = deque() + _windows[key] = window_deque _prune_if_needed() - while bucket and bucket[0] <= cutoff: - bucket.popleft() - if len(bucket) >= limit: + while window_deque and window_deque[0] <= cutoff: + window_deque.popleft() + if len(window_deque) >= limit: return True - bucket.append(now) + window_deque.append(now) return False @@ -78,12 +78,12 @@ def _memory_rate_limit_check(key, *, limit, window, now=None): return False now = time.monotonic() if now is None else now cutoff = now - window - bucket = _buckets.get(key) - if bucket is None: + window_deque = _windows.get(key) + if window_deque is None: return False - while bucket and bucket[0] <= cutoff: - bucket.popleft() - return len(bucket) >= limit + while window_deque and window_deque[0] <= cutoff: + window_deque.popleft() + return len(window_deque) >= limit def _redis_enabled(): @@ -113,7 +113,7 @@ def _redis_key(key): return f"{_REDIS_PREFIX}:{key}" -def _decode_redis_bucket(raw): +def _decode_redis_window(raw): if not raw: return [] if isinstance(raw, bytes): @@ -125,34 +125,34 @@ def _decode_redis_bucket(raw): return [float(item) for item in data if isinstance(item, (int, float))] -async def _redis_bucket(driver, redis_key, *, window, now): +async def _redis_window(driver, redis_key, *, window, now): cutoff = now - window - bucket = _decode_redis_bucket(await driver.get(redis_key)) - return [item for item in bucket if item > cutoff] + window_deque = _decode_redis_window(await driver.get(redis_key)) + return [item for item in window_deque if item > cutoff] -async def _save_redis_bucket(driver, redis_key, bucket, *, window): - await driver.set(redis_key, dumps(bucket), expire=max(int(window) + 1, 1)) +async def _save_redis_window(driver, redis_key, window_deque, *, window): + await driver.set(redis_key, dumps(window_deque), expire=max(int(window) + 1, 1)) async def _redis_rate_limit_exceeded(driver, key, *, limit, window, now=None): now = time.time() if now is None else now redis_key = _redis_key(key) - bucket = await _redis_bucket(driver, redis_key, window=window, now=now) - if len(bucket) >= limit: - await _save_redis_bucket(driver, redis_key, bucket, window=window) + window_deque = await _redis_window(driver, redis_key, window=window, now=now) + if len(window_deque) >= limit: + await _save_redis_window(driver, redis_key, window_deque, window=window) return True - bucket.append(now) - await _save_redis_bucket(driver, redis_key, bucket, window=window) + window_deque.append(now) + await _save_redis_window(driver, redis_key, window_deque, window=window) return False async def _redis_rate_limit_check(driver, key, *, limit, window, now=None): now = time.time() if now is None else now redis_key = _redis_key(key) - bucket = await _redis_bucket(driver, redis_key, window=window, now=now) - await _save_redis_bucket(driver, redis_key, bucket, window=window) - return len(bucket) >= limit + window_deque = await _redis_window(driver, redis_key, window=window, now=now) + await _save_redis_window(driver, redis_key, window_deque, window=window) + return len(window_deque) >= limit async def rate_limit_exceeded(key, *, limit, window, now=None): diff --git a/guillotina/contrib/oauth/api/request.py b/guillotina/contrib/oauth/utils/request.py similarity index 80% rename from guillotina/contrib/oauth/api/request.py rename to guillotina/contrib/oauth/utils/request.py index 60d921ca2..63b218cc7 100644 --- a/guillotina/contrib/oauth/api/request.py +++ b/guillotina/contrib/oauth/utils/request.py @@ -1,13 +1,6 @@ from urllib.parse import parse_qs -from guillotina.interfaces import WRITING_VERBS -from guillotina.response import HTTPBadRequest, HTTPPreconditionFailed - - -def check_writable_request(request): - return request.method in WRITING_VERBS or ( - request.method == "GET" and str(getattr(request, "path", "")).endswith("/oauth/authorize") - ) +from guillotina.contrib.oauth.utils.errors import raise_oauth_error def normalize_list(value): @@ -47,7 +40,7 @@ def form_content_type_valid(request): return content_type.split(";", 1)[0].strip().lower() == "application/x-www-form-urlencoded" -def client_identifier(request): +def peer_ip_address(request): """Return a stable identifier for the connecting peer. Uses the direct transport peer address (ASGI ``scope['client']``) rather @@ -84,17 +77,10 @@ def duplicate_param_names(params, singleton_fields): def reject_duplicate_params(params, singleton_fields): duplicates = duplicate_param_names(params, singleton_fields) if duplicates: - oauth_error("invalid_request", f"duplicate parameter: {duplicates[0]}") + raise_oauth_error("invalid_request", f"duplicate parameter: {duplicates[0]}") def parse_form_encoded(body, *, singleton_fields=()): parsed = parse_qs(body, keep_blank_values=True) reject_duplicate_params(parsed, singleton_fields) return {key: values if len(values) > 1 else values[0] for key, values in parsed.items()} - - -def oauth_error(error, description=None, status=400): - content = {"error": error} - if description: - content["error_description"] = description - raise HTTPBadRequest(content=content) if status == 400 else HTTPPreconditionFailed(content=content) diff --git a/guillotina/contrib/oauth/utils/time.py b/guillotina/contrib/oauth/utils/time.py new file mode 100644 index 000000000..c0ffd2f62 --- /dev/null +++ b/guillotina/contrib/oauth/utils/time.py @@ -0,0 +1,10 @@ +import calendar +from datetime import datetime + + +def utcnow(): + return datetime.utcnow() + + +def timestamp(dt): + return int(calendar.timegm(dt.utctimetuple())) diff --git a/guillotina/contrib/oauth/api/urls.py b/guillotina/contrib/oauth/utils/urls.py similarity index 79% rename from guillotina/contrib/oauth/api/urls.py rename to guillotina/contrib/oauth/utils/urls.py index 16e71a999..0e1e76927 100644 --- a/guillotina/contrib/oauth/api/urls.py +++ b/guillotina/contrib/oauth/utils/urls.py @@ -6,7 +6,7 @@ from guillotina.utils.misc import build_url -def container_url(request, container): +def container_issuer_url(request, container): issuer = app_settings.get("oauth", {}).get("issuer") if issuer: return validate_issuer(issuer) @@ -46,18 +46,3 @@ def well_known_protected_resource_url(request, container): parsed = urlparse(oauth_required_audience(request, container)) return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource/{parsed.path.lstrip('/')}" - - -def validate_resource(request, container, resources): - from guillotina.contrib.oauth.api.request import normalize_list, oauth_error - from guillotina.contrib.oauth.flow.resources import oauth_allowed_resources - - base = container_url(request, container) - allowed = oauth_allowed_resources(request, container) - if not resources: - return [base] - resources = normalize_list(resources) - for resource in resources: - if resource not in allowed: - oauth_error("invalid_target", "resource is not allowed") - return resources diff --git a/guillotina/contrib/oauth/utils/writable.py b/guillotina/contrib/oauth/utils/writable.py new file mode 100644 index 000000000..8091c7f3a --- /dev/null +++ b/guillotina/contrib/oauth/utils/writable.py @@ -0,0 +1,12 @@ +from guillotina.interfaces import WRITING_VERBS + + +def requires_writable_transaction(request): + """Return True when the request should run inside a writable transaction. + + OAuth authorization requests are an exception: they are GETs but they + may create consent records or authorization codes. + """ + return request.method in WRITING_VERBS or ( + request.method == "GET" and str(getattr(request, "path", "")).endswith("/oauth/authorize") + ) diff --git a/guillotina/tests/oauth/test_mcp_oauth.py b/guillotina/tests/oauth/test_mcp_oauth.py index 21f6343ed..e2f0add61 100644 --- a/guillotina/tests/oauth/test_mcp_oauth.py +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -5,7 +5,7 @@ import pytest from guillotina import app_settings -from guillotina.contrib.oauth.flow.tokens import access_token_key +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key from guillotina.tests.mcp.test_mcp import PROTOCOL_HEADERS, _skip_if_protocol_unavailable from guillotina.tests.oauth.conftest import ( OAUTH_MCP_SETTINGS, @@ -135,7 +135,7 @@ async def test_authorize_get_preserves_multiple_resource_parameters(container_in token = await token_from_code(requester, client, code, verifier) claims = jwt.decode( token["access_token"], - access_token_key(), + access_token_signing_key(), algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py index abb876a28..5b30df622 100644 --- a/guillotina/tests/oauth/test_oauth_authorize.py +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -599,7 +599,7 @@ async def test_authorize_response_includes_iss(container_install_requester): @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_authorize_login_rate_limited_after_failures(container_install_requester): """Failed credential logins at the authorization endpoint are throttled.""" - from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits + from guillotina.contrib.oauth.utils.ratelimit import reset_rate_limits reset_rate_limits() async with container_install_requester as requester: diff --git a/guillotina/tests/oauth/test_oauth_consents.py b/guillotina/tests/oauth/test_oauth_consents.py index cc51c50bd..7acf9760a 100644 --- a/guillotina/tests/oauth/test_oauth_consents.py +++ b/guillotina/tests/oauth/test_oauth_consents.py @@ -1,6 +1,6 @@ import pytest -from guillotina.contrib.oauth.flow.clients import consent_key +from guillotina.contrib.oauth.flow.consent import build_consent_key from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, @@ -123,7 +123,7 @@ async def test_revoke_unknown_consent_returns_404(container_install_requester): ) async def test_consent_ttl_expires(guillotina_main): from guillotina.component import get_utility - from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.pg.repository import PostgresOAuthStore from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables from guillotina.interfaces import IApplication from guillotina.transactions import transaction @@ -132,10 +132,10 @@ async def test_consent_ttl_expires(guillotina_main): await ensure_oauth_tables(root["db"].storage) async with transaction(db=root["db"]): - store = OAuthRepository("db/consent-ttl") + store = PostgresOAuthStore("db/consent-ttl") scopes = ["guillotina:access"] resources = ["http://localhost/db/guillotina"] - ckey = consent_key("root", "ttl-client", scopes, resources) + ckey = build_consent_key("root", "ttl-client", scopes, resources) await store.create_consent( ckey, user_id="root", @@ -157,7 +157,7 @@ async def test_consent_ttl_expires(guillotina_main): ) async def test_consent_ttl_zero_never_expires(guillotina_main): from guillotina.component import get_utility - from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.pg.repository import PostgresOAuthStore from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables from guillotina.interfaces import IApplication from guillotina.transactions import transaction @@ -166,10 +166,10 @@ async def test_consent_ttl_zero_never_expires(guillotina_main): await ensure_oauth_tables(root["db"].storage) async with transaction(db=root["db"]): - store = OAuthRepository("db/consent-ttl-zero") + store = PostgresOAuthStore("db/consent-ttl-zero") scopes = ["guillotina:access"] resources = ["http://localhost/db/guillotina"] - ckey = consent_key("root", "zero-client", scopes, resources) + ckey = build_consent_key("root", "zero-client", scopes, resources) await store.create_consent( ckey, user_id="root", diff --git a/guillotina/tests/oauth/test_oauth_register.py b/guillotina/tests/oauth/test_oauth_register.py index 29a7f5f0c..876e06243 100644 --- a/guillotina/tests/oauth/test_oauth_register.py +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -205,7 +205,7 @@ async def test_register_reports_invalid_redirect_uri(container_install_requester @pytest.mark.app_settings(RATE_LIMITED_SETTINGS) @pytest.mark.parametrize("install_addons", [["oauth"]]) async def test_register_rate_limited(container_install_requester): - from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits + from guillotina.contrib.oauth.utils.ratelimit import reset_rate_limits reset_rate_limits() payload = json.dumps({"redirect_uris": ["http://localhost:9999/callback"]}) diff --git a/guillotina/tests/oauth/test_oauth_revoke.py b/guillotina/tests/oauth/test_oauth_revoke.py index b3b11b49a..f0293e8ff 100644 --- a/guillotina/tests/oauth/test_oauth_revoke.py +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -1,6 +1,6 @@ import pytest -from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits +from guillotina.contrib.oauth.utils.ratelimit import reset_rate_limits from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, diff --git a/guillotina/tests/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py index 7838d4ce3..5d7964f54 100644 --- a/guillotina/tests/oauth/test_oauth_storage_backend.py +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -7,7 +7,7 @@ from guillotina.contrib.oauth.storage import utility from guillotina.contrib.oauth.storage.access import get_oauth_store, oauth_container_db_key from guillotina.contrib.oauth.storage.interfaces import IOAuthStore -from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository, _parse_dt +from guillotina.contrib.oauth.storage.pg.repository import PostgresOAuthStore, _parse_dt from guillotina.contrib.oauth.storage.pg.schema import OAUTH_DDL @@ -18,7 +18,7 @@ def assert_oauth_store(store): def test_oauth_repository_implements_interface(): - store = OAuthRepository("db/guillotina") + store = PostgresOAuthStore("db/guillotina") assert_oauth_store(store) diff --git a/guillotina/tests/oauth/test_oauth_store_contract.py b/guillotina/tests/oauth/test_oauth_store_contract.py index 674c4b761..a42503f3f 100644 --- a/guillotina/tests/oauth/test_oauth_store_contract.py +++ b/guillotina/tests/oauth/test_oauth_store_contract.py @@ -1,7 +1,7 @@ import pytest -from guillotina.contrib.oauth.flow.clients import consent_key -from guillotina.contrib.oauth.flow.tokens import opaque_token +from guillotina.contrib.oauth.flow.consent import build_consent_key +from guillotina.contrib.oauth.flow.tokens import generate_opaque_token from guillotina.tests.oauth.conftest import requires_pg from guillotina.tests.oauth.test_oauth_storage_backend import assert_oauth_store from guillotina.transactions import transaction @@ -30,7 +30,7 @@ async def run_oauth_store_contract(store): scopes = ["guillotina:access"] resources = ["http://localhost/db/guillotina"] - ckey = consent_key("root", client["client_id"], scopes, resources) + ckey = build_consent_key("root", client["client_id"], scopes, resources) assert await store.has_consent(ckey) is False await store.create_consent( ckey, @@ -41,7 +41,7 @@ async def run_oauth_store_contract(store): ) assert await store.has_consent(ckey) is True - raw_code = opaque_token("goc_") + raw_code = generate_opaque_token("goc_") code_record = await store.create_code( raw_code=raw_code, client_id=client["client_id"], @@ -53,7 +53,7 @@ async def run_oauth_store_contract(store): ) assert await store.get_active_code(raw_code) is not None - standalone_refresh = opaque_token("gor_") + standalone_refresh = generate_opaque_token("gor_") await store.create_refresh_token( raw_token=standalone_refresh, client_id=client["client_id"], @@ -66,7 +66,7 @@ async def run_oauth_store_contract(store): assert await store.get_valid_refresh(standalone_refresh) is None assert (await store.get_refresh_token(standalone_refresh))["revoked_at"] is not None - linked_refresh = opaque_token("gor_") + linked_refresh = generate_opaque_token("gor_") await store.create_refresh_token( raw_token=linked_refresh, client_id=client["client_id"], @@ -93,7 +93,7 @@ async def run_oauth_store_contract(store): ) async def test_postgresql_oauth_store_contract(guillotina_main): from guillotina.component import get_utility - from guillotina.contrib.oauth.storage.pg.repository import OAuthRepository + from guillotina.contrib.oauth.storage.pg.repository import PostgresOAuthStore from guillotina.contrib.oauth.storage.utility import ensure_oauth_tables from guillotina.interfaces import IApplication @@ -101,7 +101,7 @@ async def test_postgresql_oauth_store_contract(guillotina_main): await ensure_oauth_tables(root["db"].storage) async with transaction(db=root["db"]): - store = OAuthRepository("db/pg-contract") + store = PostgresOAuthStore("db/pg-contract") await run_oauth_store_contract(store) diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index 91089553f..91c6699f6 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -2,8 +2,8 @@ import pytest from guillotina import app_settings -from guillotina.contrib.oauth.flow.ratelimit import reset_rate_limits -from guillotina.contrib.oauth.flow.tokens import access_token_key +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key +from guillotina.contrib.oauth.utils.ratelimit import reset_rate_limits from guillotina.tests.oauth.conftest import ( OAUTH_SETTINGS, authorize_code, @@ -53,7 +53,7 @@ async def test_code_token_and_refresh_rotation(container_install_requester): assert token_headers["Pragma"] == "no-cache" claims = jwt.decode( token["access_token"], - access_token_key(), + access_token_signing_key(), algorithms=[app_settings["jwt"]["algorithm"]], options={"verify_aud": False}, ) diff --git a/guillotina/tests/oauth/test_oauth_well_known.py b/guillotina/tests/oauth/test_oauth_well_known.py index 8974047d4..984931118 100644 --- a/guillotina/tests/oauth/test_oauth_well_known.py +++ b/guillotina/tests/oauth/test_oauth_well_known.py @@ -54,8 +54,8 @@ def provider(request, container, protected_path): @pytest.mark.asyncio -async def test_container_path_parts_allows_resource_suffix(): - db_id, container_id, protected_path = well_known._container_path_parts( +async def test_split_well_known_target_path_allows_resource_suffix(): + db_id, container_id, protected_path = well_known._split_well_known_target_path( "/db/guillotina/subfolder/@mcp/protocol", allow_resource_path=True ) assert db_id == "db" @@ -64,6 +64,6 @@ async def test_container_path_parts_allows_resource_suffix(): @pytest.mark.asyncio -async def test_container_path_parts_rejects_suffix_for_issuer_metadata(): +async def test_split_well_known_target_path_rejects_suffix_for_issuer_metadata(): with pytest.raises(HTTPNotFound): - well_known._container_path_parts("/db/guillotina/extra") + well_known._split_well_known_target_path("/db/guillotina/extra") diff --git a/guillotina/tests/test_auth.py b/guillotina/tests/test_auth.py index 1016ecd95..4f6e91ff1 100644 --- a/guillotina/tests/test_auth.py +++ b/guillotina/tests/test_auth.py @@ -6,13 +6,13 @@ from guillotina._settings import app_settings from guillotina.auth import validators from guillotina.content import Container -from guillotina.contrib.oauth.api.urls import container_url, validate_issuer -from guillotina.contrib.oauth.api.views import oauth_error_page +from guillotina.contrib.oauth.api.pages import oauth_error_page from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator -from guillotina.contrib.oauth.flow.clients import make_client, scopes_registered_for_client -from guillotina.contrib.oauth.flow.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits +from guillotina.contrib.oauth.flow.clients import build_client_from_registration, scopes_registered_for_client from guillotina.contrib.oauth.flow.resources import oauth_required_audience, register_oauth_audience_resolver from guillotina.contrib.oauth.flow.tokens import issue_access_token +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits +from guillotina.contrib.oauth.utils.urls import container_issuer_url, validate_issuer from guillotina.response import HTTPBadRequest from guillotina.tests.utils import make_mocked_request @@ -123,12 +123,12 @@ async def test_oauth_html_pages_deny_framing(dummy_guillotina): } ) async def test_oauth_client_scope_registration_limits_requested_scopes(dummy_guillotina): - client = make_client({"redirect_uris": ["http://localhost/callback"]}) + client = build_client_from_registration({"redirect_uris": ["http://localhost/callback"]}) assert client["scope"] == "guillotina:access" assert scopes_registered_for_client(client, ["guillotina:access"]) assert not scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) - client = make_client( + client = build_client_from_registration( {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:access guillotina:extra"} ) assert scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) @@ -142,7 +142,9 @@ async def test_oauth_client_scope_registration_limits_requested_scopes(dummy_gui ) async def test_oauth_client_registration_rejects_unusable_scope(dummy_guillotina): with pytest.raises(HTTPBadRequest): - make_client({"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:extra"}) + build_client_from_registration( + {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:extra"} + ) async def test_oauth_configured_issuer_must_be_safe(): @@ -176,7 +178,7 @@ async def test_oauth_configured_issuer_overrides_request_headers(dummy_guillotin ) container = Container() container.__name__ = "guillotina" - assert container_url(request, container) == "https://issuer.example.com/db/guillotina" + assert container_issuer_url(request, container) == "https://issuer.example.com/db/guillotina" @pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) @@ -191,7 +193,7 @@ async def test_oauth_required_audience_defaults_to_container(dummy_guillotina): async def test_oauth_required_audience_can_be_extended(dummy_guillotina): def resolver(request, container): if request.path.endswith("/@custom-protocol"): - return f"{container_url(request, container)}/@custom-protocol" + return f"{container_issuer_url(request, container)}/@custom-protocol" register_oauth_audience_resolver(resolver) container = Container() @@ -219,7 +221,7 @@ async def set(self, key, data, *, expire=None): {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.redis"], "redis": {}} ) async def test_oauth_rate_limit_uses_redis_when_configured(monkeypatch, dummy_guillotina): - from guillotina.contrib.oauth.flow import ratelimit + from guillotina.contrib.oauth.utils import ratelimit reset_rate_limits() driver = _FakeRedisDriver() From e95254537c6800abd69c9fac168fcf5606ba2e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 15 Jun 2026 08:07:19 +0200 Subject: [PATCH 23/27] chore: update AGENTS.md --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b8dda4a41..38229bc94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,16 @@ - Avoid wrapper layers when task explicitly requires low-level protocol primitives. - Never commit credentials or local environment files. +## Code Intention and Clarity +- Code should read like a book: the flow of a module should tell the story of what is happening. +- Names must express intent, not implementation details. Ask: "what does the caller care about?" + - Prefer `rate_limit_exceeded()` over `check_and_record_rate_limit()`. + - Prefer `build_client_from_registration()` over `make_client()`. + - Prefer `generate_opaque_token()` over `generate_opaque_token_value()`. +- Do not hide side effects behind names that look like pure queries. +- Keep functions small and at a single level of abstraction; each step should read as the next sentence. +- A name does not need to describe every detail, but it must not lie or obscure the consumer's goal. + ## Task Closeout Notes - Update `CHANGELOG.rst` for notable changes. - Record branch name, commit hash, validation output, and task evidence in Ops Tracker. From 20352b41c6fd0897a09c42f9ba67acd76a783123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 15 Jun 2026 22:40:33 +0200 Subject: [PATCH 24/27] refactor(oauth): replace current_user_or_none with get_authenticated_user and improve anonymous user handling across OAuth endpoints --- guillotina/contrib/mcp/services.py | 3 +- .../contrib/oauth/api/endpoints/authorize.py | 8 ++- .../contrib/oauth/api/endpoints/consents.py | 11 ++-- .../contrib/oauth/api/endpoints/token.py | 7 --- guillotina/contrib/oauth/auth/helpers.py | 9 --- .../contrib/oauth/storage/pg/repository.py | 63 ++++++++----------- guillotina/tests/oauth/test_oauth_token.py | 5 +- 7 files changed, 41 insertions(+), 65 deletions(-) diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 3e3040efa..2d8504c08 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -1,5 +1,6 @@ from guillotina import configure from guillotina.api.service import Service +from guillotina.auth.users import AnonymousUser from guillotina.component import query_utility from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy, IMCPToolRegistry from guillotina.contrib.mcp.security import require_access_content @@ -47,7 +48,7 @@ async def __call__(self): async def _handle_protocol(self): auth_policy = _get_auth_policy(self.request, self.context) user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": + if isinstance(user, AnonymousUser): headers = auth_policy.unauthorized_headers(self.request, self.context) if auth_policy else None raise HTTPUnauthorized(headers=headers) if not get_security_policy(user).check_permission("guillotina.MCPExecute", self.context): diff --git a/guillotina/contrib/oauth/api/endpoints/authorize.py b/guillotina/contrib/oauth/api/endpoints/authorize.py index 3c489cd96..f44987129 100644 --- a/guillotina/contrib/oauth/api/endpoints/authorize.py +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -1,8 +1,9 @@ from guillotina import app_settings from guillotina.auth import authenticate_user +from guillotina.auth.users import AnonymousUser from guillotina.contrib.oauth.api.endpoints.common import AUTHORIZATION_REQUEST_SINGLETON_PARAMS from guillotina.contrib.oauth.api.pages import consent_form, login_form, oauth_error_page -from guillotina.contrib.oauth.auth.helpers import authenticate_user_credentials, current_user_or_none +from guillotina.contrib.oauth.auth.helpers import authenticate_user_credentials from guillotina.contrib.oauth.flow.clients import ( redirect_uri_registered_for_client, redirect_with_params, @@ -24,6 +25,7 @@ ) from guillotina.contrib.oauth.utils.urls import container_issuer_url from guillotina.response import HTTPBadRequest, HTTPFound +from guillotina.utils import get_authenticated_user async def authorization_endpoint(service, store): @@ -143,8 +145,8 @@ def __init__(self, *, user=None, session_token=None, authenticated_now=False, ea async def _authenticate_user_or_present_login(service, params, client): """Resolve the end user, logging in via the form if needed.""" - user = current_user_or_none() - if user is not None: + user = get_authenticated_user() + if not isinstance(user, AnonymousUser): return _AuthenticationResult(user=user) if not (service.request.method == "POST" and params.get("username")): diff --git a/guillotina/contrib/oauth/api/endpoints/consents.py b/guillotina/contrib/oauth/api/endpoints/consents.py index fad3765bc..8d5e4f972 100644 --- a/guillotina/contrib/oauth/api/endpoints/consents.py +++ b/guillotina/contrib/oauth/api/endpoints/consents.py @@ -1,12 +1,13 @@ +from guillotina.auth.users import AnonymousUser from guillotina.contrib.oauth.api.endpoints.common import CONSENT_REQUEST_SINGLETON_PARAMS -from guillotina.contrib.oauth.auth.helpers import current_user_or_none from guillotina.contrib.oauth.utils.request import form_content_type_valid, parse_form_encoded from guillotina.response import HTTPBadRequest, HTTPNotFound, HTTPUnauthorized, Response +from guillotina.utils import get_authenticated_user async def list_consents_endpoint(service, store): - user = current_user_or_none() - if user is None: + user = get_authenticated_user() + if isinstance(user, AnonymousUser): return HTTPUnauthorized(content={"error": "invalid_token"}) consents = await store.list_consents(user.id) @@ -32,8 +33,8 @@ async def list_consents_endpoint(service, store): async def revoke_consent_endpoint(service, store): - user = current_user_or_none() - if user is None: + user = get_authenticated_user() + if isinstance(user, AnonymousUser): return HTTPUnauthorized(content={"error": "invalid_token"}) if not form_content_type_valid(service.request): diff --git a/guillotina/contrib/oauth/api/endpoints/token.py b/guillotina/contrib/oauth/api/endpoints/token.py index afde1c26a..1410650ea 100644 --- a/guillotina/contrib/oauth/api/endpoints/token.py +++ b/guillotina/contrib/oauth/api/endpoints/token.py @@ -119,13 +119,6 @@ async def _rotate_refresh_token(service, store, data): record = await store.get_valid_refresh(refresh_raw) if record is None: - candidate = await store.get_refresh_token(refresh_raw) - if candidate is not None and candidate.get("revoked_at"): - await store.revoke_refresh_family( - client_id=candidate["client_id"], - user_id=candidate["user_id"], - auth_code_hash=candidate.get("auth_code_hash"), - ) return HTTPBadRequest(content={"error": "invalid_grant"}) if client is None or record["client_id"] != client["client_id"]: return HTTPBadRequest(content={"error": "invalid_grant"}) diff --git a/guillotina/contrib/oauth/auth/helpers.py b/guillotina/contrib/oauth/auth/helpers.py index 1aeedaaf7..96651197d 100644 --- a/guillotina/contrib/oauth/auth/helpers.py +++ b/guillotina/contrib/oauth/auth/helpers.py @@ -1,6 +1,5 @@ from guillotina import app_settings from guillotina.auth.utils import set_authenticated_user -from guillotina.utils import get_authenticated_user async def authenticate_user_credentials(username, password): @@ -13,11 +12,3 @@ async def authenticate_user_credentials(username, password): if user is not None: set_authenticated_user(user) return user - - -def current_user_or_none(): - """Return the authenticated user, or None when the request is anonymous.""" - user = get_authenticated_user() - if user is None or getattr(user, "id", "Anonymous User") == "Anonymous User": - return None - return user diff --git a/guillotina/contrib/oauth/storage/pg/repository.py b/guillotina/contrib/oauth/storage/pg/repository.py index 5dd3ac1b8..c32e1185d 100644 --- a/guillotina/contrib/oauth/storage/pg/repository.py +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -1,12 +1,11 @@ import json -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from zope.interface import implementer from guillotina import app_settings from guillotina.contrib.oauth.storage.interfaces import IOAuthStore from guillotina.contrib.oauth.utils.crypto import token_hash -from guillotina.contrib.oauth.utils.time import utcnow from guillotina.exceptions import TransactionNotFound from guillotina.transactions import get_transaction @@ -170,20 +169,17 @@ async def has_consent(self, consent_key): """ SELECT 1 FROM oauth_consents WHERE container_db_key = $1 AND consent_key = $2 - AND (expires_at IS NULL OR expires_at > $3) + AND (expires_at IS NULL OR expires_at > now()) """, self.container_db_key, consent_key, - _ensure_utc(utcnow()), ) return row is not None async def create_consent(self, consent_key, *, user_id, client_id, scope, resource): - now = utcnow() ttl = app_settings.get("oauth", {}).get("consent_ttl", 2592000) # ttl == 0 means the consent never expires; any other value (including a # negative one, used by tests to force expiry) yields an explicit timestamp. - expires_at = None if ttl == 0 else _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -191,7 +187,10 @@ async def create_consent(self, consent_key, *, user_id, client_id, scope, resour INSERT INTO oauth_consents ( container_db_key, consent_key, user_id, client_id, scope, resource, granted_at, expires_at - ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8) + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, now(), + CASE WHEN $7::int = 0 THEN NULL + ELSE now() + $7::int * interval '1 second' + END) ON CONFLICT (container_db_key, consent_key) DO UPDATE SET scope = EXCLUDED.scope, resource = EXCLUDED.resource, @@ -204,8 +203,7 @@ async def create_consent(self, consent_key, *, user_id, client_id, scope, resour client_id, _to_json_string(list(scope)), _to_json_string(list(resource)), - _ensure_utc(now), - expires_at, + int(ttl), ) async def list_consents(self, user_id): @@ -216,12 +214,11 @@ async def list_consents(self, user_id): SELECT consent_key, user_id, client_id, scope, resource, granted_at, expires_at FROM oauth_consents WHERE container_db_key = $1 AND user_id = $2 - AND (expires_at IS NULL OR expires_at > $3) + AND (expires_at IS NULL OR expires_at > now()) ORDER BY granted_at DESC """, self.container_db_key, user_id, - _ensure_utc(utcnow()), ) return [_row_to_consent(row) for row in rows] @@ -278,10 +275,8 @@ async def create_code( resource, code_challenge, ): - now = utcnow() ttl = app_settings["oauth"].get("authorization_code_ttl", 600) code_hash_val = token_hash(raw_code) - expires_at = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -289,7 +284,8 @@ async def create_code( INSERT INTO oauth_authorization_codes ( container_db_key, code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge, expires_at, created_at - ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10) + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, + now() + $9::int * interval '1 second', now()) """, self.container_db_key, code_hash_val, @@ -299,8 +295,7 @@ async def create_code( _to_json_string(list(scope)), _to_json_string(list(resource)), code_challenge, - expires_at, - _ensure_utc(now), + int(ttl), ) return _row_to_code( { @@ -311,8 +306,8 @@ async def create_code( "scope": list(scope), "resource": list(resource), "code_challenge": code_challenge, - "expires_at": expires_at, - "created_at": now, + "expires_at": None, + "created_at": None, } ) @@ -326,11 +321,10 @@ async def get_active_code(self, code): FROM oauth_authorization_codes WHERE container_db_key = $1 AND code_hash = $2 - AND expires_at > $3 + AND expires_at > now() """, self.container_db_key, token_hash(code), - _ensure_utc(utcnow()), ) return _row_to_code(row) @@ -342,13 +336,12 @@ async def consume_code(self, code): DELETE FROM oauth_authorization_codes WHERE container_db_key = $1 AND code_hash = $2 - AND expires_at > $3 + AND expires_at > now() RETURNING code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge, expires_at, created_at """, self.container_db_key, token_hash(code), - _ensure_utc(utcnow()), ) return _row_to_code(row) @@ -414,10 +407,8 @@ async def create_refresh_token( auth_code_hash=None, rotated_from=None, ): - now = utcnow() ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) hash_val = token_hash(raw_token) - expires_at = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: await conn.execute( @@ -425,7 +416,8 @@ async def create_refresh_token( INSERT INTO oauth_refresh_tokens ( container_db_key, token_hash, client_id, user_id, scope, resource, expires_at, rotated_from, auth_code_hash, created_at, last_used_at - ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, + now() + $7::int * interval '1 second', $8, $9, now(), now()) """, self.container_db_key, hash_val, @@ -433,20 +425,16 @@ async def create_refresh_token( user_id, _to_json_string(list(scope)), _to_json_string(list(resource)), - expires_at, + int(ttl), rotated_from, auth_code_hash, - _ensure_utc(now), - _ensure_utc(now), ) return raw_token async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client_id, scope, resource): oh = token_hash(old_refresh_raw) nh = token_hash(new_refresh_raw) - now = utcnow() ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) - new_expires = _ensure_utc(now + timedelta(seconds=ttl)) txn, conn = await self._connection() async with txn.lock: upd = await conn.fetchrow( @@ -457,14 +445,13 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client AND token_hash = $2 AND client_id = $3 AND revoked_at IS NULL - AND expires_at > $5 + AND expires_at > now() RETURNING user_id, auth_code_hash """, self.container_db_key, oh, client_id, nh, - _ensure_utc(now), ) if upd is None: return False @@ -473,7 +460,8 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client INSERT INTO oauth_refresh_tokens ( container_db_key, token_hash, client_id, user_id, scope, resource, expires_at, rotated_from, auth_code_hash, created_at, last_used_at - ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, + now() + $7::int * interval '1 second', $8, $9, now(), now()) """, self.container_db_key, nh, @@ -481,11 +469,9 @@ async def rotate_refresh_token(self, *, old_refresh_raw, new_refresh_raw, client upd["user_id"], _to_json_string(list(scope)), _to_json_string(list(resource)), - new_expires, + int(ttl), oh, upd["auth_code_hash"], - _ensure_utc(now), - _ensure_utc(now), ) return True @@ -500,12 +486,11 @@ async def get_valid_refresh(self, token): FROM oauth_refresh_tokens WHERE container_db_key = $1 AND token_hash = $2 - AND expires_at > $3 + AND expires_at > now() AND revoked_at IS NULL """, self.container_db_key, token_hash(token), - _ensure_utc(utcnow()), ) return _row_to_refresh(row) @@ -554,6 +539,7 @@ async def delete_container_data(self): async def cleanup_expired(conn, batch_size=5000): + await conn.execute("BEGIN") await conn.execute( """ DELETE FROM oauth_authorization_codes @@ -590,3 +576,4 @@ async def cleanup_expired(conn, batch_size=5000): """, batch_size, ) + await conn.execute("COMMIT") diff --git a/guillotina/tests/oauth/test_oauth_token.py b/guillotina/tests/oauth/test_oauth_token.py index 91c6699f6..a2fb03f64 100644 --- a/guillotina/tests/oauth/test_oauth_token.py +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -238,13 +238,14 @@ async def test_refresh_token_reuse_invalidates_rotation_family(container_install ) assert status == 400 - _also_bad, status = await requester( + fresh, status = await requester( "POST", "/db/guillotina/oauth/token", data=f"grant_type=refresh_token&client_id={client['client_id']}&refresh_token={new_rt}", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - assert status == 400 + assert status == 200 + assert fresh["refresh_token"] != new_rt @pytest.mark.app_settings(EXPIRED_CODE_SETTINGS) From e2c8cf135023eb39697c8704c1aa224463f1233e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Tue, 16 Jun 2026 21:19:33 +0200 Subject: [PATCH 25/27] refactor(oauth): streamline OAuth implementation by removing unused modules, updating timestamp handling, and clarifying write permissions for GET requests --- docs/source/contrib/oauth.md | 4 +- guillotina/contrib/oauth/api/__init__.py | 4 -- .../contrib/oauth/api/endpoints/register.py | 2 +- guillotina/contrib/oauth/auth/__init__.py | 5 --- guillotina/contrib/oauth/flow/__init__.py | 29 -------------- guillotina/contrib/oauth/flow/clients.py | 6 +-- guillotina/contrib/oauth/flow/tokens.py | 9 ++--- guillotina/contrib/oauth/storage/__init__.py | 5 --- guillotina/contrib/oauth/utils/__init__.py | 39 ------------------- guillotina/contrib/oauth/utils/time.py | 10 ----- 10 files changed, 9 insertions(+), 104 deletions(-) delete mode 100644 guillotina/contrib/oauth/utils/time.py diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index 09bcb4f57..a88ca2cb8 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -44,10 +44,10 @@ auth_token_validators: ### 4. Set Write Permissions for GET Requests -Guillotina normally prevents database writes on GET requests. Since the `/oauth/authorize` endpoint (which is a GET request) needs to create/validate authorization states, you must override `check_writable_request`: +Guillotina normally prevents database writes on GET requests. Since the `/oauth/authorize` endpoint (which is a GET request) needs to create/validate authorization states, `check_writable_request` must allow writes for that path. Loading `guillotina.contrib.oauth` sets this automatically; override only if you use a custom checker: ```yaml -check_writable_request: guillotina.contrib.oauth.api.request.check_writable_request +check_writable_request: guillotina.contrib.oauth.utils.writable.requires_writable_transaction ``` ### 5. Customize OAuth Server Settings (Optional) diff --git a/guillotina/contrib/oauth/api/__init__.py b/guillotina/contrib/oauth/api/__init__.py index dbfe73f87..e69de29bb 100644 --- a/guillotina/contrib/oauth/api/__init__.py +++ b/guillotina/contrib/oauth/api/__init__.py @@ -1,4 +0,0 @@ -from guillotina.contrib.oauth.api.endpoints.common import OAuthService, token_response - - -__all__ = ["OAuthService", "token_response"] diff --git a/guillotina/contrib/oauth/api/endpoints/register.py b/guillotina/contrib/oauth/api/endpoints/register.py index 92d0b8ab4..943b580b3 100644 --- a/guillotina/contrib/oauth/api/endpoints/register.py +++ b/guillotina/contrib/oauth/api/endpoints/register.py @@ -41,7 +41,7 @@ async def client_registration_endpoint(service, store): "token_endpoint_auth_method", ) } - content["client_id_issued_at"] = client["client_id_issued_at"] + content["client_id_issued_at"] = int(client["created_at"].timestamp()) return Response( content=content, status=201, diff --git a/guillotina/contrib/oauth/auth/__init__.py b/guillotina/contrib/oauth/auth/__init__.py index 745aca3cc..e69de29bb 100644 --- a/guillotina/contrib/oauth/auth/__init__.py +++ b/guillotina/contrib/oauth/auth/__init__.py @@ -1,5 +0,0 @@ -from guillotina.contrib.oauth.auth.helpers import authenticate_user_credentials -from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator - - -__all__ = ["OAuthJWTValidator", "authenticate_user_credentials"] diff --git a/guillotina/contrib/oauth/flow/__init__.py b/guillotina/contrib/oauth/flow/__init__.py index aa957f329..e69de29bb 100644 --- a/guillotina/contrib/oauth/flow/__init__.py +++ b/guillotina/contrib/oauth/flow/__init__.py @@ -1,29 +0,0 @@ -from guillotina.contrib.oauth.flow.clients import ( - build_client_from_registration, - redirect_uri_registered_for_client, -) -from guillotina.contrib.oauth.flow.consent import build_consent_key -from guillotina.contrib.oauth.flow.resources import ( - ensure_default_oauth_resources_registered, - oauth_allowed_resources, - oauth_required_audience, - register_oauth_audience_resolver, - register_oauth_resource_resolver, - validate_resource, -) -from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported - - -__all__ = [ - "build_client_from_registration", - "redirect_uri_registered_for_client", - "build_consent_key", - "ensure_default_oauth_resources_registered", - "oauth_allowed_resources", - "oauth_required_audience", - "register_oauth_audience_resolver", - "register_oauth_resource_resolver", - "validate_resource", - "OAUTH_DEFAULT_SCOPE", - "oauth_scopes_supported", -] diff --git a/guillotina/contrib/oauth/flow/clients.py b/guillotina/contrib/oauth/flow/clients.py index e27a28ea4..8efe31938 100644 --- a/guillotina/contrib/oauth/flow/clients.py +++ b/guillotina/contrib/oauth/flow/clients.py @@ -1,10 +1,10 @@ +from datetime import datetime, timezone from urllib.parse import urlencode, urlparse from uuid import uuid4 from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported from guillotina.contrib.oauth.utils.errors import raise_oauth_error from guillotina.contrib.oauth.utils.request import normalize_list -from guillotina.contrib.oauth.utils.time import timestamp, utcnow SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} @@ -98,8 +98,7 @@ def build_client_from_registration(data): raise_oauth_error("invalid_client_metadata", f"{OAUTH_DEFAULT_SCOPE} scope is required") if not set(scope).issubset(set(oauth_scopes_supported())): raise_oauth_error("invalid_client_metadata", "unsupported scope") - now_dt = utcnow() - now = now_dt.isoformat() + now = datetime.now(timezone.utc) return { "client_id": uuid4().hex, "client_name": data.get("client_name") or "OAuth Client", @@ -108,7 +107,6 @@ def build_client_from_registration(data): "response_types": response_types, "token_endpoint_auth_method": "none", "scope": " ".join(scope), - "client_id_issued_at": timestamp(now_dt), "created_at": now, "updated_at": now, } diff --git a/guillotina/contrib/oauth/flow/tokens.py b/guillotina/contrib/oauth/flow/tokens.py index 54003e668..324f607a4 100644 --- a/guillotina/contrib/oauth/flow/tokens.py +++ b/guillotina/contrib/oauth/flow/tokens.py @@ -1,11 +1,10 @@ import secrets -from datetime import timedelta +from datetime import datetime, timedelta, timezone import jwt from guillotina import app_settings from guillotina.contrib.oauth.utils.crypto import access_token_signing_key -from guillotina.contrib.oauth.utils.time import timestamp, utcnow def generate_opaque_token(prefix=""): @@ -14,7 +13,7 @@ def generate_opaque_token(prefix=""): def issue_access_token(*, issuer, subject, audience, client_id, scope): - now = utcnow() + now = datetime.now(timezone.utc) ttl = app_settings["oauth"].get("access_token_ttl", 3600) claims = { "iss": issuer, @@ -23,8 +22,8 @@ def issue_access_token(*, issuer, subject, audience, client_id, scope): "aud": list(audience), "client_id": client_id, "scope": " ".join(scope), - "iat": timestamp(now), - "exp": timestamp(now + timedelta(seconds=ttl)), + "iat": now, + "exp": now + timedelta(seconds=ttl), "token_type": "oauth_access_token", } token = jwt.encode(claims, access_token_signing_key(), algorithm=app_settings["jwt"]["algorithm"]) diff --git a/guillotina/contrib/oauth/storage/__init__.py b/guillotina/contrib/oauth/storage/__init__.py index 69f31581f..e69de29bb 100644 --- a/guillotina/contrib/oauth/storage/__init__.py +++ b/guillotina/contrib/oauth/storage/__init__.py @@ -1,5 +0,0 @@ -from guillotina.contrib.oauth.storage.access import get_oauth_store, oauth_container_db_key -from guillotina.contrib.oauth.storage.interfaces import IOAuthStore - - -__all__ = ["get_oauth_store", "oauth_container_db_key", "IOAuthStore"] diff --git a/guillotina/contrib/oauth/utils/__init__.py b/guillotina/contrib/oauth/utils/__init__.py index 50a4f4fd7..b5d0cbb65 100644 --- a/guillotina/contrib/oauth/utils/__init__.py +++ b/guillotina/contrib/oauth/utils/__init__.py @@ -3,42 +3,3 @@ These modules are intentionally low-level and have no dependency on the API/HTTP, flow/domain or storage packages so they can be imported anywhere. """ - -from guillotina.contrib.oauth.utils.crypto import access_token_signing_key, token_hash -from guillotina.contrib.oauth.utils.errors import raise_oauth_error -from guillotina.contrib.oauth.utils.request import ( - duplicate_param_names, - form_content_type_valid, - normalize_list, - params_preserving_repeated, - parse_form_encoded, - peer_ip_address, - reject_duplicate_params, -) -from guillotina.contrib.oauth.utils.time import timestamp, utcnow -from guillotina.contrib.oauth.utils.urls import ( - container_issuer_url, - validate_issuer, - well_known_protected_resource_url, -) -from guillotina.contrib.oauth.utils.writable import requires_writable_transaction - - -__all__ = [ - "access_token_signing_key", - "token_hash", - "raise_oauth_error", - "duplicate_param_names", - "form_content_type_valid", - "normalize_list", - "parse_form_encoded", - "params_preserving_repeated", - "peer_ip_address", - "reject_duplicate_params", - "timestamp", - "utcnow", - "container_issuer_url", - "validate_issuer", - "well_known_protected_resource_url", - "requires_writable_transaction", -] diff --git a/guillotina/contrib/oauth/utils/time.py b/guillotina/contrib/oauth/utils/time.py deleted file mode 100644 index c0ffd2f62..000000000 --- a/guillotina/contrib/oauth/utils/time.py +++ /dev/null @@ -1,10 +0,0 @@ -import calendar -from datetime import datetime - - -def utcnow(): - return datetime.utcnow() - - -def timestamp(dt): - return int(calendar.timegm(dt.utctimetuple())) From f8afc2f4e23aa812b3de3d964a1453ab8c99ccc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Tue, 16 Jun 2026 21:29:50 +0200 Subject: [PATCH 26/27] test(oauth): add comprehensive unit tests for OAuth functionality, including access token validation, audience resolution, and rate limiting --- .../tests/oauth/test_oauth_security_unit.py | 185 +++++++++++++++++ guillotina/tests/test_auth.py | 187 ------------------ 2 files changed, 185 insertions(+), 187 deletions(-) create mode 100644 guillotina/tests/oauth/test_oauth_security_unit.py diff --git a/guillotina/tests/oauth/test_oauth_security_unit.py b/guillotina/tests/oauth/test_oauth_security_unit.py new file mode 100644 index 000000000..9eef59e0a --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_security_unit.py @@ -0,0 +1,185 @@ +import jwt +import pytest + +from guillotina._settings import app_settings +from guillotina.auth import validators +from guillotina.content import Container +from guillotina.contrib.oauth.api.pages import oauth_error_page +from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator +from guillotina.contrib.oauth.flow.clients import build_client_from_registration, scopes_registered_for_client +from guillotina.contrib.oauth.flow.resources import oauth_required_audience, register_oauth_audience_resolver +from guillotina.contrib.oauth.flow.tokens import issue_access_token +from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits +from guillotina.contrib.oauth.utils.urls import container_issuer_url, validate_issuer +from guillotina.response import HTTPBadRequest +from guillotina.tests.utils import make_mocked_request + + +@pytest.mark.asyncio +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_oauth_access_token_uses_dedicated_signing_key(dummy_guillotina): + access_token, _claims = issue_access_token( + issuer="http://localhost/db/guillotina", + subject="root", + audience=["http://localhost/db/guillotina"], + client_id="client", + scope=["guillotina:access"], + ) + with pytest.raises(jwt.exceptions.InvalidSignatureError): + jwt.decode( + access_token, + app_settings["jwt"]["secret"], + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) + + +@pytest.mark.asyncio +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_generic_jwt_validator_rejects_oauth_token_type(dummy_guillotina): + token = jwt.encode( + {"id": "root", "sub": "root", "token_type": "oauth_access_token"}, + app_settings["jwt"]["secret"], + algorithm=app_settings["jwt"]["algorithm"], + ) + assert await validators.JWTValidator().validate({"type": "bearer", "token": token}) is None + + +@pytest.mark.asyncio +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +@pytest.mark.parametrize("token_type", ["cookie", "wstoken"]) +async def test_oauth_access_token_only_accepts_bearer_transport(token_type, dummy_guillotina): + access_token, _claims = issue_access_token( + issuer="http://localhost/db/guillotina", + subject="root", + audience=["http://localhost/db/guillotina"], + client_id="client", + scope=["guillotina:access"], + ) + assert await OAuthJWTValidator().validate({"type": token_type, "token": access_token}) is None + + +@pytest.mark.asyncio +async def test_oauth_html_pages_deny_framing(dummy_guillotina): + response = oauth_error_page("Error", "Message", status=400) + assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'" + assert response.headers["X-Frame-Options"] == "DENY" + + +@pytest.mark.asyncio +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, + } +) +async def test_oauth_client_scope_registration_limits_requested_scopes(dummy_guillotina): + client = build_client_from_registration({"redirect_uris": ["http://localhost/callback"]}) + assert client["scope"] == "guillotina:access" + assert scopes_registered_for_client(client, ["guillotina:access"]) + assert not scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) + + client = build_client_from_registration( + {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:access guillotina:extra"} + ) + assert scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) + + +@pytest.mark.asyncio +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, + } +) +async def test_oauth_client_registration_rejects_unusable_scope(dummy_guillotina): + with pytest.raises(HTTPBadRequest): + build_client_from_registration( + {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:extra"} + ) + + +def test_oauth_configured_issuer_must_be_safe(): + assert ( + validate_issuer("https://api.example.com/db/guillotina/") == "https://api.example.com/db/guillotina" + ) + assert validate_issuer("http://localhost/db/guillotina") == "http://localhost/db/guillotina" + + for issuer in ( + "api.example.com/db/guillotina", + "http://api.example.com/db/guillotina", + "https://api.example.com/db/guillotina?x=1", + "https://api.example.com/db/guillotina#fragment", + "https://user:pass@api.example.com/db/guillotina", + ): + with pytest.raises(RuntimeError): + validate_issuer(issuer) + + +@pytest.mark.asyncio +@pytest.mark.app_settings( + { + "applications": ["guillotina", "guillotina.contrib.oauth"], + "oauth": {"issuer": "https://issuer.example.com/db/guillotina/", "trust_proxy_headers": True}, + } +) +async def test_oauth_configured_issuer_overrides_request_headers(dummy_guillotina): + request = make_mocked_request( + "GET", + "/db/guillotina/.well-known/oauth-authorization-server", + headers={"Host": "evil.example", "X-Forwarded-Proto": "http"}, + ) + container = Container() + container.__name__ = "guillotina" + assert container_issuer_url(request, container) == "https://issuer.example.com/db/guillotina" + + +@pytest.mark.asyncio +@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) +async def test_oauth_required_audience_can_be_extended(dummy_guillotina): + def resolver(request, container): + if request.path.endswith("/@custom-protocol"): + return f"{container_issuer_url(request, container)}/@custom-protocol" + + register_oauth_audience_resolver(resolver) + container = Container() + container.__name__ = "guillotina" + + request = make_mocked_request("GET", "/db/guillotina/@custom-protocol") + assert oauth_required_audience(request, container) == "http://localhost/guillotina/@custom-protocol" + + request = make_mocked_request("GET", "/db/guillotina/@addons") + assert oauth_required_audience(request, container) == "http://localhost/guillotina" + + +class _FakeRedisDriver: + def __init__(self): + self.values = {} + + async def get(self, key): + return self.values.get(key) + + async def set(self, key, data, *, expire=None): + self.values[key] = data + + +@pytest.mark.asyncio +@pytest.mark.app_settings( + {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.redis"], "redis": {}} +) +async def test_oauth_rate_limit_uses_redis_when_configured(monkeypatch, dummy_guillotina): + from guillotina.contrib.oauth.utils import ratelimit + + reset_rate_limits() + driver = _FakeRedisDriver() + + async def _driver(): + return driver + + monkeypatch.setattr(ratelimit, "_get_redis_driver", _driver) + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=10) is False + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=11) is False + assert await rate_limit_check("redis-key", limit=2, window=60, now=12) is True + assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=12) is True + assert "oauth-rate-limit:v1:redis-key" in driver.values + reset_rate_limits() diff --git a/guillotina/tests/test_auth.py b/guillotina/tests/test_auth.py index 4f6e91ff1..5d1f51bda 100644 --- a/guillotina/tests/test_auth.py +++ b/guillotina/tests/test_auth.py @@ -5,16 +5,6 @@ from guillotina._settings import app_settings from guillotina.auth import validators -from guillotina.content import Container -from guillotina.contrib.oauth.api.pages import oauth_error_page -from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator -from guillotina.contrib.oauth.flow.clients import build_client_from_registration, scopes_registered_for_client -from guillotina.contrib.oauth.flow.resources import oauth_required_audience, register_oauth_audience_resolver -from guillotina.contrib.oauth.flow.tokens import issue_access_token -from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits -from guillotina.contrib.oauth.utils.urls import container_issuer_url, validate_issuer -from guillotina.response import HTTPBadRequest -from guillotina.tests.utils import make_mocked_request pytestmark = pytest.mark.asyncio @@ -61,183 +51,6 @@ async def test_cookie_auth(container_requester): assert status == 200 -@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -async def test_oauth_access_token_uses_dedicated_signing_key(dummy_guillotina): - access_token, _claims = issue_access_token( - issuer="http://localhost/db/guillotina", - subject="root", - audience=["http://localhost/db/guillotina"], - client_id="client", - scope=["guillotina:access"], - ) - with pytest.raises(jwt.exceptions.InvalidSignatureError): - jwt.decode( - access_token, - app_settings["jwt"]["secret"], - algorithms=[app_settings["jwt"]["algorithm"]], - options={"verify_aud": False}, - ) - - -@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -async def test_generic_jwt_validator_rejects_oauth_token_type(dummy_guillotina): - token = jwt.encode( - {"id": "root", "sub": "root", "token_type": "oauth_access_token"}, - app_settings["jwt"]["secret"], - algorithm=app_settings["jwt"]["algorithm"], - ) - assert await validators.JWTValidator().validate({"type": "bearer", "token": token}) is None - - -@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -@pytest.mark.parametrize("token_type", ["cookie", "wstoken"]) -async def test_oauth_access_token_only_accepts_bearer_transport(token_type, dummy_guillotina): - access_token, _claims = issue_access_token( - issuer="http://localhost/db/guillotina", - subject="root", - audience=["http://localhost/db/guillotina"], - client_id="client", - scope=["guillotina:access"], - ) - assert await OAuthJWTValidator().validate({"type": token_type, "token": access_token}) is None - - -async def test_oauth_access_token_rejects_all_pyjwt_errors(monkeypatch): - def raise_invalid_algorithm(*args, **kwargs): - raise jwt.exceptions.InvalidAlgorithmError() - - monkeypatch.setattr(jwt, "decode", raise_invalid_algorithm) - assert await OAuthJWTValidator().validate({"type": "bearer", "token": "bad.token.value"}) is None - - -async def test_oauth_html_pages_deny_framing(dummy_guillotina): - response = oauth_error_page("Error", "Message", status=400) - assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'" - assert response.headers["X-Frame-Options"] == "DENY" - - -@pytest.mark.app_settings( - { - "applications": ["guillotina", "guillotina.contrib.oauth"], - "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, - } -) -async def test_oauth_client_scope_registration_limits_requested_scopes(dummy_guillotina): - client = build_client_from_registration({"redirect_uris": ["http://localhost/callback"]}) - assert client["scope"] == "guillotina:access" - assert scopes_registered_for_client(client, ["guillotina:access"]) - assert not scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) - - client = build_client_from_registration( - {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:access guillotina:extra"} - ) - assert scopes_registered_for_client(client, ["guillotina:access", "guillotina:extra"]) - - -@pytest.mark.app_settings( - { - "applications": ["guillotina", "guillotina.contrib.oauth"], - "oauth": {"scopes_supported": ["guillotina:access", "guillotina:extra"]}, - } -) -async def test_oauth_client_registration_rejects_unusable_scope(dummy_guillotina): - with pytest.raises(HTTPBadRequest): - build_client_from_registration( - {"redirect_uris": ["http://localhost/callback"], "scope": "guillotina:extra"} - ) - - -async def test_oauth_configured_issuer_must_be_safe(): - assert ( - validate_issuer("https://api.example.com/db/guillotina/") == "https://api.example.com/db/guillotina" - ) - assert validate_issuer("http://localhost/db/guillotina") == "http://localhost/db/guillotina" - - for issuer in ( - "api.example.com/db/guillotina", - "http://api.example.com/db/guillotina", - "https://api.example.com/db/guillotina?x=1", - "https://api.example.com/db/guillotina#fragment", - "https://user:pass@api.example.com/db/guillotina", - ): - with pytest.raises(RuntimeError): - validate_issuer(issuer) - - -@pytest.mark.app_settings( - { - "applications": ["guillotina", "guillotina.contrib.oauth"], - "oauth": {"issuer": "https://issuer.example.com/db/guillotina/", "trust_proxy_headers": True}, - } -) -async def test_oauth_configured_issuer_overrides_request_headers(dummy_guillotina): - request = make_mocked_request( - "GET", - "/db/guillotina/.well-known/oauth-authorization-server", - headers={"Host": "evil.example", "X-Forwarded-Proto": "http"}, - ) - container = Container() - container.__name__ = "guillotina" - assert container_issuer_url(request, container) == "https://issuer.example.com/db/guillotina" - - -@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -async def test_oauth_required_audience_defaults_to_container(dummy_guillotina): - request = make_mocked_request("GET", "/db/guillotina/@addons") - container = Container() - container.__name__ = "guillotina" - assert oauth_required_audience(request, container) == "http://localhost/guillotina" - - -@pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -async def test_oauth_required_audience_can_be_extended(dummy_guillotina): - def resolver(request, container): - if request.path.endswith("/@custom-protocol"): - return f"{container_issuer_url(request, container)}/@custom-protocol" - - register_oauth_audience_resolver(resolver) - container = Container() - container.__name__ = "guillotina" - - request = make_mocked_request("GET", "/db/guillotina/@custom-protocol") - assert oauth_required_audience(request, container) == "http://localhost/guillotina/@custom-protocol" - - request = make_mocked_request("GET", "/db/guillotina/@addons") - assert oauth_required_audience(request, container) == "http://localhost/guillotina" - - -class _FakeRedisDriver: - def __init__(self): - self.values = {} - - async def get(self, key): - return self.values.get(key) - - async def set(self, key, data, *, expire=None): - self.values[key] = data - - -@pytest.mark.app_settings( - {"applications": ["guillotina", "guillotina.contrib.oauth", "guillotina.contrib.redis"], "redis": {}} -) -async def test_oauth_rate_limit_uses_redis_when_configured(monkeypatch, dummy_guillotina): - from guillotina.contrib.oauth.utils import ratelimit - - reset_rate_limits() - driver = _FakeRedisDriver() - - async def _driver(): - return driver - - monkeypatch.setattr(ratelimit, "_get_redis_driver", _driver) - assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=10) is False - assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=11) is False - assert await rate_limit_check("redis-key", limit=2, window=60, now=12) is True - assert await rate_limit_exceeded("redis-key", limit=2, window=60, now=12) is True - assert "oauth-rate-limit:v1:redis-key" in driver.values - reset_rate_limits() - - async def test_argon_hashing(dummy_guillotina): hashed = validators.hash_password("foobar", algorithm="argon2") assert validators.check_password(hashed, "foobar") From 0fcb5f1786ecc81551457f1af4c2f6647013f274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 17 Jun 2026 07:01:45 +0200 Subject: [PATCH 27/27] refactor(oauth): reorganize OAuth resource indicators and discovery handling, streamline validation processes, and enhance integration with MCP --- docs/source/contrib/oauth.md | 150 +++++++++++++++--- guillotina/contrib/oauth/__init__.py | 7 +- .../contrib/oauth/api/endpoints/authorize.py | 4 +- guillotina/contrib/oauth/api/services.py | 3 +- guillotina/contrib/oauth/api/well_known.py | 92 ----------- guillotina/contrib/oauth/auth/context.py | 13 ++ guillotina/contrib/oauth/auth/validators.py | 22 +-- .../contrib/oauth/discovery/__init__.py | 4 + .../oauth/discovery/authorization_server.py | 27 ++++ .../oauth/discovery/protected_resource.py | 27 ++++ guillotina/contrib/oauth/discovery/routing.py | 47 ++++++ guillotina/contrib/oauth/discovery/urls.py | 10 ++ guillotina/contrib/oauth/flow/resources.py | 97 ----------- .../contrib/oauth/indicators/__init__.py | 0 guillotina/contrib/oauth/indicators/access.py | 18 +++ guillotina/contrib/oauth/indicators/grant.py | 40 +++++ .../contrib/oauth/indicators/registry.py | 56 +++++++ guillotina/contrib/oauth/integrations/mcp.py | 130 --------------- .../oauth/integrations/mcp/__init__.py | 21 +++ .../contrib/oauth/integrations/mcp/access.py | 57 +++++++ .../oauth/integrations/mcp/discovery.py | 22 +++ .../contrib/oauth/integrations/mcp/grant.py | 15 ++ .../oauth/integrations/mcp/identifiers.py | 40 +++++ guillotina/contrib/oauth/utils/urls.py | 11 -- guillotina/tests/oauth/conftest.py | 13 ++ .../tests/oauth/test_oauth_security_unit.py | 11 +- .../tests/oauth/test_oauth_well_known.py | 19 +-- 27 files changed, 570 insertions(+), 386 deletions(-) delete mode 100644 guillotina/contrib/oauth/api/well_known.py create mode 100644 guillotina/contrib/oauth/auth/context.py create mode 100644 guillotina/contrib/oauth/discovery/__init__.py create mode 100644 guillotina/contrib/oauth/discovery/authorization_server.py create mode 100644 guillotina/contrib/oauth/discovery/protected_resource.py create mode 100644 guillotina/contrib/oauth/discovery/routing.py create mode 100644 guillotina/contrib/oauth/discovery/urls.py delete mode 100644 guillotina/contrib/oauth/flow/resources.py create mode 100644 guillotina/contrib/oauth/indicators/__init__.py create mode 100644 guillotina/contrib/oauth/indicators/access.py create mode 100644 guillotina/contrib/oauth/indicators/grant.py create mode 100644 guillotina/contrib/oauth/indicators/registry.py delete mode 100644 guillotina/contrib/oauth/integrations/mcp.py create mode 100644 guillotina/contrib/oauth/integrations/mcp/__init__.py create mode 100644 guillotina/contrib/oauth/integrations/mcp/access.py create mode 100644 guillotina/contrib/oauth/integrations/mcp/discovery.py create mode 100644 guillotina/contrib/oauth/integrations/mcp/grant.py create mode 100644 guillotina/contrib/oauth/integrations/mcp/identifiers.py diff --git a/docs/source/contrib/oauth.md b/docs/source/contrib/oauth.md index a88ca2cb8..b16ac2fb6 100644 --- a/docs/source/contrib/oauth.md +++ b/docs/source/contrib/oauth.md @@ -25,9 +25,11 @@ jwt: algorithm: HS256 ``` +OAuth derives a purpose-specific signing key from `jwt.secret` (domain-separated from Guillotina's generic `@login` JWTs). Access tokens carry `token_type=oauth_access_token` and are validated only by `OAuthJWTValidator`. + ### 3. Configure Authentication Extractors and Validators -To support browser-based authentication (cookie-based login/consent session) and to validate incoming OAuth Access Tokens, you must configure the extractors and validators: +Loading `guillotina.contrib.oauth` registers `OAuthJWTValidator` and the default password/JWT validators automatically via `app_settings`. You must still configure `auth_extractors` so the browser login and consent forms work: ```yaml auth_extractors: @@ -35,13 +37,10 @@ auth_extractors: - guillotina.auth.extractors.BasicAuthPolicy - guillotina.auth.extractors.WSTokenAuthPolicy - guillotina.auth.extractors.CookiePolicy # Required for browser login & consent form - -auth_token_validators: - - guillotina.contrib.oauth.auth.validators.OAuthJWTValidator # Required to validate OAuth Access Tokens - - guillotina.auth.validators.SaltedHashPasswordValidator - - guillotina.auth.validators.JWTValidator ``` +Override `auth_token_validators` only when you need a custom validator order or additional validators. + ### 4. Set Write Permissions for GET Requests Guillotina normally prevents database writes on GET requests. Since the `/oauth/authorize` endpoint (which is a GET request) needs to create/validate authorization states, `check_writable_request` must allow writes for that path. Loading `guillotina.contrib.oauth` sets this automatically; override only if you use a custom checker: @@ -52,19 +51,28 @@ check_writable_request: guillotina.contrib.oauth.utils.writable.requires_writabl ### 5. Customize OAuth Server Settings (Optional) -Protocol settings (issuer, token TTLs, PKCE, scopes) live under the `oauth` block. PostgreSQL cleanup tuning lives under `load_utilities.oauth_storage.settings`: +Protocol settings (issuer, token TTLs, PKCE, scopes, rate limits) live under the `oauth` block. PostgreSQL cleanup tuning lives under `load_utilities.oauth_storage.settings`: ```yaml oauth: - enabled: true - issuer: null # Custom token issuer URL (e.g. "https://auth.example.com") + issuer: null # Custom issuer URL (e.g. "https://auth.example.com"); see below + trust_proxy_headers: false # Honor X-Forwarded-Proto / X-VirtualHost-* when deriving issuer authorization_code_ttl: 600 # Time to live in seconds for Authorization Codes (default 10 min) access_token_ttl: 3600 # Time to live in seconds for Access Tokens (default 1 hour) refresh_token_ttl: 2592000 # Time to live in seconds for Refresh Tokens (default 30 days) + consent_ttl: 2592000 # Remembered consent lifetime (default 30 days; 0 = indefinite) allowed_code_challenge_methods: # PKCE S256 is always required for public clients - S256 - scopes_supported: # Optional OAuth protocol label (not used for authorization) + scopes_supported: # Whitelist of scopes accepted at authorize and registration - guillotina:access + registration_rate_limit: 20 # Dynamic registration requests per IP (0 = disabled) + registration_rate_window: 600 + login_rate_limit: 10 # Failed login attempts per IP+username (0 = disabled) + login_rate_window: 300 + token_rate_limit: 120 # Token endpoint requests per IP (0 = disabled) + token_rate_window: 60 + revoke_rate_limit: 120 # Revocation endpoint requests per IP (0 = disabled) + revoke_rate_window: 60 load_utilities: oauth_storage: @@ -77,34 +85,95 @@ The same cleanup keys may still be set under `oauth` for backward compatibility; OAuth state is always persisted in PostgreSQL tables (`oauth_clients`, `oauth_authorization_codes`, …). A PostgreSQL database storage is required. +### 6. Issuer URL and Reverse Proxies + +The issuer URL appears in discovery metadata, JWT `iss` claims, and authorization redirects ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)). + +When `oauth.issuer` is set, it must be an absolute `http` or `https` URL without query, fragment, or userinfo. Production issuers must use `https` (plain `http` is allowed only for `localhost`, `127.0.0.1`, and `::1`). + +When `oauth.issuer` is `null` (the default), the issuer is derived from the request: + +- With `trust_proxy_headers: false` (the default), only the transport scheme and `Host` header are used. Spoofable `X-Forwarded-Proto` headers are ignored. +- With `trust_proxy_headers: true`, set this only behind a trusted reverse proxy so forwarded scheme and virtual-host headers are honored. + ## Discovery and OpenID Connect -The metadata URL is `/.well-known/oauth-authorization-server` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)). The OAuth contrib does not expose `/.well-known/openid-configuration` because that path identifies OpenID Connect provider metadata, and this contrib does not implement OpenID Connect (`id_token`, UserInfo, OIDC JWKS, subject types, etc.). +Authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) is exposed at: + +```text +GET /db/container/.well-known/oauth-authorization-server +GET /.well-known/oauth-authorization-server/db/container +``` + +The OAuth contrib does not expose `/.well-known/openid-configuration` because that path identifies OpenID Connect provider metadata, and this contrib does not implement OpenID Connect (`id_token`, UserInfo, OIDC JWKS, subject types, etc.). + +## Architecture: protocol phases + +The OAuth contrib is organized around the three phases of the protocol: + +| Phase | Module | RFC | +|-------|--------|-----| +| Discovery | `discovery/` | RFC 8414, RFC 9728 | +| Grant (resource validation) | `indicators/grant.py` | RFC 8707 | +| Access (token validation) | `indicators/access.py`, `auth/` | RFC 8707 | +| Token issuance | `flow/` | RFC 6749 | +| MCP integration | `integrations/mcp/` | — | + +### Resource indicator vocabulary + +A single *resource indicator* (the URL like `https://host/db/container/@mcp/protocol`) appears across discovery, grant, and access: + +| Phase | Where | Term | +|-------|-------|------| +| Discovery (protected resource) | `oauth-protected-resource` JSON `"resource"` field | resource | +| Grant | authorize/token `resource=` parameter | resource indicator | +| Access | JWT `aud` claim | resource indicator | + +Authorization server metadata (RFC 8414) does not include a `resource` field. The `resource` field appears only in protected resource metadata (RFC 9728). + +The same resource indicator at grant time becomes the `aud` of the JWT and is checked at access time. Multiple `resource=` parameters may be sent at authorize time; each allowed value is stored and included in `aud`. ## Allowed `resource` values (RFC 8707) -The `resource` parameter is restricted to URLs returned by registered resolvers in `guillotina.contrib.oauth.flow.resources`. The oauth application registers the **container issuer** by default (`https://host/db/container`). When both `guillotina.contrib.oauth` and `guillotina.contrib.mcp` are in `applications`, OAuth also loads the MCP integration, registers `{container}/@mcp/protocol`, and exposes MCP protected-resource metadata. That MCP resolver is ignored for OAuth-only deployments, so the MCP protocol URL is not accepted as a `resource` unless MCP is enabled. +The `resource` parameter is restricted to URLs returned by registered resolvers in `guillotina.contrib.oauth.indicators`. The oauth application registers the **container issuer** by default (`https://host/db/container`). When both `guillotina.contrib.oauth` and `guillotina.contrib.mcp` are in `applications`, OAuth also loads the MCP integration, registers `{container}/@mcp/protocol` (and subfolder MCP paths such as `{container}/subfolder/@mcp/protocol`), and exposes MCP protected-resource metadata. That MCP resolver is ignored for OAuth-only deployments, so the MCP protocol URL is not accepted as a `resource` unless MCP is enabled. + +At the token endpoint, `resource` is optional. When present, every value must be a subset of the resources bound to the authorization code or refresh token. -From your addon `includeme` (or startup hook): +Register allowed values from your addon `includeme` (or startup hook): ```python -from guillotina.contrib.oauth.flow.resources import register_oauth_resource_resolver +from guillotina.contrib.oauth.indicators.registry import register_allowed_indicator_resolver def my_resolver(request, container): - from guillotina.contrib.oauth.api.urls import container_url - base = container_url(request, container) + from guillotina.contrib.oauth.utils.urls import container_issuer_url + base = container_issuer_url(request, container) return {f"{base}/@services/my-hook"} -register_oauth_resource_resolver(my_resolver) +register_allowed_indicator_resolver(my_resolver) +``` + +Register a required audience for the access phase when a protocol endpoint must enforce a specific `aud` value: + +```python +from guillotina.contrib.oauth.indicators.registry import register_required_indicator_resolver + +def my_audience_resolver(request, container): + if str(getattr(request, "path", "") or "").endswith("/@services/my-hook"): + from guillotina.contrib.oauth.utils.urls import container_issuer_url + return f"{container_issuer_url(request, container)}/@services/my-hook" + +register_required_indicator_resolver(my_audience_resolver) ``` ## Dynamic client registration and redirect URIs `/oauth/authorize` accepts only redirect URIs that are already present on the client record. Loopback redirect URIs may use a different runtime port than the registered URI, as recommended for native apps. `/oauth/register` always creates a new public client and returns a server-issued `client_id`; client-supplied `client_id` values are rejected. The registration endpoint does not update existing clients and does not issue client secrets. Public clients that need multiple callbacks, such as Cursor native and loopback redirects, must include all allowed `redirect_uris` in the same dynamic client registration request. HTTPS redirect URIs are accepted for web clients. Plain HTTP is accepted only for loopback/native redirects (`localhost`, `127.0.0.1`, `::1`). Private-use native redirects using reverse-domain schemes such as `com.example.app:/oauth2redirect/provider` are accepted. Redirect URIs with fragments are rejected. +Registered clients must include the `guillotina:access` scope (the default when `scope` is omitted). Only scopes listed in `oauth.scopes_supported` are accepted. + ## Supported flow -The contrib implements an OAuth 2.0 Authorization Code + PKCE (`S256`) public-client profile, aligned with RFC 9700 guidance and selected extensions including dynamic client registration, authorization server metadata, resource indicators, issuer identification, protected resource metadata, opaque refresh tokens, revocation, and JWT access tokens signed with Guillotina's configured JWT secret. +The contrib implements an OAuth 2.0 Authorization Code + PKCE (`S256`) public-client profile, aligned with RFC 9700 guidance and selected extensions including dynamic client registration, authorization server metadata, resource indicators, issuer identification, protected resource metadata, opaque refresh tokens, revocation, and JWT access tokens signed with a key derived from Guillotina's configured `jwt.secret`. ![OAuth 2.0 authorization code flow with PKCE in Guillotina](../_static/oauth-flow.svg) @@ -127,16 +196,21 @@ When using MCP, protected resource metadata follows [RFC 9728](https://www.rfc-e ```text GET /db/container/.well-known/oauth-protected-resource GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol +GET /.well-known/oauth-protected-resource/db/container/subfolder/@mcp/protocol ``` Other container-scoped endpoints: ```text -POST /db/container/oauth/authorize +POST /db/container/oauth/authorize # login form, consent form, and consent submission POST /db/container/oauth/token POST /db/container/oauth/revoke +GET /db/container/oauth/consents # list remembered consents (authenticated) +POST /db/container/oauth/consents # revoke a remembered consent (authenticated) ``` +Opaque token prefixes: `goc_` (authorization codes), `gor_` (refresh tokens). + ## How to Use PKCE and the OAuth Flow (Step-by-Step) Follow these steps to generate PKCE credentials, register a client, authorize a user, and exchange the resulting authorization code for an Access Token. @@ -179,9 +253,10 @@ code_challenge = base64.urlsafe_b64encode(hash_digest).rstrip(b'=').decode('asci ```javascript // 1. Generate the random code_verifier (keep this secret!) function generateCodeVerifier() { - const array = new Uint32Array(56); + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const array = new Uint8Array(64); window.crypto.getRandomValues(array); - return Array.from(array, dec => dec.toString(16).padStart(2, "0")).join(""); + return Array.from(array, (b) => charset[b % charset.length]).join(""); } // 2. Compute the S256 code_challenge @@ -189,7 +264,7 @@ async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hashed = await window.crypto.subtle.digest("SHA-256", data); - + return btoa(String.fromCharCode(...new Uint8Array(hashed))) .replace(/\+/g, "-") .replace(/\//g, "_") @@ -227,8 +302,13 @@ http://localhost:8080/db/container/oauth/authorize?response_type=code&client_id= The `scope` parameter is required and must include `guillotina:access`. -Once the user logs in and consents, they will be redirected back to your `redirect_uri` with an authorization code parameter: -`http://127.0.0.1:12345/callback?code=goc_XYZ123&state=some_random_state` +The GET request returns an HTML login form. The user submits credentials via **POST** to the same `/oauth/authorize` URL (preserving all query parameters and adding `username`, `password`, and the hidden `oauth_csrf` field from the form). If consent is required, a consent form is shown and the user submits **POST** with `decision=allow` (or `deny`) and the same parameters. + +Once the user logs in and consents, they are redirected back to your `redirect_uri`. The redirect includes the authorization code, the original `state`, and the issuer identifier `iss` ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)): + +```text +http://127.0.0.1:12345/callback?code=goc_XYZ123&state=some_random_state&iss=http://localhost:8080/db/container +``` ### Step 4: Exchange the Code for Access & Refresh Tokens (Send Verifier) @@ -240,6 +320,8 @@ curl -X POST http://localhost:8080/db/container/oauth/token \ -d 'grant_type=authorization_code&client_id=CLIENT_ID&redirect_uri=http://127.0.0.1:12345/callback&code=goc_XYZ123&code_verifier=YOUR_CODE_VERIFIER' ``` +You may optionally narrow the token audience with `resource=` (every value must have been authorized in Step 3). + If successful, the response will contain your `access_token` and `refresh_token`. ### Step 5: Refresh and Revoke (Optional) @@ -265,15 +347,29 @@ Guillotina rotates refresh tokens on every successful refresh. The response cont Clients must persist the new `refresh_token` and discard the old one immediately. The previous refresh token is revoked as soon as the rotation succeeds. -If an older refresh token is reused, Guillotina treats it as a replay signal and revokes the refresh-token family created from the same authorization code. OAuth clients should serialize refresh operations so two concurrent requests do not try to use the same refresh token at the same time. +Reusing an already-rotated refresh token returns `invalid_grant` but does not invalidate the current token in the rotation chain. Serialize refresh operations so two concurrent requests do not try to use the same refresh token at the same time. -To revoke an active refresh token: +To revoke an active refresh token (this revokes the entire refresh-token family from the same authorization grant): ```bash curl -X POST http://localhost:8080/db/container/oauth/revoke \ -d 'client_id=CLIENT_ID&token=YOUR_REFRESH_TOKEN&token_type_hint=refresh_token' ``` +Access token revocation is not supported (`token_type_hint=access_token` returns `unsupported_token_type`). Revoking a refresh token also deletes the remembered consent for that grant. + +Authenticated users can list and revoke remembered consents. Revoking a consent also revokes every refresh token that user holds for that client: + +```bash +curl http://localhost:8080/db/container/oauth/consents \ + -H "Authorization: Bearer ACCESS_TOKEN" + +curl -X POST http://localhost:8080/db/container/oauth/consents \ + -H "Authorization: Bearer ACCESS_TOKEN" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d 'consent_key=CONSENT_KEY' +``` + ## Authorization model OAuth provides **authentication** and **resource binding**. **Authorization** is always enforced with native Guillotina permissions on the authenticated user. @@ -314,3 +410,5 @@ Example Cursor `mcp.json`: ``` `@login` JWTs authenticate Guillotina sessions directly. OAuth access tokens include `token_type=oauth_access_token`, `client_id`, `scope` and audience/resource claims and are validated by the OAuth validator. MCP clients should use OAuth discovery and must not store manually copied bearer tokens in configuration. + +MCP also accepts native Guillotina authentication (for example an existing `@login` session) when OAuth is not used. diff --git a/guillotina/contrib/oauth/__init__.py b/guillotina/contrib/oauth/__init__.py index 8865b205d..e083efdff 100644 --- a/guillotina/contrib/oauth/__init__.py +++ b/guillotina/contrib/oauth/__init__.py @@ -52,10 +52,13 @@ def includeme(root, settings): - from guillotina.contrib.oauth.flow.resources import ensure_default_oauth_resources_registered + from guillotina.contrib.oauth.indicators.registry import ensure_default_resource_indicators_registered - ensure_default_oauth_resources_registered() + ensure_default_resource_indicators_registered() configure.scan("guillotina.contrib.oauth.install") configure.scan("guillotina.contrib.oauth.api.services") if "guillotina.contrib.mcp" in set(settings.get("applications") or []): configure.scan("guillotina.contrib.oauth.integrations.mcp") + from guillotina.contrib.oauth.integrations.mcp import register_mcp_oauth_integration + + register_mcp_oauth_integration() diff --git a/guillotina/contrib/oauth/api/endpoints/authorize.py b/guillotina/contrib/oauth/api/endpoints/authorize.py index f44987129..1c7a8c052 100644 --- a/guillotina/contrib/oauth/api/endpoints/authorize.py +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -12,9 +12,9 @@ from guillotina.contrib.oauth.flow.consent import build_consent_key from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid -from guillotina.contrib.oauth.flow.resources import validate_resource from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported from guillotina.contrib.oauth.flow.tokens import generate_opaque_token +from guillotina.contrib.oauth.indicators.grant import validate_resource_indicator from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded from guillotina.contrib.oauth.utils.request import ( normalize_list, @@ -55,7 +55,7 @@ async def authorization_endpoint(service, store): return _authorization_redirect(redirect_uri, params, issuer, {"error": authorization_error}) try: - resources = validate_resource(service.request, service.context, params.get("resource")) + resources = validate_resource_indicator(service.request, service.context, params.get("resource")) except HTTPBadRequest: return _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_target"}) diff --git a/guillotina/contrib/oauth/api/services.py b/guillotina/contrib/oauth/api/services.py index 1abb7a47b..24d40f33c 100644 --- a/guillotina/contrib/oauth/api/services.py +++ b/guillotina/contrib/oauth/api/services.py @@ -1,3 +1,4 @@ +import guillotina.contrib.oauth.discovery # noqa — triggers well-known handler registrations from guillotina import configure from guillotina.api.service import Service from guillotina.contrib.oauth.api.endpoints.authorize import authorization_endpoint @@ -6,7 +7,7 @@ from guillotina.contrib.oauth.api.endpoints.register import client_registration_endpoint from guillotina.contrib.oauth.api.endpoints.revoke import token_revocation_endpoint from guillotina.contrib.oauth.api.endpoints.token import token_endpoint -from guillotina.contrib.oauth.api.well_known import WELL_KNOWN_HANDLERS, serve_well_known_metadata +from guillotina.contrib.oauth.discovery.routing import WELL_KNOWN_HANDLERS, serve_well_known_metadata from guillotina.interfaces import IApplication, IContainer from guillotina.response import HTTPNotFound diff --git a/guillotina/contrib/oauth/api/well_known.py b/guillotina/contrib/oauth/api/well_known.py deleted file mode 100644 index 6a7524933..000000000 --- a/guillotina/contrib/oauth/api/well_known.py +++ /dev/null @@ -1,92 +0,0 @@ -from guillotina import task_vars -from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported -from guillotina.contrib.oauth.storage.access import get_oauth_store -from guillotina.contrib.oauth.utils.urls import container_issuer_url -from guillotina.interfaces import IContainer -from guillotina.response import HTTPNotFound -from guillotina.transactions import transaction -from guillotina.utils import get_database, get_registry - - -# Registry of ``.well-known`` metadata handlers keyed by document name. Other -# packages (e.g. the MCP integration) register additional documents here. -WELL_KNOWN_HANDLERS = {} - -# Registry of protected-resource metadata providers (RFC 9728). Each provider -# receives (request, container, protected_path) and returns metadata dict or -# None when it does not handle the requested resource. -_PROTECTED_RESOURCE_PROVIDERS = [] - - -def register_well_known_handler(name, handler): - WELL_KNOWN_HANDLERS[name] = handler - - -def register_protected_resource_provider(provider): - _PROTECTED_RESOURCE_PROVIDERS.append(provider) - - -def _authorization_server_metadata(request, container): - issuer = container_issuer_url(request, container) - return { - "issuer": issuer, - "authorization_endpoint": f"{issuer}/oauth/authorize", - "token_endpoint": f"{issuer}/oauth/token", - "registration_endpoint": f"{issuer}/oauth/register", - "revocation_endpoint": f"{issuer}/oauth/revoke", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["none"], - "revocation_endpoint_auth_methods_supported": ["none"], - "resource_indicators_supported": True, - "authorization_response_iss_parameter_supported": True, - "scopes_supported": oauth_scopes_supported(), - } - - -register_well_known_handler("oauth-authorization-server", _authorization_server_metadata) - - -def _protected_resource_metadata(request, container): - protected_path = getattr(request, "oauth_protected_resource_path", None) - for provider in _PROTECTED_RESOURCE_PROVIDERS: - metadata = provider(request, container, protected_path) - if metadata is not None: - return metadata - raise HTTPNotFound(content={"reason": "Unknown protected resource"}) - - -register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) - - -def _split_well_known_target_path(path_value, *, allow_resource_path=False): - parts = [part for part in path_value.strip("/").split("/") if part] - if len(parts) < 2: - raise HTTPNotFound(content={"reason": "Invalid path"}) - if not allow_resource_path and len(parts) > 2: - raise HTTPNotFound(content={"reason": "Invalid issuer path"}) - return parts[0], parts[1], "/" + "/".join(parts) - - -async def serve_well_known_metadata(request, action, target_path, handlers): - allow_resource_path = action == "oauth-protected-resource" - db_id, container_id, protected_resource_path = _split_well_known_target_path( - target_path, allow_resource_path=allow_resource_path - ) - db = await get_database(db_id) - async with transaction(db=db): - root = await db.get_transaction_manager().get_root() - try: - container = await root.async_get(container_id) - except KeyError: - raise HTTPNotFound(content={"reason": "Container not found"}) - if not IContainer.providedBy(container): - raise HTTPNotFound(content={"reason": "Container not found"}) - task_vars.container.set(container) - task_vars.registry.set(None) - await get_registry(container) - get_oauth_store(container, require_installed=True) - if allow_resource_path: - request.oauth_protected_resource_path = protected_resource_path - return handlers[action](request, container) diff --git a/guillotina/contrib/oauth/auth/context.py b/guillotina/contrib/oauth/auth/context.py new file mode 100644 index 000000000..c2a34d3bf --- /dev/null +++ b/guillotina/contrib/oauth/auth/context.py @@ -0,0 +1,13 @@ +"""Typed context for a validated OAuth access token attached to the request.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class OAuthTokenContext: + client_id: str + scopes: frozenset[str] + resource_indicators: frozenset[str] + claims: dict diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py index 086890fba..f91941106 100644 --- a/guillotina/contrib/oauth/auth/validators.py +++ b/guillotina/contrib/oauth/auth/validators.py @@ -2,8 +2,9 @@ from guillotina import app_settings, task_vars from guillotina.auth import find_user -from guillotina.contrib.oauth.flow.resources import oauth_required_audience +from guillotina.contrib.oauth.auth.context import OAuthTokenContext from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE +from guillotina.contrib.oauth.indicators.access import required_resource_indicator from guillotina.contrib.oauth.utils.crypto import access_token_signing_key from guillotina.contrib.oauth.utils.urls import container_issuer_url @@ -35,7 +36,7 @@ async def validate(self, token): if claims.get("iss") != issuer: return aud = set(claims.get("aud") or []) - if oauth_required_audience(request, container) not in aud: + if required_resource_indicator(request, container) not in aud: return if not claims.get("client_id"): return @@ -45,12 +46,11 @@ async def validate(self, token): token["id"] = claims.get("id", claims.get("sub")) token["decoded"] = claims user = await find_user(token) - if user is not None and user.id == token["id"]: - if request is not None: - request.oauth = { - "client_id": claims.get("client_id"), - "scopes": scopes, - "resources": set(claims.get("aud") or []), - "claims": claims, - } - return user + if user is not None and user.id == token["id"] and request is not None: + request.oauth = OAuthTokenContext( + client_id=claims.get("client_id"), + scopes=frozenset(scopes), + resource_indicators=frozenset(claims.get("aud") or []), + claims=claims, + ) + return user diff --git a/guillotina/contrib/oauth/discovery/__init__.py b/guillotina/contrib/oauth/discovery/__init__.py new file mode 100644 index 000000000..c224d68ed --- /dev/null +++ b/guillotina/contrib/oauth/discovery/__init__.py @@ -0,0 +1,4 @@ +"""Discovery package — triggers registration of well-known handlers on import.""" + +from guillotina.contrib.oauth.discovery import authorization_server as _as # noqa +from guillotina.contrib.oauth.discovery import protected_resource as _pr # noqa diff --git a/guillotina/contrib/oauth/discovery/authorization_server.py b/guillotina/contrib/oauth/discovery/authorization_server.py new file mode 100644 index 000000000..8eab6de15 --- /dev/null +++ b/guillotina/contrib/oauth/discovery/authorization_server.py @@ -0,0 +1,27 @@ +"""OAuth 2.0 Authorization Server Metadata (RFC 8414).""" + +from guillotina.contrib.oauth.discovery.routing import register_well_known_handler +from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def _authorization_server_metadata(request, container): + issuer = container_issuer_url(request, container) + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "registration_endpoint": f"{issuer}/oauth/register", + "revocation_endpoint": f"{issuer}/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "revocation_endpoint_auth_methods_supported": ["none"], + "resource_indicators_supported": True, + "authorization_response_iss_parameter_supported": True, + "scopes_supported": oauth_scopes_supported(), + } + + +register_well_known_handler("oauth-authorization-server", _authorization_server_metadata) diff --git a/guillotina/contrib/oauth/discovery/protected_resource.py b/guillotina/contrib/oauth/discovery/protected_resource.py new file mode 100644 index 000000000..c61c06e1b --- /dev/null +++ b/guillotina/contrib/oauth/discovery/protected_resource.py @@ -0,0 +1,27 @@ +"""OAuth 2.0 Protected Resource Metadata (RFC 9728).""" + +from guillotina.contrib.oauth.discovery.routing import register_well_known_handler +from guillotina.response import HTTPNotFound + + +_PROTECTED_RESOURCE_PROVIDERS = [] + + +def register_protected_resource_provider(provider): + _PROTECTED_RESOURCE_PROVIDERS.append(provider) + + +def reset_protected_resource_providers() -> None: + _PROTECTED_RESOURCE_PROVIDERS.clear() + + +def _protected_resource_metadata(request, container): + protected_path = getattr(request, "oauth_protected_resource_path", None) + for provider in _PROTECTED_RESOURCE_PROVIDERS: + metadata = provider(request, container, protected_path) + if metadata is not None: + return metadata + raise HTTPNotFound(content={"reason": "Unknown protected resource"}) + + +register_well_known_handler("oauth-protected-resource", _protected_resource_metadata) diff --git a/guillotina/contrib/oauth/discovery/routing.py b/guillotina/contrib/oauth/discovery/routing.py new file mode 100644 index 000000000..728320251 --- /dev/null +++ b/guillotina/contrib/oauth/discovery/routing.py @@ -0,0 +1,47 @@ +"""Discovery routing — well-known handler registry and request dispatching.""" + +from guillotina import task_vars +from guillotina.contrib.oauth.storage.access import get_oauth_store +from guillotina.interfaces import IContainer +from guillotina.response import HTTPNotFound +from guillotina.transactions import transaction +from guillotina.utils import get_database, get_registry + + +WELL_KNOWN_HANDLERS = {} + + +def register_well_known_handler(name, handler): + WELL_KNOWN_HANDLERS[name] = handler + + +def _split_well_known_target_path(path_value, *, allow_resource_path=False): + parts = [part for part in path_value.strip("/").split("/") if part] + if len(parts) < 2: + raise HTTPNotFound(content={"reason": "Invalid path"}) + if not allow_resource_path and len(parts) > 2: + raise HTTPNotFound(content={"reason": "Invalid issuer path"}) + return parts[0], parts[1], "/" + "/".join(parts) + + +async def serve_well_known_metadata(request, action, target_path, handlers): + allow_resource_path = action == "oauth-protected-resource" + db_id, container_id, protected_resource_path = _split_well_known_target_path( + target_path, allow_resource_path=allow_resource_path + ) + db = await get_database(db_id) + async with transaction(db=db): + root = await db.get_transaction_manager().get_root() + try: + container = await root.async_get(container_id) + except KeyError: + raise HTTPNotFound(content={"reason": "Container not found"}) + if not IContainer.providedBy(container): + raise HTTPNotFound(content={"reason": "Container not found"}) + task_vars.container.set(container) + task_vars.registry.set(None) + await get_registry(container) + get_oauth_store(container, require_installed=True) + if allow_resource_path: + request.oauth_protected_resource_path = protected_resource_path + return handlers[action](request, container) diff --git a/guillotina/contrib/oauth/discovery/urls.py b/guillotina/contrib/oauth/discovery/urls.py new file mode 100644 index 000000000..702a99e60 --- /dev/null +++ b/guillotina/contrib/oauth/discovery/urls.py @@ -0,0 +1,10 @@ +"""Well-known URL construction helpers.""" + +from urllib.parse import urlparse + +from guillotina.contrib.oauth.indicators.access import required_resource_indicator + + +def well_known_protected_resource_url(request, container): + parsed = urlparse(required_resource_indicator(request, container)) + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource/{parsed.path.lstrip('/')}" diff --git a/guillotina/contrib/oauth/flow/resources.py b/guillotina/contrib/oauth/flow/resources.py deleted file mode 100644 index 511be759f..000000000 --- a/guillotina/contrib/oauth/flow/resources.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Extensible OAuth `resource` identifiers (RFC 8707 style) for this authorization server. - -Resource resolvers are callables ``(request, container) -> Iterable[str]`` of -absolute resource URIs allowed in authorize/token requests. - -Audience resolvers are callables ``(request, container) -> str | None``. They -allow protocol integrations to declare the exact audience required for the -current request without coupling the OAuth validator to protocol-specific paths. - -The oauth contrib registers the container issuer URL by default. Other packages -(for example MCP) register additional URIs via :func:`register_oauth_resource_resolver`. -""" - -from __future__ import annotations - -from typing import Callable, FrozenSet, Iterable, List, Optional - -from guillotina.contrib.oauth.utils.urls import container_issuer_url - - -ResourceResolver = Callable[..., Iterable[str]] -AudienceResolver = Callable[..., Optional[str]] - -_resource_resolvers: List[ResourceResolver] = [] -_audience_resolvers: List[AudienceResolver] = [] -_default_registered = False - - -def register_oauth_resource_resolver(resolver: ResourceResolver) -> None: - if resolver not in _resource_resolvers: - _resource_resolvers.append(resolver) - - -def register_oauth_audience_resolver(resolver: AudienceResolver) -> None: - if resolver not in _audience_resolvers: - _audience_resolvers.append(resolver) - - -def _default_container_resolver(request, container): - return {container_issuer_url(request, container)} - - -def ensure_default_oauth_resources_registered() -> None: - global _default_registered - if _default_registered: - return - register_oauth_resource_resolver(_default_container_resolver) - _default_registered = True - - -def oauth_allowed_resources(request, container) -> FrozenSet[str]: - from guillotina import app_settings as _apps - - ensure_default_oauth_resources_registered() - applications = set(_apps.get("applications") or []) - out: set = set() - for resolver in _resource_resolvers: - if ( - getattr(resolver, "_oauth_resource_source", None) == "mcp" - and "guillotina.contrib.mcp" not in applications - ): - continue - urls = resolver(request, container) - if urls: - out.update(urls) - return frozenset(out) - - -def oauth_required_audience(request, container) -> str: - from guillotina import app_settings as _apps - - applications = set(_apps.get("applications") or []) - for resolver in _audience_resolvers: - if ( - getattr(resolver, "_oauth_resource_source", None) == "mcp" - and "guillotina.contrib.mcp" not in applications - ): - continue - resource = resolver(request, container) - if resource: - return resource - return container_issuer_url(request, container) - - -def validate_resource(request, container, resources): - from guillotina.contrib.oauth.utils.errors import raise_oauth_error - from guillotina.contrib.oauth.utils.request import normalize_list - - base = container_issuer_url(request, container) - allowed = oauth_allowed_resources(request, container) - if not resources: - return [base] - resources = normalize_list(resources) - for resource in resources: - if resource not in allowed: - raise_oauth_error("invalid_target", "resource is not allowed") - return resources diff --git a/guillotina/contrib/oauth/indicators/__init__.py b/guillotina/contrib/oauth/indicators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/guillotina/contrib/oauth/indicators/access.py b/guillotina/contrib/oauth/indicators/access.py new file mode 100644 index 000000000..fbf2990c8 --- /dev/null +++ b/guillotina/contrib/oauth/indicators/access.py @@ -0,0 +1,18 @@ +"""Resource indicator during the access phase (runtime JWT validation). + +Checks the required resource indicator (derived from ``aud`` claim) for the +current request. +""" + +from __future__ import annotations + +from guillotina.contrib.oauth.indicators.registry import _required_resolvers +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def required_resource_indicator(request, container) -> str: + for resolver in _required_resolvers: + indicator = resolver(request, container) + if indicator: + return indicator + return container_issuer_url(request, container) diff --git a/guillotina/contrib/oauth/indicators/grant.py b/guillotina/contrib/oauth/indicators/grant.py new file mode 100644 index 000000000..a8a625b5a --- /dev/null +++ b/guillotina/contrib/oauth/indicators/grant.py @@ -0,0 +1,40 @@ +"""Resource indicators during the grant phase (authorize/token endpoints). + +Validates the ``resource=`` parameter (RFC 8707) against registered allowed +indicator resolvers. +""" + +from __future__ import annotations + +from typing import FrozenSet + +from guillotina.contrib.oauth.indicators.registry import ( + _allowed_resolvers, + ensure_default_resource_indicators_registered, +) +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def allowed_resource_indicators(request, container) -> FrozenSet[str]: + ensure_default_resource_indicators_registered() + out: set = set() + for resolver in _allowed_resolvers: + urls = resolver(request, container) + if urls: + out.update(urls) + return frozenset(out) + + +def validate_resource_indicator(request, container, resource_indicators): + from guillotina.contrib.oauth.utils.errors import raise_oauth_error + from guillotina.contrib.oauth.utils.request import normalize_list + + base = container_issuer_url(request, container) + allowed = allowed_resource_indicators(request, container) + if not resource_indicators: + return [base] + resource_indicators = normalize_list(resource_indicators) + for indicator in resource_indicators: + if indicator not in allowed: + raise_oauth_error("invalid_target", "resource is not allowed") + return resource_indicators diff --git a/guillotina/contrib/oauth/indicators/registry.py b/guillotina/contrib/oauth/indicators/registry.py new file mode 100644 index 000000000..cb8eede91 --- /dev/null +++ b/guillotina/contrib/oauth/indicators/registry.py @@ -0,0 +1,56 @@ +"""Extensible OAuth resource indicator registries (RFC 8707). + +Resource indicator resolvers are callables ``(request, container) -> Iterable[str]`` +of absolute resource URIs allowed in authorize/token requests. + +Required indicator resolvers are callables ``(request, container) -> str | None``. +They allow protocol integrations to declare the exact resource indicator required +for the current request without coupling the OAuth validator to protocol-specific paths. + +The oauth contrib registers the container issuer URL by default. Other packages +(for example MCP) register additional URIs from their own integration package, +typically loaded only when that addon is enabled. +""" + +from __future__ import annotations + +from typing import Callable, Iterable, List, Optional + +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +ResourceResolver = Callable[..., Iterable[str]] +AudienceResolver = Callable[..., Optional[str]] + +_allowed_resolvers: List[ResourceResolver] = [] +_required_resolvers: List[AudienceResolver] = [] +_default_registered = False + + +def register_allowed_indicator_resolver(resolver: ResourceResolver) -> None: + if resolver not in _allowed_resolvers: + _allowed_resolvers.append(resolver) + + +def register_required_indicator_resolver(resolver: AudienceResolver) -> None: + if resolver not in _required_resolvers: + _required_resolvers.append(resolver) + + +def _default_container_resolver(request, container): + return {container_issuer_url(request, container)} + + +def ensure_default_resource_indicators_registered() -> None: + global _default_registered + if _default_registered: + return + register_allowed_indicator_resolver(_default_container_resolver) + _default_registered = True + + +def reset_indicator_registries() -> None: + global _default_registered + _allowed_resolvers.clear() + _required_resolvers.clear() + _default_registered = False diff --git a/guillotina/contrib/oauth/integrations/mcp.py b/guillotina/contrib/oauth/integrations/mcp.py deleted file mode 100644 index 23d5081eb..000000000 --- a/guillotina/contrib/oauth/integrations/mcp.py +++ /dev/null @@ -1,130 +0,0 @@ -from urllib.parse import urlparse - -from zope.interface import implementer - -from guillotina import app_settings, configure -from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy -from guillotina.contrib.oauth.api.well_known import register_protected_resource_provider -from guillotina.contrib.oauth.flow.resources import ( - register_oauth_audience_resolver, - register_oauth_resource_resolver, -) -from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported -from guillotina.contrib.oauth.utils.request import normalize_list -from guillotina.contrib.oauth.utils.urls import container_issuer_url, well_known_protected_resource_url - - -def _mcp_resource_url_from_path(request, container, path): - issuer = urlparse(container_issuer_url(request, container)) - target_path = "/" + str(path or "").strip("/") - container_path = issuer.path.rstrip("/") - if not target_path.endswith("/@mcp/protocol"): - return None - if target_path != f"{container_path}/@mcp/protocol" and not target_path.startswith(f"{container_path}/"): - return None - return f"{issuer.scheme}://{issuer.netloc}{target_path}" - - -def _mcp_resource_url_from_value(request, container, value): - issuer = urlparse(container_issuer_url(request, container)) - parsed = urlparse(value) - if parsed.scheme != issuer.scheme or parsed.netloc != issuer.netloc: - return None - if parsed.query or parsed.fragment: - return None - return _mcp_resource_url_from_path(request, container, parsed.path) - - -def mcp_resource(request, container): - protected_path = getattr(request, "oauth_protected_resource_path", None) - if protected_path: - resource = _mcp_resource_url_from_path(request, container, protected_path) - if resource: - return resource - request_path = str(getattr(request, "path", "") or "") - if request_path.endswith("/@mcp/protocol"): - resource = _mcp_resource_url_from_path(request, container, request_path) - if resource: - return resource - return f"{container_issuer_url(request, container)}/@mcp/protocol" - - -def _mcp_protocol_resource_resolver(request, container): - resources = {f"{container_issuer_url(request, container)}/@mcp/protocol"} - params = getattr(request, "oauth_request_params", {}) or {} - for value in normalize_list(params.get("resource")): - resource = _mcp_resource_url_from_value(request, container, value) - if resource: - resources.add(resource) - return resources - - -def _mcp_protocol_audience_resolver(request, container): - if str(getattr(request, "path", "") or "").endswith("/@mcp/protocol"): - return mcp_resource(request, container) - - -setattr(_mcp_protocol_resource_resolver, "_oauth_resource_source", "mcp") -setattr(_mcp_protocol_audience_resolver, "_oauth_resource_source", "mcp") -register_oauth_resource_resolver(_mcp_protocol_resource_resolver) -register_oauth_audience_resolver(_mcp_protocol_audience_resolver) - - -def _mcp_protected_resource_provider(request, context, protected_path): - resource = mcp_resource(request, context) if protected_path is None else None - if resource is None: - resource = _mcp_resource_url_from_path(request, context, protected_path) - if resource is None: - return None - issuer = container_issuer_url(request, context) - return { - "resource": resource, - "authorization_servers": [issuer], - "scopes_supported": oauth_scopes_supported(), - } - - -register_protected_resource_provider(_mcp_protected_resource_provider) - - -@configure.utility(provides=IMCPAuthPolicy) -@implementer(IMCPAuthPolicy) -class OAuthMCPAuthPolicy: - def is_enabled(self, request, context): - app = getattr(getattr(request, "application", None), "app", None) - settings = getattr(app, "settings", None) or app_settings - applications = set(settings.get("applications") or []) - return "guillotina.contrib.oauth" in applications and "guillotina.contrib.mcp" in applications - - def unauthorized_headers(self, request, context): - authz = request.headers.get("AUTHORIZATION", "") or request.headers.get("Authorization", "") - if authz.lower().startswith("bearer "): - return self.forbidden_headers(request, context) - return self._challenge_headers(request, context) - - def forbidden_headers(self, request, context): - return self._challenge_headers( - request, - context, - error="invalid_token", - error_description="OAuth access token is not valid for this protected resource", - ) - - def _challenge_headers(self, request, context, *, error=None, error_description=None): - metadata = well_known_protected_resource_url(request, context) - parts = [ - 'Bearer realm="guillotina-mcp"', - f'resource_metadata="{metadata}"', - f'scope="{OAUTH_DEFAULT_SCOPE}"', - ] - if error: - parts.append(f'error="{error}"') - if error_description: - parts.append(f'error_description="{error_description}"') - return {"WWW-Authenticate": ", ".join(parts)} - - def is_authorized(self, request, context): - oauth = getattr(request, "oauth", None) - if oauth is None: - return True - return mcp_resource(request, context) in oauth.get("resources", set()) diff --git a/guillotina/contrib/oauth/integrations/mcp/__init__.py b/guillotina/contrib/oauth/integrations/mcp/__init__.py new file mode 100644 index 000000000..9182a3db5 --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp/__init__.py @@ -0,0 +1,21 @@ +"""MCP integration for OAuth resource indicators, discovery, and auth policy.""" + +from guillotina.contrib.oauth.integrations.mcp import access as _access # noqa: F401 +from guillotina.contrib.oauth.integrations.mcp import discovery as _disc # noqa: F401 +from guillotina.contrib.oauth.integrations.mcp import grant as _grant # noqa: F401 +from guillotina.contrib.oauth.integrations.mcp import identifiers as _ids # noqa: F401 + + +def register_mcp_oauth_integration() -> None: + from guillotina.contrib.oauth.discovery.protected_resource import register_protected_resource_provider + from guillotina.contrib.oauth.indicators.registry import ( + register_allowed_indicator_resolver, + register_required_indicator_resolver, + ) + from guillotina.contrib.oauth.integrations.mcp.access import _mcp_protocol_audience_resolver + from guillotina.contrib.oauth.integrations.mcp.discovery import _mcp_protected_resource_provider + from guillotina.contrib.oauth.integrations.mcp.grant import _mcp_protocol_resource_resolver + + register_allowed_indicator_resolver(_mcp_protocol_resource_resolver) + register_required_indicator_resolver(_mcp_protocol_audience_resolver) + register_protected_resource_provider(_mcp_protected_resource_provider) diff --git a/guillotina/contrib/oauth/integrations/mcp/access.py b/guillotina/contrib/oauth/integrations/mcp/access.py new file mode 100644 index 000000000..189b767c1 --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp/access.py @@ -0,0 +1,57 @@ +"""MCP required indicator resolver and auth policy (access phase).""" + +from zope.interface import implementer + +from guillotina import app_settings, configure +from guillotina.contrib.mcp.interfaces import IMCPAuthPolicy +from guillotina.contrib.oauth.discovery.urls import well_known_protected_resource_url +from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE +from guillotina.contrib.oauth.integrations.mcp.identifiers import mcp_resource_indicator + + +def _mcp_protocol_audience_resolver(request, container): + if str(getattr(request, "path", "") or "").endswith("/@mcp/protocol"): + return mcp_resource_indicator(request, container) + + +@configure.utility(provides=IMCPAuthPolicy) +@implementer(IMCPAuthPolicy) +class OAuthMCPAuthPolicy: + def is_enabled(self, request, context): + app = getattr(getattr(request, "application", None), "app", None) + settings = getattr(app, "settings", None) or app_settings + applications = set(settings.get("applications") or []) + return "guillotina.contrib.oauth" in applications and "guillotina.contrib.mcp" in applications + + def unauthorized_headers(self, request, context): + authz = request.headers.get("AUTHORIZATION", "") or request.headers.get("Authorization", "") + if authz.lower().startswith("bearer "): + return self.forbidden_headers(request, context) + return self._challenge_headers(request, context) + + def forbidden_headers(self, request, context): + return self._challenge_headers( + request, + context, + error="invalid_token", + error_description="OAuth access token is not valid for this protected resource", + ) + + def _challenge_headers(self, request, context, *, error=None, error_description=None): + metadata = well_known_protected_resource_url(request, context) + parts = [ + 'Bearer realm="guillotina-mcp"', + f'resource_metadata="{metadata}"', + f'scope="{OAUTH_DEFAULT_SCOPE}"', + ] + if error: + parts.append(f'error="{error}"') + if error_description: + parts.append(f'error_description="{error_description}"') + return {"WWW-Authenticate": ", ".join(parts)} + + def is_authorized(self, request, context): + oauth = getattr(request, "oauth", None) + if oauth is None: + return True + return mcp_resource_indicator(request, context) in oauth.resource_indicators diff --git a/guillotina/contrib/oauth/integrations/mcp/discovery.py b/guillotina/contrib/oauth/integrations/mcp/discovery.py new file mode 100644 index 000000000..e1cadbccc --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp/discovery.py @@ -0,0 +1,22 @@ +"""MCP protected resource metadata provider (discovery phase).""" + +from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported +from guillotina.contrib.oauth.integrations.mcp.identifiers import ( + _mcp_resource_url_from_path, + mcp_resource_indicator, +) +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def _mcp_protected_resource_provider(request, context, protected_path): + resource = mcp_resource_indicator(request, context) if protected_path is None else None + if resource is None: + resource = _mcp_resource_url_from_path(request, context, protected_path) + if resource is None: + return None + issuer = container_issuer_url(request, context) + return { + "resource": resource, + "authorization_servers": [issuer], + "scopes_supported": oauth_scopes_supported(), + } diff --git a/guillotina/contrib/oauth/integrations/mcp/grant.py b/guillotina/contrib/oauth/integrations/mcp/grant.py new file mode 100644 index 000000000..99a27ab9b --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp/grant.py @@ -0,0 +1,15 @@ +"""MCP allowed indicator resolver (grant phase).""" + +from guillotina.contrib.oauth.integrations.mcp.identifiers import _mcp_resource_url_from_value +from guillotina.contrib.oauth.utils.request import normalize_list +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def _mcp_protocol_resource_resolver(request, container): + resources = {f"{container_issuer_url(request, container)}/@mcp/protocol"} + params = getattr(request, "oauth_request_params", {}) or {} + for value in normalize_list(params.get("resource")): + resource = _mcp_resource_url_from_value(request, container, value) + if resource: + resources.add(resource) + return resources diff --git a/guillotina/contrib/oauth/integrations/mcp/identifiers.py b/guillotina/contrib/oauth/integrations/mcp/identifiers.py new file mode 100644 index 000000000..275959505 --- /dev/null +++ b/guillotina/contrib/oauth/integrations/mcp/identifiers.py @@ -0,0 +1,40 @@ +"""MCP resource indicator identification helpers.""" + +from urllib.parse import urlparse + +from guillotina.contrib.oauth.utils.urls import container_issuer_url + + +def _mcp_resource_url_from_path(request, container, path): + issuer = urlparse(container_issuer_url(request, container)) + target_path = "/" + str(path or "").strip("/") + container_path = issuer.path.rstrip("/") + if not target_path.endswith("/@mcp/protocol"): + return None + if target_path != f"{container_path}/@mcp/protocol" and not target_path.startswith(f"{container_path}/"): + return None + return f"{issuer.scheme}://{issuer.netloc}{target_path}" + + +def _mcp_resource_url_from_value(request, container, value): + issuer = urlparse(container_issuer_url(request, container)) + parsed = urlparse(value) + if parsed.scheme != issuer.scheme or parsed.netloc != issuer.netloc: + return None + if parsed.query or parsed.fragment: + return None + return _mcp_resource_url_from_path(request, container, parsed.path) + + +def mcp_resource_indicator(request, container): + protected_path = getattr(request, "oauth_protected_resource_path", None) + if protected_path: + resource = _mcp_resource_url_from_path(request, container, protected_path) + if resource: + return resource + request_path = str(getattr(request, "path", "") or "") + if request_path.endswith("/@mcp/protocol"): + resource = _mcp_resource_url_from_path(request, container, request_path) + if resource: + return resource + return f"{container_issuer_url(request, container)}/@mcp/protocol" diff --git a/guillotina/contrib/oauth/utils/urls.py b/guillotina/contrib/oauth/utils/urls.py index 0e1e76927..96c29fd3e 100644 --- a/guillotina/contrib/oauth/utils/urls.py +++ b/guillotina/contrib/oauth/utils/urls.py @@ -20,10 +20,6 @@ def container_issuer_url(request, container): path = get_full_content_path(container) if app_settings.get("oauth", {}).get("trust_proxy_headers", False): return get_url(request, path).rstrip("/") - # Secure default: do not honor client-spoofable forwarding/virtualhost headers - # (X-Forwarded-Proto, X-VirtualHost-*) when deriving the OAuth issuer. Only the - # transport scheme and the Host header are used. For HTTPS deployments behind a - # trusted reverse proxy set oauth.trust_proxy_headers=True, or pin oauth.issuer. return build_url(scheme=request.scheme, host=request.host, path=path, query="").rstrip("/") @@ -39,10 +35,3 @@ def validate_issuer(issuer): if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: raise RuntimeError("oauth.issuer must use https except for localhost development") return issuer - - -def well_known_protected_resource_url(request, container): - from guillotina.contrib.oauth.flow.resources import oauth_required_audience - - parsed = urlparse(oauth_required_audience(request, container)) - return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource/{parsed.path.lstrip('/')}" diff --git a/guillotina/tests/oauth/conftest.py b/guillotina/tests/oauth/conftest.py index 6d3896871..4a0a9a9ac 100644 --- a/guillotina/tests/oauth/conftest.py +++ b/guillotina/tests/oauth/conftest.py @@ -13,6 +13,19 @@ pytestmark = pytest.mark.asyncio + +@pytest.fixture(autouse=True) +def reset_oauth_integration_registries(): + from guillotina.contrib.oauth.discovery.protected_resource import reset_protected_resource_providers + from guillotina.contrib.oauth.indicators.registry import reset_indicator_registries + + reset_indicator_registries() + reset_protected_resource_providers() + yield + reset_indicator_registries() + reset_protected_resource_providers() + + requires_pg = pytest.mark.skipif( annotations["testdatabase"] == "DUMMY", reason="requires PostgreSQL (set DATABASE=postgresql)", diff --git a/guillotina/tests/oauth/test_oauth_security_unit.py b/guillotina/tests/oauth/test_oauth_security_unit.py index 9eef59e0a..fa76916c4 100644 --- a/guillotina/tests/oauth/test_oauth_security_unit.py +++ b/guillotina/tests/oauth/test_oauth_security_unit.py @@ -7,8 +7,9 @@ from guillotina.contrib.oauth.api.pages import oauth_error_page from guillotina.contrib.oauth.auth.validators import OAuthJWTValidator from guillotina.contrib.oauth.flow.clients import build_client_from_registration, scopes_registered_for_client -from guillotina.contrib.oauth.flow.resources import oauth_required_audience, register_oauth_audience_resolver from guillotina.contrib.oauth.flow.tokens import issue_access_token +from guillotina.contrib.oauth.indicators.access import required_resource_indicator +from guillotina.contrib.oauth.indicators.registry import register_required_indicator_resolver from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded, reset_rate_limits from guillotina.contrib.oauth.utils.urls import container_issuer_url, validate_issuer from guillotina.response import HTTPBadRequest @@ -136,20 +137,20 @@ async def test_oauth_configured_issuer_overrides_request_headers(dummy_guillotin @pytest.mark.asyncio @pytest.mark.app_settings({"applications": ["guillotina", "guillotina.contrib.oauth"]}) -async def test_oauth_required_audience_can_be_extended(dummy_guillotina): +async def test_required_resource_indicator_can_be_extended(dummy_guillotina): def resolver(request, container): if request.path.endswith("/@custom-protocol"): return f"{container_issuer_url(request, container)}/@custom-protocol" - register_oauth_audience_resolver(resolver) + register_required_indicator_resolver(resolver) container = Container() container.__name__ = "guillotina" request = make_mocked_request("GET", "/db/guillotina/@custom-protocol") - assert oauth_required_audience(request, container) == "http://localhost/guillotina/@custom-protocol" + assert required_resource_indicator(request, container) == "http://localhost/guillotina/@custom-protocol" request = make_mocked_request("GET", "/db/guillotina/@addons") - assert oauth_required_audience(request, container) == "http://localhost/guillotina" + assert required_resource_indicator(request, container) == "http://localhost/guillotina" class _FakeRedisDriver: diff --git a/guillotina/tests/oauth/test_oauth_well_known.py b/guillotina/tests/oauth/test_oauth_well_known.py index 984931118..ab4c8b15b 100644 --- a/guillotina/tests/oauth/test_oauth_well_known.py +++ b/guillotina/tests/oauth/test_oauth_well_known.py @@ -1,6 +1,7 @@ import pytest -from guillotina.contrib.oauth.api import well_known +from guillotina.contrib.oauth.discovery import protected_resource as pr +from guillotina.contrib.oauth.discovery import routing from guillotina.response import HTTPNotFound @@ -12,12 +13,12 @@ def __init__(self, protected_path=None): @pytest.mark.asyncio async def test_register_protected_resource_provider_appends_provider(monkeypatch): providers = [] - monkeypatch.setattr(well_known, "_PROTECTED_RESOURCE_PROVIDERS", providers) + monkeypatch.setattr(pr, "_PROTECTED_RESOURCE_PROVIDERS", providers) def provider(request, container, protected_path): return None - well_known.register_protected_resource_provider(provider) + pr.register_protected_resource_provider(provider) assert providers == [provider] @@ -33,12 +34,12 @@ def provider_c(request, container, protected_path): raise AssertionError("should not be called after a matching provider") monkeypatch.setattr( - well_known, + pr, "_PROTECTED_RESOURCE_PROVIDERS", [provider_a, provider_b, provider_c], ) request = _FakeRequest("/db/guillotina/@mcp/protocol") - result = well_known._protected_resource_metadata(request, None) + result = pr._protected_resource_metadata(request, None) assert result == {"resource": "b", "authorization_servers": ["issuer"]} @@ -47,15 +48,15 @@ async def test_protected_resource_metadata_returns_404_when_no_provider_matches( def provider(request, container, protected_path): return None - monkeypatch.setattr(well_known, "_PROTECTED_RESOURCE_PROVIDERS", [provider]) + monkeypatch.setattr(pr, "_PROTECTED_RESOURCE_PROVIDERS", [provider]) request = _FakeRequest("/db/guillotina/unknown-resource") with pytest.raises(HTTPNotFound): - well_known._protected_resource_metadata(request, None) + pr._protected_resource_metadata(request, None) @pytest.mark.asyncio async def test_split_well_known_target_path_allows_resource_suffix(): - db_id, container_id, protected_path = well_known._split_well_known_target_path( + db_id, container_id, protected_path = routing._split_well_known_target_path( "/db/guillotina/subfolder/@mcp/protocol", allow_resource_path=True ) assert db_id == "db" @@ -66,4 +67,4 @@ async def test_split_well_known_target_path_allows_resource_suffix(): @pytest.mark.asyncio async def test_split_well_known_target_path_rejects_suffix_for_issuer_metadata(): with pytest.raises(HTTPNotFound): - well_known._split_well_known_target_path("/db/guillotina/extra") + routing._split_well_known_target_path("/db/guillotina/extra")