OIDC Consumer (Relying Party)

Tetrapus acts as an OpenID Connect Relying Party. Users sign in with Google, Okta, Azure AD or any OIDC-compliant IdP, and Tetrapus mints a local session bound to the verified email. The Authorization Code flow with PKCE is the only flow exposed — implicit and hybrid are deliberately omitted.

Per-Org IdP configuration

Each Org defines its own set of OIDC providers in oidc_identity_providers. Provider records carry the discovery URL, client ID, client secret (encrypted at rest), allowed callback path, and a short alias used in URL paths.

SQL
CREATE TABLE oidc_identity_providers (
    id              TEXT PRIMARY KEY,
    org_id          TEXT NOT NULL REFERENCES orgs(id),
    alias           TEXT NOT NULL,        -- "google", "acme-okta"
    issuer_url      TEXT NOT NULL,        -- e.g. https://accounts.google.com
    client_id       TEXT NOT NULL,
    client_secret   TEXT NOT NULL,        -- encrypted via SecretStore
    scopes          TEXT NOT NULL,        -- "openid email profile"
    enabled         INTEGER NOT NULL DEFAULT 1,
    UNIQUE (org_id, alias)
);

CREATE TABLE oidc_links (
    user_id     TEXT NOT NULL REFERENCES users(id),
    provider_id TEXT NOT NULL REFERENCES oidc_identity_providers(id),
    subject     TEXT NOT NULL,             -- "sub" claim from id_token
    PRIMARY KEY (provider_id, subject)
);

Authorization Code + PKCE flow

graph TD USER["User clicks 'Sign in with Google'"] --> START["GET /api/v1/auth/oidc/google/start"] START --> PKCE["Generate code_verifier + code_challenge"] PKCE --> STATE["Store state + verifier in session"] STATE -->|302 Location: issuer/authorize| IDP["Google /authorize"] IDP -->|user authenticates + consents| CALLBACK["GET /api/v1/auth/oidc/google/callback?code=...&state=..."] CALLBACK --> EXCHANGE["POST issuer/token\n+ code + code_verifier"] EXCHANGE --> IDTOKEN["id_token (JWT) + access_token"] IDTOKEN --> VERIFY["Verify signature via JWKS\nVerify iss, aud, exp, nonce"] VERIFY --> LINK{"oidc_links match?"} LINK -->|yes| SESSION["Mint session for linked user"] LINK -->|no, but verified email matches existing user| BIND["Auto-bind: insert oidc_links row"] BIND --> SESSION LINK -->|no match anywhere| DENY["Reject — user must be pre-provisioned"]

Link-by-verified-email semantics

First-time login from an IdP produces an id_token with a stable sub claim and an email_verified=true claim. Tetrapus uses the verified email as a one-time bridge: if a local user with that email exists, an oidc_links row is inserted binding the IdP sub to that user. All subsequent logins go straight through the (provider_id, subject) primary key — the email is no longer consulted.

If email_verified is missing or false, the bind is refused. This prevents an attacker who controls a side IdP from claiming an unverified email and hijacking a Tetrapus account.

REST routes

Start authorization

Bash
# Browser hits this directly. 302 to the IdP.
curl -i 'https://tetrapus.example.com/api/v1/auth/oidc/google/start?relay=/dashboard'
# HTTP/1.1 302 Found
# Set-Cookie: oidc_state=...; Path=/; HttpOnly; Secure; SameSite=Lax
# Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&code_challenge=...&state=...

Callback

Bash
# IdP redirects user-agent here. Should not be invoked manually.
curl -i 'https://tetrapus.example.com/api/v1/auth/oidc/google/callback?code=...&state=...'
# HTTP/1.1 302 Found
# Set-Cookie: dm_session=...; HttpOnly; Secure; SameSite=Strict
# Location: /dashboard

Tokens stored, tokens discarded

Tetrapus does not persist the IdP's access_token or refresh_token. It only uses the id_token at login time to verify identity, then discards the entire OAuth grant. The Tetrapus session cookie is independently issued and rotated on its own clock.

Discovery & JWKS caching

On first use, Tetrapus fetches {issuer_url}/.well-known/openid-configuration and the JWKS URL it advertises. Both are cached in-memory for 1 hour. Key rotation by the IdP is handled transparently — a key ID miss triggers a re-fetch.

Status

Shipped: Authorization Code + PKCE, JWKS verification, link-by-verified-email, per-Org provider config. Deferred: dynamic client registration, RP-initiated logout via end_session_endpoint, JIT user creation (must pre-provision).

Related

Questions?

Reach out for help with integration, deployment, or custom domain codecs.