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>
66 lines
2.5 KiB
JavaScript
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
|