Files
website/server/src/auth/providers/base.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

66 lines
2.5 KiB
JavaScript

// ── Auth provider contract (base) ──────────────────────────────────────────
//
// The abstract interface every auth provider implements. Concrete providers:
// - local → username/password (LocalProvider, unchanged live flow)
// - google, discord, generic OIDC → OAuth2Provider subclasses
//
// A provider config (a row from auth_providers, or a built-in default) looks like:
// { id, kind, name, enabled, clientId, clientSecret,
// authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }
//
// Interface (per the Part 3 spec). OAuth providers implement the SSO-flow methods;
// LocalProvider implements authenticate(). Anything not applicable stays a throw.
class BaseProvider {
constructor(config = {}) {
this.config = config
this.id = config.id || config.kind || 'base'
this.name = config.name || this.id
this.kind = config.kind || 'base'
this.type = this.kind // legacy alias
}
isEnabled() {
return Boolean(this.config.enabled)
}
// Direct-credential auth (local providers). Resolve to an internal user or null.
// eslint-disable-next-line no-unused-vars
async authenticate(credentials) {
throw new Error(`authenticate() not implemented for provider '${this.id}'`)
}
// Begin an SSO redirect flow: the provider's authorization URL.
// eslint-disable-next-line no-unused-vars
getAuthorizationUrl(state, options) {
throw new Error(`getAuthorizationUrl() not implemented for provider '${this.id}'`)
}
// Complete an SSO redirect flow: exchange the callback code for a normalized
// user profile ({ subject, email, emailVerified, name }).
// eslint-disable-next-line no-unused-vars
async handleCallback(params) {
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
}
// Fetch the raw external profile using an access token.
// eslint-disable-next-line no-unused-vars
async getUserProfile(accessToken) {
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
}
// Normalize a raw external profile to { subject, email, emailVerified, name }.
// eslint-disable-next-line no-unused-vars
mapUser(profile) {
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
}
// Link an external identity to an internal user (shared by OAuth2Provider).
// eslint-disable-next-line no-unused-vars
async linkAccount(user, profile) {
throw new Error(`linkAccount() not implemented for provider '${this.id}'`)
}
}
module.exports = BaseProvider