Skip to main content

OAuth 2.0 authorization server for guillotina

Project description

guillotina_oauth_server

guillotina_oauth_server is an OAuth 2.0 Authorization Code + PKCE (S256) public-client authorization server for the Guillotina framework.

It implements an OAuth 2.0 authorization server profile aligned with RFC 9700 guidance, including dynamic client registration (RFC 7591), authorization server metadata (RFC 8414), resource indicators (RFC 8707), issuer identification (RFC 9207), protected resource metadata (RFC 9728), opaque refresh tokens, token revocation (RFC 7009), and JWT access tokens signed with a key derived from Guillotina’s configured jwt.secret.

OAuth state is stored in PostgreSQL tables, configured via the oauth_storage utility settings. A PostgreSQL database storage is required.

This package was previously distributed as guillotina.contrib.oauth.

Installation

pip install guillotina_oauth_server

Optional extras:

pip install guillotina_oauth_server[mcp]     # Model Context Protocol integration
pip install guillotina_oauth_server[redis]   # shared rate-limit counters via Redis

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_oauth_server to your list of active applications:

applications:
  - guillotina_oauth_server

2. Configure JWT secrets

Since OAuth access tokens are issued as signed JSON Web Tokens (JWT), you must configure the global JWT signing settings:

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_oauth_server 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:

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_oauth_server sets this automatically; override only if you use a custom checker:

check_writable_request: guillotina_oauth_server.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:

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     # TTL in seconds for authorization codes (default 10 min)
  access_token_ttl: 3600          # TTL in seconds for access tokens (default 1 hour)
  refresh_token_ttl: 2592000      # TTL 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 also be set under oauth for backward compatibility; utility settings take precedence.

OAuth state is always persisted in PostgreSQL tables (oauth_clients, oauth_authorization_codes, oauth_refresh_tokens, oauth_consents).

6. Issuer URL and reverse proxies

The issuer URL appears in discovery metadata, JWT iss claims, and authorization redirects (RFC 9207).

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.

Installing the addon

Install the oauth addon in each container that should act as an authorization server:

# Install
curl -X POST http://localhost:8080/db/container/@addons \
  -H "Content-Type: application/json" \
  -d '{"id": "oauth"}'

# Uninstall (removes all OAuth data for the container)
curl -X DELETE http://localhost:8080/db/container/@addons \
  -H "Content-Type: application/json" \
  -d '{"id": "oauth"}'

Database schema and upgrades

OAuth uses a versioned PostgreSQL schema. Fresh installations automatically bootstrap the schema on startup. Existing environments must run the migration command before upgrading.

The oauth_schema_meta table tracks the current schema version, and oauth_schema_migration_log records every applied migration with timestamps, SQL hashes, and success/failure status for auditability.

When Guillotina starts and no OAuth tables exist, the baseline schema (version 1) is created automatically. An advisory lock (pg_advisory_lock) ensures only one worker performs the initialization, even with multiple Gunicorn/Uvicorn workers.

Upgrade existing environments:

# Phase 1: Check pending migrations
g -c config.yaml oauth-migrate --dry-run

# Phase 2: Apply migrations
g -c config.yaml oauth-migrate

# Phase 3: Deploy new code

# Phase 4: Verify
g -c config.yaml oauth-migrate --show-version

Always take a database backup before running g oauth-migrate in production:

pg_dump guillotina > backup_before_migration.sql

Additional command flags:

g oauth-migrate --database db           # Target a specific database
g oauth-migrate --target-version 3      # Migrate to a specific version
g oauth-migrate --rollback              # Rollback last migration (requires backward_sql)
g oauth-migrate --bootstrap-legacy      # Mark pre-versioning tables as version 1

For production, enable strict mode so startup fails immediately on schema mismatch:

oauth:
  schema_strict: true

When schema_strict is false (default), startup logs warnings but continues. Note: version > CURRENT (newer DB schema than code) always raises a RuntimeError to prevent data corruption, regardless of schema_strict.

Endpoints

Endpoints are container scoped:

GET  /db/container/.well-known/oauth-authorization-server
POST /db/container/oauth/register
GET  /db/container/oauth/authorize
POST /db/container/oauth/authorize    # 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)

RFC 8414 discovery for issuers with a path component (such as /db/container) is also exposed at the application root:

GET /.well-known/oauth-authorization-server/db/container

When using MCP, protected resource metadata follows RFC 9728:

GET /db/container/.well-known/oauth-protected-resource
GET /.well-known/oauth-protected-resource/db/container/@mcp/protocol

Opaque token prefixes: goc_ (authorization codes), gor_ (refresh tokens).

The OAuth application does not expose /.well-known/openid-configuration because that path identifies OpenID Connect provider metadata, and this package does not implement OpenID Connect (id_token, UserInfo, OIDC JWKS, subject types, etc.).

Architecture: protocol phases

The package is organized around the three phases of the protocol:

Phase

Module

RFC

Discovery

discovery/

RFC 8414, RFC 9728

Grant (resource validation)

indicators/grant

RFC 8707

Access (token validation)

indicators/access, auth/

RFC 8707

Token issuance

flow/

RFC 6749

MCP integration

integrations/mcp/

Resource indicators (RFC 8707)

The resource parameter is restricted to URLs returned by registered resolvers in guillotina_oauth_server.indicators. The oauth application registers the container issuer by default (https://host/db/container). When both guillotina_oauth_server and guillotina.contrib.mcp are in applications, OAuth also loads the MCP integration, registers {container}/@mcp/protocol (and subfolder MCP paths), and exposes MCP protected-resource metadata.

Register allowed values from your addon includeme (or startup hook):

from guillotina_oauth_server.indicators.registry import register_allowed_indicator_resolver

def my_resolver(request, container):
    from guillotina_oauth_server.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:

from guillotina_oauth_server.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_oauth_server.utils.urls import container_issuer_url
        return f"{container_issuer_url(request, container)}/@services/my-hook"

register_required_indicator_resolver(my_audience_resolver)

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

OAuth access tokens must include the guillotina:access scope.

Using PKCE and the OAuth flow (step by step)

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):

import base64
import hashlib
import secrets

code_verifier = secrets.token_urlsafe(64)
hash_digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(hash_digest).rstrip(b"=").decode("ascii")

Step 2 – Register a public client:

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 (append code_challenge and set code_challenge_method=S256):

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

The GET request returns an HTML login form. After login and consent, the user is redirected back to redirect_uri with the authorization code, the original state, and the issuer identifier iss (RFC 9207).

Step 4 – Exchange the code for tokens (provide the original code_verifier in plaintext):

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'

Step 5 – Refresh and revoke (optional). Guillotina rotates refresh tokens on every successful refresh. Clients must persist the new refresh_token and discard the old one immediately:

curl -X POST http://localhost:8080/db/container/oauth/token \
  -d 'grant_type=refresh_token&client_id=CLIENT_ID&refresh_token=YOUR_REFRESH_TOKEN'

To revoke an active refresh token (revokes the entire refresh-token family from the same authorization grant):

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).

Running the tests

pip install -e .[test]
DATABASE=postgresql pytest guillotina_oauth_server

Integration tests require PostgreSQL (provided via pytest-docker-fixtures). Tests run with DATABASE=DUMMY skip the PostgreSQL-backed cases.

Releasing

Publishing is automated and requires no PyPI token on any machine.

One-time PyPI setup (Trusted Publishing). On PyPI, configure a trusted publisher for this project so GitHub Actions can upload via OIDC:

  • Go to the project’s Publishing settings (or Your projects -> Manage -> Publishing) at https://pypi.org/manage/account/publishing/

  • Add a GitHub publisher with:

    • Owner: guillotinaweb

    • Repository: guillotina_oauth_server

    • Workflow filename: release.yml

    • Environment name: pypi

  • In the GitHub repo, create an Environment named pypi (Settings -> Environments). Optionally add required reviewers so a release must be approved before it is published.

No secrets are stored anywhere; PyPI trusts the workflow identity directly.

Cutting a release. Version, changelog, tag and the dev-version bump are all handled by zest.releaser (already configured in setup.cfg):

pip install zest.releaser
fullrelease

fullrelease interactively:

  1. sets the final version in VERSION and dates the CHANGELOG.rst entry,

  2. commits and creates a vX.Y.Z git tag,

  3. pushes the commit and tag,

  4. bumps VERSION to the next .devN and adds a new changelog section.

Publishing. After fullrelease pushes the tag, create a GitHub Release for that tag (e.g. vX.Y.Z). This triggers .github/workflows/release.yml, which builds the sdist/wheel, runs twine check, verifies the tag matches the VERSION file, and publishes to PyPI via Trusted Publishing.

To avoid having zest.releaser upload to PyPI itself (CI does that), answer “no” when it asks to upload, or set release = no under [zest.releaser] in setup.cfg.

License

GPL version 3. See LICENSE.

CHANGELOG

1.0.2 (2026-06-25)

  • Reject OAuth access tokens in the generic Guillotina JWT validator: a custom guillotina_oauth_server.auth.validators.JWTValidator now replaces guillotina.auth.validators.JWTValidator so OAuth-issued access tokens are only honored through the dedicated OAuth validator (with signature, issuer and audience checks) and never through the generic JWT path. [rboixaderg]

  • MCP: emit the OAuth WWW-Authenticate challenge for /@mcp/protocol from a self-contained ASGI middleware (OAuthMCPChallengeMiddleware) instead of importing guillotina.contrib.mcp.interfaces.IMCPAuthPolicy, which is absent from upstream Guillotina and broke the MCP integration on import. [rboixaderg]

  • CI: pin and run gitleaks directly for secret scanning instead of the marketplace action. [rboixaderg]

  • CI: fail pull requests that do not update CHANGELOG.rst (use the skip changelog label to opt out for changes that need no release note). [rboixaderg]

1.0.1 (2026-06-24)

  • Add automated PyPI publishing via GitHub Actions using Trusted Publishing (OIDC); no API token is stored. Releases are cut with zest.releaser and published when a GitHub Release is created. [rboixaderg]

  • Add gitleaks secret scanning to CI and extend .gitignore with credential/secret file patterns. [rboixaderg]

  • Document the versioned PostgreSQL schema and oauth-migrate upgrade workflow in the README (“Releasing” and “Database schema and upgrades”). [rboixaderg]

1.0.0 (2026-06-24)

  • Initial release of guillotina oauth server. [rboixaderg]

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

guillotina_oauth_server-1.0.2.tar.gz (85.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

guillotina_oauth_server-1.0.2-py3-none-any.whl (105.0 kB view details)

Uploaded Python 3

File details

Details for the file guillotina_oauth_server-1.0.2.tar.gz.

File metadata

  • Download URL: guillotina_oauth_server-1.0.2.tar.gz
  • Upload date:
  • Size: 85.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for guillotina_oauth_server-1.0.2.tar.gz
Algorithm Hash digest
SHA256 fe9c4c202dc4ac20088ef395d39cb64c7bc0477e051915c90e44b1ceb79a205e
MD5 3558fc9be8c4f7632a5dc7fabaa59b42
BLAKE2b-256 6466127f4fa9a5ad22312aa7758e82f127ab9a276b6e3b96d06d2ae33a2ab08c

See more details on using hashes here.

Provenance

The following attestation bundles were made for guillotina_oauth_server-1.0.2.tar.gz:

Publisher: release.yml on guillotinaweb/guillotina_oauth_server

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file guillotina_oauth_server-1.0.2-py3-none-any.whl.

File metadata

File hashes

Hashes for guillotina_oauth_server-1.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 e2913bbd4ccbe5b7b18da691ae47deab57857621c726b84783cf74a5c92a6c2e
MD5 e53f8e5954df3dc2d0dba89c66656a88
BLAKE2b-256 b420c15d193eb3a22bfa1bb733da15cc7becb71bfd655522f03c52ff0d730838

See more details on using hashes here.

Provenance

The following attestation bundles were made for guillotina_oauth_server-1.0.2-py3-none-any.whl:

Publisher: release.yml on guillotinaweb/guillotina_oauth_server

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page