porchlight/docs/plans/2026-02-16-oidc-provider-design.md
2026-02-16 12:43:29 +01:00

13 KiB

OIDC Provider Integration Design (Phase 5)

Status: APPROVED — Minimal working OP with idpyoidc.

Goal

Integrate idpyoidc into the existing Porchlight application to serve as a standards-compliant OpenID Connect Provider. This phase delivers a minimal working OP: discovery, authorization code flow with PKCE, token exchange, userinfo, and JWKS. Dynamic client registration and federation are deferred.

Architecture

Thin FastAPI wrapper around idpyoidc.server.Server. The idpyoidc Server handles all OIDC protocol logic (request validation, token minting, session/grant management, key management). FastAPI routes act as HTTP adapters calling the idpyoidc endpoint pipeline: parse_requestprocess_requestdo_response.

New Files

File Purpose
src/fastapi_oidc_op/oidc/provider.py Initialize idpyoidc.server.Server with config, custom auth method, custom userinfo source
src/fastapi_oidc_op/oidc/endpoints.py FastAPI routes wrapping idpyoidc endpoint processing
src/fastapi_oidc_op/oidc/claims.py User model → OIDC standard claims dict + custom UserInfo subclass

Modified Files

File Change
src/fastapi_oidc_op/config.py Add signing_key_path setting
src/fastapi_oidc_op/app.py Initialize idpyoidc Server in lifespan, include OIDC router
src/fastapi_oidc_op/authn/routes.py After login, detect pending OIDC authorization request and resume flow

Endpoints

Route Method idpyoidc Endpoint Purpose
/.well-known/openid-configuration GET ProviderConfiguration Discovery
/authorization GET Authorization Authorization endpoint
/authorization/complete GET (internal) Resume flow after login
/token POST Token Token exchange
/userinfo GET, POST UserInfo User claims
/jwks GET (keyjar) Public signing keys

Authorization Code Flow

Happy Path

RP → GET /authorization?response_type=code&client_id=...&redirect_uri=...&scope=openid&state=...&nonce=...&code_challenge=...&code_challenge_method=S256
  ↓
idpyoidc parses + validates request
  ↓
No existing session → store auth request in Starlette session["oidc_auth_request"]
  ↓
Redirect to /login
  ↓
User authenticates (password or webauthn — existing routes)
  ↓
Login route detects session["oidc_auth_request"] → redirect to /authorization/complete
  ↓
/authorization/complete:
  1. Pop auth request from session
  2. Create idpyoidc session via authorization endpoint's create_session()
  3. Call authz_part2() to mint authorization code
  4. do_response() → redirect to RP's redirect_uri with code + state
  ↓
RP → POST /token (code + code_verifier)
  ↓
idpyoidc validates code, PKCE, client auth → returns access_token + id_token
  ↓
RP → GET /userinfo (Bearer access_token)
  ↓
idpyoidc validates token → returns claims from PorchlightUserInfo

Already Authenticated

If the user has an active idpyoidc session (tracked via cookie), the authorization endpoint skips the login redirect and immediately mints a code. The authenticated_as() method on our custom auth method checks for this.

Error Cases

  • Invalid client_id or redirect_uri → error page (not redirected to RP, per spec)
  • Invalid scope, response_type, etc. → error redirect to RP with error parameter
  • Login failure → stays on /login (existing behavior), pending auth request remains in session
  • Token endpoint errors → JSON error response per RFC 6749

Custom Authentication Method

PorchlightAuthnMethod(UserAuthnMethod):

  • __call__(**kwargs) — Not used for rendering. The /authorization route handles the redirect to /login directly, bypassing idpyoidc's built-in login page rendering. Returns empty string.
  • verify(*args, **kwargs) — Not called directly; our login routes handle credential verification.
  • authenticated_as(client_id, cookie, **kwargs) — Checks the Starlette session for userid/username. Returns ({"uid": username}, timestamp) when authenticated, (None, 0) when not.

In practice, the authorization route does not rely on idpyoidc's process_request() for the auth redirect. Instead, it calls parse_request() to validate the OIDC parameters, then checks the Starlette session itself. If not authenticated, it stores the request and redirects to /login. If authenticated, it calls create_session() + authz_part2() directly.

This bypasses idpyoidc's AuthnBroker machinery, which is designed for server-rendered login pages embedded in the authorization response. Our architecture uses a separate login page with HTMX, so we handle the redirect/resume ourselves.

UserInfo Source

The Sync/Async Bridge Problem

idpyoidc's UserInfo.__call__() is synchronous, but our UserRepository is async.

Solution: PorchlightUserInfo maintains an in-memory dict (self.db) that acts as a claims cache. When an idpyoidc session is created (in /authorization/complete), we look up the user from the async repo and populate the cache. The UserInfo.__call__() reads from this sync dict.

class PorchlightUserInfo(UserInfo):
    def __init__(self):
        super().__init__(db={})
    
    def set_user_claims(self, user_id: str, claims: dict) -> None:
        """Called after authentication to populate claims cache."""
        self.db[user_id] = claims

The cache is per-process (in-memory). For a single-process deployment this is fine. For multi-process, claims may need re-fetching (tolerable since sessions are per-process too in idpyoidc's default in-memory session store).

Claims Mapping

User Field OIDC Claim Scope
userid sub openid
preferred_username (or username) preferred_username profile
given_name given_name profile
family_name family_name profile
nickname nickname profile
email email email
email_verified email_verified email
phone_number phone_number phone
phone_number_verified phone_number_verified phone
picture picture profile
locale locale profile
updated_at updated_at profile

groups is not a standard OIDC claim but may be included via a custom scope in a future phase.

Key Management

idpyoidc manages its own KeyJar with RSA and EC signing keys. Configuration:

"key_conf": {
    "key_defs": [
        {"type": "RSA", "use": ["sig"]},
        {"type": "EC", "crv": "P-256", "use": ["sig"]},
    ],
    "uri_path": "jwks",           # Served at /jwks
    "private_path": "data/keys/private_jwks.json",
    "read_only": False,
}

Keys are generated on first startup and persisted to signing_key_path (default: data/keys/). Subsequent startups load existing keys. The /jwks endpoint serves the public portion.

Static Client Registration

Clients are registered in-memory via server.context.cdb during the lifespan. The management app auto-registers itself:

server.context.cdb[settings.manage_client_id] = {
    "client_id": settings.manage_client_id,
    "client_secret": "<generated>",
    "redirect_uris": [(f"{settings.issuer}/manage/callback", {})],
    "response_types_supported": ["code"],
    "token_endpoint_auth_method": "client_secret_basic",
    "scope": ["openid", "profile", "email"],
    "allowed_scopes": ["openid", "profile", "email"],
    "client_salt": "<generated>",
}

Additional clients can be registered via config or CLI in a future phase.

idpyoidc Server Configuration

{
    "issuer": settings.issuer,
    "key_conf": { ... },  # as above
    "endpoint": {
        "provider_config": {
            "path": ".well-known/openid-configuration",
            "class": "idpyoidc.server.oidc.provider_config.ProviderConfiguration",
            "kwargs": {"client_authn_method": None},
        },
        "authorization": {
            "path": "authorization",
            "class": "idpyoidc.server.oidc.authorization.Authorization",
            "kwargs": {
                "client_authn_method": None,
                "response_types_supported": ["code"],
                "response_modes_supported": ["query"],
            },
        },
        "token": {
            "path": "token",
            "class": "idpyoidc.server.oidc.token.Token",
            "kwargs": {
                "client_authn_method": ["client_secret_post", "client_secret_basic"],
            },
        },
        "userinfo": {
            "path": "userinfo",
            "class": "idpyoidc.server.oidc.userinfo.UserInfo",
            "kwargs": {},
        },
    },
    "userinfo": {
        "class": "fastapi_oidc_op.oidc.claims.PorchlightUserInfo",
        "kwargs": {},
    },
    "authentication": {},  # We handle auth outside idpyoidc
    "authz": {
        "class": "idpyoidc.server.authz.AuthzHandling",
        "kwargs": {
            "grant_config": {
                "usage_rules": {
                    "authorization_code": {
                        "supports_minting": ["access_token", "refresh_token", "id_token"],
                        "max_usage": 1,
                        "expires_in": 120,
                    },
                    "access_token": {"expires_in": 3600},
                    "refresh_token": {
                        "supports_minting": ["access_token", "refresh_token", "id_token"],
                        "expires_in": 86400,
                    },
                },
                "expires_in": 2592000,  # 30 days
            },
        },
    },
    "token_handler_args": {
        "jwks_file": "data/keys/token_jwks.json",
        "code": {"kwargs": {"lifetime": 600}},
        "token": {
            "class": "idpyoidc.server.token.jwt_token.JWTToken",
            "kwargs": {"lifetime": 3600, "add_claims_by_scope": True},
        },
        "refresh": {
            "class": "idpyoidc.server.token.jwt_token.JWTToken",
            "kwargs": {"lifetime": 86400},
        },
        "id_token": {
            "class": "idpyoidc.server.token.id_token.IDToken",
            "kwargs": {
                "lifetime": 3600,
                "add_claims_by_scope": True,
            },
        },
    },
    "scopes_to_claims": {
        "openid": ["sub"],
        "profile": [
            "preferred_username", "given_name", "family_name",
            "nickname", "picture", "locale", "updated_at",
        ],
        "email": ["email", "email_verified"],
        "phone": ["phone_number", "phone_number_verified"],
    },
}

Config Additions

class Settings(BaseSettings):
    # ... existing fields ...
    signing_key_path: str = "data/keys"

Login Route Changes

In authn/routes.py, the login success handlers (login_password, login_webauthn_complete) gain a check:

# After setting session["userid"] and session["username"]:
if "oidc_auth_request" in request.session:
    redirect_target = "/authorization/complete"
else:
    redirect_target = "/manage/credentials"

The /authorization/complete route (in oidc/endpoints.py):

  1. Pops oidc_auth_request from the session
  2. Looks up the user from the repo, populates the userinfo cache
  3. Creates an idpyoidc session
  4. Calls authz_part2() to mint the authorization code
  5. Returns the redirect to the RP

Testing Strategy

Tests use httpx.AsyncClient with the app's test configuration (in-memory SQLite, localhost issuer). A test client is registered statically.

Test Area What's Tested
Discovery /.well-known/openid-configuration returns valid metadata with correct endpoints
JWKS /jwks returns valid JWK Set with signing keys
Authorization Unauthenticated request redirects to /login; authenticated request returns code
Token Valid code exchange returns access_token + id_token; invalid code returns error
UserInfo Valid access token returns claims; invalid token returns 401
Full flow End-to-end: authorize → login → code → token → userinfo
Login integration Password/WebAuthn login with pending OIDC request resumes the flow

Known Constraints

  • idpyoidc's session/grant storage is in-memory. Restarting the server invalidates all active sessions and tokens. This is acceptable for initial development; persistent storage can be added later.
  • The userinfo claims cache is per-process. Multi-worker deployments need shared storage (deferred).
  • Only response_type=code is supported (Authorization Code flow). Implicit and Hybrid flows are out of scope.
  • PKCE is supported but not enforced for confidential clients (can be tightened later).
  • No dynamic client registration — clients must be pre-configured.