Skip to content

Commit 0fcb5f1

Browse files
committed
refactor(oauth): reorganize OAuth resource indicators and discovery handling, streamline validation processes, and enhance integration with MCP
1 parent f8afc2f commit 0fcb5f1

27 files changed

Lines changed: 570 additions & 386 deletions

docs/source/contrib/oauth.md

Lines changed: 124 additions & 26 deletions
Large diffs are not rendered by default.

guillotina/contrib/oauth/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,13 @@
5252

5353

5454
def includeme(root, settings):
55-
from guillotina.contrib.oauth.flow.resources import ensure_default_oauth_resources_registered
55+
from guillotina.contrib.oauth.indicators.registry import ensure_default_resource_indicators_registered
5656

57-
ensure_default_oauth_resources_registered()
57+
ensure_default_resource_indicators_registered()
5858
configure.scan("guillotina.contrib.oauth.install")
5959
configure.scan("guillotina.contrib.oauth.api.services")
6060
if "guillotina.contrib.mcp" in set(settings.get("applications") or []):
6161
configure.scan("guillotina.contrib.oauth.integrations.mcp")
62+
from guillotina.contrib.oauth.integrations.mcp import register_mcp_oauth_integration
63+
64+
register_mcp_oauth_integration()

guillotina/contrib/oauth/api/endpoints/authorize.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
from guillotina.contrib.oauth.flow.consent import build_consent_key
1313
from guillotina.contrib.oauth.flow.csrf import OAUTH_CSRF_FIELD, csrf_valid
1414
from guillotina.contrib.oauth.flow.pkce import pkce_challenge_valid
15-
from guillotina.contrib.oauth.flow.resources import validate_resource
1615
from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE, oauth_scopes_supported
1716
from guillotina.contrib.oauth.flow.tokens import generate_opaque_token
17+
from guillotina.contrib.oauth.indicators.grant import validate_resource_indicator
1818
from guillotina.contrib.oauth.utils.ratelimit import rate_limit_check, rate_limit_exceeded
1919
from guillotina.contrib.oauth.utils.request import (
2020
normalize_list,
@@ -55,7 +55,7 @@ async def authorization_endpoint(service, store):
5555
return _authorization_redirect(redirect_uri, params, issuer, {"error": authorization_error})
5656

5757
try:
58-
resources = validate_resource(service.request, service.context, params.get("resource"))
58+
resources = validate_resource_indicator(service.request, service.context, params.get("resource"))
5959
except HTTPBadRequest:
6060
return _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_target"})
6161

guillotina/contrib/oauth/api/services.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import guillotina.contrib.oauth.discovery # noqa — triggers well-known handler registrations
12
from guillotina import configure
23
from guillotina.api.service import Service
34
from guillotina.contrib.oauth.api.endpoints.authorize import authorization_endpoint
@@ -6,7 +7,7 @@
67
from guillotina.contrib.oauth.api.endpoints.register import client_registration_endpoint
78
from guillotina.contrib.oauth.api.endpoints.revoke import token_revocation_endpoint
89
from guillotina.contrib.oauth.api.endpoints.token import token_endpoint
9-
from guillotina.contrib.oauth.api.well_known import WELL_KNOWN_HANDLERS, serve_well_known_metadata
10+
from guillotina.contrib.oauth.discovery.routing import WELL_KNOWN_HANDLERS, serve_well_known_metadata
1011
from guillotina.interfaces import IApplication, IContainer
1112
from guillotina.response import HTTPNotFound
1213

guillotina/contrib/oauth/api/well_known.py

Lines changed: 0 additions & 92 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Typed context for a validated OAuth access token attached to the request."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
8+
@dataclass(frozen=True)
9+
class OAuthTokenContext:
10+
client_id: str
11+
scopes: frozenset[str]
12+
resource_indicators: frozenset[str]
13+
claims: dict

guillotina/contrib/oauth/auth/validators.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from guillotina import app_settings, task_vars
44
from guillotina.auth import find_user
5-
from guillotina.contrib.oauth.flow.resources import oauth_required_audience
5+
from guillotina.contrib.oauth.auth.context import OAuthTokenContext
66
from guillotina.contrib.oauth.flow.scopes import OAUTH_DEFAULT_SCOPE
7+
from guillotina.contrib.oauth.indicators.access import required_resource_indicator
78
from guillotina.contrib.oauth.utils.crypto import access_token_signing_key
89
from guillotina.contrib.oauth.utils.urls import container_issuer_url
910

@@ -35,7 +36,7 @@ async def validate(self, token):
3536
if claims.get("iss") != issuer:
3637
return
3738
aud = set(claims.get("aud") or [])
38-
if oauth_required_audience(request, container) not in aud:
39+
if required_resource_indicator(request, container) not in aud:
3940
return
4041
if not claims.get("client_id"):
4142
return
@@ -45,12 +46,11 @@ async def validate(self, token):
4546
token["id"] = claims.get("id", claims.get("sub"))
4647
token["decoded"] = claims
4748
user = await find_user(token)
48-
if user is not None and user.id == token["id"]:
49-
if request is not None:
50-
request.oauth = {
51-
"client_id": claims.get("client_id"),
52-
"scopes": scopes,
53-
"resources": set(claims.get("aud") or []),
54-
"claims": claims,
55-
}
56-
return user
49+
if user is not None and user.id == token["id"] and request is not None:
50+
request.oauth = OAuthTokenContext(
51+
client_id=claims.get("client_id"),
52+
scopes=frozenset(scopes),
53+
resource_indicators=frozenset(claims.get("aud") or []),
54+
claims=claims,
55+
)
56+
return user
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Discovery package — triggers registration of well-known handlers on import."""
2+
3+
from guillotina.contrib.oauth.discovery import authorization_server as _as # noqa
4+
from guillotina.contrib.oauth.discovery import protected_resource as _pr # noqa
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""OAuth 2.0 Authorization Server Metadata (RFC 8414)."""
2+
3+
from guillotina.contrib.oauth.discovery.routing import register_well_known_handler
4+
from guillotina.contrib.oauth.flow.scopes import oauth_scopes_supported
5+
from guillotina.contrib.oauth.utils.urls import container_issuer_url
6+
7+
8+
def _authorization_server_metadata(request, container):
9+
issuer = container_issuer_url(request, container)
10+
return {
11+
"issuer": issuer,
12+
"authorization_endpoint": f"{issuer}/oauth/authorize",
13+
"token_endpoint": f"{issuer}/oauth/token",
14+
"registration_endpoint": f"{issuer}/oauth/register",
15+
"revocation_endpoint": f"{issuer}/oauth/revoke",
16+
"response_types_supported": ["code"],
17+
"grant_types_supported": ["authorization_code", "refresh_token"],
18+
"code_challenge_methods_supported": ["S256"],
19+
"token_endpoint_auth_methods_supported": ["none"],
20+
"revocation_endpoint_auth_methods_supported": ["none"],
21+
"resource_indicators_supported": True,
22+
"authorization_response_iss_parameter_supported": True,
23+
"scopes_supported": oauth_scopes_supported(),
24+
}
25+
26+
27+
register_well_known_handler("oauth-authorization-server", _authorization_server_metadata)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""OAuth 2.0 Protected Resource Metadata (RFC 9728)."""
2+
3+
from guillotina.contrib.oauth.discovery.routing import register_well_known_handler
4+
from guillotina.response import HTTPNotFound
5+
6+
7+
_PROTECTED_RESOURCE_PROVIDERS = []
8+
9+
10+
def register_protected_resource_provider(provider):
11+
_PROTECTED_RESOURCE_PROVIDERS.append(provider)
12+
13+
14+
def reset_protected_resource_providers() -> None:
15+
_PROTECTED_RESOURCE_PROVIDERS.clear()
16+
17+
18+
def _protected_resource_metadata(request, container):
19+
protected_path = getattr(request, "oauth_protected_resource_path", None)
20+
for provider in _PROTECTED_RESOURCE_PROVIDERS:
21+
metadata = provider(request, container, protected_path)
22+
if metadata is not None:
23+
return metadata
24+
raise HTTPNotFound(content={"reason": "Unknown protected resource"})
25+
26+
27+
register_well_known_handler("oauth-protected-resource", _protected_resource_metadata)

0 commit comments

Comments
 (0)