feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak, Authentik, …) alongside local accounts. Resolves the feature request in #73. Flow (backend-driven Authorization Code + PKCE): - GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce transaction in Redis (single-use, 10m TTL); the callback validates the ID token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the identity to a local user, and mints the platform's normal session JWT — handed to the SPA via URL fragment. No sessions/cookies introduced. Identity mapping (users table gains email/oidc_subject/oidc_issuer/ auth_provider; password_hash now nullable; partial unique indexes): - First sign-in auto-provisions a user, or links to an existing local account when the IdP email is verified and matches. - Roles derive from a configurable IdP group -> role map with a default fallback; existing users are never auto-demoted on a groupless login. Configuration (admin-only, no env/redeploy needed): - Stored as an app_settings row with the client secret encrypted at rest (utils/crypto); masked in responses and excluded from GET /api/settings. - New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role mapping, provisioning toggles, discovery test, copyable redirect URI), a login-page SSO button, and an SSO badge in Users & Roles. Security: local break-glass login preserved; PKCE + single-use state; email linking requires email_verified; same-site-only post-login redirect. Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping, username derivation, config secret round-trip/masking); 114 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ea1ad956ff
commit
c0f230bdf2
@@ -21,6 +21,7 @@ import type {
|
||||
ConfigSnapshotFull,
|
||||
ConfigDiffResponse,
|
||||
ConfigCaptureResult,
|
||||
UserRole,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
@@ -66,6 +67,38 @@ export const authApi = {
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
securityStatus: () =>
|
||||
api.get<{ warnings: string[] }>('/auth/security-status'),
|
||||
oidcStatus: () =>
|
||||
api.get<{ enabled: boolean; button_label: string }>('/auth/oidc/status'),
|
||||
};
|
||||
|
||||
export interface OidcConfigView {
|
||||
enabled: boolean;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
has_secret: boolean;
|
||||
scopes: string;
|
||||
username_claim: string;
|
||||
email_claim: string;
|
||||
groups_claim: string;
|
||||
group_role_map: Record<string, UserRole>;
|
||||
default_role: UserRole;
|
||||
auto_provision: boolean;
|
||||
link_by_verified_email: boolean;
|
||||
allowed_email_domains: string[];
|
||||
button_label: string;
|
||||
public_base_url: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export const oidcApi = {
|
||||
getConfig: () => api.get<OidcConfigView>('/auth/oidc/config'),
|
||||
updateConfig: (data: Partial<OidcConfigView> & { client_secret?: string }) =>
|
||||
api.put<OidcConfigView>('/auth/oidc/config', data),
|
||||
test: (issuer_url?: string) =>
|
||||
api.post<{ ok: boolean; issuer?: string; claims_supported?: string[]; error?: string }>(
|
||||
'/auth/oidc/test',
|
||||
{ issuer_url }
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user