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

@@ -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}