Skip to content

Commit 17651a7

Browse files
authored
fix(oidc): Enterprise IdP compatibility — RFC-compliant errors, auth_time, TTL, discovery (#604)
* fix(oidc): hybrid flow stores nonce instead of encrypted session for code exchange The hybrid flow path in authorize.go stored authToken.FingerPrint (the raw nonce) instead of authToken.FingerPrintHash (the AES-encrypted session data) when stashing the code for /oauth/token exchange. When /oauth/token later calls ValidateBrowserSession on sessionDataSplit[1], it tries to AES-decrypt the value. Since the nonce is not AES-encrypted, this always fails for hybrid flow codes. The other two code paths (code flow at line 520 and oauth_callback at line 331) correctly store AES-encrypted session values. * fix(graphql): scope override uses wrong length check in signup and session The scope override condition in signup.go and session.go checked len(scope) (the default list, always 3) instead of len(params.Scope), making it impossible to pass an empty scope list and retain defaults. Fixed to match the correct pattern already used in login.go. Added integration tests verifying that an empty Scope slice falls back to the default scopes (openid, email, profile). * fix: null pointer in verify_otp, vestigial nonce separator, constant-time token comparison - verify_otp.go: change `otp == nil && err != nil` to `otp == nil` to prevent nil pointer dereference when storage returns (nil, nil) - token.go: only append "@@" + code to nonce when code is non-empty, avoiding vestigial "uuid@@" in refresh_token grant flow - revoke_refresh_token.go: use crypto/subtle.ConstantTimeCompare for token comparison to prevent timing oracle attacks (RFC 7009) - add integration tests for all three fixes * fix(graphql): resend_otp uses unsanitized input for OTP lookup and wrong error message - use sanitized email/phoneNumber locals instead of raw params.Email and params.PhoneNumber when calling GetOTPByEmail/GetOTPByPhoneNumber - fix SMS-disabled error message from "email service not enabled" to "SMS service not enabled" - add clarifying comment on the MFA/verified guard condition - add integration tests for sanitized-email resend and SMS error message * fix(oauth): scope parsing, safe type assertions, and JSON error handling in oauth_callback - Fix scope parsing to use else-if so comma-delimited scopes aren't silently overwritten by space-split; handle single-value scopes - Convert all unsafe type assertions to safe form with ok-checking across Facebook, LinkedIn, Twitter, Discord, and Roblox processors - Add error checking for all json.Unmarshal calls that were silently dropping parse failures (GitHub, Facebook, LinkedIn, Twitter, Roblox) - Extract parseScopes helper with unit tests covering comma, space, single-value, and mixed-delimiter inputs * fix: phone uniqueness in admin update_user, OIDC-compliant logout redirect handling - Remove stale TODO comment in update_user.go; phone uniqueness check already exists at lines 198-214 with proper length validation - Change logout handler to silently ignore invalid post_logout_redirect_uri per OIDC RP-Initiated Logout §2 instead of returning a JSON 400 error - Add integration test for duplicate phone number rejection via admin _update_user mutation - Add integration test verifying invalid redirect URI no longer produces a JSON error response * chore: update pkce flow and oidc * fix(oauth): RFC-compliant PKCE, redirect_uri validation, and security hardening - Fix S256 PKCE: tolerate base64url padding in code_challenge (Auth0 compat) - Fix client_secret bypass: always validate when provided, even with PKCE - Fix PKCE bypass: reject code_verifier when no code_challenge was registered - Add redirect_uri validation at token endpoint per RFC 6749 §4.1.3 - URL-encode redirect_uri in state to prevent @@ delimiter injection - Make authorize state removal synchronous to prevent code reuse race - Use constant-time comparison for redirect_uri at token endpoint - Remove code_challenge/state/nonce from authorize handler logs - Replace leaked err.Error() with generic message in refresh token path - Fix non-standard error codes (error_binding_json -> invalid_request) - Fix Makefile dev target: proper multi-line RSA key handling * docs: comprehensive OAuth2/OIDC guide with IdP vs RP, grant types, PKCE, integration examples Covers all flows in plain English with architecture diagrams, code examples (JS, Go, Python), Auth0 enterprise connection setup, security considerations, and FAQ. * fix(oidc): frontend redirect handles all response types, modes, and URL-encodes authState Back channel fix: - URL-encode all params in authState query string sent to login UI (redirect_uri, scope, state, etc.) to prevent parsing corruption Front channel fix (root cause of Auth0 "cannot read undefined 'tenant'"): - React app now reads response_type and response_mode from URL params - For response_type=id_token: includes id_token in redirect (was missing entirely) - For response_type=token: includes access_token, token_type, expires_in - For hybrid flows: includes code + relevant tokens - Respects response_mode: uses fragment (#) for implicit flows, query (?) for code flow - Reads params from both query string and fragment to support all response_modes - Same-origin redirects now work (previously silently skipped) - Proper encodeURIComponent on all redirect params * fix(oidc): store authorization code state when session auto-detected by SDK Root cause: when /authorize forces re-auth (prompt=login from Auth0) but the session cookie is still valid, the React SDK auto-detects the session via the GraphQL session query and redirects immediately — without ever calling the login mutation. Since SetState(code, ...) only happens in the login mutation, the code state was never stored, causing /oauth/token to return "authorization code is invalid or has expired". Fix: - Add `state` field to SessionQueryRequest GraphQL schema - Session resolver now consumes authorize state and stores code state (same logic as login/signup/verify_otp resolvers) - React app detects OIDC flow (code in URL) and calls session with state param before redirecting, ensuring the code state is stored * fix(oidc): use redirect_uri from URL params, prevent redirect loop on /app * fix(oidc): handle forceReauth with existing session inline, use authorizer-js Root cause of all three Auth0 failures (PKCE plain, PKCE disabled, already-logged-in): - /authorize with prompt=login cleared the session and sent user to login UI, but the React SDK auto-detected the session cookie and redirected immediately without calling the login mutation, so the authorization code state was never stored in the backend. Fix (authorize.go): - When forceReauth is true but a valid session EXISTS, validate it and proceed with the normal code flow (session rollover + code state storage + redirect to RP) instead of sending to the login UI. - Only falls through to login UI when the session is truly invalid. Fix (Root.tsx): - Use @authorizerdev/authorizer-js client instead of raw GraphQL fetch - getSession with state param ensures code state is stored as fallback - Simplified redirect logic, no infinite loop risk * fix(oidc): revert forceReauth handler change, fix lives in session resolver + React app The /authorize forceReauth change caused redirect issues. Reverted to original behavior (prompt=login clears session, redirects to login UI). The fix for the "already logged in" case works via: 1. Session resolver (session.go) accepts state param and stores code state 2. React app (Root.tsx) detects OIDC flow and calls getSession(state) via authorizer-js before redirecting to RP 3. This ensures code state is stored even when SDK auto-detects session * fix(oidc): prompt=login keeps valid session for inline code state storage When prompt=login and a valid session cookie exists, the /authorize handler now keeps the session instead of discarding it. The normal flow validates the session, performs a rollover, stores the authorization code state, and redirects directly to the RP — never hitting the login UI. This fixes the Auth0 back-channel failures (PKCE plain, PKCE disabled, already-logged-in) caused by the React SDK auto-detecting the session cookie and redirecting without the login mutation ever storing the code state. For max_age=0 or max_age exceeded, the session IS discarded (spec requires genuine re-authentication based on time). * fix(oidc): simplify React app redirect — remove async getSession, redirect immediately on token * fix(oidc): RFC-compliant errors, auth_time, TTL, discovery for Enterprise IdP compat - Refactor validateAuthorizeRequest to return RFC 6749 error codes (invalid_request, unauthorized_client, unsupported_response_type) instead of freeform strings that break Auth0/Okta/Keycloak parsing - After redirect_uri validation, redirect errors to RP via configured response_mode instead of returning JSON (RFC 6749 §4.1.2.1) - Add redirectErrorToRP helper for spec-compliant error redirects supporting query, fragment, form_post, and web_message modes - Set AuthTime (claims.IssuedAt) on all token creation paths so id_token includes auth_time claim per OIDC Core §2 when max_age used - Normalize token_type to "Bearer" (capitalized) in redirect params for consistency with token endpoint and strict RP validation - Add "none" to token_endpoint_auth_methods_supported in discovery document for PKCE-only public clients (OIDC Core §9) - Add Cache-Control header to discovery endpoint (public, max-age=3600) - Add 10-minute TTL to in-memory state store entries matching Redis provider behavior (RFC 6749 §4.1.2 authorization code expiry) - Add TTL enforcement to DB state store provider (600s expiry) - Fix React App.tsx scope default from string to array matching Root.tsx - Fix React App.tsx redirectURL fallback from window.location.href (includes query params) to window.location.origin (clean origin) - Add debug log when response_type=token used with openid scope - Remove duplicate response_type validation in validateAuthorizeRequest * fix(oidc): forward nonce in authState for code flow — fixes Auth0 nonce mismatch The nonce parameter from the RP (Auth0/Okta/Keycloak) was only appended to authState for implicit flows, not for the code flow. This caused: 1. Auth0 sends /authorize?nonce=ABC&response_type=code 2. Authorizer stores nonce=ABC in state, redirects to /app WITHOUT nonce 3. React app logs user in, redirects back to /authorize — nonce missing 4. Second /authorize visit generates NEW nonce=XYZ (line 242 fallback) 5. Token endpoint returns id_token with nonce=XYZ 6. Auth0 expected nonce=ABC → "unexpected ID Token nonce claim value" Fix: always append &nonce= to authState before the code/implicit branch, so the login UI preserves and forwards the RP-provided nonce on the second /authorize round-trip. * fix(oidc): don't delete browser session during code exchange at /oauth/token When the RP (Auth0/Okta/Keycloak) exchanges an authorization code at /oauth/token, the endpoint was: 1. Deleting the user's browser session (rollover at line 288) 2. Creating a new session + setting a cookie Both are wrong for the code exchange flow because /oauth/token is called server-to-server by the RP, not by the user's browser. The cookie goes to the RP (useless), and the deletion invalidates the session the user's browser holds from /authorize — causing "record not found" on subsequent session lookups. Fix: - Remove session deletion for authorization_code grant (the /authorize endpoint already performed its own rollover) - Only create new browser session + cookie for refresh_token grant (where the caller IS the user's app holding the refresh token) - Access/refresh tokens are still persisted for both grant types (needed for introspection/revocation) * fix(oidc): forward code_challenge, code_challenge_method, client_id through login UI Same class of bug as the nonce loss: PKCE parameters and client_id were not included in authState, so when the React login UI redirects back to /authorize after authentication, these values were missing. For PKCE (code_challenge): 1. Auth0 sends /authorize?code_challenge=ABC&code_challenge_method=S256 2. User not logged in → redirect to /app WITHOUT code_challenge 3. After login, second /authorize has empty code_challenge 4. Authorization code stored with empty challenge 5. Auth0 sends code_verifier to /oauth/token → rejected with "code_verifier was provided but no code_challenge was registered" For client_id: Without forwarding, the second /authorize round-trip would have empty client_id, failing validateAuthorizeRequest with unauthorized_client. Fix: append code_challenge, code_challenge_method, and client_id to authState so they survive the login UI round-trip. Note: code_challenge is a hash (not a secret) — safe to include in URLs. The code_verifier (the secret) is never in URLs, only in /oauth/token. * fix(oidc): default code_challenge_method to plain per RFC 7636 §4.2 RFC 7636 §4.2 states: "If no code_challenge_method is present, the server MUST use 'plain' as the default." The previous code defaulted to S256, which meant when Auth0 sends a plain-text code_challenge without specifying the method: 1. /authorize stores challenge as "challenge::S256" 2. /oauth/token SHA-256 hashes the code_verifier 3. Compares hash against the plain-text challenge → mismatch 4. "The code_verifier does not match the code_challenge" Fixed in both authorize.go (default when storing) and token.go (default when parsing legacy format without ::method separator). * fix(oidc): prompt=login must delete session and force re-authentication OIDC Core §3.1.2.1 requires prompt=login to force re-authentication even when a valid session exists. The previous code intentionally kept the session alive for prompt=login to avoid a redirect loop, but this meant the user was never shown the login form — the React SDK detected the existing cookie, auto-redirected back to /authorize, which issued a code immediately without re-authentication. Fix: when forceReauth is true (prompt=login or max_age=0): 1. Delete the server-side session from memory store 2. Delete the browser session cookie 3. Clear sessionToken so the handler redirects to the login UI The prompt parameter is intentionally NOT forwarded in authState, so the second /authorize call (after re-authentication) won't see prompt=login and won't loop. Flow after fix: 1. /authorize?prompt=login → delete session+cookie → redirect to /app 2. React app has no session → shows login form 3. User authenticates → login mutation creates new session 4. React redirects to /authorize (without prompt=login) 5. /authorize finds fresh session → issues code → redirects to RP * Revert "fix(oidc): prompt=login must delete session and force re-authentication" This reverts commit 2f88665. * fix(oidc): CSP form-action must include full path URI for form_post mode The form_post response mode renders an HTML page that auto-submits a form to the RP's redirect_uri. The CSP form-action directive was only including the origin (scheme://host), which some browsers reject when the form action includes a path. Fix: include both the origin and the path-level URI (without query string, since CSP source expressions don't support query parameters) in the form-action directive. This gives: form-action 'self' https://rp.example.com https://rp.example.com/callback Also reverts the prompt=login session deletion change — the previous flow of keeping the session for prompt=login was correct since the React SDK auto-detects the session. The real issue was this CSP block. * chore: fix client check + frontend channel * fix: restore client_id validation, fix tests for RFC-compliant error format - Restore client_id check in validateAuthorizeRequest that was accidentally removed during the duplicate response_type cleanup - Update TestAuthorizeEndpointCompliance to check for RFC 6749 error codes (invalid_request) instead of freeform error strings - Update TestTokenEndpointCompliance PKCE test to use explicit ::S256 method separator (default is now plain per RFC 7636 §4.2) - Update TestClientIDMismatchMetric to expect 200 for dashboard paths (ClientCheckMiddleware only validates on /graphql route) * fix: remove client_id from validateAuthorizeRequest, update test client_id validation is not needed in validateAuthorizeRequest — it is handled elsewhere. Update test to expect 302 redirect for invalid client_id at /authorize instead of 400. * docs: Enterprise OIDC IdP integration guide for Auth0/Okta/Keycloak Comprehensive guide covering: - Authorization code flow with PKCE (S256 and plain) - Nonce handling for backchannel (code) flow — required by Enterprise RPs - Response modes (query, fragment, form_post, web_message) with CSP details - Session management during token exchange (auth_code vs refresh_token) - Auth0, Okta, Keycloak-specific configuration and behavior - Prompt parameter handling (login, none, consent) - Troubleshooting common integration failures - RFC compliance table * security: fix 7 audit findings — atomic code exchange, CSP nonce, sync session ops M1 — Atomic GetAndRemoveState (TOCTOU race on authorization code): Add GetAndRemoveState to Provider interface and all 3 implementations (in-memory, Redis via Lua EVAL, DB). Token endpoint now uses single atomic call instead of separate GetState+RemoveState, preventing authorization code replay under concurrent requests (RFC 6749 §4.1.2). M2 — Document PKCE requirement for "none" auth method: Add comment in openid_config.go explaining that "none" requires PKCE; token endpoint rejects "none" without code_verifier. M3 — Warning log when PKCE plain method is used: Log debug message when code_challenge_method defaults to or is explicitly set to "plain", noting that S256 is recommended since plain exposes the code_verifier in URL parameters. L1 — Replace unsafe-inline with nonce-based CSP in form_post: setFormPostCSP now generates a crypto/rand nonce, returns it, and sets script-src 'nonce-<value>' instead of 'unsafe-inline'. Template uses <script nonce="..."> instead of onload attribute. Also restores the form-action directive with origin+path (was lost in prior commit). L3 — Synchronous old session deletion in authorize.go: Replace async go-routine with synchronous call + error logging. Prevents silent failures during session rollover. L4 — Synchronous refresh token deletion in token.go: Same pattern — synchronous with error logging for token rotation. L5 — Addressed by L1: redirectURI param is now used for form-action CSP. * chore: fix form actions * security: fix 20 audit findings — timing attacks, open redirect, CSRF, enumeration Critical: - Use crypto/subtle.ConstantTimeCompare for token validation (auth_token.go) - Validate redirect URI in OAuth callback against allowlist (oauth_callback.go) - Use atomic GetAndRemoveState in consumeAuthorizeState (oauth_authorize_state.go) High: - Remove refresh token from URL query params in OAuth callback - Require X-Authorizer-Client-ID header for /graphql route - Add configurable --app-cookie-same-site flag (lax/strict/none), default lax - Sanitize internal error messages in OAuth callback responses Medium: - Add dummy bcrypt on signup "user exists" path (timing equalization) - Return generic success in resend_otp for non-existent users - Fix unconditional "Failed" log in resend_otp - Check delete error in DB GetAndRemoveState for replay prevention - Fix login MFA: use isEmailLogin instead of isMobileLogin - Return generic error in verify_otp for user not found / revoked Low: - Replace Redis KEYS with SCAN in DeleteAllUserSessions - Sanitize logout error responses - Remove client_id from debug log - Reduce OpenID Discovery cache from 1h to 5m * feat: add --app-cookie-same-site flag for configurable SameSite attribute Add AppCookieSameSite config field with CLI flag --app-cookie-same-site (values: none, lax, strict; default: none). Default "none" preserves backward compatibility — previously SameSite=None was hardcoded when AppCookieSecure=true. Operators can now explicitly set "lax" or "strict" for same-origin deployments to improve CSRF protection. * chore: check
1 parent 41d533b commit 17651a7

42 files changed

Lines changed: 1720 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# Alpine v3.23 main still ships busybox 1.37.0-r30 (e.g. CVE-2025-60876); edge/main has r31+.
55
# Pin busybox from edge until the stable branch backports it. See alpine/aports work item #17940.
6-
FROM golang:1.25-alpine3.23 AS go-builder
6+
FROM golang:1.26-alpine3.23 AS go-builder
77
ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main
88
RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31"
99
WORKDIR /authorizer

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PROJECT := authorizer
22
DEFAULT_VERSION=0.1.0-local
33
VERSION := $(or $(VERSION),$(DEFAULT_VERSION))
4-
DOCKER_IMAGE ?= authorizerdev/authorizer:$(VERSION)
4+
DOCKER_IMAGE ?= lakhansamani/authorizer:$(VERSION)
55

66
# Full module test run. Storage provider tests honour TEST_DBS (defaults to all).
77
# Integration tests and memory_store/db tests always use SQLite.

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func init() {
167167

168168
// Cookies flags
169169
f.BoolVar(&rootArgs.config.AppCookieSecure, "app-cookie-secure", true, "Application secure cookie flag")
170+
f.StringVar(&rootArgs.config.AppCookieSameSite, "app-cookie-same-site", "none", "SameSite attribute for session cookies (lax, strict, none)")
170171
f.BoolVar(&rootArgs.config.AdminCookieSecure, "admin-cookie-secure", true, "Admin secure cookie flag")
171172
f.BoolVar(&rootArgs.config.DisableAdminHeaderAuth, "disable-admin-header-auth", false, "Disable admin authentication via X-Authorizer-Admin-Secret header")
172173

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
# Enterprise OIDC IdP Integration Guide
2+
3+
Authorizer can act as an **Enterprise OpenID Connect Identity Provider (IdP)** for platforms like Auth0, Okta, and Keycloak. This document covers the technical details, configuration, and known requirements for this integration.
4+
5+
---
6+
7+
## Table of Contents
8+
9+
- [Overview](#overview)
10+
- [How It Works](#how-it-works)
11+
- [Auth0 Configuration](#auth0-configuration)
12+
- [Okta Configuration](#okta-configuration)
13+
- [Keycloak Configuration](#keycloak-configuration)
14+
- [OIDC Endpoints](#oidc-endpoints)
15+
- [Authorization Code Flow with PKCE](#authorization-code-flow-with-pkce)
16+
- [Nonce Handling](#nonce-handling)
17+
- [Response Modes](#response-modes)
18+
- [Prompt Parameter](#prompt-parameter)
19+
- [Session Management](#session-management)
20+
- [Troubleshooting](#troubleshooting)
21+
- [RFC Compliance](#rfc-compliance)
22+
23+
---
24+
25+
## Overview
26+
27+
When configured as an Enterprise OIDC IdP, Authorizer handles:
28+
29+
1. **Discovery** — RP reads `/.well-known/openid-configuration` to discover endpoints
30+
2. **Authorization** — RP redirects users to `/authorize` with OIDC parameters
31+
3. **Authentication** — Authorizer shows its login UI, user authenticates
32+
4. **Code Exchange** — RP exchanges the authorization code at `/oauth/token`
33+
5. **UserInfo** — RP optionally fetches user claims from `/userinfo`
34+
35+
## How It Works
36+
37+
```
38+
┌─────────┐ 1. /authorize?client_id=...&nonce=... ┌────────────┐
39+
│ Auth0 │ ─────────────────────────────────────────────▶│ Authorizer │
40+
│ (RP) │ │ (IdP) │
41+
│ │ 2. User authenticates at /app │ │
42+
│ │ │ │
43+
│ │ 3. 302 redirect_uri?code=...&state=... │ │
44+
│ │ ◀─────────────────────────────────────────────│ │
45+
│ │ │ │
46+
│ │ 4. POST /oauth/token (code + verifier) │ │
47+
│ │ ─────────────────────────────────────────────▶│ │
48+
│ │ │ │
49+
│ │ 5. { access_token, id_token } │ │
50+
│ │ ◀─────────────────────────────────────────────│ │
51+
└─────────┘ └────────────┘
52+
```
53+
54+
### Key Parameters Forwarded Through Login UI
55+
56+
When the user is not yet authenticated, Authorizer redirects to its login UI (`/app`). The following parameters are forwarded via the `authState` URL so the React login app can send them back to `/authorize` after authentication:
57+
58+
| Parameter | Purpose | Required |
59+
|-----------|---------|----------|
60+
| `state` | CSRF protection, RP state preservation | Yes |
61+
| `nonce` | Replay protection — echoed in `id_token` | Required for backchannel (code) flow by most RPs |
62+
| `scope` | Requested claims (`openid profile email`) | Yes |
63+
| `redirect_uri` | RP's callback URL | Yes |
64+
| `response_type` | Flow type (`code`, `id_token`, etc.) | Yes |
65+
| `response_mode` | Delivery method (`query`, `fragment`, `form_post`, `web_message`) | Yes |
66+
| `client_id` | RP's client identifier | Yes |
67+
| `code_challenge` | PKCE challenge (hash of verifier) | When PKCE is used |
68+
| `code_challenge_method` | `S256` or `plain` | When PKCE is used |
69+
| `login_hint` | Pre-fill email in login form | Optional |
70+
| `ui_locales` | Preferred UI language | Optional |
71+
72+
**Important:** `prompt` is intentionally NOT forwarded to prevent redirect loops. See [Prompt Parameter](#prompt-parameter).
73+
74+
---
75+
76+
## Auth0 Configuration
77+
78+
### Setting Up Enterprise Connection
79+
80+
1. In Auth0 Dashboard → **Authentication****Enterprise****OpenID Connect**
81+
2. Create new connection with:
82+
- **Issuer URL**: `https://your-authorizer-domain.com`
83+
- **Client ID**: Your Authorizer `client_id`
84+
- **Client Secret**: Your Authorizer `client_secret`
85+
- **Scopes**: `openid profile email`
86+
3. Auth0 auto-discovers endpoints from `/.well-known/openid-configuration`
87+
88+
### Auth0-Specific Behavior
89+
90+
- **PKCE**: Auth0 may send `code_challenge` without `code_challenge_method`. Per RFC 7636 §4.2, Authorizer defaults to `plain` when the method is absent.
91+
- **Nonce**: Auth0 **always** sends a `nonce` parameter, even for the `code` flow (where OIDC Core makes it optional). Authorizer must echo this nonce back in the `id_token`.
92+
- **Response Mode**: Auth0 uses `form_post` for the authorization response. Authorizer sets a CSP header that allows the form to POST to Auth0's callback URL.
93+
- **Token Exchange**: Auth0's server calls `/oauth/token` with the authorization code. This is a server-to-server call — Authorizer does not set a browser cookie on this response.
94+
95+
### Testing
96+
97+
Use Auth0's connection tester:
98+
```
99+
https://YOUR_TENANT.us.auth0.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&connection=YOUR_CONNECTION_NAME&prompt=login&scope=openid%20profile%20email&redirect_uri=https://YOUR_TENANT.us.auth0.com/login/callback
100+
```
101+
102+
---
103+
104+
## Okta Configuration
105+
106+
1. In Okta Admin → **Security****Identity Providers****Add Identity Provider****OpenID Connect**
107+
2. Configure:
108+
- **Issuer**: `https://your-authorizer-domain.com`
109+
- **Client ID / Secret**: Authorizer credentials
110+
- **Scopes**: `openid profile email`
111+
3. Okta reads the discovery document automatically
112+
113+
### Okta-Specific Behavior
114+
115+
- Okta typically uses S256 PKCE and sends `code_challenge_method=S256` explicitly
116+
- Okta validates the `auth_time` claim in the `id_token` when `max_age` is sent
117+
118+
---
119+
120+
## Keycloak Configuration
121+
122+
1. In Keycloak Admin → **Identity Providers****OpenID Connect v1.0**
123+
2. Configure:
124+
- **Authorization URL**: Discovered from `/.well-known/openid-configuration`
125+
- **Token URL**: Discovered automatically
126+
- **Client ID / Secret**: Authorizer credentials
127+
3. Enable **PKCE** in the provider settings (recommended)
128+
129+
---
130+
131+
## OIDC Endpoints
132+
133+
All endpoints are discoverable via `/.well-known/openid-configuration`.
134+
135+
| Endpoint | Method | Purpose |
136+
|----------|--------|---------|
137+
| `/.well-known/openid-configuration` | GET | OIDC Discovery document |
138+
| `/.well-known/jwks.json` | GET | Public signing keys (JWKS) |
139+
| `/authorize` | GET | Authorization endpoint |
140+
| `/oauth/token` | POST | Token endpoint |
141+
| `/userinfo` | GET | UserInfo endpoint (Bearer token) |
142+
| `/logout` | GET/POST | RP-Initiated Logout |
143+
| `/oauth/revoke` | POST | Token Revocation (RFC 7009) |
144+
| `/oauth/introspect` | POST | Token Introspection (RFC 7662) |
145+
146+
### Endpoint Middleware
147+
148+
- `/.well-known/*` and `/authorize` — exempt from `client_id` header middleware (client_id is passed as query/body param)
149+
- `/oauth/token`, `/oauth/revoke`, `/oauth/introspect` — exempt from CSRF middleware (use bearer/client credentials, not cookies)
150+
- `/.well-known/*` — exempt from rate limiting
151+
152+
---
153+
154+
## Authorization Code Flow with PKCE
155+
156+
### Standard Flow (S256)
157+
158+
```
159+
RP generates:
160+
code_verifier = random(43-128 chars)
161+
code_challenge = BASE64URL(SHA256(code_verifier))
162+
163+
1. GET /authorize?
164+
response_type=code
165+
&client_id=...
166+
&redirect_uri=...
167+
&scope=openid profile email
168+
&state=RANDOM
169+
&nonce=RANDOM ← echoed in id_token
170+
&code_challenge=BASE64URL(SHA256(v))
171+
&code_challenge_method=S256
172+
173+
2. User authenticates → 302 redirect_uri?code=CODE&state=STATE
174+
175+
3. POST /oauth/token
176+
grant_type=authorization_code
177+
&client_id=...
178+
&code=CODE
179+
&code_verifier=ORIGINAL_VERIFIER ← server hashes and compares
180+
&redirect_uri=... ← must match step 1
181+
182+
4. Response: { access_token, id_token, token_type: "Bearer", expires_in }
183+
```
184+
185+
### Plain PKCE
186+
187+
When `code_challenge_method` is omitted, Authorizer defaults to `plain` per RFC 7636 §4.2. In this case `code_verifier == code_challenge` (direct comparison, no hashing).
188+
189+
### Client Authentication
190+
191+
The token endpoint supports three authentication methods (advertised in discovery):
192+
193+
| Method | How |
194+
|--------|-----|
195+
| `client_secret_basic` | HTTP Basic: `Authorization: Basic base64(client_id:client_secret)` |
196+
| `client_secret_post` | Form body: `client_id=...&client_secret=...` |
197+
| `none` | Public client with PKCE (no secret required) |
198+
199+
---
200+
201+
## Nonce Handling
202+
203+
### Why Nonce Matters
204+
205+
The `nonce` parameter prevents token replay attacks. The RP generates a random nonce, sends it to `/authorize`, and expects it back as a claim in the `id_token`. If the nonce doesn't match, the RP rejects the token.
206+
207+
### Nonce in Code Flow (Backchannel)
208+
209+
**Nonce is required for backchannel (code) flow when using Enterprise IdP integrations.** While OIDC Core §3.1.2.1 makes nonce optional for the code flow, Auth0, Okta, and Keycloak all send and validate it.
210+
211+
Flow:
212+
1. RP sends `nonce=ABC` to `/authorize`
213+
2. Authorizer stores `nonce=ABC` with the authorization code
214+
3. If user needs to log in, `nonce` is forwarded through the login UI via `authState`
215+
4. After login, React app redirects back to `/authorize` with `nonce=ABC`
216+
5. `/authorize` stores `nonce=ABC` in the code state data
217+
6. RP exchanges code at `/oauth/token`
218+
7. Token endpoint reads `nonce=ABC` from stored state → embeds it in `id_token`
219+
8. RP validates `id_token.nonce == ABC`
220+
221+
### Nonce in Implicit Flow
222+
223+
For `response_type=id_token` or `response_type=id_token token`, nonce is **required** per OIDC Core §3.2.2.1. Authorizer enforces this and returns `invalid_request` if nonce is missing.
224+
225+
---
226+
227+
## Response Modes
228+
229+
| Mode | Description | Token Delivery |
230+
|------|-------------|---------------|
231+
| `query` | Redirect with params in query string | Only for `code` flow (no tokens in URLs) |
232+
| `fragment` | Redirect with params in URL fragment | Default for implicit/hybrid flows |
233+
| `form_post` | Auto-submitting HTML form POST | Used by Auth0 for Enterprise connections |
234+
| `web_message` | `window.postMessage()` to RP | For SPA integrations |
235+
236+
### form_post CSP
237+
238+
When using `form_post`, Authorizer overrides the Content-Security-Policy header to allow the form to POST to the RP's redirect_uri:
239+
240+
```
241+
form-action 'self' https://rp-domain.com https://rp-domain.com/callback/path
242+
```
243+
244+
The CSP includes both the origin and the full path (without query params) for maximum browser compatibility.
245+
246+
---
247+
248+
## Prompt Parameter
249+
250+
### prompt=login
251+
252+
Forces re-authentication. Authorizer keeps the existing session for the immediate code generation (since the React SDK would auto-detect the session and redirect immediately), but the RP can enforce its own re-authentication logic.
253+
254+
### prompt=none
255+
256+
Checks for an existing session without showing the login UI. If no valid session exists, Authorizer returns `login_required` error to the RP's `redirect_uri` via the configured `response_mode`.
257+
258+
### prompt=consent / prompt=select_account
259+
260+
Accepted but not yet implemented — proceeds normally with a debug log.
261+
262+
---
263+
264+
## Session Management
265+
266+
### Token Endpoint Session Behavior
267+
268+
| Grant Type | Session Behavior |
269+
|------------|-----------------|
270+
| `authorization_code` | **Does NOT** create a new browser session or delete the existing one. The `/authorize` endpoint already created the session. Token endpoint is called server-to-server by the RP. |
271+
| `refresh_token` | **Does** create a new browser session (session rollover). Old session is deleted for security. |
272+
273+
This distinction is critical: for `authorization_code` grant, the token endpoint caller is the RP's server (Auth0/Okta/Keycloak), not the user's browser. Setting cookies or deleting sessions here would affect the RP's HTTP client, not the user.
274+
275+
### auth_time Claim
276+
277+
When `max_age` is included in the authorization request, the `id_token` includes the `auth_time` claim (Unix timestamp of when the user authenticated). This is populated from the session's `IssuedAt` field.
278+
279+
---
280+
281+
## Troubleshooting
282+
283+
### "unexpected ID Token nonce claim value"
284+
285+
**Cause:** The nonce from the RP was lost during the login UI round-trip.
286+
**Fix:** Ensure the `nonce` parameter is included in `authState` (fixed in this release).
287+
288+
### "code_verifier does not match code_challenge"
289+
290+
**Cause:** Either (a) PKCE parameters lost during login UI round-trip, or (b) `code_challenge_method` defaulting to wrong value.
291+
**Fix:** `code_challenge` and `code_challenge_method` are now forwarded through `authState`. Default method is `plain` per RFC 7636 §4.2.
292+
293+
### "Sending form data violates Content Security Policy"
294+
295+
**Cause:** CSP `form-action` didn't include the RP's callback URL.
296+
**Fix:** CSP now includes both the origin and full path of the redirect_uri.
297+
298+
### Session not found after OIDC login
299+
300+
**Cause:** `/oauth/token` was deleting the browser session during code exchange.
301+
**Fix:** Token endpoint no longer deletes/replaces the browser session for `authorization_code` grant.
302+
303+
---
304+
305+
## RFC Compliance
306+
307+
| Standard | Status | Notes |
308+
|----------|--------|-------|
309+
| RFC 6749 (OAuth 2.0) | Compliant | Authorization code, refresh token, implicit grants |
310+
| RFC 6750 (Bearer Token) | Compliant | Token type "Bearer", WWW-Authenticate on 401 |
311+
| RFC 7636 (PKCE) | Compliant | S256 and plain methods, default plain per §4.2 |
312+
| RFC 7009 (Token Revocation) | Compliant | Always returns 200, constant-time comparison |
313+
| RFC 7662 (Token Introspection) | Compliant | active/inactive, validates iss/aud/exp |
314+
| OIDC Core 1.0 | Compliant | id_token, nonce, auth_time, at_hash, c_hash |
315+
| OIDC Discovery 1.0 | Compliant | All required + recommended fields |
316+
| OIDC RP-Initiated Logout | Compliant | post_logout_redirect_uri validation |
317+
318+
### Error Response Format
319+
320+
All error responses use the RFC 6749 §5.2 format:
321+
```json
322+
{
323+
"error": "invalid_request",
324+
"error_description": "Human-readable description"
325+
}
326+
```
327+
328+
Standard error codes used: `invalid_request`, `invalid_client`, `invalid_grant`, `unauthorized_client`, `unsupported_grant_type`, `unsupported_response_type`, `login_required`.
329+
330+
### Authorization Code TTL
331+
332+
Authorization codes expire after **10 minutes** (RFC 6749 §4.1.2 recommendation). This is enforced in:
333+
- **Redis**: Native TTL (`stateTTL = 10 * time.Minute`)
334+
- **In-memory**: Entry-level expiration on read
335+
- **Database**: `CreatedAt`-based expiration on read (600 seconds)

0 commit comments

Comments
 (0)