| name | integrate-holeauth-idp-consumer |
| description | Consume an external OAuth 2.0 / OpenID Connect Identity Provider as a Relying Party (RP). Use when: signing users in via another holeauth IDP, signing users in via Auth0 / Keycloak / Okta / generic OIDC, building an SSO client, OIDC RP, OAuth client app, federated login, third-party login. The OPPOSITE of integrate-holeauth-idp (which IS the IDP). Note: Google and GitHub are built into @holeauth/core/sso — use that instead for those two. |
| argument-hint | Optional: upstream issuer URL |
| domain | authentication, authorization, holeauth, sso, oidc, oauth, plugins, drizzle |
Integrate holeauth — IDP Consumer (OIDC Relying Party)
Sign users in via an external OIDC provider (another holeauth IdP, Auth0, Keycloak, Okta, generic OIDC, etc.) and maintain a local session.
For Google / GitHub: use GoogleProvider / GithubProvider from @holeauth/core/sso instead — covered by integrate-holeauth-core.
Prerequisites
- A Drizzle DB or another store for local user/session rows
- Upstream OIDC provider with a registered client (client_id, client_secret if confidential, redirect URI)
Source of truth
- Reference:
apps/client-playground/lib/oidc.ts and apps/client-playground/lib/session.ts in the holeauth repo
- Docs:
https://docs.holeauth.dev/docs/sso/consumer, https://docs.holeauth.dev/docs/sso/consumer/generic-oidc
Procedure
Step 1 — Interview
| # | Variable | Type | Notes |
|---|
| 1 | upstreamType | radio | Generic OIDC · Another holeauth IdP · OAuth 2.0 only (no OIDC userinfo) |
| 2 | issuerUrl | text | Used for discovery at <issuer>/.well-known/openid-configuration |
| 3 | clientType | radio | Public (PKCE) · Confidential (client_secret) |
| 4 | clientId / clientSecret | text | If confidential, secret is required |
| 5 | redirectUri | text | e.g. ${APP_URL}/api/auth/callback |
| 6 | scopes | multi-select | openid · profile · email · offline_access · custom |
| 7 | sessionStrategy | radio | Drizzle (server) · JWT cookie (stateless) |
| 8 | refreshStrategy | radio | On every SSR · On 401 only · Manual |
| 9 | logoutBehavior | radio | Local only · RP-initiated end-session |
| 10 | pkce | radio | Yes (recommended; required for public clients) · No |
Step 2 — Install
pnpm add jose drizzle-orm
pnpm add pg
pnpm add -D drizzle-kit @types/pg
No @holeauth/* package is strictly required for this skill. Optionally, you can layer @holeauth/core on top later for primitives like password hashing — but it is NOT used for the OIDC flow itself.
Step 3 — Local session schema
db/schema.ts:
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const clientUsers = pgTable('client_users', {
id: text('id').primaryKey(),
email: text('email').notNull(),
name: text('name'),
image: text('image'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const clientSessions = pgTable('client_sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => clientUsers.id),
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessExpiresAt: timestamp('access_expires_at', { withTimezone: true }).notNull(),
refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
Step 4 — OIDC client helpers (lib/oidc.ts)
Implement these functions — see reference apps/client-playground/lib/oidc.ts:
| Function | Purpose |
|---|
getConfig() | Reads env vars into a typed config |
discoverIssuer() | Fetches .well-known/openid-configuration, cache 5 min |
getJwks() | Fetches jwks_uri, cache 5 min |
randomUrlSafe(bytes) | Crypto-safe random for state/nonce/verifier |
s256Challenge(verifier) | SHA-256 base64url for PKCE |
buildAuthorizeUrl({state, nonce, verifier}) | Constructs the redirect URL |
exchangeCode({code, verifier}) | POSTs to token_endpoint, returns tokens |
refresh(refreshToken) | Token refresh |
verifyIdToken(idToken, {nonce, audience}) | Verifies signature + claims using jose |
fetchUserInfo(accessToken) | Calls userinfo_endpoint |
revokeToken(token) | RFC 7009 revocation (if supported by upstream) |
endSessionUrl(idToken, postLogoutUri) | RP-initiated logout URL |
Fetch the reference implementation via fetch_webpage('https://docs.holeauth.dev/docs/sso/consumer/generic-oidc') if needed.
Step 5 — Routes
app/login/route.ts:
app/api/auth/callback/route.ts:
app/logout/route.ts:
Step 6 — Session helpers (lib/session.ts)
Reference: apps/client-playground/lib/session.ts. Required functions:
createSession({userId, tokens}) — persists row, returns sessionId
setSessionCookie(sessionId) — httpOnly cookie
getCurrentSession() — reads cookie, returns session auto-refreshing if accessExpiresAt < now + 60s
deleteSession(sessionId)
clearSessionCookie()
upsertUser(claims) — idempotent insert/update by sub
Step 7 — Environment
HOLEAUTH_ISSUER=https://idp.example.com/api/auth
HOLEAUTH_CLIENT_ID=...
HOLEAUTH_CLIENT_SECRET=... # only if confidential
HOLEAUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback
HOLEAUTH_SCOPES=openid profile email offline_access
APP_URL=http://localhost:3000
SESSION_SECRET=<openssl rand -base64 32>
Step 8 — Refresh middleware (if refreshStrategy === 'On every SSR')
On each incoming request: read the session cookie, check if accessExpiresAt < now + 60s, and if so, refresh the access token and update the session cookie before serving the response.
This step is platform-specific — the AI agent implements it in the pattern appropriate for framework:
- Next.js App Router 16+:
proxy.ts at the project root
- Next.js App Router 15 and earlier:
middleware.ts
- Express / Hono: middleware registered before route handlers
Docs: https://docs.holeauth.dev/docs/sso/consumer#refresh
Hardcoded gotchas
- Cache discovery and JWKS — without caching, every request hits the upstream provider. 5 minute TTL is a reasonable default; longer if the upstream rotates infrequently.
- PKCE verifier must NOT leave the server. Store it in an httpOnly cookie keyed by state, then delete it after the token exchange.
- Verify
nonce from the id_token against the one you generated — this is what prevents replay. jose's jwtVerify does NOT check nonce; you must.
sub is the stable user identifier. Email can change upstream; do NOT key your local user table on email.
offline_access scope is required to receive a refresh token from most providers (including holeauth IdPs).
- RP-initiated logout requires
id_token_hint — store the id_token server-side at login if you want true single-logout. Otherwise the user remains signed in upstream.
- State cookie MUST be
sameSite: 'lax' (not strict) — strict blocks the cookie on the cross-site redirect back from the IdP.
Verification checklist
[ ] DB migration applied after schema change: pnpm db:push
[ ] GET <issuerUrl>/.well-known/openid-configuration reachable
[ ] Login redirect sends user to upstream authorization URL with PKCE
[ ] Callback handler exchanges code, verifies id_token, creates local session
[ ] Session cookie set and user is authenticated after callback
[ ] Token refresh works when access token expires
[ ] Logout clears local session (and upstream session if RP-initiated)
[ ] pnpm typecheck passes
Need more detail?
GET https://docs.holeauth.dev/api/search?q=oidc+consumer+<topic>
Useful topics: discovery, PKCE, id_token verification, refresh rotation, end-session, account-linking.