Files
2026-04-27 02:44:32 +09:30

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
}

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