OIDC Provider — Implementation Architecture

Internal documentation for developers maintaining the Calimatic Auth platform's OIDC/OAuth2 provider.


Table of Contents

  1. System Overview
  2. Directory Structure
  3. Database Schema
  4. RSA Key Management
  5. Token Lifecycle
  6. Authorization Flow (Detailed)
  7. Consent System
  8. Login Integration
  9. Rate Limiting
  10. Audit Logging
  11. Security Model
  12. Configuration
  13. 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

LayerResponsibility
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:

  1. Load current keys
  2. Generate new keypair
  3. Move currentprevious
  4. Set new keypair as current
  5. Encrypt and update platform_settings
  6. 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/authorize with all params
  • Passes oauthParams to 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 ConsentForm client 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_consent is false on 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=1 and user is authenticated → redirect to authorize endpoint
  • Passes oauthParams through BrandedLoginPageLoginForm

branded-login-page.tsx — New OAuthFlowParams type and prop passthrough.

login-form.tsx — After successful credential auth:

  • If oauthParams.client_id present → 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 callbackUrl for 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

EndpointMaxWindowKey
token2060sclient_id
authorize3060sIP address
introspect6060sclient_id
revoke2060sclient_id
userinfo6060stoken prefix
register560sIP 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 TypeWhenDetail Fields
oauth_authorizeAuthorization code issuedclientId, scopes, keycloakUserId
oauth_tokenToken issued/refreshedgrantType, clientId, keycloakUserId
oauth_revokeToken revokedclientId, tokenTypeHint
oauth_consent_grantedUser grants consent(available, not yet used)
oauth_consent_deniedUser denies consent(available, not yet used)
oauth_introspectToken introspectedclientId, tokenType, active
oauth_client_registeredDynamic registrationclientId, clientName, redirectUris, ipAddress
oauth_end_sessionSession endedkeycloakUserId, 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_prefix for display
  • The plaintext secret is returned exactly once at creation time

Token Security

TokenStorageSecret Material
Authorization codePlaintext in DB48 random bytes
Access token (JWT)Not stored (stateless)RSA-signed, verifiable via JWKS
ID token (JWT)Not storedRSA-signed, verifiable via JWKS
Refresh tokenSHA-256 hash in DB48 random bytes, only hash stored
RSA private keyAES-256-GCM encrypted in DBEncrypted 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

VariableRequiredDefaultDescription
OIDC_ISSUER_URLNoNEXTAUTH_URL or https://auth.calimatic.comIssuer URL for JWT iss claim
NEXTAUTH_URLYesBase URL of the auth platform
NEXTAUTH_SECRETYesNextAuth session signing secret
ENCRYPTION_KEYYes64-hex-char key for AES-256-GCM (encrypts RSA keys, tokens)
DATABASE_URLYesPostgreSQL connection string

Per-Client Configuration

Set at client registration time (stored in app_clients):

FieldDefaultDescription
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_pkcetrueWhether PKCE is mandatory
require_consenttrueWhether consent screen is shown
access_token_ttl3600Access token lifetime in seconds
refresh_token_ttl86400Refresh 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

  1. User authenticates via NextAuth → Keycloak (OIDC or ROPC)
  2. NextAuth receives Keycloak tokens and stores them in its JWT session
  3. The OIDC authorize endpoint reads the NextAuth session to verify authentication
  4. The platform's token service issues its own JWTs signed with its own RSA keys
  5. 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:

  1. The user is created in Keycloak and the platform database (same as admin-initiated invites)
  2. The calling app's license is automatically assigned to the new user (user_app_licenses)
  3. Only apps enabled for the target organization (organization_app_access with isEnabled = true) can be assigned
  4. 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.