Files
website/server/src/auth/providers/genericOidc.provider.js
wtclaude fbb4b0bd91
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s
feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 01:53:50 -05:00

43 lines
1.5 KiB
JavaScript

// Generic, fully-configurable OAuth2 / OIDC provider for custom IdPs (Authentik,
// Keycloak, Okta, Azure AD, Zitadel, …). Unlike the built-ins, its endpoints and
// scopes come from the stored config. Profile mapping follows OIDC conventions
// with sensible fallbacks for plain OAuth2 userinfo shapes.
const OAuth2Provider = require('./oauth2.provider')
class GenericOidcProvider extends OAuth2Provider {
constructor(config = {}) {
super({ kind: config.kind || 'oidc', ...config })
this.authorizeUrl = config.authorizeUrl ?? config.authorize_url ?? null
this.tokenUrl = config.tokenUrl ?? config.token_url ?? null
this.userinfoUrl = config.userinfoUrl ?? config.userinfo_url ?? null
this.scopes = config.scopes || 'openid email profile'
}
authEndpoint() {
return this.authorizeUrl
}
tokenEndpoint() {
return this.tokenUrl
}
userinfoEndpoint() {
return this.userinfoUrl
}
scopeString() {
return this.scopes
}
normalizeProfile(p = {}) {
return {
subject: p.sub || p.id || p.user_id || p.uid || null,
email: p.email || null,
// The standard OIDC claim. An IdP that omits it has not asserted anything,
// so the address stays unverified and the user proves it the ordinary way —
// absent is treated as false, never as true.
emailVerified: p.email_verified === true || p.email_verified === 'true',
name: p.name || p.preferred_username || p.username || p.email || null,
}
}
}
module.exports = GenericOidcProvider