feat: generic OIDC role extraction from access token
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:
8
ui/src/api/schema.d.ts
vendored
8
ui/src/api/schema.d.ts
vendored
@@ -1445,6 +1445,8 @@ export interface components {
|
||||
autoSignup?: boolean;
|
||||
displayNameClaim?: string;
|
||||
userIdClaim?: string;
|
||||
audience?: string;
|
||||
additionalScopes?: string[];
|
||||
};
|
||||
/** @description Error response */
|
||||
ErrorResponse: {
|
||||
@@ -1462,6 +1464,8 @@ export interface components {
|
||||
autoSignup?: boolean;
|
||||
displayNameClaim?: string;
|
||||
userIdClaim?: string;
|
||||
audience?: string;
|
||||
additionalScopes?: string[];
|
||||
};
|
||||
UpdateGroupRequest: {
|
||||
name?: string;
|
||||
@@ -2029,6 +2033,10 @@ export interface components {
|
||||
authorizationEndpoint: string;
|
||||
/** @description Present if the provider supports RP-initiated logout */
|
||||
endSessionEndpoint?: string;
|
||||
/** @description RFC 8707 resource indicator for the authorization request */
|
||||
resource?: string;
|
||||
/** @description Additional scopes to request beyond openid email profile */
|
||||
additionalScopes?: string[];
|
||||
};
|
||||
/** @description Agent instance summary with runtime metrics */
|
||||
AgentInstanceResponse: {
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -16,6 +16,8 @@ interface OidcFormData {
|
||||
displayNameClaim: string;
|
||||
userIdClaim: string;
|
||||
defaultRoles: string[];
|
||||
audience: string;
|
||||
additionalScopes: string[];
|
||||
}
|
||||
|
||||
const EMPTY_CONFIG: OidcFormData = {
|
||||
@@ -28,6 +30,8 @@ const EMPTY_CONFIG: OidcFormData = {
|
||||
displayNameClaim: 'name',
|
||||
userIdClaim: 'sub',
|
||||
defaultRoles: ['VIEWER'],
|
||||
audience: '',
|
||||
additionalScopes: [],
|
||||
};
|
||||
|
||||
export default function OidcConfigPage() {
|
||||
@@ -51,6 +55,8 @@ export default function OidcConfigPage() {
|
||||
displayNameClaim: data.displayNameClaim ?? 'name',
|
||||
userIdClaim: data.userIdClaim ?? 'sub',
|
||||
defaultRoles: data.defaultRoles ?? ['VIEWER'],
|
||||
audience: (data as any).audience ?? '',
|
||||
additionalScopes: (data as any).additionalScopes ?? [],
|
||||
}))
|
||||
.catch(() => setForm(EMPTY_CONFIG));
|
||||
}, []);
|
||||
@@ -176,11 +182,27 @@ export default function OidcConfigPage() {
|
||||
onChange={(e) => update('clientSecret', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Audience / API Resource" htmlFor="audience" hint="RFC 8707 resource indicator sent in the authorization request">
|
||||
<Input
|
||||
id="audience"
|
||||
placeholder="https://api.example.com"
|
||||
value={form.audience}
|
||||
onChange={(e) => update('audience', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Additional Scopes" htmlFor="additional-scopes" hint="Extra scopes to request beyond openid email profile (comma-separated)">
|
||||
<Input
|
||||
id="additional-scopes"
|
||||
placeholder="urn:scope:organizations, urn:scope:roles"
|
||||
value={(form.additionalScopes || []).join(', ')}
|
||||
onChange={(e) => update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Claim Mapping</SectionHeader>
|
||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
|
||||
<Input
|
||||
id="roles-claim"
|
||||
value={form.rolesClaim}
|
||||
|
||||
Reference in New Issue
Block a user