feat: generic OIDC role extraction from access token
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m48s
CI / docker (push) Successful in 1m1s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

The OIDC login flow now reads roles from the access_token (JWT) in
addition to the id_token. This fixes role extraction with providers
like Logto that put scopes/roles in access tokens rather than id_tokens.

- Add audience and additionalScopes to OidcConfig for RFC 8707 resource
  indicator support and configurable extra scopes
- OidcTokenExchanger decodes access_token with at+jwt-compatible processor,
  falls back to id_token if access_token is opaque or has no roles
- syncOidcRoles preserves existing local roles when OIDC returns none
- SPA includes resource and additionalScopes in authorization requests
- Admin UI exposes new config fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 10:16:52 +02:00
parent 95eb388283
commit 03ff9a3813
11 changed files with 173 additions and 22 deletions

View File

@@ -10,6 +10,8 @@ import styles from './LoginPage.module.css';
interface OidcInfo {
clientId: string;
authorizationEndpoint: string;
resource?: string;
additionalScopes?: string[];
}
/** Generate a random code_verifier for PKCE (RFC 7636). */
@@ -71,7 +73,12 @@ export function LoginPage() {
api.GET('/auth/oidc/config')
.then(({ data }) => {
if (data?.authorizationEndpoint && data?.clientId) {
setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint });
setOidc({
clientId: data.clientId,
authorizationEndpoint: data.authorizationEndpoint,
resource: data.resource ?? undefined,
additionalScopes: data.additionalScopes ?? undefined,
});
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
@@ -88,15 +95,17 @@ export function LoginPage() {
const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier);
deriveCodeChallenge(verifier).then((challenge) => {
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
scope: scopes.join(' '),
prompt: 'none',
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
});
}
@@ -116,14 +125,16 @@ export function LoginPage() {
const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier);
const challenge = await deriveCodeChallenge(verifier);
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
scope: scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
};

View File

@@ -27,15 +27,27 @@ export function OidcCallback() {
// consent_required — retry without prompt=none so user can grant scopes
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
sessionStorage.setItem('oidc-consent-retry', '1');
api.GET('/auth/oidc/config').then(({ data }) => {
api.GET('/auth/oidc/config').then(async ({ data }) => {
if (data?.authorizationEndpoint && data?.clientId) {
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
sessionStorage.setItem('oidc-code-verifier', verifier);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const scopes = ['openid', 'email', 'profile', ...(data.additionalScopes || [])];
const p = new URLSearchParams({
response_type: 'code',
client_id: data.clientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
scope: scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (data.resource) p.set('resource', data.resource);
window.location.href = `${data.authorizationEndpoint}?${p}`;
}
}).catch(() => {