7.8 KiB
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:
{
"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.
{
"redirect_uris": ["http://localhost:8080/callback", "https://app.example.com/callback"],
"allowed_cors_origins": ["+"]
}
This is equivalent to:
{
"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.
{
"allowed_cors_origins": null
}
Allow All Origins (Not Recommended)
Set allowed_cors_origins to ["*"] to allow any origin. This is not recommended for production.
{
"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:
{
"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:
// 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:
// 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
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
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:
{
"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: <request-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