241 lines
7.8 KiB
Markdown
241 lines
7.8 KiB
Markdown
# 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: <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
|
|
```
|