OIDC Provider — Implementation Architecture
Internal documentation for developers maintaining the Calimatic Auth platform's OIDC/OAuth2 provider.
Table of Contents
- System Overview
- Directory Structure
- Database Schema
- RSA Key Management
- Token Lifecycle
- Authorization Flow (Detailed)
- Consent System
- Login Integration
- Rate Limiting
- Audit Logging
- Security Model
- Configuration
- Relationship to Keycloak
1. System Overview
Calimatic Auth Platform
~~~~~~~~~~~~~~~~~~~~~~
External App ────── OIDC/OAuth2 ──────► OIDC Endpoints
│
▼
Token Service
(Platform RSA)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Platform DB Keycloak ROPC NextAuth JWT
(codes, tokens, (password auth) (session mgmt)
consents, keys)
Key design principle: Keycloak is a hidden backend implementation detail. External apps interact exclusively with the platform's OIDC endpoints and receive platform-signed JWTs — never Keycloak tokens.
What Each Layer Does
| Layer | Responsibility |
|---|---|
OIDC Endpoints (src/app/api/v1/oidc/) | HTTP API surface — validates requests, orchestrates flow |
OIDC Library (src/lib/oidc/) | Core logic — key management, token signing, PKCE, scopes, validation |
Auth Library (src/lib/auth/) | NextAuth config, Keycloak bridge, user sync, permissions |
Services (src/services/) | Business logic — app client CRUD, user invitation with auto-licensing |
Database (src/lib/db/) | Drizzle ORM schemas and queries |
2. Directory Structure
src/lib/oidc/
├── index.ts # Barrel re-export
├── keys.ts # RSA key generation, storage, signing, JWKS
├── scopes.ts # Scope definitions, claim mappings
├── errors.ts # OAuth2 error response builders
├── validators.ts # Request validation (redirect_uri, PKCE, client auth)
├── token-service.ts # Authorization code + token issuance/validation
└── rate-limit.ts # In-memory sliding window rate limiter
src/app/api/v1/oidc/
├── authorize/route.ts # GET - Authorization endpoint
├── token/route.ts # POST - Token endpoint
├── userinfo/route.ts # GET/POST - UserInfo endpoint
├── jwks/route.ts # GET - JWKS endpoint
├── introspect/route.ts # POST - Token introspection (RFC 7662)
├── revoke/route.ts # POST - Token revocation (RFC 7009)
├── end-session/route.ts # GET - RP-Initiated Logout
└── register/route.ts # POST - Dynamic client registration (RFC 7591)
src/app/.well-known/
└── openid-configuration/route.ts # GET - Discovery document
src/app/(auth)/consent/
└── page.tsx # Consent page (server component)
src/components/auth/
├── consent-form.tsx # Consent form (client component)
├── login-form.tsx # Login form (modified for OAuth flow)
├── social-buttons.tsx # Social login buttons (modified for OAuth flow)
└── branded-login-page.tsx # Wrapper (passes oauthParams)
src/lib/db/schema/
├── app-clients.ts # Extended with OIDC columns
├── oauth-authorization-codes.ts # Authorization codes table
├── oauth-refresh-tokens.ts # Refresh tokens table
└── oauth-user-consents.ts # User consent records
3. Database Schema
app_clients (Extended)
Existing table extended with OIDC/OAuth2 columns:
Core columns (pre-existing):
id UUID PK
application VARCHAR(50) UNIQUE
client_id VARCHAR(100) UNIQUE
client_secret_hash VARCHAR(255)
client_secret_prefix VARCHAR(15)
name VARCHAR(255)
permissions JSONB (string[])
is_active BOOLEAN DEFAULT true
last_used_at TIMESTAMP
created_at TIMESTAMP
created_by UUID FK → user_identities
New OIDC columns:
redirect_uris JSONB (string[]) DEFAULT []
allowed_scopes JSONB (string[]) DEFAULT ['openid','profile','email']
grant_types JSONB (string[]) DEFAULT ['authorization_code']
token_endpoint_auth_method VARCHAR(50) DEFAULT 'client_secret_post'
client_type VARCHAR(20) DEFAULT 'confidential'
logo_url TEXT
client_uri TEXT
require_pkce BOOLEAN DEFAULT true
require_consent BOOLEAN DEFAULT true
access_token_ttl INTEGER DEFAULT 3600
refresh_token_ttl INTEGER DEFAULT 86400
oauth_authorization_codes
id UUID PK
code VARCHAR(128) UNIQUE -- base64url, 48 bytes
client_id UUID FK → app_clients
keycloak_user_id VARCHAR(255)
redirect_uri TEXT
scope TEXT -- space-separated
code_challenge VARCHAR(128) -- S256 hash
code_challenge_method VARCHAR(10) -- "S256"
nonce VARCHAR(255)
used BOOLEAN DEFAULT false
expires_at TIMESTAMP -- created_at + 60s
created_at TIMESTAMP
oauth_refresh_tokens
id UUID PK
token_hash VARCHAR(64) UNIQUE -- SHA-256 of plaintext token
client_id UUID FK → app_clients
keycloak_user_id VARCHAR(255)
scope TEXT
keycloak_refresh_token TEXT -- AES-256-GCM encrypted
expires_at TIMESTAMP
revoked BOOLEAN DEFAULT false
created_at TIMESTAMP
oauth_user_consents
id UUID PK
keycloak_user_id VARCHAR(255)
client_id UUID FK → app_clients
granted_scopes JSONB (string[])
created_at TIMESTAMP
updated_at TIMESTAMP
UNIQUE INDEX: (keycloak_user_id, client_id)
platform_settings (Used for Key Storage)
key VARCHAR(255) PK -- 'oidc_signing_key'
value TEXT -- AES-256-GCM encrypted JSON
category VARCHAR(100) -- 'oidc'
is_secret VARCHAR(5) -- 'true'
updated_at TIMESTAMP
4. RSA Key Management
File: src/lib/oidc/keys.ts
Storage
The RSA keypair is stored as an encrypted JSON blob in platform_settings:
{
"current": {
"kid": "hex-random-16-bytes",
"privateKey": { "kty": "RSA", "n": "...", "e": "...", "d": "...", ... },
"publicKey": { "kty": "RSA", "n": "...", "e": "...", "kid": "...", "alg": "RS256", "use": "sig" },
"createdAt": "2024-01-01T00:00:00.000Z"
},
"previous": {
// Same structure — retained for graceful rotation
}
}
The JSON is encrypted with AES-256-GCM using the ENCRYPTION_KEY environment variable before storage.
Caching
Keys are cached in-memory for 5 minutes to avoid repeated DB queries and decryption:
cachedKeys: StoredKeyData | null
cacheLoadedAt: number
CACHE_TTL_MS = 5 * 60 * 1000
Auto-Generation
On first request, if no key exists in platform_settings, a new RSA 2048-bit keypair is generated and stored. No manual key provisioning is needed.
Key Rotation
rotateSigningKey() performs:
- Load current keys
- Generate new keypair
- Move
current→previous - Set new keypair as
current - Encrypt and update
platform_settings - Invalidate in-memory cache
The JWKS endpoint exposes both keys, so tokens signed with the previous key remain verifiable until the next rotation.
Signing
JWTs are signed using the jose library's SignJWT class:
Header: { alg: "RS256", kid: "<current-kid>", typ: "JWT" }
Payload: { iss, sub, aud, iat, exp, scope, token_type, ...claims }
5. Token Lifecycle
Authorization Code
Created: createAuthorizationCode() in token-service.ts
Stored: oauth_authorization_codes table
Lifetime: 60 seconds
Redeemed: redeemAuthorizationCode() — marks used=true, returns record
Cleanup: cleanupExpiredOAuthData() — deletes codes >5 min old
Access Token (JWT)
Created: issueAccessToken() in token-service.ts
Format: RS256-signed JWT
Claims: iss, sub (keycloak user id), aud (client_id), scope, token_type, user claims
Lifetime: Configurable per client (default 3600s)
Validation: verifyJWT() in keys.ts — checks signature against JWKS
Revocation: Not revocable (stateless). Expires naturally.
ID Token (JWT)
Created: issueIdToken() in token-service.ts
Format: RS256-signed JWT
Claims: iss, sub, aud, nonce, token_type, user profile claims
Lifetime: Same as access token
Purpose: Authentication assertion — contains user identity claims
Refresh Token (Opaque)
Created: issueRefreshToken() in token-service.ts
Format: base64url-encoded 48 random bytes
Storage: SHA-256 hash in oauth_refresh_tokens table
Lifetime: Configurable per client (default 86400s)
Validation: validateRefreshToken() — looks up hash, checks not revoked/expired
Revocation: revokeRefreshToken() — sets revoked=true
Bulk revoke: revokeAllRefreshTokens(userId, clientDbId)
6. Authorization Flow (Detailed)
GET /api/v1/oidc/authorize
?client_id=...&redirect_uri=...&response_type=code&scope=...
&state=...&code_challenge=...&code_challenge_method=S256&nonce=...
Step-by-Step
1. Parse query parameters
↓
2. Validate client_id exists in app_clients (active)
↓ Error → 400 JSON { "error": "invalid_client" }
3. Validate redirect_uri is registered (exact match)
↓ Error → 400 JSON (NOT a redirect — prevents open redirect)
4. Validate response_type = "code"
↓ Error → redirect to redirect_uri with error=unsupported_response_type
5. Parse + validate scopes (filter invalid, ensure openid, check allowed)
↓ Error → redirect with error=invalid_scope
6. Validate PKCE params (if require_pkce is true)
↓ Error → redirect with error=invalid_request
7. Check consent=denied query param
↓ If denied → redirect with error=access_denied
8. Get NextAuth JWT token (getToken from next-auth/jwt)
↓ If missing → redirect to /login with all OAuth params preserved
9. Check consent status
├─ If require_consent=false → skip
├─ If existing consent covers all scopes → skip
└─ If no consent → redirect to /consent with all OAuth params
10. If consent=granted → upsert oauth_user_consents record
11. Create authorization code in oauth_authorization_codes
12. Log OAUTH_AUTHORIZE audit event
13. Redirect to redirect_uri?code=...&state=...
Login Redirect (Step 8)
When the user is not authenticated, the authorize endpoint redirects to:
/login?oauth=1&client_id=...&redirect_uri=...&response_type=code&scope=...
&state=...&code_challenge=...&code_challenge_method=S256&nonce=...
The login page (src/app/(auth)/login/page.tsx) detects oauth=1 and:
- If user becomes authenticated → redirects back to
/api/v1/oidc/authorizewith all params - Passes
oauthParamsto the login form and social buttons
After credential login, login-form.tsx redirects to:
/api/v1/oidc/authorize?client_id=...&redirect_uri=...&... (all params preserved)
For social login, social-buttons.tsx sets the NextAuth callbackUrl to the authorize endpoint URL with all params, so after the Keycloak→social provider round-trip, the user lands back at the authorize endpoint.
7. Consent System
Consent Check
The authorize endpoint checks oauth_user_consents for a matching (keycloak_user_id, client_id) record where granted_scopes contains all requested scopes.
Consent Page
Server component at src/app/(auth)/consent/page.tsx:
- Requires authenticated session
- Looks up client metadata (name, logo, URI)
- Resolves branding for the client's application
- Parses scopes and generates human-readable descriptions
- Renders
ConsentFormclient component
Consent Form
Client component at src/components/auth/consent-form.tsx:
- Displays client info + scope descriptions
- Allow → redirects to authorize endpoint with
consent=granted - Deny → redirects to authorize endpoint with
consent=denied - Preserves all OAuth params
Consent Storage
On consent=granted, the authorize endpoint upserts into oauth_user_consents:
INSERT INTO oauth_user_consents (keycloak_user_id, client_id, granted_scopes)
VALUES ($1, $2, $3)
ON CONFLICT (keycloak_user_id, client_id) DO UPDATE
SET granted_scopes = $3, updated_at = NOW()
Skipping Consent
Consent is skipped when:
require_consentisfalseon the client (for first-party apps)- An existing consent record covers all requested scopes
8. Login Integration
Modified Components
login/page.tsx — Server component changes:
- Accepts new search params:
oauth,client_id,redirect_uri,response_type,scope,state,code_challenge,code_challenge_method,nonce - If
oauth=1and user is authenticated → redirect to authorize endpoint - Passes
oauthParamsthroughBrandedLoginPage→LoginForm
branded-login-page.tsx — New OAuthFlowParams type and prop passthrough.
login-form.tsx — After successful credential auth:
- If
oauthParams.client_idpresent → build authorize URL and redirect - Otherwise → original exchange code / internal redirect logic
social-buttons.tsx — When oauthParams present:
- Build authorize URL with all OAuth params as the
callbackUrlfor NextAuth's Keycloak provider - After social login round-trip, user returns to the authorize endpoint
9. Rate Limiting
File: src/lib/oidc/rate-limit.ts
In-memory sliding window implementation. No external dependencies.
How It Works
- Each key (e.g.,
oidc:token:clientId123) maintains a sorted array of request timestamps - On each request, timestamps outside the window are removed
- If count >= max, the request is rejected with 429
- Cleanup runs every 5 minutes to remove stale entries
Configured Limits
| Endpoint | Max | Window | Key |
|---|---|---|---|
| token | 20 | 60s | client_id |
| authorize | 30 | 60s | IP address |
| introspect | 60 | 60s | client_id |
| revoke | 20 | 60s | client_id |
| userinfo | 60 | 60s | token prefix |
| register | 5 | 60s | IP address |
Limitation
The in-memory store does not survive process restarts and is not shared across multiple instances. For multi-instance deployments, replace with a Redis-backed implementation.
10. Audit Logging
All OIDC operations log to the identity_audit_log table via logAuditEvent().
OIDC Event Types
| Event Type | When | Detail Fields |
|---|---|---|
oauth_authorize | Authorization code issued | clientId, scopes, keycloakUserId |
oauth_token | Token issued/refreshed | grantType, clientId, keycloakUserId |
oauth_revoke | Token revoked | clientId, tokenTypeHint |
oauth_consent_granted | User grants consent | (available, not yet used) |
oauth_consent_denied | User denies consent | (available, not yet used) |
oauth_introspect | Token introspected | clientId, tokenType, active |
oauth_client_registered | Dynamic registration | clientId, clientName, redirectUris, ipAddress |
oauth_end_session | Session ended | keycloakUserId, clientId |
11. Security Model
Client Secret Handling
- Client secrets are generated as 48 random bytes, base64url-encoded with prefix
ccas_ - Only the SHA-256 hash is stored in
client_secret_hash - First 8 characters stored in
client_secret_prefixfor display - The plaintext secret is returned exactly once at creation time
Token Security
| Token | Storage | Secret Material |
|---|---|---|
| Authorization code | Plaintext in DB | 48 random bytes |
| Access token (JWT) | Not stored (stateless) | RSA-signed, verifiable via JWKS |
| ID token (JWT) | Not stored | RSA-signed, verifiable via JWKS |
| Refresh token | SHA-256 hash in DB | 48 random bytes, only hash stored |
| RSA private key | AES-256-GCM encrypted in DB | Encrypted with ENCRYPTION_KEY |
PKCE
- Required by default for all clients (
require_pkce: true) - Only S256 method supported (plain not allowed)
- Verification:
BASE64URL(SHA256(code_verifier)) === code_challenge
Redirect URI Validation
- Exact string match only — no wildcards, no pattern matching
- Validated before any redirect to prevent open redirector attacks
- Errors before redirect_uri validation return JSON, not redirects
CORS
All OIDC endpoints and .well-known return permissive CORS to support browser-based clients:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
This is standard for OAuth/OIDC providers — the token endpoint and userinfo endpoint must be accessible from SPAs.
12. Configuration
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
OIDC_ISSUER_URL | No | NEXTAUTH_URL or https://auth.calimatic.com | Issuer URL for JWT iss claim |
NEXTAUTH_URL | Yes | — | Base URL of the auth platform |
NEXTAUTH_SECRET | Yes | — | NextAuth session signing secret |
ENCRYPTION_KEY | Yes | — | 64-hex-char key for AES-256-GCM (encrypts RSA keys, tokens) |
DATABASE_URL | Yes | — | PostgreSQL connection string |
Per-Client Configuration
Set at client registration time (stored in app_clients):
| Field | Default | Description |
|---|---|---|
redirect_uris | [] | Registered callback URLs |
allowed_scopes | ['openid','profile','email'] | Max scopes the client can request |
grant_types | ['authorization_code'] | Allowed grant types |
client_type | 'confidential' | confidential or public |
require_pkce | true | Whether PKCE is mandatory |
require_consent | true | Whether consent screen is shown |
access_token_ttl | 3600 | Access token lifetime in seconds |
refresh_token_ttl | 86400 | Refresh token lifetime in seconds |
13. Relationship to Keycloak
What Keycloak Does
- Authenticates users via ROPC (email/password → Keycloak token endpoint)
- Manages social login (Google, Microsoft, Apple) via Keycloak IDP hints
- Stores user credentials (passwords, social provider links)
- Manages SSO (per-org SAML, OIDC, LDAP providers)
What Keycloak Does NOT Do
- Issue tokens to external apps (the platform does this)
- Manage client registrations for external apps
- Handle consent screens
- Sign JWTs that external apps see
The Bridge
- User authenticates via NextAuth → Keycloak (OIDC or ROPC)
- NextAuth receives Keycloak tokens and stores them in its JWT session
- The OIDC authorize endpoint reads the NextAuth session to verify authentication
- The platform's token service issues its own JWTs signed with its own RSA keys
- Keycloak refresh tokens are stored (encrypted) alongside platform refresh tokens for potential upstream refresh
Token Chain
External App ← Platform JWT (RS256, platform key)
↑
Token Service
↑
User Identity (platform DB, synced from Keycloak)
↑
NextAuth Session (Keycloak tokens)
↑
Keycloak Authentication
External apps never interact with or receive Keycloak tokens. They only see platform-signed JWTs and interact with the platform's OIDC endpoints.
User Provisioning by App Clients
App clients can create users via the user invite API (POST /api/v1/organizations/[orgId]/users) using their x-client-id + x-client-secret headers. When an app client invites a user:
- The user is created in Keycloak and the platform database (same as admin-initiated invites)
- The calling app's license is automatically assigned to the new user (
user_app_licenses) - Only apps enabled for the target organization (
organization_app_accesswithisEnabled = true) can be assigned - If no explicit apps are specified, all enabled apps on the org are auto-assigned
This mirrors the Okta model: app registration → org enablement → user assignment. See the Integration Guide for details.