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/AGENTS.md b/AGENTS.md index 2bb39ab88..38229bc94 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. @@ -35,7 +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. - diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0326c4a9d..06e69b8c2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,11 @@ 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 diff --git a/docs/source/_static/oauth-flow.svg b/docs/source/_static/oauth-flow.svg new file mode 100644 index 000000000..f906ac9dd --- /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 + 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/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.md b/docs/source/contrib/oauth.md new file mode 100644 index 000000000..b16ac2fb6 --- /dev/null +++ b/docs/source/contrib/oauth.md @@ -0,0 +1,414 @@ +# 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.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 + +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 +``` + +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 + +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: + - guillotina.auth.extractors.BearerAuthPolicy + - guillotina.auth.extractors.BasicAuthPolicy + - guillotina.auth.extractors.WSTokenAuthPolicy + - guillotina.auth.extractors.CookiePolicy # Required for browser login & consent form +``` + +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: + +```yaml +check_writable_request: guillotina.contrib.oauth.utils.writable.requires_writable_transaction +``` + +### 5. Customize OAuth Server Settings (Optional) + +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: + 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: # 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: + 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. + +### 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 + +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.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. + +Register allowed values from your addon `includeme` (or startup hook): + +```python +from guillotina.contrib.oauth.indicators.registry import register_allowed_indicator_resolver + +def my_resolver(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_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 a key derived from Guillotina's configured `jwt.secret`. + +![OAuth 2.0 authorization code flow with PKCE in Guillotina](../_static/oauth-flow.svg) + +Endpoints are container scoped: + +```text +GET /db/container/.well-known/oauth-authorization-server +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 +``` + +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 +GET /.well-known/oauth-protected-resource/db/container/subfolder/@mcp/protocol +``` + +Other container-scoped endpoints: + +```text +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. + +### 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 charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const array = new Uint8Array(64); + window.crypto.getRandomValues(array); + return Array.from(array, (b) => charset[b % charset.length]).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 required and must include `guillotina:access`. + +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) + +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' +``` + +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) + +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' +``` + +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. + +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 (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. + +| 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`, …) | + +OAuth access tokens must include the `guillotina:access` scope. Authorization is still enforced with native Guillotina permissions on the authenticated user. + +### 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 +"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. + +MCP also accepts native Guillotina authentication (for example an existing `@login` session) when OAuth is not used. diff --git a/guillotina/auth/validators.py b/guillotina/auth/validators.py index 508c4dec6..be1046138 100644 --- a/guillotina/auth/validators.py +++ b/guillotina/auth/validators.py @@ -127,12 +127,14 @@ 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) 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 @@ -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: @@ -167,7 +171,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/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index 957f61cbc..f42ed7ae8 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -30,3 +30,17 @@ 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 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/services.py b/guillotina/contrib/mcp/services.py index 07d11f576..2d8504c08 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -1,10 +1,18 @@ 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 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 +24,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 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): + 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/__init__.py b/guillotina/contrib/oauth/__init__.py new file mode 100644 index 000000000..e083efdff --- /dev/null +++ b/guillotina/contrib/oauth/__init__.py @@ -0,0 +1,64 @@ +from guillotina import configure + + +app_settings = { + "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, + "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, + "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.utils.writable.requires_writable_transaction", + "auth_token_validators": [ + "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.indicators.registry import ensure_default_resource_indicators_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/__init__.py b/guillotina/contrib/oauth/api/__init__.py new file mode 100644 index 000000000..e69de29bb 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..1c7a8c052 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/authorize.py @@ -0,0 +1,248 @@ +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 +from guillotina.contrib.oauth.flow.clients import ( + 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.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, + 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 authorization_endpoint(service, store): + params, error = await _collect_authorization_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_issuer_url(service.request, service.context) + + authorization_error = _validate_authorization_request(params, client) + if authorization_error is not None: + return _authorization_redirect(redirect_uri, params, issuer, {"error": authorization_error}) + + try: + resources = validate_resource_indicator(service.request, service.context, params.get("resource")) + except HTTPBadRequest: + return _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_target"}) + + scopes = normalize_list(params.get("scope")) + + 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 _grant_or_request_consent( + service, + store, + params=params, + client=client, + user=auth_result.user, + scopes=scopes, + resources=resources, + redirect_uri=redirect_uri, + issuer=issuer, + authenticated_now=auth_result.authenticated_now, + ) + + 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_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, AUTHORIZATION_REQUEST_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=AUTHORIZATION_REQUEST_SINGLETON_PARAMS + ) + except HTTPBadRequest as exc: + return None, exc + params.update(data) + return params, None + + +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: + 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 + + +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 = get_authenticated_user() + if not isinstance(user, AnonymousUser): + return _AuthenticationResult(user=user) + + if not (service.request.method == "POST" and params.get("username")): + 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:{peer_ip_address(service.request)}:{params.get('username')}" + + if await rate_limit_check(login_key, limit=login_limit, window=login_window): + 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_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 _AuthenticationResult( + early_response=oauth_error_page( + "Login failed", + "The username or password could not be verified.", + status=401, + ) + ) + + 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 _grant_or_request_consent( + service, + store, + *, + params, + client, + user, + scopes, + resources, + redirect_uri, + issuer, + authenticated_now, +): + """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 _authorization_redirect(redirect_uri, params, issuer, {"error": "invalid_request"}) + + if not existing_consent and decision != "allow": + if decision == "deny": + 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( + consent_key, + user_id=user.id, + client_id=client["client_id"], + scope=scopes, + resource=resources, + ) + + raw_code = generate_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 _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 new file mode 100644 index 000000000..f80020fcb --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/common.py @@ -0,0 +1,46 @@ +from guillotina.api.service import Service +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. +AUTHORIZATION_REQUEST_SINGLETON_PARAMS = { + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "decision", + "username", + "password", + "oauth_csrf", +} +TOKEN_REQUEST_SINGLETON_PARAMS = { + "grant_type", + "client_id", + "redirect_uri", + "code", + "code_verifier", + "refresh_token", + "scope", +} +REVOCATION_REQUEST_SINGLETON_PARAMS = {"client_id", "token", "token_type_hint"} +CONSENT_REQUEST_SINGLETON_PARAMS = {"consent_key", "client_id"} + + +class OAuthService(Service): + def oauth_store(self): + return get_oauth_store(self.context) + + +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..8d5e4f972 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/consents.py @@ -0,0 +1,66 @@ +from guillotina.auth.users import AnonymousUser +from guillotina.contrib.oauth.api.endpoints.common import CONSENT_REQUEST_SINGLETON_PARAMS +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 = get_authenticated_user() + if isinstance(user, AnonymousUser): + 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_endpoint(service, store): + user = get_authenticated_user() + if isinstance(user, AnonymousUser): + 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_REQUEST_SINGLETON_PARAMS + ) + except HTTPBadRequest as exc: + return exc + + 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(consent_key) + if consent is None: + return HTTPNotFound(content={"error": "not_found", "error_description": "unknown consent"}) + + 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"]) + 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..943b580b3 --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/register.py @@ -0,0 +1,52 @@ +from guillotina import app_settings +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 client_registration_endpoint(service, store): + oauth_settings = app_settings.get("oauth", {}) + if await rate_limit_exceeded( + f"oauth-register:{peer_ip_address(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 = build_client_from_registration(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"] = int(client["created_at"].timestamp()) + 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..79f4e38ed --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/revoke.py @@ -0,0 +1,61 @@ +from guillotina import app_settings +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.response import HTTPBadRequest, HTTPTooManyRequests + + +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=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:{peer_ip_address(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( + build_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..1410650ea --- /dev/null +++ b/guillotina/contrib/oauth/api/endpoints/token.py @@ -0,0 +1,159 @@ +from guillotina import app_settings +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.utils.urls import container_issuer_url +from guillotina.response import HTTPBadRequest, HTTPTooManyRequests + + +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_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:{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 _exchange_authorization_code(service, store, data) + if grant_type == "refresh_token": + return await _rotate_refresh_token(service, store, data) + return HTTPBadRequest(content={"error": "unsupported_grant_type"}) + + +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"}) + 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_issuer_url(service.request, service.context), + subject=record["user_id"], + audience=resources, + client_id=client["client_id"], + scope=record["scope"], + ) + refresh_token = generate_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 _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: + 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 = generate_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_issuer_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/pages.py b/guillotina/contrib/oauth/api/pages.py new file mode 100644 index 000000000..b69566419 --- /dev/null +++ b/guillotina/contrib/oauth/api/pages.py @@ -0,0 +1,150 @@ +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_response(body, status=200): + 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) +def _load_template(name): + return Template((TEMPLATE_DIR / name).read_text(encoding="utf-8")) + + +@lru_cache(maxsize=None) +def _load_template_text(name): + return (TEMPLATE_DIR / name).read_text(encoding="utf-8") + + +@lru_cache(maxsize=None) +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 _load_template(template_name).substitute(context) + + +def _render_oauth_page(title, heading, body, *, status=200, tone="default"): + return _html_response( + _render_template( + "base.html", + title=html_escape(title), + logo_src=_load_logo_data_uri(), + style=_load_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 _render_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 _render_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 _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 new file mode 100644 index 000000000..24d40f33c --- /dev/null +++ b/guillotina/contrib/oauth/api/services.py @@ -0,0 +1,93 @@ +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 +from guillotina.contrib.oauth.api.endpoints.common import OAuthService +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.discovery.routing 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": authorization_endpoint, + "consents": list_consents_endpoint, +} +OAUTH_POST_ACTIONS = { + "register": client_registration_endpoint, + "authorize": authorization_endpoint, + "token": token_endpoint, + "revoke": token_revocation_endpoint, + "consents": revoke_consent_endpoint, +} + + +@configure.service( + context=IContainer, + method="GET", + permission="guillotina.Public", + name=".well-known/{action}", + allow_access=True, +) +class ContainerOAuthWellKnownService(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 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 serve_well_known_metadata(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 ContainerOAuthGetService(OAuthService): + async def __call__(self): + action = self.request.matchdict.get("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( + context=IContainer, + method="POST", + permission="guillotina.Public", + name="oauth/{action}", + allow_access=True, +) +class ContainerOAuthPostService(OAuthService): + async def __call__(self): + action = self.request.matchdict.get("action", "") + 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/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

+ +
+
+

Resources this client can access

+ +
+

+ 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/auth/__init__.py b/guillotina/contrib/oauth/auth/__init__.py new file mode 100644 index 000000000..e69de29bb 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/helpers.py b/guillotina/contrib/oauth/auth/helpers.py new file mode 100644 index 000000000..96651197d --- /dev/null +++ b/guillotina/contrib/oauth/auth/helpers.py @@ -0,0 +1,14 @@ +from guillotina import app_settings +from guillotina.auth.utils import set_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 diff --git a/guillotina/contrib/oauth/auth/validators.py b/guillotina/contrib/oauth/auth/validators.py new file mode 100644 index 000000000..f91941106 --- /dev/null +++ b/guillotina/contrib/oauth/auth/validators.py @@ -0,0 +1,56 @@ +import jwt + +from guillotina import app_settings, task_vars +from guillotina.auth import find_user +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 + + +class OAuthJWTValidator: + for_validators = ("bearer",) + + 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, + access_token_signing_key(), + algorithms=[app_settings["jwt"]["algorithm"]], + options={"verify_aud": False}, + ) + except (jwt.exceptions.PyJWTError, 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_issuer_url(request, container) + if claims.get("iss") != issuer: + return + aud = set(claims.get("aud") or []) + if required_resource_indicator(request, container) not in aud: + 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) + 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/__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..8efe31938 --- /dev/null +++ b/guillotina/contrib/oauth/flow/clients.py @@ -0,0 +1,122 @@ +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 + + +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): + if not uri: + return False + if "*" in uri: + return False + parsed = urlparse(uri) + if parsed.fragment: + return False + if parsed.scheme in ("javascript", "data"): + return False + if parsed.scheme == "https": + return bool(parsed.netloc and parsed.path.startswith("/")) + if _is_loopback_http_redirect(parsed): + return True + return _is_private_use_redirect(parsed) + + +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 [] + 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 build_client_from_registration(data): + if data.get("client_id"): + 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): + raise_oauth_error("invalid_client_metadata", "redirect_uris is required") + if any(not validate_redirect_uri(uri) for uri in redirect_uris): + raise_oauth_error("invalid_redirect_uri", "unsafe redirect_uri") + method = data.get("token_endpoint_auth_method", "none") + if method != "none": + 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: + raise_oauth_error("invalid_client_metadata", "grant_types must be a non-empty array") + if not isinstance(response_types, list) or not response_types: + 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): + raise_oauth_error("invalid_client_metadata", "unsupported grant_type") + if any(response_type not in SUPPORTED_RESPONSE_TYPES for response_type in response_types): + raise_oauth_error("invalid_client_metadata", "unsupported response_type") + if "authorization_code" in grant_types and "code" not in response_types: + 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: + 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: + 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 = datetime.now(timezone.utc) + return { + "client_id": uuid4().hex, + "client_name": data.get("client_name") or "OAuth Client", + "redirect_uris": redirect_uris, + "grant_types": grant_types, + "response_types": response_types, + "token_endpoint_auth_method": "none", + "scope": " ".join(scope), + "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 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/csrf.py b/guillotina/contrib/oauth/flow/csrf.py new file mode 100644 index 000000000..f77aa7f16 --- /dev/null +++ b/guillotina/contrib/oauth/flow/csrf.py @@ -0,0 +1,69 @@ +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 + + +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 = derive_key("csrf") + 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/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/pkce.py b/guillotina/contrib/oauth/flow/pkce.py new file mode 100644 index 000000000..a82508950 --- /dev/null +++ b/guillotina/contrib/oauth/flow/pkce.py @@ -0,0 +1,39 @@ +import base64 +import hashlib +import re +from typing import Optional + + +_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: + """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 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") + + +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/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/flow/tokens.py b/guillotina/contrib/oauth/flow/tokens.py new file mode 100644 index 000000000..324f607a4 --- /dev/null +++ b/guillotina/contrib/oauth/flow/tokens.py @@ -0,0 +1,30 @@ +import secrets +from datetime import datetime, timedelta, timezone + +import jwt + +from guillotina import app_settings +from guillotina.contrib.oauth.utils.crypto import access_token_signing_key + + +def generate_opaque_token(prefix=""): + value = secrets.token_urlsafe(48) + return f"{prefix}{value}" if prefix else value + + +def issue_access_token(*, issuer, subject, audience, client_id, scope): + now = datetime.now(timezone.utc) + 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": now, + "exp": now + timedelta(seconds=ttl), + "token_type": "oauth_access_token", + } + token = jwt.encode(claims, access_token_signing_key(), algorithm=app_settings["jwt"]["algorithm"]) + return token, claims 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/install.py b/guillotina/contrib/oauth/install.py new file mode 100644 index 000000000..b4d3c57d3 --- /dev/null +++ b/guillotina/contrib/oauth/install.py @@ -0,0 +1,15 @@ +from guillotina import configure +from guillotina.addons import Addon +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): + pass + + @classmethod + async def uninstall(cls, container, request): + 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/__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/interfaces.py b/guillotina/contrib/oauth/interfaces.py new file mode 100644 index 000000000..0fc635b87 --- /dev/null +++ b/guillotina/contrib/oauth/interfaces.py @@ -0,0 +1,5 @@ +from zope.interface import Interface + + +class IOAuthStorageUtility(Interface): + """Utility that initializes OAuth storage backends and runs periodic cleanup.""" 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..7c157b3f2 --- /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 PostgresOAuthStore + + return PostgresOAuthStore(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..19e2bd3f4 --- /dev/null +++ b/guillotina/contrib/oauth/storage/interfaces.py @@ -0,0 +1,87 @@ +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 (and not-yet-expired) consent for this key.""" + + 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): + """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.""" + + 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 changed.""" + + 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(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 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/__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..c32e1185d --- /dev/null +++ b/guillotina/contrib/oauth/storage/pg/repository.py @@ -0,0 +1,579 @@ +import json +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.exceptions import TransactionNotFound +from guillotina.transactions import get_transaction + + +def _format_iso_datetime(value): + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _to_json_string(value): + return json.dumps(value) + + +def _parse_json_string(value): + if value is None: + return [] + if isinstance(value, str): + return json.loads(value) + return list(value) + + +def _ensure_utc(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 _ensure_utc(value) + if isinstance(value, str): + return _ensure_utc(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": _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": _format_iso_datetime(row["created_at"]), + "updated_at": _format_iso_datetime(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": _parse_json_string(row["scope"]), + "resource": _parse_json_string(row["resource"]), + "code_challenge": row["code_challenge"], + "code_challenge_method": "S256", + "expires_at": _format_iso_datetime(row["expires_at"]), + "created_at": _format_iso_datetime(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": _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": _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"], + } + + +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": _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 PostgresOAuthStore: + 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"], + _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"]), + ) + + 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 + AND (expires_at IS NULL OR expires_at > now()) + """, + self.container_db_key, + consent_key, + ) + return row is not None + + async def create_consent(self, consent_key, *, user_id, client_id, scope, resource): + 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. + 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, + granted_at, expires_at + ) 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, + granted_at = EXCLUDED.granted_at, + expires_at = EXCLUDED.expires_at + """, + self.container_db_key, + consent_key, + user_id, + client_id, + _to_json_string(list(scope)), + _to_json_string(list(resource)), + int(ttl), + ) + + 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 > now()) + ORDER BY granted_at DESC + """, + self.container_db_key, + user_id, + ) + 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, + *, + raw_code, + client_id, + user_id, + redirect_uri, + scope, + resource, + code_challenge, + ): + ttl = app_settings["oauth"].get("authorization_code_ttl", 600) + code_hash_val = token_hash(raw_code) + 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, + now() + $9::int * interval '1 second', now()) + """, + self.container_db_key, + code_hash_val, + client_id, + user_id, + redirect_uri, + _to_json_string(list(scope)), + _to_json_string(list(resource)), + code_challenge, + int(ttl), + ) + 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": None, + "created_at": None, + } + ) + + 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 > now() + """, + self.container_db_key, + token_hash(code), + ) + 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 > 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), + ) + 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( + """ + 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, + ) + return int(result.split()[-1]) > 0 + + async def revoke_refresh_family(self, *, client_id, user_id, auth_code_hash): + 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 client_id = $2 + AND user_id = $3 + AND ( + ($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, + *, + raw_token, + client_id, + user_id, + scope, + resource, + auth_code_hash=None, + rotated_from=None, + ): + ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) + hash_val = token_hash(raw_token) + 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, + now() + $7::int * interval '1 second', $8, $9, now(), now()) + """, + self.container_db_key, + hash_val, + client_id, + user_id, + _to_json_string(list(scope)), + _to_json_string(list(resource)), + int(ttl), + rotated_from, + auth_code_hash, + ) + 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) + ttl = app_settings["oauth"].get("refresh_token_ttl", 2592000) + 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 > now() + RETURNING user_id, auth_code_hash + """, + self.container_db_key, + oh, + client_id, + nh, + ) + 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, + now() + $7::int * interval '1 second', $8, $9, now(), now()) + """, + self.container_db_key, + nh, + client_id, + upd["user_id"], + _to_json_string(list(scope)), + _to_json_string(list(resource)), + int(ttl), + oh, + upd["auth_code_hash"], + ) + 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 > now() + AND revoked_at IS NULL + """, + self.container_db_key, + token_hash(token), + ) + 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 revoke_refresh_token(self, token): + txn, conn = await self._connection() + async with txn.lock: + await conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, now()) + 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("BEGIN") + 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, + ) + await conn.execute("COMMIT") diff --git a/guillotina/contrib/oauth/storage/pg/schema.py b/guillotina/contrib/oauth/storage/pg/schema.py new file mode 100644 index 000000000..3b7457e54 --- /dev/null +++ b/guillotina/contrib/oauth/storage/pg/schema.py @@ -0,0 +1,93 @@ +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(), + 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 +""", +] diff --git a/guillotina/contrib/oauth/storage/utility.py b/guillotina/contrib/oauth/storage/utility.py new file mode 100644 index 000000000..7324388c3 --- /dev/null +++ b/guillotina/contrib/oauth/storage/utility.py @@ -0,0 +1,136 @@ +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_initialized = set() + +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 + + storage_key = id(storage.pool) + 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) +class OAuthStorageUtility: + def __init__(self, settings=None): + self._settings = settings or {} + 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: + 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/__init__.py b/guillotina/contrib/oauth/utils/__init__.py new file mode 100644 index 000000000..b5d0cbb65 --- /dev/null +++ b/guillotina/contrib/oauth/utils/__init__.py @@ -0,0 +1,5 @@ +"""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. +""" 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/utils/ratelimit.py b/guillotina/contrib/oauth/utils/ratelimit.py new file mode 100644 index 000000000..496e9233c --- /dev/null +++ b/guillotina/contrib/oauth/utils/ratelimit.py @@ -0,0 +1,186 @@ +"""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. + +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 +_windows: "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 + _windows.clear() + + +def _prune_if_needed(): + 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(_windows.keys())[: len(_windows) // 2]: + _windows.pop(key, 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 + 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 + window_deque = _windows.get(key) + if window_deque is None: + window_deque = deque() + _windows[key] = window_deque + _prune_if_needed() + while window_deque and window_deque[0] <= cutoff: + window_deque.popleft() + if len(window_deque) >= limit: + return True + window_deque.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 + window_deque = _windows.get(key) + if window_deque is None: + return False + while window_deque and window_deque[0] <= cutoff: + window_deque.popleft() + return len(window_deque) >= 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_window(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_window(driver, redis_key, *, window, now): + cutoff = now - window + window_deque = _decode_redis_window(await driver.get(redis_key)) + return [item for item in window_deque if item > cutoff] + + +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) + 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 + 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) + 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): + """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/utils/request.py b/guillotina/contrib/oauth/utils/request.py new file mode 100644 index 000000000..63b218cc7 --- /dev/null +++ b/guillotina/contrib/oauth/utils/request.py @@ -0,0 +1,86 @@ +from urllib.parse import parse_qs + +from guillotina.contrib.oauth.utils.errors import raise_oauth_error + + +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 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" + + +def peer_ip_address(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: + 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()} diff --git a/guillotina/contrib/oauth/utils/urls.py b/guillotina/contrib/oauth/utils/urls.py new file mode 100644 index 000000000..96c29fd3e --- /dev/null +++ b/guillotina/contrib/oauth/utils/urls.py @@ -0,0 +1,37 @@ +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 +from guillotina.utils.misc import build_url + + +def container_issuer_url(request, container): + issuer = app_settings.get("oauth", {}).get("issuer") + if issuer: + return validate_issuer(issuer) + 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") + path = get_full_content_path(container) + if app_settings.get("oauth", {}).get("trust_proxy_headers", False): + return get_url(request, path).rstrip("/") + return build_url(scheme=request.scheme, host=request.host, path=path, query="").rstrip("/") + + +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 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/conftest.py b/guillotina/tests/oauth/conftest.py new file mode 100644 index 000000000..4a0a9a9ac --- /dev/null +++ b/guillotina/tests/oauth/conftest.py @@ -0,0 +1,149 @@ +import base64 +import hashlib +import json +import re +from html import unescape +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import pytest + +from guillotina.tests.fixtures import annotations + + +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)", +) + +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": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], +} +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": [ + "guillotina.auth.extractors.BearerAuthPolicy", + "guillotina.auth.extractors.BasicAuthPolicy", + "guillotina.auth.extractors.WSTokenAuthPolicy", + "guillotina.auth.extractors.CookiePolicy", + ], +} + + +def verifier_pair(verifier="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"): + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + 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", + "/db/guillotina/oauth/register", + data=json.dumps( + { + "client_name": "Test", + "redirect_uris": [redirect_uri], + "scope": "guillotina:access", + } + ), + headers={"Content-Type": "application/json"}, + ) + assert status == 201 + return response + + +async def authorize_code( + requester, + client, + *, + scope="guillotina:access", + 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", + } + 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", + data=urlencode(data), + 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 = 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", + "/db/guillotina/oauth/token", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 200, response + 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..e2f0add61 --- /dev/null +++ b/guillotina/tests/oauth/test_mcp_oauth.py @@ -0,0 +1,288 @@ +import json +from urllib.parse import parse_qs, urlencode, urlparse + +import jwt +import pytest + +from guillotina import app_settings +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, + authorize_code, + oauth_csrf_from_body, + register_client, + requires_pg, + token_from_code, + verifier_pair, +) + + +pytestmark = [pytest.mark.asyncio, requires_pg] + + +@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") + 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) +@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 + 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) +@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): + 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_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_signing_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): + 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, headers = await requester.make_request( + "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 + assert 'error="invalid_token"' in headers["WWW-Authenticate"] + + +@pytest.mark.app_settings(OAUTH_MCP_SETTINGS) +@pytest.mark.parametrize("install_addons", [["oauth", "mcp"]]) +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, + 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"], + ) + _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_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/call", + "params": { + "name": "resolve_path", + "arguments": {"path": "/", "include_serialized": True}, + }, + } + ), + 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_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/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) +@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/subfolder/@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 diff --git a/guillotina/tests/oauth/test_oauth_authorize.py b/guillotina/tests/oauth/test_oauth_authorize.py new file mode 100644 index 000000000..5b30df622 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_authorize.py @@ -0,0 +1,656 @@ +import json +from urllib.parse import parse_qs, urlencode, urlparse + +import pytest + +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + oauth_csrf_from_body, + register_client, + requires_pg, + verifier_pair, +) + + +pytestmark = [pytest.mark.asyncio, requires_pg] + + +@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_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", + ], + } + ), + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 201 + 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_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): + 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"], + } + ), + headers={"Content-Type": "application/json"}, + 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): + 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_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() + 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["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:access", + "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() + 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() + 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", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + 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_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) + _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) +@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", + "scope": "guillotina:access", + "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", + "scope": "guillotina:access", + "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 == 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) +@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 + + +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.utils.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_consents.py b/guillotina/tests/oauth/test_oauth_consents.py new file mode 100644 index 000000000..7acf9760a --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_consents.py @@ -0,0 +1,186 @@ +import pytest + +from guillotina.contrib.oauth.flow.consent import build_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 PostgresOAuthStore + 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 = PostgresOAuthStore("db/consent-ttl") + scopes = ["guillotina:access"] + resources = ["http://localhost/db/guillotina"] + ckey = build_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 PostgresOAuthStore + 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 = PostgresOAuthStore("db/consent-ttl-zero") + scopes = ["guillotina:access"] + resources = ["http://localhost/db/guillotina"] + ckey = build_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() diff --git a/guillotina/tests/oauth/test_oauth_metadata.py b/guillotina/tests/oauth/test_oauth_metadata.py new file mode 100644 index 000000000..78b2c2394 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_metadata.py @@ -0,0 +1,74 @@ +import copy + +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, requires_pg + + +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): + 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") + assert response["revocation_endpoint_auth_methods_supported"] == ["none"] + assert response["resource_indicators_supported"] is True + + +@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 == 404 + + +@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 + + +@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 new file mode 100644 index 000000000..876e06243 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_register.py @@ -0,0 +1,231 @@ +import json + +import pytest + +from guillotina.tests.oauth.conftest import OAUTH_SETTINGS, requires_pg + + +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): + async with container_install_requester as 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 == 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) +@pytest.mark.parametrize( + "payload", + [ + {"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"]}, + { + "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), + 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) +@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"]}), + headers={"Content-Type": "application/json"}, + ) + assert status == 201 + + +@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"], + } + ), + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 201 + 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): + 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", + ], + } + ), + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 201 + 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"], + } + ), + headers={"Content-Type": "application/json"}, + 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_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 guillotina.contrib.oauth.utils.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, + headers={"Content-Type": "application/json"}, + authenticated=False, + ) + assert status == 201 + response, status = await requester( + "POST", + "/db/guillotina/oauth/register", + data=payload, + headers={"Content-Type": "application/json"}, + 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 new file mode 100644 index 000000000..f0293e8ff --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_revoke.py @@ -0,0 +1,198 @@ +import pytest + +from guillotina.contrib.oauth.utils.ratelimit import reset_rate_limits +from guillotina.tests.oauth.conftest import ( + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, +) + + +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"]]) +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", + 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", + 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_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): + 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 + + +@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_security_unit.py b/guillotina/tests/oauth/test_oauth_security_unit.py new file mode 100644 index 000000000..fa76916c4 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_security_unit.py @@ -0,0 +1,186 @@ +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.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 +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_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_required_indicator_resolver(resolver) + container = Container() + container.__name__ = "guillotina" + + request = make_mocked_request("GET", "/db/guillotina/@custom-protocol") + assert required_resource_indicator(request, container) == "http://localhost/guillotina/@custom-protocol" + + request = make_mocked_request("GET", "/db/guillotina/@addons") + assert required_resource_indicator(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/oauth/test_oauth_storage_backend.py b/guillotina/tests/oauth/test_oauth_storage_backend.py new file mode 100644 index 000000000..5d7964f54 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_storage_backend.py @@ -0,0 +1,90 @@ +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 PostgresOAuthStore, _parse_dt +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 = PostgresOAuthStore("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_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) + + +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_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/oauth/test_oauth_store_contract.py b/guillotina/tests/oauth/test_oauth_store_contract.py new file mode 100644 index 000000000..a42503f3f --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_store_contract.py @@ -0,0 +1,133 @@ +import pytest + +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 + + +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 = build_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 = generate_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 = generate_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.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 = generate_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 + 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 + 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 PostgresOAuthStore + 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 = PostgresOAuthStore("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 new file mode 100644 index 000000000..a2fb03f64 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_token.py @@ -0,0 +1,340 @@ +import jwt +import pytest + +from guillotina import app_settings +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, + oauth_csrf_from_body, + register_client, + requires_pg, + token_from_code, +) + + +pytestmark = [pytest.mark.asyncio, requires_pg] + +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}, +} +TOKEN_RATE_LIMIT_SETTINGS = { + **OAUTH_SETTINGS, + "oauth": {**OAUTH_SETTINGS["oauth"], "token_rate_limit": 2, "token_rate_window": 300}, +} + + +@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, 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"], + access_token_signing_key(), + 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:access" + assert claims["aud"] + 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']}", + 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 + + +@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=( + "grant_type=authorization_code" + f"&client_id={client['client_id']}" + 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") + _response, status = await requester( + "POST", + "/db/guillotina/oauth/token", + 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}" + ), + 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_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): + 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 + + +@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, urlencode, 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) + 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=urlencode(data), + 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 + + 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 == 200 + assert fresh["refresh_token"] != new_rt + + +@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}" + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert status == 400 + + +@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']}", + 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_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}" + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + 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']}", + 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 new file mode 100644 index 000000000..d4b29cc05 --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_validator.py @@ -0,0 +1,50 @@ +import pytest + +from guillotina.tests.oauth.conftest import ( + OAUTH_MCP_SETTINGS, + OAUTH_SETTINGS, + authorize_code, + register_client, + requires_pg, + token_from_code, +) + + +pytestmark = [pytest.mark.asyncio, requires_pg] + + +@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_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) + 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) 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..ab4c8b15b --- /dev/null +++ b/guillotina/tests/oauth/test_oauth_well_known.py @@ -0,0 +1,70 @@ +import pytest + +from guillotina.contrib.oauth.discovery import protected_resource as pr +from guillotina.contrib.oauth.discovery import routing +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(pr, "_PROTECTED_RESOURCE_PROVIDERS", providers) + + def provider(request, container, protected_path): + return None + + pr.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( + pr, + "_PROTECTED_RESOURCE_PROVIDERS", + [provider_a, provider_b, provider_c], + ) + request = _FakeRequest("/db/guillotina/@mcp/protocol") + result = pr._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(pr, "_PROTECTED_RESOURCE_PROVIDERS", [provider]) + request = _FakeRequest("/db/guillotina/unknown-resource") + with pytest.raises(HTTPNotFound): + 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 = routing._split_well_known_target_path( + "/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_split_well_known_target_path_rejects_suffix_for_issuer_metadata(): + with pytest.raises(HTTPNotFound): + routing._split_well_known_target_path("/db/guillotina/extra")