Skip to content

Generic OIDC client#3

Merged
rowforce merged 18 commits into
mainfrom
generic-oidc-client
May 21, 2026
Merged

Generic OIDC client#3
rowforce merged 18 commits into
mainfrom
generic-oidc-client

Conversation

@rowforce
Copy link
Copy Markdown
Contributor

No description provided.

rowforce added 18 commits May 21, 2026 13:01
The OAuth/OIDC *client* side — ManyRows signing users in via someone
else's IdP — as a one-to-many per-app config (an app can wire several
at once), distinct from the bespoke Google/Apple/Microsoft/GitHub
singletons and from the oidc_* *provider* tables (00002, ManyRows AS
IdP).

Two modes baked into the schema: 'oidc' (discover endpoints from an
issuer URL, verify a signed id_token) and 'oauth2' (explicit endpoints,
identity from userinfo). A per-mode CHECK enforces the right endpoints
per row. Client secret stored AAD-encrypted like the bespoke providers.

Repo CRUD + a test that doubles as migration validation (CHECK
constraints, NULL/COALESCE round-trip, claim-field defaults).
ResolveOAuthSignInIdentity used one `source` value as both the coarse
user.source AND the user_identities.provider link key. That breaks for
generic external IdPs: user_identities is unique on (user_id, provider)
and (pool, provider, subject), so every generic provider sharing
provider="oidc" would (a) let a user link only ONE external IdP and
(b) collide on `sub` across issuers (a sub is unique only per-issuer).

Split them: new optional ProviderKey threads through to the identity
match/write as "idp:<slug>" (per-IdP, mode-neutral, can't collide with
the bespoke colon-free keys), while user.source stays the coarse
UserSourceExternalIDP ("external"). ProviderKey defaults to
string(source) when empty, so the bespoke four are byte-for-byte
unchanged — they don't set it.

New test proves two IdPs sharing a sub link as two distinct identities
on one pool user. Full api suite green.
Low-level client for signing users in via an external IdP, both modes:

  oidc   — discover endpoints from the issuer's well-known doc, verify a
           signed id_token against the provider JWKS (RSA + EC keys; RS/
           PS/ES * methods), checking iss / aud==client_id / exp / nonce.
  oauth2 — explicit endpoints, identity read from the userinfo endpoint
           (no id_token). Generalizes the GitHub posture.

PKCE (S256) and nonce on the authorize leg; configurable claim/field
mapping (sub/email/email_verified/name) with OIDC-standard defaults;
discovery + JWKS caches keyed per-provider with a 1h TTL. Takes
primitives (no core import) so it stays a composable building block.

Tested against an httptest mock IdP: happy path, nonce mismatch, wrong
audience, tampered signature, expired token, userinfo email-fallback,
OAuth2 userinfo with a non-standard subject field, and PKCE+nonce on
the authorize URL.
One route pair — /auth/idp/{providerSlug}/{authorize,callback} — serves
every configured provider, resolving the config by slug. Mirrors the
bespoke Tier-1 providers: signed single-use state (keyed by the
"idp:<slug>" provider key), opener-origin allowlist, popup postMessage
wrapper, and the shared completeTier1OAuthLogin (so cookie + bearer
delivery, 2FA, audit, and the new per-IdP identity linking all come for
free).

PKCE (S256) + OIDC nonce are derived deterministically from
HMAC(serverKey, state) rather than persisted: the state row already
enforces single-use + expiry, and deriving from the secret key means the
public state value can't reveal the verifier. Zero new per-flow storage,
no change to the shared oauth_states infra.

Also extracts the opener-origin allow check into a shared
isOpenerOriginAllowed helper (bespoke Tier-1 handler refactored onto it;
full api suite green) and adds the AuthMethodExternalIDP audit method.

Derivation unit-tested (determinism, S256, RFC-7636 length, key+state
dependence). The full browser-flow integration test pairs with the
admin endpoint (it provisions the encrypted client secret).
The generic callback checked only that an email was present, not that
the IdP marked it verified — unlike every bespoke provider (Google
rejects unverified, GitHub filters to verified, Microsoft uses xms_edov)
and contrary to completeTier1OAuthLogin's documented contract.

Without the check, an external IdP that lets a user assert an arbitrary
unverified email could hijack an existing account: the email-fallback
branch in ResolveOAuthSignInIdentity matches the victim by email and
links the attacker's external identity to them, then issues a session.

Reject with error.emailNotVerified (403) when email_verified isn't true,
mirroring the bespoke Google path. Providers that don't emit the claim
are rejected until a per-IdP opt-out exists (tracked).
The provider key was "idp:<slug>", but identities are matched POOL-wide
(FindUserByIdentity keys on user_pool_id) while slugs are unique only
per-app. Two apps sharing a pool could both use slug "okta" for
DIFFERENT issuers, and since a `sub` is unique only per-issuer, that
could match two distinct people onto one account — the exact hazard the
provider-key decoupling was meant to remove, left half-solved. Key by
the config's UUID instead (pool-global, stable for the provider's life).

This surfaced two schema constraints sized for the bespoke names that
reject "idp:<uuid>" (40 chars), fixed in migration 00006:
  - user_identities.provider varchar(20) -> varchar(64)
  - oauth_states.provider CHECK allowlist -> also accept 'idp:%'
    (this one would have failed SignOAuthState at runtime — the
    authorize handler was broken for generic providers; no test
    exercised it until now).

New state sign/verify round-trip test covers the oauth_states path;
the per-provider isolation test now uses UUID keys. Full suite green.
Reject cleartext URLs so tokens, client secrets, and authorization
codes can't ride http, and a downgraded endpoint can't be fetched
server-side (SSRF/MITM). Enforced on: the configured OIDC issuer (before
discovery), every endpoint the discovery doc returns (downgrade defense
against a malicious issuer), and the explicit OAuth2-mode endpoints.
http is allowed only to loopback hosts so local dev and tests work.
REST sub-resource on an app (mirrors the webhooks shape):
  GET/POST            /external-idps
  POST                /external-idps/validate-discovery
  PUT/DELETE          /external-idps/{idpId}

Create encrypts the client secret AAD-bound to the row id (generated
before encryption). Update merges the secret: an empty clientSecret
keeps the stored ciphertext (UpdateExternalIDP is a full overwrite, so
the audit-flagged wipe is avoided), a non-empty one rotates it — the UI
never round-trips the secret. Responses expose hasClientSecret, never
the secret itself. validate-discovery powers the admin "fetch discovery"
button, surfacing a bad issuer as a 400 before the provider is saved.

Up-front validation (slug format, mode, required fields per mode, https
via the now-exported oidc.RequireSecureURL) yields clean 400s; duplicate
slug -> 409.

Tests: CRUD lifecycle, the secret-merge invariant (asserted on the raw
DB ciphertext), all validation rejections, and discovery against a mock.
Drives the real WorkspaceExternalIDPAuthorize + Callback handlers
through a chi router (workspace + app context) against an httptest mock
OpenID provider (discovery + JWKS + token). Closes the integration gap
the audit flagged — this exercises the path that the oauth_states CHECK
silently broke.

Asserts the happy path (authorize URL targets the IdP with PKCE+nonce;
callback exchanges, verifies the id_token, mints a session, links the
identity under idp:<config-uuid> with the right subject) and the
verified-email gate (an unverified id_token email is refused and no
account is created). Full api + oidc suites green.
claimString only accepted a string, so a provider returning a numeric
user id — common in OAuth2 userinfo (GitHub-style {"id": 12345}) and
the occasional non-spec numeric `sub` — extracted as "" and the sign-in
failed with "no usable identity" / "missing subject". Coerce json.Number
and float64 to a string. FetchUserinfo now decodes with UseNumber so a
large id keeps full precision (json.Number) instead of a lossy float64.

Test: OAuth2 userinfo with a numeric id resolves the subject.
Two robustness fixes for the external IdP fetches:

- Cap every decoded response (discovery, JWKS, token, userinfo) at
  1 MiB via io.LimitReader. These bodies are a few KB in practice; the
  cap stops a malformed or misconfigured endpoint from OOMing the auth
  server with an unbounded body.

- Add 60s leeway to id_token validation so a freshly minted token from
  an IdP whose clock runs slightly ahead isn't spuriously rejected at
  exp/nbf.
…t-out

Two remaining backend items:

- Disconnect (#44): the self-serve and admin "delete connected identity"
  endpoints allowlisted only the four bespoke providers, so a generic
  idp:<uuid> identity couldn't be unlinked. Extend both to accept the
  idp: namespace via core.IsExternalIDPProviderKey.

- Verified-email opt-out (#46): migration 00007 adds
  trust_unverified_email (default false = secure; a bool whose zero
  value is the safe default, so omitting it can't weaken the check).
  The callback gate now reads `!TrustUnverifiedEmail && !EmailVerified`,
  letting an admin trust an IdP that verifies emails but omits the
  email_verified claim. Plumbed through the model, repo, and admin DTO.

E2E gains a third case proving a trust_unverified_email=true provider
accepts an unverified email while the default provider still rejects it.
HandleGetAppForAppKit now includes externalIdps: [{slug, displayName,
buttonIcon}] for the app's enabled providers, so AppKit can render a
sign-in button per provider. Public-safe by construction — the DTO has
no client id/secret/endpoint fields — and best-effort (a lookup failure
logs and omits them rather than breaking the whole AppKit boot).

E2E gains a sub-test asserting the config lists enabled providers, hides
disabled ones, and leaks no sensitive fields.
Generalize the OAuth popup helper (runPopupOAuth) so a flow can supply
an explicit authorize path + callback messageType instead of deriving
them from one of the four bespoke providers — the bespoke call sites are
unchanged (the new fields default to the provider-derived values). The
generic path opens /auth/idp/<slug>/authorize and listens for the
external-idp-oauth-callback postMessage.

Auth renders one outlined button per app.externalIdps entry (label =
displayName, icon = buttonIcon with a key fallback; Icon returns null
for unknown names so an arbitrary icon can't break the button), wired
to the same popup → token-pair → onTokenPair path the bespoke providers
use. The OAuth section now also shows when only external IdPs are
configured. tsc + bundle build clean.
A new tab in AppAuthMethods renders the generic external-IdP providers
as a CRUD list (display name, slug, mode, enabled chip; edit/delete) with
an "Add provider" dialog form covering: OIDC vs OAuth2 mode (conditional
issuer-URL vs explicit endpoints), a "Fetch discovery" validate button,
client id/secret (blank-keeps-existing on edit), scopes, an advanced
accordion for claim mapping + button icon, and the trust_unverified_email
switch with a security warning. Wired to the admin REST endpoints
(GET/POST/PUT/DELETE /external-idps + validate-discovery). tsc + build clean.
Audit pass 4 (frontend + #44/#46 deltas). The disconnect endpoint is
/a/me/identities/{provider} and an external-IdP key is "idp:<uuid>" — a
colon. A new routing test pins down that chi does NOT percent-decode
path params: the raw colon routes correctly, but %3A does not round-trip.
So the client must send the key verbatim; URL-encoding it would silently
break the lookup (DeleteUserIdentity would search "idp%3A<uuid>"). The
current Profile.tsx already sends it raw — added a comment so nobody
"helpfully" wraps it in encodeURIComponent later, plus a regression guard
in the test.

Also: the connected-accounts row showed the raw "idp:<uuid>" key as the
label; fall back to "Single sign-on" for external IdPs.
The server translation table (T) returned the raw key for the new
error.externalIdp* codes, so the admin UI surfaced "error.externalIdp\
InvalidSlug" etc. verbatim (WriteError sends body.message = T(code),
which extractApiError prefers). Added English messages for all nine
codes so every client shows human text. The admin validation test now
asserts the 400 body carries a translated message (message != code),
guarding against an untranslated code regressing.

Minor UI polish: clear the "Fetch discovery" confirmation when the
issuer URL changes so a stale success banner can't linger.
… tests

Audit pass 5 — closing the gaps I'd flagged as unaudited:

- azp check (OIDC §3.1.3.7): a multi-audience id_token must carry
  azp == our client. WithAudience only checks our client is *among* the
  audiences; without azp, a token minted for a different client that
  co-lists ours would pass. Single-aud tokens are unaffected. Tested both
  ways against the mock IdP.

- Provider-deletion behavior pinned: user_identities has no FK to
  external_idps, so deleting a provider leaves the identity links
  orphaned (inert — the key is the provider's UUID, never re-queried)
  rather than cascade-deleting user data. New test asserts the link
  survives and the user is untouched.

- Concurrency: a new test hammers Authenticate from 24 goroutines
  against one issuer to stress the shared discovery + JWKS caches; passes
  under -race (whole new package + external-IdP api tests also -race
  clean).

Verified-and-clean (no change needed): the janitor already sweeps
oauth_states by expiry, so the generic flow's idp: state rows are
cleaned up; cross-provider JWKS/key confusion is prevented by per-config
iss+aud+jwks scoping.
@rowforce rowforce merged commit 7a9f37e into main May 21, 2026
@rowforce rowforce deleted the generic-oidc-client branch May 21, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant