# Per-Client CORS Origins for OIDC Endpoints ## Overview Gatehouse OIDC now supports **per-client CORS origins**. This allows each OIDC client to declare which browser origins are permitted to make cross-origin requests to OIDC endpoints (`/oidc/token`, `/oidc/revoke`, `/oidc/userinfo`, `/oidc/introspect`). Previously, CORS was controlled by a single server-wide `CORS_ORIGINS` environment variable. If your SPA's origin wasn't in that list, the browser would block requests to OIDC endpoints — even if your OIDC client was properly configured. ## How It Works ### The Problem When a browser-based SPA (e.g., running at `http://localhost:8080`) exchanges an authorization code for tokens, it makes a POST request to `/oidc/token`. The browser sends a preflight OPTIONS request first, and the server must respond with CORS headers allowing the SPA's origin. Previously, if `http://localhost:8080` wasn't in the server's `CORS_ORIGINS` env var, the preflight would fail and the SPA couldn't get tokens. ### The Solution Each OIDC client can now declare its own `allowed_cors_origins`. When a request hits an OIDC endpoint, the server checks the client's CORS configuration first, then falls back to the global config. ## Configuration ### Setting CORS Origins on an OIDC Client When creating or updating an OIDC client, set the `allowed_cors_origins` field: ```json { "name": "My SPA", "client_id": "oidc_myapp", "redirect_uris": ["http://localhost:8080/callback", "https://app.example.com/callback"], "allowed_cors_origins": ["http://localhost:8080", "https://app.example.com"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scopes": ["openid", "profile", "email"] } ``` ### Auto-Derive from Redirect URIs Set `allowed_cors_origins` to `["+"]` to automatically derive CORS origins from the client's `redirect_uris`. The server extracts the scheme, hostname, and port from each redirect URI. ```json { "redirect_uris": ["http://localhost:8080/callback", "https://app.example.com/callback"], "allowed_cors_origins": ["+"] } ``` This is equivalent to: ```json { "allowed_cors_origins": ["http://localhost:8080", "https://app.example.com"] } ``` ### Use Global Config (Default) Set `allowed_cors_origins` to `null` (or omit it) to use the server's global `CORS_ORIGINS` config. This is the default behavior for existing clients. ```json { "allowed_cors_origins": null } ``` ### Allow All Origins (Not Recommended) Set `allowed_cors_origins` to `["*"]` to allow any origin. **This is not recommended for production.** ```json { "allowed_cors_origins": ["*"] } ``` ## Affected Endpoints The following OIDC endpoints support per-client CORS: | Endpoint | Method | How Client is Identified | |---|---|---| | `/oidc/token` | POST | `client_id` in request body or Basic Auth header | | `/oidc/revoke` | POST | `client_id` in request body or Basic Auth header | | `/oidc/introspect` | POST | `client_id` in request body or Basic Auth header | | `/oidc/userinfo` | GET/POST | `client_id` extracted from Bearer token | ## SPA Integration Guide ### Step 1: Register Your OIDC Client Register your SPA as an OIDC client with the correct redirect URIs and CORS origins: ```json { "name": "My React App", "redirect_uris": ["http://localhost:3000/callback"], "allowed_cors_origins": ["http://localhost:3000"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scopes": ["openid", "profile", "email"], "is_confidential": false, "require_pkce": true } ``` ### Step 2: Use PKCE (Required for Public Clients) Gatehouse requires PKCE for public clients. Generate a code verifier and challenge before redirecting to the authorize endpoint: ```javascript // Generate PKCE const codeVerifier = generateRandomString(128); const codeChallenge = await sha256(codeVerifier); const state = generateRandomString(32); // Store verifier for later sessionStorage.setItem('pkce_verifier', codeVerifier); // Redirect to authorize const authUrl = new URL('https://api.example.com/api/v1/oidc/authorize'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', 'oidc_myapp'); authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback'); authUrl.searchParams.set('scope', 'openid profile email'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString(); ``` ### Step 3: Exchange Code for Tokens After the user authenticates and is redirected back to your callback page, exchange the authorization code for tokens: ```javascript // Extract code from URL const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // Verify state matches if (state !== sessionStorage.getItem('pkce_state')) { throw new Error('State mismatch'); } // Exchange code for tokens const response = await fetch('https://api.example.com/api/v1/oidc/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: 'http://localhost:3000/callback', client_id: 'oidc_myapp', code_verifier: sessionStorage.getItem('pkce_verifier'), }), }); const tokens = await response.json(); // tokens.access_token, tokens.id_token, tokens.refresh_token ``` The server will return CORS headers because `http://localhost:3000` is in the client's `allowed_cors_origins`. ### Step 4: Refresh Tokens ```javascript const response = await fetch('https://api.example.com/api/v1/oidc/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: storedRefreshToken, client_id: 'oidc_myapp', }), }); ``` ### Step 5: Call UserInfo ```javascript const response = await fetch('https://api.example.com/api/v1/oidc/userinfo', { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); const userInfo = await response.json(); ``` ## Troubleshooting ### "CORS error" when exchanging code for tokens **Cause**: Your SPA's origin is not in the client's `allowed_cors_origins` or the server's global `CORS_ORIGINS`. **Fix**: Add your SPA's origin to the client's `allowed_cors_origins`: ```json { "allowed_cors_origins": ["http://localhost:3000"] } ``` ### "CORS error" on preflight OPTIONS request **Cause**: The preflight request doesn't carry client credentials, so the server can't identify which client to check CORS origins for. It falls back to the global `CORS_ORIGINS`. **Fix**: Either add your origin to the global `CORS_ORIGINS` env var, or ensure the actual POST request (after preflight) includes the `client_id` in the request body. ### CORS works for `/oidc/token` but not `/oidc/userinfo` **Cause**: The userinfo endpoint identifies the client from the Bearer token. If the token doesn't contain a `client_id` claim, the server falls back to global config. **Fix**: Ensure your access tokens include the `client_id` claim (this is the default behavior). ## API Reference ### OIDCClient Fields | Field | Type | Description | |---|---|---| | `allowed_cors_origins` | `string[]` or `null` | List of allowed browser origins. `null` = use global config. `["+"]` = auto-derive from redirect URIs. `["*"]` = allow all (not recommended). | ### CORS Headers Returned When a request's origin matches the client's allowed origins: ``` Access-Control-Allow-Origin: Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Request-ID, Cache-Control, Pragma, X-WebAuthn-Session-Token Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 ```