@luckystack/login 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,7 +179,9 @@ var microsoftProvider = (input) => {
179
179
  { method: "GET", headers: { Authorization: `Bearer ${accessToken}` } }
180
180
  );
181
181
  if (!response.ok) return;
182
- const contentType = response.headers.get("content-type") ?? "image/jpeg";
182
+ const rawContentType = response.headers.get("content-type") ?? "image/jpeg";
183
+ const SAFE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
184
+ const contentType = SAFE_IMAGE_TYPES.has(rawContentType) ? rawContentType : "image/jpeg";
183
185
  const buffer = Buffer.from(await response.arrayBuffer());
184
186
  if (buffer.byteLength > MAX_PHOTO_BYTES) return;
185
187
  return `data:${contentType};base64,${buffer.toString("base64")}`;
@@ -296,4 +298,4 @@ export {
296
298
  defaultPrismaUserAdapter,
297
299
  getUserAdapter
298
300
  };
299
- //# sourceMappingURL=chunk-22NFCMM7.js.map
301
+ //# sourceMappingURL=chunk-2WYYKD2D.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/oauthProviders.ts","../src/userAdapter.ts"],"sourcesContent":["//? OAuth provider registry. Replaces the previously hardcoded provider list\n//? in `loginConfig.ts`. Consumers register providers from their\n//? `luckystack/login/oauthProviders.ts` overlay file at boot.\n//?\n//? Two-layer surface:\n//? 1. `registerOAuthProviders([...])` — accept an array of provider definitions.\n//? 2. Built-in helpers (`googleProvider`, `githubProvider`, ...) so the common\n//? case is one function call per provider with only the secrets + callback.\n//?\n//? Custom providers (Okta, Apple, X, etc.) plug in as raw `OAuthProvider`\n//? objects alongside the helpers.\n\nimport { tryCatch } from '@luckystack/core';\n\nexport type OAuthUserData = Record<string, unknown>;\n\nexport const asOAuthUserData = (value: unknown): OAuthUserData => {\n if (value && typeof value === 'object') {\n return value as OAuthUserData;\n }\n return {};\n};\n\nexport interface CredentialsProvider {\n name: 'credentials';\n}\n\nexport interface FullOAuthProvider {\n name: string;\n clientID: string;\n clientSecret: string;\n callbackURL: string;\n authorizationURL: string;\n tokenExchangeURL: string;\n tokenExchangeMethod: 'json' | 'form';\n userInfoURL: string;\n /**\n * OAuth scopes requested at the authorization step. Consumers can extend\n * a built-in provider's defaults to request additional permissions:\n *\n * ```ts\n * const customGoogle = googleProvider({...});\n * customGoogle.scope.push('https://www.googleapis.com/auth/calendar.readonly');\n * ```\n *\n * Or pass `extraScopes` to the built-in factory (see `OAuthHelperInput`).\n */\n scope: string[];\n nameKey: string;\n emailKey: string;\n /**\n * Optional key on the userInfo response holding a provider \"email verified\"\n * boolean. When set AND the field is present-and-falsy, the framework rejects\n * the profile rather than trusting an unverified address — defense-in-depth\n * against account-linking takeover under the `'unified'` strategy (SEC-21).\n * A missing field (provider doesn't report it) is treated as \"trusted\",\n * preserving behaviour for providers that don't expose the flag.\n */\n emailVerifiedKey?: string;\n avatarKey?: string;\n avatarCodeKey: string;\n getEmail?: (accessToken: string) => Promise<string | false | undefined>;\n getAvatar?: (params: { userData: OAuthUserData; avatarId?: string; accessToken: string }) => string | undefined | Promise<string | undefined>;\n /**\n * Optional per-provider hook to attach extra runtime-only data to the\n * session (NOT to the User row). Return key/value pairs that get merged\n * into `BaseSessionLayout` before `saveSession`. To get strict typing\n * downstream, extend `BaseSessionLayout` via module augmentation:\n *\n * ```ts\n * declare module '@luckystack/core' {\n * interface BaseSessionLayout {\n * googleCalendarToken?: string;\n * }\n * }\n * ```\n *\n * Data lives on the session in Redis and disappears on logout — no Prisma\n * schema change required. Don't store secrets you wouldn't want surfaced\n * to a user inspecting their own session.\n */\n extraSessionFields?: (params: {\n userData: OAuthUserData;\n accessToken: string;\n }) => Promise<Record<string, unknown>> | Record<string, unknown>;\n /**\n * Extra query params appended to the authorization-redirect URL (CFG-21).\n * Lets a provider request e.g. `access_type=offline`, `login_hint=...`, or\n * override the framework's default `prompt=select_account`. The server's\n * authorize route merges these over the built-in params (a key present here\n * wins). Reserved OAuth params (`client_id`, `redirect_uri`, `scope`,\n * `response_type`, `state`, `code_challenge`, `code_challenge_method`) should\n * NOT be set here — they are framework-owned. Default unset.\n */\n extraAuthorizationParams?: Record<string, string>;\n /**\n * Opt this provider into PKCE (RFC 7636, S256) (F11). When `true`, the\n * framework generates a `code_verifier` at flow start, stores it server-side\n * with the OAuth state, sends `code_challenge`/`code_challenge_method=S256` on\n * the authorize redirect, and replays the verifier at token exchange. Required\n * by OAuth 2.1 / PKCE-mandating providers (X/Twitter, some Okta/Auth0\n * policies). Default `false` (no PKCE — existing flows are byte-identical).\n */\n usePkce?: boolean;\n}\n\nexport type OAuthProvider = CredentialsProvider | FullOAuthProvider;\n\nexport const isFullOAuthProvider = (provider: OAuthProvider): provider is FullOAuthProvider =>\n provider.name !== 'credentials';\n\nexport interface OAuthEndpointOverrides {\n /** Override the provider's authorization URL (e.g. GitHub Enterprise host, Microsoft custom tenant). */\n authorizationURL?: string;\n /** Override the token exchange URL. */\n tokenExchangeURL?: string;\n /** Override the userInfo URL. */\n userInfoURL?: string;\n}\n\ninterface OAuthHelperInput {\n clientId: string | undefined;\n clientSecret: string | undefined;\n callbackUrl: string;\n /** Optional URL overrides for self-hosted / custom-tenant deployments. */\n endpoints?: OAuthEndpointOverrides;\n /**\n * Additional OAuth scopes to request alongside the built-in defaults. Use\n * for \"give me access to the user's calendar / drive / repos\" flows.\n * Merged with the helper's default scopes; duplicates are deduplicated.\n */\n extraScopes?: string[];\n /**\n * Per-provider hook for attaching runtime-only fields to the session.\n * See `FullOAuthProvider.extraSessionFields` for usage + module-augmentation\n * pattern.\n */\n extraSessionFields?: FullOAuthProvider['extraSessionFields'];\n /**\n * Extra query params for the authorize redirect (CFG-21). See\n * `FullOAuthProvider.extraAuthorizationParams`.\n */\n extraAuthorizationParams?: Record<string, string>;\n /** Opt this provider into PKCE (S256) (F11). See `FullOAuthProvider.usePkce`. */\n usePkce?: boolean;\n}\n\nconst mergeScopes = (defaults: string[], extra: string[] | undefined): string[] => {\n if (!extra || extra.length === 0) return defaults;\n return [...new Set([...defaults, ...extra])];\n};\n\nconst requireString = (value: string | undefined, label: string): string => {\n if (!value || value.length === 0) {\n throw new Error(`OAuth provider configuration error: ${label} is empty.`);\n }\n return value;\n};\n\nexport const credentialsProvider = (): CredentialsProvider => ({ name: 'credentials' });\n\nexport const googleProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'google',\n clientID: requireString(input.clientId, 'google clientId'),\n clientSecret: requireString(input.clientSecret, 'google clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://accounts.google.com/o/oauth2/v2/auth',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://oauth2.googleapis.com/token',\n tokenExchangeMethod: 'json',\n //? OIDC userinfo (v3) — the legacy `oauth2/v1/userinfo` is deprecated and\n //? reports verification as `verified_email`; v3 reports the OIDC-standard\n //? `email_verified`. Use v3 so `emailVerifiedKey` below lines up (SEC-21/QUA-24).\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://www.googleapis.com/oauth2/v3/userinfo',\n scope: mergeScopes([\n 'https://www.googleapis.com/auth/userinfo.profile',\n 'https://www.googleapis.com/auth/userinfo.email',\n ], input.extraScopes),\n nameKey: 'name',\n emailKey: 'email',\n //? Google's OIDC userinfo returns `email_verified`; reject an unverified\n //? address so a Google account with an unverified email can't link to a\n //? victim's existing account under the `'unified'` strategy (SEC-21).\n emailVerifiedKey: 'email_verified',\n avatarKey: 'picture',\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n});\n\nexport const githubProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'github',\n clientID: requireString(input.clientId, 'github clientId'),\n clientSecret: requireString(input.clientSecret, 'github clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://github.com/login/oauth/authorize',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://github.com/login/oauth/access_token',\n tokenExchangeMethod: 'json',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://api.github.com/user',\n scope: mergeScopes(['read:user', 'user:email'], input.extraScopes),\n nameKey: 'login',\n emailKey: 'email',\n avatarKey: 'avatar_url',\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getEmail: async (accessToken) => {\n const fetchEmails = async () => {\n const response = await fetch('https://api.github.com/user/emails', {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${accessToken}`,\n },\n });\n if (!response.ok) return false;\n const emails: unknown = await response.json();\n if (!Array.isArray(emails)) return false;\n return emails as { primary?: boolean; verified?: boolean; email?: string }[];\n };\n\n const [error, emails] = await tryCatch(fetchEmails);\n if (error || !emails) return false;\n\n //? Defense-in-depth: only ever select a `verified === true` address. GitHub\n //? forbids an unverified email from being `primary`, so in the normal case\n //? this is a no-op — but the `emails[0]` fallback (when no primary exists)\n //? could otherwise pick an UNVERIFIED address an attacker added to their\n //? GitHub account, which under the `'unified'` account strategy would link\n //? to a victim's existing account by that email (SEC-21). Filtering on\n //? `verified` first closes that fallback path.\n const verified = emails.filter((entry) => entry.verified === true);\n let mainEmail: string | undefined;\n for (const entry of verified) {\n if (entry.primary && typeof entry.email === 'string') mainEmail = entry.email;\n }\n mainEmail ??= verified[0]?.email;\n return mainEmail;\n },\n});\n\nexport const discordProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'discord',\n clientID: requireString(input.clientId, 'discord clientId'),\n clientSecret: requireString(input.clientSecret, 'discord clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://discord.com/oauth2/authorize',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://discord.com/api/oauth2/token',\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://discord.com/api/users/@me',\n scope: mergeScopes(['identify', 'email'], input.extraScopes),\n nameKey: 'username',\n emailKey: 'email',\n //? Discord's /users/@me returns a `verified` boolean for the account email;\n //? reject the address when it is explicitly unverified (SEC-21).\n emailVerifiedKey: 'verified',\n avatarCodeKey: 'avatar',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getAvatar: ({ userData, avatarId }) => {\n if (!avatarId) return;\n const userId = typeof userData.id === 'string' ? userData.id : '';\n if (!userId) return;\n const format = avatarId.startsWith('a_') ? 'gif' : 'png';\n //? Encode the provider-supplied id + avatar hash before interpolation so a\n //? malformed value can't inject `../` path segments into the CDN URL (SEC-23).\n return `https://cdn.discordapp.com/avatars/${encodeURIComponent(userId)}/${encodeURIComponent(avatarId)}.${format}`;\n },\n});\n\nexport interface FacebookProviderInput extends OAuthHelperInput {\n /** Graph API version segment used in the URLs. Default `'v18.0'` (current as of 2026 — bump as Meta deprecates older versions). */\n apiVersion?: string;\n}\n\nexport const facebookProvider = (input: FacebookProviderInput): FullOAuthProvider => {\n const v = input.apiVersion ?? 'v18.0';\n return {\n name: 'facebook',\n clientID: requireString(input.clientId, 'facebook clientId'),\n clientSecret: requireString(input.clientSecret, 'facebook clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? `https://www.facebook.com/${v}/dialog/oauth`,\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://graph.facebook.com/${v}/oauth/access_token`,\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://graph.facebook.com/me?fields=id,name,email,picture.type(large)',\n scope: mergeScopes(['public_profile', 'email'], input.extraScopes),\n nameKey: 'name',\n emailKey: 'email',\n //? Facebook's Graph `/me` only returns `email` for an address the user has\n //? CONFIRMED with Facebook (unconfirmed emails are omitted), so there is no\n //? separate verified flag to gate on — the presence of `email` IS the verified\n //? signal. If a consumer opts into receiving unverified emails via a future\n //? Graph field, set `emailVerifiedKey` on the returned provider (SEC-21).\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getAvatar: ({ userData }) => {\n const picture = asOAuthUserData(userData.picture);\n const pictureData = asOAuthUserData(picture.data);\n const url = pictureData.url;\n return typeof url === 'string' ? url : undefined;\n },\n };\n};\n\nexport interface MicrosoftProviderInput extends OAuthHelperInput {\n /** Tenant id segment in the authorize/token URLs. Default `'common'`. */\n tenant?: string;\n /** OAuth API version segment. Default `'v2.0'`. */\n apiVersion?: string;\n /** Graph API version segment for /me. Default `'v1.0'`. */\n graphApiVersion?: string;\n /**\n * Allow falling back to `userPrincipalName` as the account email when Graph\n * `/me.mail` is empty (SEC-11). A UPN is NOT guaranteed routable or verified;\n * under the `'unified'` account strategy a UPN equal to a victim's email would\n * link accounts, and it commonly mis-keys an account on a non-deliverable\n * address (reset emails never arrive). Default `false` — only enable when the\n * tenant guarantees UPN == primary SMTP. When `false` and `.mail` is empty the\n * provider reports \"no email\" and the login is rejected rather than linked.\n */\n allowUpnFallback?: boolean;\n}\n\n//? NOTE: Microsoft flow is implemented but has not been end-to-end verified\n//? against an Azure AD tenant as of 2026-05-14. The token-exchange URL, the\n//? Graph /me shape, and the photo data-URL pipeline are based on Microsoft\n//? docs and pattern-matching against Google/GitHub. First consumer to wire a\n//? real Azure tenant should report back so this note can be dropped.\nexport const microsoftProvider = (input: MicrosoftProviderInput): FullOAuthProvider => {\n const tenant = input.tenant ?? 'common';\n const oauthVersion = input.apiVersion ?? 'v2.0';\n const graphVersion = input.graphApiVersion ?? 'v1.0';\n const allowUpnFallback = input.allowUpnFallback ?? false;\n return {\n name: 'microsoft',\n clientID: requireString(input.clientId, 'microsoft clientId'),\n clientSecret: requireString(input.clientSecret, 'microsoft clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/authorize`,\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/token`,\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? `https://graph.microsoft.com/${graphVersion}/me`,\n scope: mergeScopes(['openid', 'profile', 'email', 'User.Read'], input.extraScopes),\n nameKey: 'displayName',\n emailKey: 'mail',\n avatarCodeKey: 'id',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n //? Graph's /photo/$value requires bearer auth, so we can't store the URL —\n //? a browser <img> would 401. Fetch the bytes and inline as a data URL.\n getAvatar: async ({ avatarId, accessToken }) => {\n if (!avatarId) return;\n // 200 KB base64 cap: a data-URI is inlined into the Redis session record.\n // An unbounded photo (multi-MB) would bloat every session read and could\n // exhaust memory or hit Redis value-size limits on large tenants.\n const MAX_PHOTO_BYTES = 200 * 1024;\n const fetchPhoto = async () => {\n //? `encodeURIComponent` the provider-supplied id before interpolating it\n //? into the path: a malicious / malformed id could otherwise inject `../`\n //? segments and redirect the bearer-authenticated fetch to another Graph\n //? resource (SEC-23). The host is fixed, so this is not full SSRF, but the\n //? result is inlined into the session, so still worth closing.\n const response = await fetch(\n `https://graph.microsoft.com/${graphVersion}/users/${encodeURIComponent(avatarId)}/photo/$value`,\n { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } },\n );\n if (!response.ok) return;\n const contentType = response.headers.get('content-type') ?? 'image/jpeg';\n const buffer = Buffer.from(await response.arrayBuffer());\n if (buffer.byteLength > MAX_PHOTO_BYTES) return;\n return `data:${contentType};base64,${buffer.toString('base64')}`;\n };\n const [error, dataUrl] = await tryCatch(fetchPhoto);\n if (error) return;\n return dataUrl ?? undefined;\n },\n getEmail: async (accessToken) => {\n const fetchProfile = async () => {\n const response = await fetch(`https://graph.microsoft.com/${graphVersion}/me`, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n },\n });\n if (!response.ok) return false;\n const data: unknown = await response.json();\n if (!data || typeof data !== 'object') return false;\n return data as Record<string, unknown>;\n };\n\n const [error, profile] = await tryCatch(fetchProfile);\n if (error || !profile) return false;\n\n const record = asOAuthUserData(profile);\n const mail = typeof record.mail === 'string' ? record.mail : '';\n if (mail) return mail;\n //? Only fall back to `userPrincipalName` when the consumer explicitly opted\n //? in (SEC-11). A UPN is not guaranteed routable/verified; using it as the\n //? account email under `'unified'` enables account-linking takeover and\n //? mis-keys accounts on non-deliverable addresses.\n if (allowUpnFallback) {\n const userPrincipalName = typeof record.userPrincipalName === 'string' ? record.userPrincipalName : '';\n return userPrincipalName || false;\n }\n return false;\n },\n };\n};\n\nlet registeredProviders: OAuthProvider[] = [{ name: 'credentials' }];\n//? LOGIN-01: optional factory that builds the provider list lazily on first\n//? `getOAuthProviders()` call. This lets `register.ts` (the package auto-wire\n//? side-effect) defer all config/env reads to request time rather than baking\n//? them at module-load time. Once resolved the factory is discarded and the\n//? static list is used on every subsequent call (same performance as before).\nlet providerFactory: (() => OAuthProvider[]) | null = null;\n\n//? Always returns the active list. The default value contains only the\n//? `credentials` entry so calls into `loginWithCredentials` keep working in\n//? environments that never register OAuth providers (tests, CLI, etc.).\nexport const getOAuthProviders = (): OAuthProvider[] => {\n if (providerFactory) {\n registeredProviders = providerFactory();\n providerFactory = null;\n }\n return registeredProviders;\n};\n\nexport const registerOAuthProviders = (providers: OAuthProvider[]): OAuthProvider[] => {\n providerFactory = null;\n registeredProviders = providers;\n return registeredProviders;\n};\n\n/**\n * Register a factory function that builds the provider list on first use.\n * Prefer this over `registerOAuthProviders` from the package `register` side-\n * effect so config/env reads are deferred to request time and survive any\n * `registerProjectConfig` call that happens after package import.\n * A subsequent `registerOAuthProviders` call replaces the factory.\n */\nexport const registerOAuthProviderFactory = (factory: () => OAuthProvider[]): void => {\n providerFactory = factory;\n};\n","//? User adapter registry. Decouples auth flows from a specific Prisma User\n//? schema so consumers with different column names, additional fields, or a\n//? non-Prisma data layer can plug their own implementation.\n//?\n//? `defaultPrismaUserAdapter()` implements the framework's recommended schema\n//? (id, email, password?, provider, name, avatar, avatarFallback, admin,\n//? language) and is used automatically when no adapter has been registered.\n\nimport { prisma, type BaseSessionLayout } from '@luckystack/core';\n\nexport interface UserAdapterCreateInput {\n email: string;\n provider: string;\n name?: string | null;\n password?: string | null;\n avatar?: string | null;\n avatarFallback?: string | null;\n language?: string | null;\n}\n\nexport interface UserRecord extends BaseSessionLayout {\n password?: string | null;\n provider?: string | null;\n /** Most recent successful login. Mirrors the optional `lastLogin` column on User. */\n lastLogin?: Date | null;\n}\n\nexport interface UserAdapter {\n findByEmail: (params: { email: string; provider: string }) => Promise<UserRecord | null>;\n /**\n * Look up a user by email IRRESPECTIVE of provider. Required only when\n * `auth.providerAccountStrategy === 'unified'` — the framework resolves\n * accounts by email alone so the same address maps to one User across\n * providers. Optional so existing custom adapters keep compiling; when a\n * project sets `'unified'` but its adapter omits this method, the framework\n * logs a one-time warning and falls back to provider-scoped lookup.\n */\n findByEmailAnyProvider?: (params: { email: string }) => Promise<UserRecord | null>;\n findById: (id: string) => Promise<UserRecord | null>;\n create: (input: UserAdapterCreateInput) => Promise<UserRecord>;\n update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;\n /**\n * Hard-delete a user. Account-deletion flows (`deleteAccount_v1`) call this\n * instead of reaching past the adapter to `prisma.user.delete`, so a custom\n * adapter / non-Prisma store / soft-delete strategy stays in control. To\n * implement a soft delete, ignore the hard delete here and instead expose it\n * through `update({ deletedAt: new Date() })` from your route.\n *\n * Optional so existing custom adapters keep compiling; the default Prisma\n * adapter implements it. Consumers calling account deletion against a custom\n * adapter that omits `delete` get a clear \"adapter does not support delete\"\n * failure rather than a silent Prisma bypass.\n */\n delete?: (id: string) => Promise<void>;\n}\n\nlet registeredAdapter: UserAdapter | null = null;\n\nexport const registerUserAdapter = (adapter: UserAdapter): UserAdapter => {\n registeredAdapter = adapter;\n return registeredAdapter;\n};\n\nexport const isUserAdapterRegistered = (): boolean => registeredAdapter !== null;\n\n//? Default adapter: backed by the Prisma client exposed from `@luckystack/core`.\n//?\n//? The single `as unknown as PrismaUserDelegate` below is intentional and\n//? load-bearing — it is the abstraction boundary between the framework's\n//? loose `UserAdapterCreateInput` (string-typed fields) and the consumer's\n//? strict Prisma model (which may declare enums for `provider`, `language`,\n//? etc.). The framework cannot know those enum types statically, so it\n//? type-erases once at this seam and assumes the runtime `prisma.user`\n//? satisfies the documented `PrismaUserDelegate` shape. Consumers whose User\n//? schema diverges (different column names, mandatory extra fields) should\n//? register their own `UserAdapter` via `registerUserAdapter(...)` rather\n//? than relying on this default.\ninterface PrismaUserDelegate {\n findFirst: (args: { where: { email: string; provider?: string }; orderBy?: { createdAt?: 'asc' | 'desc' } }) => Promise<UserRecord | null>;\n findUnique: (args: { where: { id: string } }) => Promise<UserRecord | null>;\n create: (args: { data: Record<string, unknown> }) => Promise<UserRecord>;\n update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<UserRecord>;\n delete: (args: { where: { id: string } }) => Promise<UserRecord>;\n}\n\n//? Bridge between Prisma's generated client surface and the adapter contract.\n//? Structural cast required because Prisma's auto-generated delegate types\n//? don't structurally match our trimmed `PrismaUserDelegate` shape; this is a\n//? known framework boundary (see /docs/ARCHITECTURE_AUTH.md).\nconst getPrismaUser = (): PrismaUserDelegate =>\n // eslint-disable-next-line no-restricted-syntax -- structural Prisma boundary\n prisma.user as unknown as PrismaUserDelegate;\n\nexport const defaultPrismaUserAdapter = (): UserAdapter => {\n return {\n findByEmail: async ({ email, provider }) =>\n getPrismaUser().findFirst({ where: { email, provider } }),\n //? LOGIN-04: add a deterministic tiebreak so duplicate-email rows (possible\n //? under the 'unified' strategy before `email @unique` is enforced) always\n //? resolve to the OLDEST account. Without orderBy the row returned is\n //? engine-defined — two concurrent logins for the same email can resolve to\n //? different User rows AND the cross-provider link guard flips non-deterministically.\n findByEmailAnyProvider: async ({ email }) =>\n getPrismaUser().findFirst({ where: { email }, orderBy: { createdAt: 'asc' } }),\n findById: async (id) =>\n getPrismaUser().findUnique({ where: { id } }),\n create: async (input) =>\n getPrismaUser().create({\n data: {\n email: input.email,\n provider: input.provider,\n name: input.name,\n password: input.password ?? null,\n avatar: input.avatar ?? '',\n avatarFallback: input.avatarFallback,\n language: input.language,\n },\n }),\n update: async (id, patch) =>\n getPrismaUser().update({ where: { id }, data: patch }),\n delete: async (id) => {\n await getPrismaUser().delete({ where: { id } });\n },\n };\n};\n\nlet cachedDefaultAdapter: UserAdapter | null = null;\n\nexport const getUserAdapter = (): UserAdapter => {\n if (registeredAdapter) return registeredAdapter;\n cachedDefaultAdapter ??= defaultPrismaUserAdapter();\n return cachedDefaultAdapter;\n};\n"],"mappings":"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","names":["emails"]}
1
+ {"version":3,"sources":["../src/oauthProviders.ts","../src/userAdapter.ts"],"sourcesContent":["//? OAuth provider registry. Replaces the previously hardcoded provider list\n//? in `loginConfig.ts`. Consumers register providers from their\n//? `luckystack/login/oauthProviders.ts` overlay file at boot.\n//?\n//? Two-layer surface:\n//? 1. `registerOAuthProviders([...])` — accept an array of provider definitions.\n//? 2. Built-in helpers (`googleProvider`, `githubProvider`, ...) so the common\n//? case is one function call per provider with only the secrets + callback.\n//?\n//? Custom providers (Okta, Apple, X, etc.) plug in as raw `OAuthProvider`\n//? objects alongside the helpers.\n\nimport { tryCatch } from '@luckystack/core';\n\nexport type OAuthUserData = Record<string, unknown>;\n\nexport const asOAuthUserData = (value: unknown): OAuthUserData => {\n if (value && typeof value === 'object') {\n return value as OAuthUserData;\n }\n return {};\n};\n\nexport interface CredentialsProvider {\n name: 'credentials';\n}\n\nexport interface FullOAuthProvider {\n name: string;\n clientID: string;\n clientSecret: string;\n callbackURL: string;\n authorizationURL: string;\n tokenExchangeURL: string;\n tokenExchangeMethod: 'json' | 'form';\n userInfoURL: string;\n /**\n * OAuth scopes requested at the authorization step. Consumers can extend\n * a built-in provider's defaults to request additional permissions:\n *\n * ```ts\n * const customGoogle = googleProvider({...});\n * customGoogle.scope.push('https://www.googleapis.com/auth/calendar.readonly');\n * ```\n *\n * Or pass `extraScopes` to the built-in factory (see `OAuthHelperInput`).\n */\n scope: string[];\n nameKey: string;\n emailKey: string;\n /**\n * Optional key on the userInfo response holding a provider \"email verified\"\n * boolean. When set AND the field is present-and-falsy, the framework rejects\n * the profile rather than trusting an unverified address — defense-in-depth\n * against account-linking takeover under the `'unified'` strategy (SEC-21).\n * A missing field (provider doesn't report it) is treated as \"trusted\",\n * preserving behaviour for providers that don't expose the flag.\n */\n emailVerifiedKey?: string;\n avatarKey?: string;\n avatarCodeKey: string;\n getEmail?: (accessToken: string) => Promise<string | false | undefined>;\n getAvatar?: (params: { userData: OAuthUserData; avatarId?: string; accessToken: string }) => string | undefined | Promise<string | undefined>;\n /**\n * Optional per-provider hook to attach extra runtime-only data to the\n * session (NOT to the User row). Return key/value pairs that get merged\n * into `BaseSessionLayout` before `saveSession`. To get strict typing\n * downstream, extend `BaseSessionLayout` via module augmentation:\n *\n * ```ts\n * declare module '@luckystack/core' {\n * interface BaseSessionLayout {\n * googleCalendarToken?: string;\n * }\n * }\n * ```\n *\n * Data lives on the session in Redis and disappears on logout — no Prisma\n * schema change required. Don't store secrets you wouldn't want surfaced\n * to a user inspecting their own session.\n */\n extraSessionFields?: (params: {\n userData: OAuthUserData;\n accessToken: string;\n }) => Promise<Record<string, unknown>> | Record<string, unknown>;\n /**\n * Extra query params appended to the authorization-redirect URL (CFG-21).\n * Lets a provider request e.g. `access_type=offline`, `login_hint=...`, or\n * override the framework's default `prompt=select_account`. The server's\n * authorize route merges these over the built-in params (a key present here\n * wins). Reserved OAuth params (`client_id`, `redirect_uri`, `scope`,\n * `response_type`, `state`, `code_challenge`, `code_challenge_method`) should\n * NOT be set here — they are framework-owned. Default unset.\n */\n extraAuthorizationParams?: Record<string, string>;\n /**\n * Opt this provider into PKCE (RFC 7636, S256) (F11). When `true`, the\n * framework generates a `code_verifier` at flow start, stores it server-side\n * with the OAuth state, sends `code_challenge`/`code_challenge_method=S256` on\n * the authorize redirect, and replays the verifier at token exchange. Required\n * by OAuth 2.1 / PKCE-mandating providers (X/Twitter, some Okta/Auth0\n * policies). Default `false` (no PKCE — existing flows are byte-identical).\n */\n usePkce?: boolean;\n}\n\nexport type OAuthProvider = CredentialsProvider | FullOAuthProvider;\n\nexport const isFullOAuthProvider = (provider: OAuthProvider): provider is FullOAuthProvider =>\n provider.name !== 'credentials';\n\nexport interface OAuthEndpointOverrides {\n /** Override the provider's authorization URL (e.g. GitHub Enterprise host, Microsoft custom tenant). */\n authorizationURL?: string;\n /** Override the token exchange URL. */\n tokenExchangeURL?: string;\n /** Override the userInfo URL. */\n userInfoURL?: string;\n}\n\ninterface OAuthHelperInput {\n clientId: string | undefined;\n clientSecret: string | undefined;\n callbackUrl: string;\n /** Optional URL overrides for self-hosted / custom-tenant deployments. */\n endpoints?: OAuthEndpointOverrides;\n /**\n * Additional OAuth scopes to request alongside the built-in defaults. Use\n * for \"give me access to the user's calendar / drive / repos\" flows.\n * Merged with the helper's default scopes; duplicates are deduplicated.\n */\n extraScopes?: string[];\n /**\n * Per-provider hook for attaching runtime-only fields to the session.\n * See `FullOAuthProvider.extraSessionFields` for usage + module-augmentation\n * pattern.\n */\n extraSessionFields?: FullOAuthProvider['extraSessionFields'];\n /**\n * Extra query params for the authorize redirect (CFG-21). See\n * `FullOAuthProvider.extraAuthorizationParams`.\n */\n extraAuthorizationParams?: Record<string, string>;\n /** Opt this provider into PKCE (S256) (F11). See `FullOAuthProvider.usePkce`. */\n usePkce?: boolean;\n}\n\nconst mergeScopes = (defaults: string[], extra: string[] | undefined): string[] => {\n if (!extra || extra.length === 0) return defaults;\n return [...new Set([...defaults, ...extra])];\n};\n\nconst requireString = (value: string | undefined, label: string): string => {\n if (!value || value.length === 0) {\n throw new Error(`OAuth provider configuration error: ${label} is empty.`);\n }\n return value;\n};\n\nexport const credentialsProvider = (): CredentialsProvider => ({ name: 'credentials' });\n\nexport const googleProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'google',\n clientID: requireString(input.clientId, 'google clientId'),\n clientSecret: requireString(input.clientSecret, 'google clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://accounts.google.com/o/oauth2/v2/auth',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://oauth2.googleapis.com/token',\n tokenExchangeMethod: 'json',\n //? OIDC userinfo (v3) — the legacy `oauth2/v1/userinfo` is deprecated and\n //? reports verification as `verified_email`; v3 reports the OIDC-standard\n //? `email_verified`. Use v3 so `emailVerifiedKey` below lines up (SEC-21/QUA-24).\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://www.googleapis.com/oauth2/v3/userinfo',\n scope: mergeScopes([\n 'https://www.googleapis.com/auth/userinfo.profile',\n 'https://www.googleapis.com/auth/userinfo.email',\n ], input.extraScopes),\n nameKey: 'name',\n emailKey: 'email',\n //? Google's OIDC userinfo returns `email_verified`; reject an unverified\n //? address so a Google account with an unverified email can't link to a\n //? victim's existing account under the `'unified'` strategy (SEC-21).\n emailVerifiedKey: 'email_verified',\n avatarKey: 'picture',\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n});\n\nexport const githubProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'github',\n clientID: requireString(input.clientId, 'github clientId'),\n clientSecret: requireString(input.clientSecret, 'github clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://github.com/login/oauth/authorize',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://github.com/login/oauth/access_token',\n tokenExchangeMethod: 'json',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://api.github.com/user',\n scope: mergeScopes(['read:user', 'user:email'], input.extraScopes),\n nameKey: 'login',\n emailKey: 'email',\n avatarKey: 'avatar_url',\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getEmail: async (accessToken) => {\n const fetchEmails = async () => {\n const response = await fetch('https://api.github.com/user/emails', {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${accessToken}`,\n },\n });\n if (!response.ok) return false;\n const emails: unknown = await response.json();\n if (!Array.isArray(emails)) return false;\n return emails as { primary?: boolean; verified?: boolean; email?: string }[];\n };\n\n const [error, emails] = await tryCatch(fetchEmails);\n if (error || !emails) return false;\n\n //? Defense-in-depth: only ever select a `verified === true` address. GitHub\n //? forbids an unverified email from being `primary`, so in the normal case\n //? this is a no-op — but the `emails[0]` fallback (when no primary exists)\n //? could otherwise pick an UNVERIFIED address an attacker added to their\n //? GitHub account, which under the `'unified'` account strategy would link\n //? to a victim's existing account by that email (SEC-21). Filtering on\n //? `verified` first closes that fallback path.\n const verified = emails.filter((entry) => entry.verified === true);\n let mainEmail: string | undefined;\n for (const entry of verified) {\n if (entry.primary && typeof entry.email === 'string') mainEmail = entry.email;\n }\n mainEmail ??= verified[0]?.email;\n return mainEmail;\n },\n});\n\nexport const discordProvider = (input: OAuthHelperInput): FullOAuthProvider => ({\n name: 'discord',\n clientID: requireString(input.clientId, 'discord clientId'),\n clientSecret: requireString(input.clientSecret, 'discord clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? 'https://discord.com/oauth2/authorize',\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? 'https://discord.com/api/oauth2/token',\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://discord.com/api/users/@me',\n scope: mergeScopes(['identify', 'email'], input.extraScopes),\n nameKey: 'username',\n emailKey: 'email',\n //? Discord's /users/@me returns a `verified` boolean for the account email;\n //? reject the address when it is explicitly unverified (SEC-21).\n emailVerifiedKey: 'verified',\n avatarCodeKey: 'avatar',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getAvatar: ({ userData, avatarId }) => {\n if (!avatarId) return;\n const userId = typeof userData.id === 'string' ? userData.id : '';\n if (!userId) return;\n const format = avatarId.startsWith('a_') ? 'gif' : 'png';\n //? Encode the provider-supplied id + avatar hash before interpolation so a\n //? malformed value can't inject `../` path segments into the CDN URL (SEC-23).\n return `https://cdn.discordapp.com/avatars/${encodeURIComponent(userId)}/${encodeURIComponent(avatarId)}.${format}`;\n },\n});\n\nexport interface FacebookProviderInput extends OAuthHelperInput {\n /** Graph API version segment used in the URLs. Default `'v18.0'` (current as of 2026 — bump as Meta deprecates older versions). */\n apiVersion?: string;\n}\n\nexport const facebookProvider = (input: FacebookProviderInput): FullOAuthProvider => {\n const v = input.apiVersion ?? 'v18.0';\n return {\n name: 'facebook',\n clientID: requireString(input.clientId, 'facebook clientId'),\n clientSecret: requireString(input.clientSecret, 'facebook clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? `https://www.facebook.com/${v}/dialog/oauth`,\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://graph.facebook.com/${v}/oauth/access_token`,\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? 'https://graph.facebook.com/me?fields=id,name,email,picture.type(large)',\n scope: mergeScopes(['public_profile', 'email'], input.extraScopes),\n nameKey: 'name',\n emailKey: 'email',\n //? Facebook's Graph `/me` only returns `email` for an address the user has\n //? CONFIRMED with Facebook (unconfirmed emails are omitted), so there is no\n //? separate verified flag to gate on — the presence of `email` IS the verified\n //? signal. If a consumer opts into receiving unverified emails via a future\n //? Graph field, set `emailVerifiedKey` on the returned provider (SEC-21).\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getAvatar: ({ userData }) => {\n const picture = asOAuthUserData(userData.picture);\n const pictureData = asOAuthUserData(picture.data);\n const url = pictureData.url;\n return typeof url === 'string' ? url : undefined;\n },\n };\n};\n\nexport interface MicrosoftProviderInput extends OAuthHelperInput {\n /** Tenant id segment in the authorize/token URLs. Default `'common'`. */\n tenant?: string;\n /** OAuth API version segment. Default `'v2.0'`. */\n apiVersion?: string;\n /** Graph API version segment for /me. Default `'v1.0'`. */\n graphApiVersion?: string;\n /**\n * Allow falling back to `userPrincipalName` as the account email when Graph\n * `/me.mail` is empty (SEC-11). A UPN is NOT guaranteed routable or verified;\n * under the `'unified'` account strategy a UPN equal to a victim's email would\n * link accounts, and it commonly mis-keys an account on a non-deliverable\n * address (reset emails never arrive). Default `false` — only enable when the\n * tenant guarantees UPN == primary SMTP. When `false` and `.mail` is empty the\n * provider reports \"no email\" and the login is rejected rather than linked.\n */\n allowUpnFallback?: boolean;\n}\n\n//? NOTE: Microsoft flow is implemented but has not been end-to-end verified\n//? against an Azure AD tenant as of 2026-05-14. The token-exchange URL, the\n//? Graph /me shape, and the photo data-URL pipeline are based on Microsoft\n//? docs and pattern-matching against Google/GitHub. First consumer to wire a\n//? real Azure tenant should report back so this note can be dropped.\nexport const microsoftProvider = (input: MicrosoftProviderInput): FullOAuthProvider => {\n const tenant = input.tenant ?? 'common';\n const oauthVersion = input.apiVersion ?? 'v2.0';\n const graphVersion = input.graphApiVersion ?? 'v1.0';\n const allowUpnFallback = input.allowUpnFallback ?? false;\n return {\n name: 'microsoft',\n clientID: requireString(input.clientId, 'microsoft clientId'),\n clientSecret: requireString(input.clientSecret, 'microsoft clientSecret'),\n callbackURL: input.callbackUrl,\n authorizationURL: input.endpoints?.authorizationURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/authorize`,\n tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/token`,\n tokenExchangeMethod: 'form',\n userInfoURL: input.endpoints?.userInfoURL ?? `https://graph.microsoft.com/${graphVersion}/me`,\n scope: mergeScopes(['openid', 'profile', 'email', 'User.Read'], input.extraScopes),\n nameKey: 'displayName',\n emailKey: 'mail',\n avatarCodeKey: 'id',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n //? Graph's /photo/$value requires bearer auth, so we can't store the URL —\n //? a browser <img> would 401. Fetch the bytes and inline as a data URL.\n getAvatar: async ({ avatarId, accessToken }) => {\n if (!avatarId) return;\n // 200 KB base64 cap: a data-URI is inlined into the Redis session record.\n // An unbounded photo (multi-MB) would bloat every session read and could\n // exhaust memory or hit Redis value-size limits on large tenants.\n const MAX_PHOTO_BYTES = 200 * 1024;\n const fetchPhoto = async () => {\n //? `encodeURIComponent` the provider-supplied id before interpolating it\n //? into the path: a malicious / malformed id could otherwise inject `../`\n //? segments and redirect the bearer-authenticated fetch to another Graph\n //? resource (SEC-23). The host is fixed, so this is not full SSRF, but the\n //? result is inlined into the session, so still worth closing.\n const response = await fetch(\n `https://graph.microsoft.com/${graphVersion}/users/${encodeURIComponent(avatarId)}/photo/$value`,\n { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } },\n );\n if (!response.ok) return;\n const rawContentType = response.headers.get('content-type') ?? 'image/jpeg';\n //? SEC: allowlist content-type to safe image MIME types before embedding in\n //? a data-URI that is stored in Redis and later served to the browser.\n const SAFE_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);\n const contentType = SAFE_IMAGE_TYPES.has(rawContentType) ? rawContentType : 'image/jpeg';\n const buffer = Buffer.from(await response.arrayBuffer());\n if (buffer.byteLength > MAX_PHOTO_BYTES) return;\n return `data:${contentType};base64,${buffer.toString('base64')}`;\n };\n const [error, dataUrl] = await tryCatch(fetchPhoto);\n if (error) return;\n return dataUrl ?? undefined;\n },\n getEmail: async (accessToken) => {\n const fetchProfile = async () => {\n const response = await fetch(`https://graph.microsoft.com/${graphVersion}/me`, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n },\n });\n if (!response.ok) return false;\n const data: unknown = await response.json();\n if (!data || typeof data !== 'object') return false;\n return data as Record<string, unknown>;\n };\n\n const [error, profile] = await tryCatch(fetchProfile);\n if (error || !profile) return false;\n\n const record = asOAuthUserData(profile);\n const mail = typeof record.mail === 'string' ? record.mail : '';\n if (mail) return mail;\n //? Only fall back to `userPrincipalName` when the consumer explicitly opted\n //? in (SEC-11). A UPN is not guaranteed routable/verified; using it as the\n //? account email under `'unified'` enables account-linking takeover and\n //? mis-keys accounts on non-deliverable addresses.\n if (allowUpnFallback) {\n const userPrincipalName = typeof record.userPrincipalName === 'string' ? record.userPrincipalName : '';\n return userPrincipalName || false;\n }\n return false;\n },\n };\n};\n\nlet registeredProviders: OAuthProvider[] = [{ name: 'credentials' }];\n//? LOGIN-01: optional factory that builds the provider list lazily on first\n//? `getOAuthProviders()` call. This lets `register.ts` (the package auto-wire\n//? side-effect) defer all config/env reads to request time rather than baking\n//? them at module-load time. Once resolved the factory is discarded and the\n//? static list is used on every subsequent call (same performance as before).\nlet providerFactory: (() => OAuthProvider[]) | null = null;\n\n//? Always returns the active list. The default value contains only the\n//? `credentials` entry so calls into `loginWithCredentials` keep working in\n//? environments that never register OAuth providers (tests, CLI, etc.).\nexport const getOAuthProviders = (): OAuthProvider[] => {\n if (providerFactory) {\n registeredProviders = providerFactory();\n providerFactory = null;\n }\n return registeredProviders;\n};\n\nexport const registerOAuthProviders = (providers: OAuthProvider[]): OAuthProvider[] => {\n providerFactory = null;\n registeredProviders = providers;\n return registeredProviders;\n};\n\n/**\n * Register a factory function that builds the provider list on first use.\n * Prefer this over `registerOAuthProviders` from the package `register` side-\n * effect so config/env reads are deferred to request time and survive any\n * `registerProjectConfig` call that happens after package import.\n * A subsequent `registerOAuthProviders` call replaces the factory.\n */\nexport const registerOAuthProviderFactory = (factory: () => OAuthProvider[]): void => {\n providerFactory = factory;\n};\n","//? User adapter registry. Decouples auth flows from a specific Prisma User\n//? schema so consumers with different column names, additional fields, or a\n//? non-Prisma data layer can plug their own implementation.\n//?\n//? `defaultPrismaUserAdapter()` implements the framework's recommended schema\n//? (id, email, password?, provider, name, avatar, avatarFallback, admin,\n//? language) and is used automatically when no adapter has been registered.\n\nimport { prisma, type BaseSessionLayout } from '@luckystack/core';\n\nexport interface UserAdapterCreateInput {\n email: string;\n provider: string;\n name?: string | null;\n password?: string | null;\n avatar?: string | null;\n avatarFallback?: string | null;\n language?: string | null;\n}\n\nexport interface UserRecord extends BaseSessionLayout {\n password?: string | null;\n provider?: string | null;\n /** Most recent successful login. Mirrors the optional `lastLogin` column on User. */\n lastLogin?: Date | null;\n}\n\nexport interface UserAdapter {\n findByEmail: (params: { email: string; provider: string }) => Promise<UserRecord | null>;\n /**\n * Look up a user by email IRRESPECTIVE of provider. Required only when\n * `auth.providerAccountStrategy === 'unified'` — the framework resolves\n * accounts by email alone so the same address maps to one User across\n * providers. Optional so existing custom adapters keep compiling; when a\n * project sets `'unified'` but its adapter omits this method, the framework\n * logs a one-time warning and falls back to provider-scoped lookup.\n */\n findByEmailAnyProvider?: (params: { email: string }) => Promise<UserRecord | null>;\n findById: (id: string) => Promise<UserRecord | null>;\n create: (input: UserAdapterCreateInput) => Promise<UserRecord>;\n update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;\n /**\n * Hard-delete a user. Account-deletion flows (`deleteAccount_v1`) call this\n * instead of reaching past the adapter to `prisma.user.delete`, so a custom\n * adapter / non-Prisma store / soft-delete strategy stays in control. To\n * implement a soft delete, ignore the hard delete here and instead expose it\n * through `update({ deletedAt: new Date() })` from your route.\n *\n * Optional so existing custom adapters keep compiling; the default Prisma\n * adapter implements it. Consumers calling account deletion against a custom\n * adapter that omits `delete` get a clear \"adapter does not support delete\"\n * failure rather than a silent Prisma bypass.\n */\n delete?: (id: string) => Promise<void>;\n}\n\nlet registeredAdapter: UserAdapter | null = null;\n\nexport const registerUserAdapter = (adapter: UserAdapter): UserAdapter => {\n registeredAdapter = adapter;\n return registeredAdapter;\n};\n\nexport const isUserAdapterRegistered = (): boolean => registeredAdapter !== null;\n\n//? Default adapter: backed by the Prisma client exposed from `@luckystack/core`.\n//?\n//? The single `as unknown as PrismaUserDelegate` below is intentional and\n//? load-bearing — it is the abstraction boundary between the framework's\n//? loose `UserAdapterCreateInput` (string-typed fields) and the consumer's\n//? strict Prisma model (which may declare enums for `provider`, `language`,\n//? etc.). The framework cannot know those enum types statically, so it\n//? type-erases once at this seam and assumes the runtime `prisma.user`\n//? satisfies the documented `PrismaUserDelegate` shape. Consumers whose User\n//? schema diverges (different column names, mandatory extra fields) should\n//? register their own `UserAdapter` via `registerUserAdapter(...)` rather\n//? than relying on this default.\ninterface PrismaUserDelegate {\n findFirst: (args: { where: { email: string; provider?: string }; orderBy?: { createdAt?: 'asc' | 'desc' } }) => Promise<UserRecord | null>;\n findUnique: (args: { where: { id: string } }) => Promise<UserRecord | null>;\n create: (args: { data: Record<string, unknown> }) => Promise<UserRecord>;\n update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<UserRecord>;\n delete: (args: { where: { id: string } }) => Promise<UserRecord>;\n}\n\n//? Bridge between Prisma's generated client surface and the adapter contract.\n//? Structural cast required because Prisma's auto-generated delegate types\n//? don't structurally match our trimmed `PrismaUserDelegate` shape; this is a\n//? known framework boundary (see /docs/ARCHITECTURE_AUTH.md).\nconst getPrismaUser = (): PrismaUserDelegate =>\n // eslint-disable-next-line no-restricted-syntax -- structural Prisma boundary\n prisma.user as unknown as PrismaUserDelegate;\n\nexport const defaultPrismaUserAdapter = (): UserAdapter => {\n return {\n findByEmail: async ({ email, provider }) =>\n getPrismaUser().findFirst({ where: { email, provider } }),\n //? LOGIN-04: add a deterministic tiebreak so duplicate-email rows (possible\n //? under the 'unified' strategy before `email @unique` is enforced) always\n //? resolve to the OLDEST account. Without orderBy the row returned is\n //? engine-defined — two concurrent logins for the same email can resolve to\n //? different User rows AND the cross-provider link guard flips non-deterministically.\n findByEmailAnyProvider: async ({ email }) =>\n getPrismaUser().findFirst({ where: { email }, orderBy: { createdAt: 'asc' } }),\n findById: async (id) =>\n getPrismaUser().findUnique({ where: { id } }),\n create: async (input) =>\n getPrismaUser().create({\n data: {\n email: input.email,\n provider: input.provider,\n name: input.name,\n password: input.password ?? null,\n avatar: input.avatar ?? '',\n avatarFallback: input.avatarFallback,\n language: input.language,\n },\n }),\n update: async (id, patch) =>\n getPrismaUser().update({ where: { id }, data: patch }),\n delete: async (id) => {\n await getPrismaUser().delete({ where: { id } });\n },\n };\n};\n\nlet cachedDefaultAdapter: UserAdapter | null = null;\n\nexport const getUserAdapter = (): UserAdapter => {\n if (registeredAdapter) return registeredAdapter;\n cachedDefaultAdapter ??= defaultPrismaUserAdapter();\n return cachedDefaultAdapter;\n};\n"],"mappings":"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","names":["emails"]}
package/dist/index.d.ts CHANGED
@@ -65,8 +65,6 @@ interface PasswordResetRequestedPayload {
65
65
  matched: boolean;
66
66
  /** The userId, when matched. */
67
67
  userId?: string;
68
- /** The reset token (for audit; do not log). */
69
- token?: string;
70
68
  /** Token TTL in seconds. */
71
69
  ttlSeconds?: number;
72
70
  }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  registerOAuthProviderFactory,
15
15
  registerOAuthProviders,
16
16
  registerUserAdapter
17
- } from "./chunk-22NFCMM7.js";
17
+ } from "./chunk-2WYYKD2D.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -433,6 +433,10 @@ var consumeOAuthState = async (providerName, state, cookieNonce) => {
433
433
  if (!getResult || getResult[0]) {
434
434
  return null;
435
435
  }
436
+ const delResult = txResult[1];
437
+ if (!delResult || delResult[0]) {
438
+ return null;
439
+ }
436
440
  const rawValue = getResult[1];
437
441
  if (typeof rawValue !== "string") {
438
442
  return null;
@@ -663,6 +667,7 @@ var loginWithCredentialsCore = async ({ email, password }, options) => {
663
667
  }
664
668
  await dispatchHook("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
665
669
  void clearAuthFailures(email, requesterIp);
670
+ if (requesterIp) void clearAuthFailures(email);
666
671
  if (isDevMode()) {
667
672
  getLogger3().debug(`credentials login success for user ${newUser.id}`);
668
673
  }
@@ -943,6 +948,13 @@ var applyExtraSessionFields = async (user, provider, profile, accessToken) => {
943
948
  `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) \u2014 dropped`
944
949
  );
945
950
  }
951
+ const CREDENTIAL_PATTERN = /token|secret|key|auth/i;
952
+ const suspiciousKeys = Object.keys(safeExtra).filter((k) => CREDENTIAL_PATTERN.test(k));
953
+ if (suspiciousKeys.length > 0) {
954
+ getLogger3().warn(
955
+ `[oauth:${provider.name}] extraSessionFields contains field(s) that look like credentials (${suspiciousKeys.join(", ")}) \u2014 these will be stored in the Redis session and broadcast to connected sockets. Consider keeping them server-side only.`
956
+ );
957
+ }
946
958
  Object.assign(user, safeExtra);
947
959
  };
948
960
  var resolveOAuthFallbackUrl = (stateEntry, options) => {
@@ -1105,7 +1117,6 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1105
1117
  email,
1106
1118
  matched: true,
1107
1119
  userId: user.id,
1108
- token,
1109
1120
  ttlSeconds: config.auth.passwordResetTtlSeconds
1110
1121
  });
1111
1122
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
@@ -1118,7 +1129,7 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1118
1129
  adapterHint: "transactional"
1119
1130
  });
1120
1131
  if (result.ok) {
1121
- getLogger4().info("[forgotPassword] reset email dispatched", { userId: user.id, to: user.email });
1132
+ getLogger4().info("[forgotPassword] reset email dispatched", { userId: user.id });
1122
1133
  } else {
1123
1134
  getLogger4().warn("[forgotPassword] reset email send FAILED", { userId: user.id, reason: result.reason });
1124
1135
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/authLockout.ts","../src/redirectResolver.ts","../src/login.ts","../src/accountStrategy.ts","../src/passwordPolicy.ts","../src/data/commonPasswords.ts","../src/passwordReset.ts","../src/forgotPassword.ts","../src/emailModuleLoader.ts","../src/emailChange.ts","../src/emailChangeNotification.ts"],"sourcesContent":["// Registers module augmentations on @luckystack/core for auth/session hooks.\nimport './hookPayloads';\n\n//? Decoupling seam (0.2.0): register this package's session implementation into\n//? @luckystack/core so api/sync/presence/server read sessions through core's\n//? null-safe accessors WITHOUT importing @luckystack/login directly. This is\n//? what makes login a genuine OPTIONAL package — when it isn't installed nothing\n//? registers and the accessors return null (unauthenticated). Side-effect at\n//? module load: any app that imports @luckystack/login (its overlay, LoginForm,\n//? config) wires this before the first request is served.\nimport { registerSessionProvider } from '@luckystack/core';\nimport { saveSession as _saveSession, getSession as _getSession, deleteSession as _deleteSession } from './session';\nimport { logout as _logout } from './logout';\nregisterSessionProvider({\n getSession: _getSession,\n saveSession: _saveSession,\n deleteSession: _deleteSession,\n logout: ({ token, socket, userId, skipSessionDelete }) =>\n _logout({ token, socket, userId: userId ?? null, skipSessionDelete }),\n});\n\n//? Subscribe the per-account brute-force lockout to the `loginFailed` hook at\n//? boot (F7). Idempotent + cheap; the recorder no-ops unless\n//? `rateLimiting.auth.enabled` is set, so this is safe to wire unconditionally.\nimport { registerAuthLockoutHook } from './authLockout';\nregisterAuthLockoutHook();\n\nexport type { BaseSessionLayout, SessionLocation, AuthProps } from './sessionLayout';\nexport type {\n PreLoginPayload,\n PostLoginPayload,\n PreRegisterPayload,\n PostRegisterPayload,\n PreLogoutPayload,\n PostLogoutPayload,\n PreSessionCreatePayload,\n PostSessionCreatePayload,\n PreSessionDeletePayload,\n PostSessionDeletePayload,\n PreEmailChangePayload,\n PostEmailChangeRequestedPayload,\n PostEmailChangedPayload,\n LoginFailedPayload,\n PreAccountDeletePayload,\n PostAccountDeletePayload,\n} from './hookPayloads';\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor } from './session';\nexport { registerSessionAdapter, getSessionAdapter, redisSessionAdapter } from './sessionAdapter';\nexport type { SessionAdapter } from './sessionAdapter';\nexport { registerSessionSanitizer, getSessionSanitizer } from './sessionSanitizer';\nexport type { SessionSanitizer } from './sessionSanitizer';\nexport { loginWithCredentials, loginCallback, createOAuthState, registerWithCredentials, loginWithCredentialsCore } from './login';\nexport type {\n CredentialsLoginResult,\n CredentialsLoginSuccess,\n CredentialsLoginFailure,\n OAuthCallbackResult,\n} from './login';\nexport type { CreateOAuthStateResult } from './login';\nexport { OAUTH_STATE_COOKIE_NAME } from './login';\nexport { logout } from './logout';\n\n// Per-account brute-force lockout (F7). The hook is auto-registered at boot;\n// these are exported for consumers who want to query/clear a lock explicitly\n// (e.g. an admin \"unlock account\" action) or build their own surface on top.\nexport { isAccountLocked, recordAuthFailure, clearAuthFailures } from './authLockout';\n\n// Password-reset primitives. Used by the framework's `framework`-mode\n// forgot-password flow AND exported for consumers who picked `'custom'`.\nexport {\n createPasswordResetToken,\n consumePasswordResetToken,\n updatePasswordHash,\n verifyPassword,\n PasswordPolicyError,\n} from './passwordReset';\nexport { validatePassword } from './passwordPolicy';\nexport { sendPasswordResetEmail } from './forgotPassword';\n\n// Email-change primitives. Used by the framework's settings flow to confirm\n// a new email address before applying it (token mailed to NEW address proves\n// ownership of the new mailbox).\nexport {\n createEmailChangeToken,\n consumeEmailChangeToken,\n} from './emailChange';\nexport type { EmailChangePayload } from './emailChange';\nexport { sendEmailChangeConfirmation } from './emailChangeNotification';\n\n// OAuth provider registry + composable helpers.\nexport {\n registerOAuthProviders,\n registerOAuthProviderFactory,\n getOAuthProviders,\n isFullOAuthProvider,\n asOAuthUserData,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n} from './oauthProviders';\nexport type {\n OAuthProvider,\n CredentialsProvider,\n FullOAuthProvider,\n OAuthUserData,\n} from './oauthProviders';\n\n// User adapter registry: lets consumers swap out the Prisma User model behind auth flows.\nexport {\n registerUserAdapter,\n getUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\nexport type {\n UserAdapter,\n UserAdapterCreateInput,\n UserRecord,\n} from './userAdapter';\n\n// Post-login redirect resolver — lets consumers compute the OAuth callback\n// destination dynamically (per-user, per-tenant, per-provider).\nexport {\n registerPostLoginRedirect,\n getPostLoginRedirect,\n} from './redirectResolver';\nexport type {\n PostLoginRedirectResolver,\n PostLoginRedirectInput,\n} from './redirectResolver';\n","//? Per-account brute-force lockout (F7 / MIS-017). The generic per-IP throttle\n//? on `/auth/api/credentials` (in @luckystack/server) does NOT stop distributed\n//? credential stuffing against a SINGLE account from many IPs. This module adds\n//? a per-account failed-attempt counter, keyed independently from the api/ip\n//? limits, that locks an account after `rateLimiting.auth.maxAttempts` failures\n//? within `rateLimiting.auth.windowMs`.\n//?\n//? It is OPT-IN: when `rateLimiting.auth.enabled === false` (the default) every\n//? function here is a no-op and behaviour is byte-identical to before — only\n//? the existing per-IP throttle applies.\n//?\n//? The counter rides on the framework rate limiter (`checkRateLimit`) so it\n//? inherits the configured store (memory/redis) and multi-instance behaviour.\n//? `recordAuthFailure` increments; `isAccountLocked` reads WITHOUT incrementing\n//? (so merely checking the lock can't itself trip it); `clearAuthFailures`\n//? resets the counter on a successful login.\n\nimport {\n getProjectConfig,\n checkRateLimit,\n getRateLimitStatus,\n clearRateLimit,\n getLogger,\n registerHook,\n} from '@luckystack/core';\n\n//? DD-LOGIN-F5: build the lockout bucket key. When `requesterIp` is supplied\n//? the key is an IP+account composite (`auth:<email>:<ip>`) so a remote attacker\n//? from one IP cannot lock out an account for legitimate users on different IPs.\n//? Without an IP the key is the bare account key (`auth:<email>`) — same\n//? behaviour as before. The composite bucket is cleared on success the same way\n//? (same key is used in `clearAuthFailures`). Lower-case + trim the account\n//? identifier so `Alice@x.com` and `alice@x.com` share one lockout bucket\n//? (matches the credentials email normalization).\nconst lockoutKey = (accountKey: string, requesterIp?: string): string => {\n const base = `auth:${accountKey.trim().toLowerCase()}`;\n return requesterIp ? `${base}:${requesterIp}` : base;\n};\n\n/** Read the active auth-lockout config (returns null when the feature is off). */\nconst getAuthLockoutConfig = (): { maxAttempts: number; windowMs: number } | null => {\n const auth = getProjectConfig().rateLimiting.auth;\n if (!auth.enabled) return null;\n return { maxAttempts: auth.maxAttempts, windowMs: auth.windowMs };\n};\n\n/**\n * Whether `accountKey` is currently locked out (failed-attempt counter already\n * at/over the cap). Non-incrementing — safe to call on every login attempt.\n * Always `false` when the feature is disabled.\n * Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const isAccountLocked = async (accountKey: string, requesterIp?: string): Promise<boolean> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return false;\n const { remaining } = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);\n return remaining <= 0;\n};\n\n/**\n * Increment the failed-attempt counter for `accountKey`. Call on every failed\n * credentials login for a known/looked-up account. No-op when disabled.\n * Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const recordAuthFailure = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n const { allowed, remaining } = await checkRateLimit({\n key: lockoutKey(accountKey, requesterIp),\n limit: cfg.maxAttempts,\n windowMs: cfg.windowMs,\n });\n if (!allowed) {\n getLogger().warn('[authLockout] account temporarily locked after repeated failures', {\n remaining,\n maxAttempts: cfg.maxAttempts,\n });\n }\n};\n\n/**\n * Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL\n * login so a user who eventually gets their password right isn't penalised by\n * earlier typos). No-op when disabled.\n * Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const clearAuthFailures = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n await clearRateLimit(lockoutKey(accountKey, requesterIp));\n};\n\n//? ADR 0012 — the lockout counter must reflect GENUINE credential failures only.\n//? This is an ALLOW-LIST, not a deny-list: only a real wrong-password attempt\n//? (`login.wrongPassword`, covering both the no-account/no-hash dummy-compare\n//? path and a failed bcrypt compare) feeds the per-account counter. Everything\n//? else is excluded by construction — `preLogin` veto rejections (whose reason\n//? is a consumer-controlled `errorCode`), password-POLICY rejections (M-15),\n//? input-shape errors, the `login.accountLocked` self-trip, and infra failures\n//? (DB find / bcrypt throw, surfaced via `toReasonKey`). A deny-list let any of\n//? those — most dangerously a consumer `preLogin` veto an attacker can trigger\n//? with just a victim's email and no password — silently drive the lockout and\n//? DoS the account. Keep this allow-list narrow.\nconst COUNTING_REASONS = new Set([\n 'login.wrongPassword',\n]);\n\nlet lockoutHookRegistered = false;\n\n/**\n * Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once\n * (idempotent) and increments the failed-attempt counter only on a credentials\n * LOGIN failure that carries an email AND is a genuine wrong-password attempt\n * (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder\n * itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the\n * config at runtime is honoured without re-wiring.\n */\nexport const registerAuthLockoutHook = (): void => {\n if (lockoutHookRegistered) return;\n lockoutHookRegistered = true;\n registerHook('loginFailed', async ({ email, provider, reason, stage, requesterIp }) => {\n if (stage !== 'login' || provider !== 'credentials') return;\n if (!email || !COUNTING_REASONS.has(reason)) return;\n //? DD-LOGIN-F5: use the IP+account composite key when an IP was threaded in.\n await recordAuthFailure(email, requesterIp);\n });\n};\n","//? Post-login redirect resolver. Lets consumers compute the OAuth callback\n//? redirect URL dynamically (per-user, per-tenant, per-provider).\n//?\n//? Why a dedicated registry instead of a hook:\n//? The framework's hooks are \"stop-or-continue\" — handlers return undefined\n//? or a stop signal. They don't carry a value. A redirect-resolution\n//? handler must RETURN a string, so it lives in its own registry.\n//?\n//? Validation: the resolved URL is checked against\n//? `ProjectConfig.http.cors.allowedOrigins` (plus localhost + the bound\n//? SERVER_IP) before being used. An invalid URL falls back to\n//? `ProjectConfig.loginRedirectUrl`.\n\nexport interface PostLoginRedirectInput {\n userId: string;\n provider: string;\n isNewUser: boolean;\n defaultUrl: string;\n}\n\nexport type PostLoginRedirectResolver = (\n input: PostLoginRedirectInput,\n) => string | Promise<string>;\n\nlet activeResolver: PostLoginRedirectResolver | null = null;\n\nexport const registerPostLoginRedirect = (\n resolver: PostLoginRedirectResolver,\n): PostLoginRedirectResolver => {\n activeResolver = resolver;\n return resolver;\n};\n\nexport const getPostLoginRedirect = (): PostLoginRedirectResolver | null => activeResolver;\n","import { getOAuthProviders, isFullOAuthProvider, type FullOAuthProvider, type OAuthUserData } from './oauthProviders';\nimport { getPostLoginRedirect } from './redirectResolver';\nimport { IncomingMessage, ServerResponse } from 'node:http';\nimport { URLSearchParams } from 'node:url';\nimport { tryCatch, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger, formatKey, getProjectConfig, getCookieValue } from '@luckystack/core';\nimport bcrypt from 'bcryptjs';\nimport { randomBytes, createHash, timingSafeEqual } from 'node:crypto';\nimport { saveSession } from \"./session\"\nimport validator from 'validator';\nimport type { BaseSessionLayout as SessionLayout } from './sessionLayout';\nimport { getUserAdapter } from './userAdapter';\nimport { resolveUserByEmail } from './accountStrategy';\nimport { validatePassword } from './passwordPolicy';\nimport { isAccountLocked, clearAuthFailures } from './authLockout';\nimport path from 'node:path';\nimport { promises as fsPromises } from 'node:fs';\n\n//? Combined login/register request body. When `name` + `confirmPassword` are\n//? both present the dispatcher registers; otherwise it logs in.\ninterface LoginOrRegisterParams {\n email?: string,\n password?: string,\n name?: string,\n confirmPassword?: string,\n}\n\n//? Discriminated result of the credentials login/register surface (QUA-080).\n//? Exporting this lets `@luckystack/server`'s auth route narrow on `status`\n//? instead of casting the result to an inline shape. On `status: true`,\n//? `newToken` + `session` are always present; on `status: false` only `reason`\n//? is meaningful (the failure branch never mints a token).\nexport interface CredentialsLoginSuccess {\n status: true;\n reason: string;\n newToken: string;\n session: SessionLayout;\n}\nexport interface CredentialsLoginFailure {\n status: false;\n reason: string;\n}\nexport type CredentialsLoginResult = CredentialsLoginSuccess | CredentialsLoginFailure;\n\n//? Resolved at call time via getUploadsDir() so consumer path overrides win.\nconst uploadsFolder = (): string => getUploadsDir();\n//? Resolve at call time so dotenv/test-setup timing can't capture a stale\n//? value at module load. Routes through `projectConfig.logging.devLogs`\n//? which is the standard predicate everywhere else in the codebase.\nconst isDevMode = (): boolean => getProjectConfig().logging.devLogs;\nconst { compare, genSalt, hash } = bcrypt;\n\n//? LOGIN-F22: the dummy hash used on the user-not-found path must be generated\n//? at the CONFIGURED bcrypt rounds, not hard-pinned to cost 10. If the consumer\n//? raises `auth.bcryptRounds` (e.g. to 12), a cost-10 dummy compare finishes in\n//? ~80 ms while a real compare takes ~400 ms — the 5× difference reopens the\n//? user-enumeration timing channel we closed with the dummy compare. We lazily\n//? generate one hash per unique rounds value (cached so the per-request compare\n//? stays O(1) after the first login at a given cost) and use that hash for all\n//? subsequent not-found compares at the same cost.\nconst dummyHashCache = new Map<number, string>();\nconst getDummyBcryptHash = async (): Promise<string> => {\n const rounds = getProjectConfig().auth.bcryptRounds;\n const cached = dummyHashCache.get(rounds);\n if (cached) return cached;\n const salt = await genSalt(rounds);\n const generated = await hash(randomBytes(16).toString('hex'), salt);\n dummyHashCache.set(rounds, generated);\n return generated;\n};\n//? `validator` is a CommonJS module whose helpers hang off the default export;\n//? destructuring the default is the documented usage. The named-export warning\n//? is a false positive for this CJS interop shape.\n// eslint-disable-next-line import-x/no-named-as-default-member\nconst { escape, isEmail } = validator;\n\n//? Use the shared `getProjectName()` helper so the OAuth state Redis key\n//? prefix matches the namespace used everywhere else (sessions, activeUsers,\n//? rate-limit, password-reset). Previously this used a separate\n//? `auth.oauthStateProjectNameFallback` config field which could silently\n//? drift from `session.projectName`.\nconst getOAuthStateKey = (providerName: string, state: string): string => {\n return formatKey('-oauth-state', `${providerName}:${state}`);\n};\n\n//? Cookie the server sets at the OAuth-authorize step (F1). It carries a random\n//? nonce bound to the Redis state entry; the callback requires the cookie nonce\n//? to hash to the value stored alongside the state before it accepts the\n//? callback. This proves the SAME browser that started the flow is completing it\n//? (closes the login-CSRF / session-fixation hole where an attacker delivers\n//? their own valid `code+state` to a victim). Server + login MUST agree on this\n//? name — it is exported so `@luckystack/server`'s authorize route reuses it.\nexport const OAUTH_STATE_COOKIE_NAME = 'ls-oauth-state';\n\n//? Hash the browser-binding nonce before persisting it in Redis: the Redis\n//? state value is server-side-readable, so storing only the SHA-256 means a\n//? leaked state entry can't be replayed without the matching cookie nonce.\nconst hashStateNonce = (nonce: string): string =>\n createHash('sha256').update(nonce).digest('hex');\n\n//? Generate a PKCE verifier + its S256 challenge (RFC 7636). Only used when a\n//? provider opts in via `usePkce` — default OAuth flows are byte-identical to\n//? before. The verifier is stored server-side (in the Redis state value) and\n//? replayed at token exchange; the challenge is handed to the authorize step.\nconst base64Url = (buf: Buffer): string =>\n buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');\nconst generatePkcePair = (): { verifier: string; challenge: string } => {\n const verifier = base64Url(randomBytes(32));\n const challenge = base64Url(createHash('sha256').update(verifier).digest());\n return { verifier, challenge };\n};\n\n//? Server-side payload persisted under the Redis state key. `nonceHash` binds\n//? the flow to the initiating browser's cookie; `codeVerifier` (PKCE) is the\n//? secret replayed at token exchange.\ninterface OAuthStateEntry {\n nonceHash: string;\n codeVerifier?: string;\n //? Frontend URL to redirect to after the OAuth callback completes. Stored\n //? server-side in Redis (never echoed from the client at callback time) so it\n //? is tamper-proof. Validated against allowedOrigins + allowLocalhost before\n //? use — see isAllowedRedirectUrl. Absent when the auth initiation did not\n //? carry a return_url param (falls back to the configured loginRedirectUrl).\n returnUrl?: string;\n}\n\nexport interface CreateOAuthStateResult {\n /** Opaque `state` param put on the authorize URL + echoed back on callback. */\n state: string;\n /**\n * Random nonce the server must set as the value of the {@link OAUTH_STATE_COOKIE_NAME}\n * cookie (HttpOnly, SameSite=Lax, short TTL) at the authorize redirect. The\n * callback requires this back to accept the flow (browser-binding, F1).\n */\n stateCookie: string;\n /**\n * PKCE `code_challenge` (S256) when the provider opted into PKCE — the server\n * appends `code_challenge=<value>&code_challenge_method=S256` to the authorize\n * URL. `undefined` for non-PKCE providers (default).\n */\n codeChallenge?: string;\n}\n\nexport const createOAuthState = async (\n providerName: string,\n options?: { usePkce?: boolean; returnUrl?: string },\n): Promise<CreateOAuthStateResult | null> => {\n const state = randomBytes(32).toString('hex');\n const nonce = randomBytes(32).toString('hex');\n const key = getOAuthStateKey(providerName, state);\n\n const pkce = options?.usePkce ? generatePkcePair() : null;\n const entry: OAuthStateEntry = {\n nonceHash: hashStateNonce(nonce),\n ...(pkce ? { codeVerifier: pkce.verifier } : {}),\n ...(options?.returnUrl ? { returnUrl: options.returnUrl } : {}),\n };\n\n //? State TTL is consumer-configurable via `auth.oauthStateTtlSeconds`\n //? (default 600s / 10 min). Too short and a legitimate, slow provider\n //? round-trip expires mid-flow and the callback fails state validation\n //? (UX/soft-DoS); too long and a stolen state stays replayable. The `NX`\n //? guard keeps each state single-use even before the TTL elapses, and\n //? `consumeOAuthState` deletes it on first redemption. The value is now a\n //? JSON envelope (nonce hash + optional PKCE verifier) instead of the literal\n //? `'1'` so the flow can be bound to the initiating browser (F1) and carry a\n //? PKCE secret (F11).\n const result = await redisClient.set(\n key,\n JSON.stringify(entry),\n 'EX',\n getProjectConfig().auth.oauthStateTtlSeconds,\n 'NX',\n );\n\n if (result !== 'OK') {\n return null;\n }\n\n return {\n state,\n stateCookie: nonce,\n ...(pkce ? { codeChallenge: pkce.challenge } : {}),\n };\n};\n\n//? Atomically read + delete the state entry (single-use), then verify the\n//? browser-binding nonce. Returns the parsed entry on success (so the caller can\n//? read `codeVerifier` for the PKCE token exchange), or `null` on any failure:\n//? missing state, missing/empty cookie nonce, Redis miss, malformed payload, or\n//? a nonce that doesn't match the stored hash. `cookieNonce` is the value the\n//? server read from the {@link OAUTH_STATE_COOKIE_NAME} cookie on the callback\n//? request.\nconst consumeOAuthState = async (\n providerName: string,\n state: string,\n cookieNonce: string,\n): Promise<OAuthStateEntry | null> => {\n if (!state || !cookieNonce) {\n return null;\n }\n\n const key = getOAuthStateKey(providerName, state);\n const txResult = await redisClient.multi().get(key).del(key).exec();\n if (!txResult || txResult.length < 2) {\n return null;\n }\n\n const getResult = txResult[0];\n if (!getResult || getResult[0]) {\n return null;\n }\n\n const rawValue = getResult[1];\n if (typeof rawValue !== 'string') {\n return null;\n }\n\n const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue) as OAuthStateEntry);\n if (parseErr || !entry || typeof entry.nonceHash !== 'string') {\n return null;\n }\n\n //? Constant-time compare of the two fixed-length (SHA-256) hex digests via\n //? `timingSafeEqual` (QUA-21). Both operands are 64-char hex strings, so their\n //? byte buffers are equal-length; a length mismatch (malformed stored value)\n //? falls through to reject. A mismatch means the browser completing the flow is\n //? NOT the one that started it — reject (F1).\n const incomingHash = Buffer.from(hashStateNonce(cookieNonce));\n const storedHash = Buffer.from(entry.nonceHash);\n if (incomingHash.length !== storedHash.length || !timingSafeEqual(incomingHash, storedHash)) {\n return null;\n }\n\n return entry;\n};\n\nconst asRecord = (value: unknown): Record<string, unknown> => {\n if (value && typeof value === 'object') {\n return value as Record<string, unknown>;\n }\n return {};\n};\n\n//? Read a string-ish field from an untyped provider JSON record. Strings pass\n//? through; finite numbers / booleans are coerced (preserving the previous\n//? `String(value)` behaviour for primitive fields); objects, arrays, null and\n//? undefined collapse to `fallback` instead of stringifying to `[object Object]`.\nconst readStringField = (record: Record<string, unknown>, key: string, fallback = ''): string => {\n const value = record[key];\n if (typeof value === 'string') return value;\n if (typeof value === 'number' && Number.isFinite(value)) return String(value);\n if (typeof value === 'boolean') return String(value);\n return fallback;\n};\n\n//? Random 6-digit hex color used as the avatar placeholder for users without\n//? an uploaded image. Shared by the credentials-register and OAuth-create paths.\nconst generateAvatarFallbackColor = (): string =>\n `#${Math.floor(Math.random() * 0xFF_FF_FF).toString(16).padStart(6, \"0\")}`;\n\n//? LOGIN-F18: check whether a user has an uploaded webp avatar on disk and\n//? return the filename when found. Used by both the credentials and OAuth login\n//? paths to avoid duplicating the async stat pattern.\nconst resolveUploadedAvatar = async (userId: string): Promise<string | null> => {\n const filePath = path.join(uploadsFolder(), `${userId}.webp`);\n const [, avatarStat] = await tryCatch(() => fsPromises.stat(filePath));\n return avatarStat ? `${userId}.webp` : null;\n};\n\nconst sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {\n const { password: _password, ...safeUser } = user;\n return safeUser;\n};\n\n//? Fire-and-forget observational failure signal. Dispatched on every failed\n//? login/register/OAuth attempt so consumers can audit, feed a SIEM, or build\n//? per-account lockout (HOK-10). Never awaited and never throws — a failing\n//? handler must not change the auth outcome the user already got.\nconst emitLoginFailed = (payload: {\n email?: string;\n userId?: string;\n provider: string;\n reason: string;\n stage: 'login' | 'register' | 'oauth';\n //? DD-LOGIN-F5: optional resolved client IP — threaded from the HTTP auth\n //? route so the per-account lockout can build an IP+account composite key.\n requesterIp?: string;\n}): void => {\n void dispatchHook('loginFailed', payload);\n};\n\nconst toReasonKey = (error: unknown, fallback = 'api.internalServerError'): string => {\n if (typeof error === 'string' && error.length > 0) {\n return error;\n }\n\n if (error instanceof Error && error.message) {\n const message = error.message.toLowerCase();\n if (message.includes('authentication failed') || message.includes('scram failure')) {\n return 'api.internalServerError';\n }\n }\n\n return fallback;\n};\n\ninterface NormalizedCredentials {\n email: string;\n password: string;\n name: string | undefined;\n confirmPassword: string | undefined;\n}\n\nconst normalizeCredentials = (params: LoginOrRegisterParams): NormalizedCredentials => ({\n //? Don't HTML-escape password OR confirmPassword before bcrypt — neither\n //? reaches HTML, and they're memory-compared for equality during register.\n //? Escaping confirmPassword while leaving password raw made any password\n //? containing `& < > \" '` fail the `password !== confirmPassword` check\n //? (regression introduced in pass-1 fix #9 which only un-escaped password).\n //? Email is normalized (lowercase + trim) and validated via `isEmail`;\n //? HTML-escaping it would make legacy raw rows unfindable.\n email: (params.email ?? '').trim().toLowerCase(),\n password: params.password ?? '',\n name: params.name ? escape(params.name) : undefined,\n //? Empty-string confirmPassword must collapse to `undefined` so the\n //? register-vs-login dispatcher's truthiness check treats a blank field as\n //? \"absent\". `??` would keep the empty string (only nullish collapses), so the\n //? falsy-coercing `||` is deliberate here.\n // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional empty-string → undefined collapse\n confirmPassword: params.confirmPassword || undefined,\n});\n\n//? Shape-validate normalized credentials. `mode` decides whether the\n//? password-POLICY check runs:\n//? - `'register'` enforces the full policy (length, complexity, common-list,\n//? customValidator) — a new account MUST meet the active policy.\n//? - `'login'` does NOT run the policy (M-15). A login must accept ANY password\n//? string and let the bcrypt compare decide pass/fail. Running the policy on\n//? login is both a lockout-DoS vector (an attacker POSTing policy-violating\n//? passwords for a victim's email could otherwise feed the per-account\n//? failed-attempt counter) AND a correctness bug (tightening the policy would\n//? lock out existing users whose stored password no longer meets it). Empty /\n//? email-shape checks still run in both modes.\nconst validateCredentialsShape = (\n creds: NormalizedCredentials,\n mode: 'login' | 'register',\n): { status: false; reason: string } | null => {\n const authLimits = getProjectConfig().auth;\n if (!creds.email || !creds.password) return { status: false, reason: 'login.empty' };\n if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: 'login.emailCharacterLimit' };\n if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: 'login.nameCharacterLimit' };\n if (!isEmail(creds.email)) return { status: false, reason: 'login.invalidEmailFormat' };\n if (mode === 'register') {\n //? Full password-policy check (length, complexity, common-list, customValidator).\n //? Policy lives in `projectConfig.auth.passwordPolicy`; the deprecated\n //? `passwordMinLength`/`passwordMaxLength` top-level fields are still read\n //? from the policy's `minLength`/`maxLength` defaults. REGISTER-only — see the\n //? function doc for why the login branch skips it (M-15).\n const passwordReason = validatePassword(creds.password);\n if (passwordReason) return { status: false, reason: passwordReason };\n }\n return null;\n};\n\nconst registerWithCredentials = async (\n {\n email,\n password,\n name,\n confirmPassword,\n }: {\n email: string;\n password: string;\n name: string;\n confirmPassword: string;\n },\n options?: { supersedeToken?: string },\n): Promise<CredentialsLoginResult> => {\n if (password !== confirmPassword) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.passwordNotMatch', stage: 'register' });\n return { status: false, reason: 'login.passwordNotMatch' };\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: 'credentials', name });\n if (preRegisterResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preRegisterResult.signal.errorCode, stage: 'register' });\n return { status: false, reason: preRegisterResult.signal.errorCode };\n }\n\n //? LOGIN-F15: apply the per-account lockout check before the email-existence\n //? lookup. Without this an attacker can call the register endpoint repeatedly\n //? for a victim's email and perform unbounded `resolveUserByEmail` probes —\n //? effectively bypassing the lockout by choosing the register code path. A\n //? locked account means the credentials flow is temporarily suspended for that\n //? email; the same sentinel applies regardless of which surface (login vs\n //? register) is being used. No-op when `rateLimiting.auth.enabled` is false.\n if (await isAccountLocked(email)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'register' });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [checkEmailError, checkEmailResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (checkEmailError) {\n getLogger().error('login: findByEmail failed during register', checkEmailError);\n const reason = toReasonKey(checkEmailError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (checkEmailResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n\n const createNewUser = async () => {\n const salt = await genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await hash(password, salt);\n return await userAdapter.create({\n email,\n provider: 'credentials',\n name,\n password: hashedPassword,\n avatar: '',\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n };\n\n const [createNewUserError, createNewUserResponse] = await tryCatch(createNewUser);\n if (createNewUserError) {\n //? LOGIN-F7: treat a unique-constraint violation as a TOCTOU race — another\n //? concurrent request created this row between our findFirst and our create.\n //? Under the `'unified'` strategy (where `email @unique` is recommended) this\n //? is the expected concurrent-registration signal; return `login.emailExists`\n //? so the client can prompt the user to log in instead.\n const errorMessage =\n createNewUserError instanceof Error ? createNewUserError.message : String(createNewUserError);\n const isUniqueViolation =\n errorMessage.includes('Unique constraint') ||\n errorMessage.includes('unique constraint') ||\n // Prisma error code P2002 covers all DB engines\n (createNewUserError as { code?: unknown }).code === 'P2002';\n if (isUniqueViolation) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n const reason = toReasonKey(createNewUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (!createNewUserResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.createUserFailed', stage: 'register' });\n return { status: false, reason: 'login.createUserFailed' };\n }\n\n await dispatchHook('postRegister', { userId: createNewUserResponse.id, provider: 'credentials' });\n\n //? Auto-login after register (ARCHITECTURE_AUTH.md: both flows return\n //? `{ status, session, newToken }`). Mirrors the login branch: mint a token,\n //? persist the session, and hand the token back so the route can set the\n //? cookie / the client can store it and land on `loginRedirectUrl`.\n const newToken = randomBytes(32).toString('hex');\n const nowLogin = new Date();\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(createNewUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin: null,\n };\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Account exists but the session never persisted — surface the failure so\n //? the route does not report an authenticated state it cannot back up. The\n //? user can recover by logging in normally.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'register' });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: true, token: newToken });\n\n return {\n status: true,\n reason: 'login.userCreated',\n newToken,\n session: newUser,\n };\n};\n\nconst loginWithCredentialsCore = async (\n { email, password }: { email: string; password: string },\n //? DD-LOGIN-F5: `requesterIp` is optional so all existing callers stay\n //? compatible. The HTTP auth route resolves and passes the IP; socket paths\n //? omit it and fall back to the pure-account lockout bucket.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const requesterIp = options?.requesterIp;\n const preLoginResult = await dispatchHook('preLogin', { email, provider: 'credentials' });\n if (preLoginResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preLoginResult.signal.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: preLoginResult.signal.errorCode };\n }\n\n //? Per-account brute-force lockout (F7 / MIS-017). When the auth rate-limit\n //? slot is enabled and this account has exhausted its failed-attempt budget,\n //? refuse the attempt BEFORE any bcrypt work — distributed credential stuffing\n //? against one account (which the per-IP throttle can't see) is throttled\n //? per-account here. The counter is incremented by the registered `loginFailed`\n //? handler (see `registerAuthLockoutHook`); merely checking the lock does not\n //? increment it. No-op when `rateLimiting.auth.enabled` is false (default).\n if (await isAccountLocked(email, requesterIp)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'login', requesterIp });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [findUserError, findUserResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (findUserError) {\n getLogger().error('login: findByEmail failed', findUserError);\n const reason = toReasonKey(findUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n //? Anti-enumeration: a missing account and a missing/invalid password hash\n //? must be INDISTINGUISHABLE from a wrong password — same reason key AND\n //? similar timing. Returning a distinct `login.userNotFound` (and short-\n //? circuiting before any bcrypt work) let an attacker probe which addresses\n //? have credentials accounts via both the response key and a timing side\n //? channel. When there is no real hash to compare, run a bcrypt compare\n //? against a fixed dummy hash so the not-found path spends roughly the same\n //? CPU as a real wrong-password compare, then return the shared key.\n const passwordHash = findUserResponse?.password ?? null;\n if (!findUserResponse || !passwordHash) {\n const dummyHash = await getDummyBcryptHash();\n await tryCatch(() => compare(password, dummyHash));\n emitLoginFailed({ email, userId: findUserResponse?.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const [checkPasswordError, checkPasswordResponse] = await tryCatch(() =>\n compare(password, passwordHash)\n );\n if (checkPasswordError) {\n getLogger().error('login: bcrypt compare failed', checkPasswordError);\n const reason = toReasonKey(checkPasswordError);\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n if (!checkPasswordResponse) {\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const newToken = randomBytes(32).toString('hex');\n const previousLogin = findUserResponse.lastLogin ?? null;\n const nowLogin = new Date();\n\n // Best-effort lastLogin update — silently no-ops if the user adapter\n // (or User schema) doesn't accept the field.\n await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));\n\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(findUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin,\n };\n\n const uploadedAvatar = await resolveUploadedAvatar(newUser.id);\n if (uploadedAvatar) {\n newUser.avatar = uploadedAvatar;\n }\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted (adapter blip / preSessionCreate veto). Fail the\n //? login so the route does NOT set a cookie or delete the prior session for\n //? a token that getSession() can't resolve.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: false, token: newToken });\n //? Reset the brute-force counter on success so earlier typos don't keep a\n //? legitimate user locked out (F7). No-op when the feature is disabled.\n //? Pass `requesterIp` so the composite key (DD-LOGIN-F5) is cleared when\n //? an IP was threaded in; falls back to the bare account key otherwise.\n void clearAuthFailures(email, requesterIp);\n if (isDevMode()) {\n getLogger().debug(`credentials login success for user ${newUser.id}`);\n }\n return { status: true, reason: 'login.loggedIn', newToken, session: newUser };\n};\n\n//? Thin dispatcher: keeps the existing single-entry HTTP surface but forwards\n//? to the dedicated register / login functions. `name && confirmPassword`\n//? present in the body means the client is registering; otherwise it's a\n//? login.\nconst loginWithCredentials = async (\n params: LoginOrRegisterParams,\n //? DD-LOGIN-F5: `requesterIp` forwarded to `loginWithCredentialsCore` to\n //? enable IP+account composite lockout keys on the HTTP auth path.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const creds = normalizeCredentials(params);\n\n if (isDevMode()) {\n getLogger().debug(`credentials auth attempt for ${creds.email || 'unknown-email'}`);\n }\n\n //? Decide the branch BEFORE shape-validation so the password-POLICY check only\n //? runs on register (M-15): `name && confirmPassword` present => register;\n //? otherwise login. A login must accept any password string (the bcrypt\n //? compare is the only authority), so an attacker can't trip a victim's\n //? lockout counter by POSTing policy-violating passwords for their email.\n //? Narrowing on the destructured locals keeps `name`/`confirmPassword` typed as\n //? `string` inside the register branch (no cast needed).\n const { name, confirmPassword } = creds;\n if (name && confirmPassword) {\n const validationError = validateCredentialsShape(creds, 'register');\n if (validationError) return validationError;\n //? Public-registration gate (F18). When `auth.allowRegistration === false`\n //? (invite-only / admin-provisioned apps) refuse the register branch with a\n //? dedicated reason key BEFORE any user lookup or hook. OAuth-driven\n //? first-login account creation is governed separately by the provider flow.\n //? Default `true` keeps today's open-registration behavior.\n if (!getProjectConfig().auth.allowRegistration) {\n emitLoginFailed({ email: creds.email, provider: 'credentials', reason: 'auth.registrationDisabled', stage: 'register' });\n return { status: false, reason: 'auth.registrationDisabled' };\n }\n return registerWithCredentials({\n email: creds.email,\n password: creds.password,\n name,\n confirmPassword,\n }, options);\n }\n\n const validationError = validateCredentialsShape(creds, 'login');\n if (validationError) return validationError;\n return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);\n};\n\nexport interface OAuthCallbackResult {\n token: string;\n redirectUrl: string;\n userId: string;\n provider: string;\n isNewUser: boolean;\n}\n\nconst isAllowedRedirectUrl = (url: string): boolean => {\n if (!URL.canParse(url, 'http://placeholder')) return false;\n //? Reject relative URLs whose path starts with a backslash or contains any\n //? backslash before the same-origin shortcut (SEC-12): `new URL('/\\\\evil.com',\n //? base)` keeps the placeholder origin and would pass the same-origin check,\n //? yet browsers normalise `/\\` → `//` (protocol-relative) → open-redirect to\n //? `evil.com`. A legitimate same-origin path never needs a backslash.\n if (url.includes('\\\\')) return false;\n const parsed = new URL(url, 'http://placeholder');\n if (parsed.origin === 'http://placeholder') {\n // relative URL — same-origin, always safe\n return true;\n }\n //? `allowedOrigins` is `string[] | (origin: string) => boolean` since we\n //? added the function-resolver variant. Branch on the shape.\n //? Defensive default at a security boundary (redirect-origin validation):\n //? the type is non-nullable, but a malformed consumer config could still\n //? hand us `undefined` at runtime, so the `?? []` fail-closed guard stays.\n //? `allowLocalhost` is the dev convenience that accepts any http://localhost:*\n //? origin without needing an explicit allowedOrigins entry. Safe in production\n //? because allowLocalhost defaults to false there.\n const cfg = getProjectConfig();\n if (cfg.http.cors.allowLocalhost && parsed.hostname === 'localhost') return true;\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime-defensive guard at a security boundary\n const allowed = cfg.http.cors.allowedOrigins ?? [];\n if (typeof allowed === 'function') {\n return allowed(parsed.origin);\n }\n return allowed.some((origin) => {\n if (!URL.canParse(origin)) return false;\n return new URL(origin).origin === parsed.origin;\n });\n};\n\nconst exchangeOAuthToken = async (\n provider: FullOAuthProvider,\n code: string,\n codeVerifier?: string,\n): Promise<string | null> => {\n //? `redirect_uri` is pinned to the registered, immutable `provider.callbackURL`\n //? — the SAME static config value the authorization request is built from.\n //? It is never derived from the incoming request, so there is no per-request\n //? drift to re-validate against (the authorization-time value and the\n //? exchange-time value are guaranteed identical by construction). Persisting\n //? it into the Redis state would be a behaviour-preserving no-op here; if the\n //? callback URL ever becomes request-/tenant-derived, store it alongside the\n //? state in `createOAuthState` and compare before exchange.\n //? `code_verifier` (PKCE, F11) is included ONLY when the provider opted in and\n //? a verifier was stored with the state — for every existing flow it is\n //? `undefined` and the exchange body is unchanged.\n const values = {\n code,\n client_id: provider.clientID,\n client_secret: provider.clientSecret,\n redirect_uri: provider.callbackURL,\n grant_type: 'authorization_code',\n ...(codeVerifier ? { code_verifier: codeVerifier } : {}),\n };\n\n //? `Promise<unknown>` return annotation: `response.json()` is `any`, and an\n //? explicit `unknown` boundary type forces every caller to narrow via\n //? `asRecord(...)` instead of leaking `any` through the token-exchange seam.\n const getToken = async (): Promise<unknown> => {\n if (provider.tokenExchangeMethod === 'json') {\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: JSON.stringify(values),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n }\n //? `tokenExchangeMethod` is the closed union `'json' | 'form'`; the `json`\n //? branch returned above, so this is the exhaustive `form` case.\n const formParams = new URLSearchParams();\n formParams.append('client_id', provider.clientID);\n formParams.append('client_secret', provider.clientSecret);\n formParams.append('code', values.code);\n formParams.append('grant_type', 'authorization_code');\n formParams.append('redirect_uri', provider.callbackURL);\n if (codeVerifier) {\n formParams.append('code_verifier', codeVerifier);\n }\n\n if (isDevMode()) {\n //? NEVER stringify the raw form — it carries `client_secret` (a\n //? long-lived credential) and the single-use authorization `code`. Log\n //? only the param NAMES so the dev sees the shape without leaking secrets\n //? into the log sink.\n getLogger().debug('oauth: token-exchange form params', { paramNames: [...formParams.keys()] });\n }\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },\n body: formParams.toString(),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getToken);\n if (error) {\n getLogger().error('oauth: token exchange failed', error);\n return null;\n }\n const tokenData = asRecord(response);\n const accessToken = typeof tokenData.access_token === 'string' ? tokenData.access_token : '';\n if (!accessToken) {\n getLogger().warn('oauth: no access_token in token response', { provider: provider.name });\n return null;\n }\n return accessToken;\n};\n\ninterface OAuthProfile {\n email: string | undefined;\n name: string;\n avatar: string;\n /**\n * `true` only when the provider POSITIVELY reported the email as verified\n * (via `emailVerifiedKey`) or supplied it through a `getEmail` accessor that\n * itself filters on a verified flag (GitHub). `false` when the provider\n * exposes NO verified-email signal at all — used to fail-closed on\n * cross-provider account LINKING under the `'unified'` strategy (SEC-21/SEC-40),\n * where linking an unverified address to a victim's existing account is an\n * account-takeover vector. First-time account CREATION is unaffected.\n */\n emailVerified: boolean;\n /**\n * The RAW, un-stripped provider userInfo record. Threaded through so the\n * `extraSessionFields` hook receives every provider-specific claim (`sub`,\n * tenant id, custom claims) instead of the trimmed `{email,name,avatar}`\n * projection — the documented contract (SEC-3 / oauthProviders.ts hook docs).\n */\n rawUserData: OAuthUserData;\n}\n\nconst fetchOAuthProfile = async (\n provider: FullOAuthProvider,\n accessToken: string,\n): Promise<OAuthProfile | null> => {\n const getUserData = async (): Promise<unknown> => {\n const response = await fetch(provider.userInfoURL, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${accessToken}`,\n },\n });\n if (!response.ok) {\n getLogger().warn('oauth: userInfo fetch returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getUserData);\n if (error) {\n getLogger().error('oauth: userInfo fetch failed', error);\n return null;\n }\n\n const userData = asRecord(response);\n //? Provider responses are untyped JSON; read each field through `readString`\n //? so a non-string (object/number/missing) value falls back instead of\n //? stringifying to `[object Object]`.\n //? A present-but-empty name string falls back too (mirrors the old\n //? `String(value || 'didnt find a name')` truthy-coercion).\n const resolvedName = readStringField(userData, provider.nameKey);\n const name = resolvedName === '' ? 'didnt find a name' : resolvedName;\n const emailValue = userData[provider.emailKey];\n let email: string | undefined = typeof emailValue === 'string' ? emailValue : undefined;\n\n //? Track whether the provider POSITIVELY confirmed this email as verified.\n //? `emailVerifiedKey === true` (or any non-false value when the key is set and\n //? present) counts as verified; a provider with NO `emailVerifiedKey` exposes\n //? no signal, so the email stays `emailVerified: false` and only first-time\n //? account creation (not cross-provider linking) is allowed downstream.\n let emailVerified = false;\n\n //? Reject a provider-supplied email that the provider explicitly marks\n //? unverified (e.g. Discord's `verified: false`). A missing flag is treated\n //? as \"no signal\" (not verified, but not rejected outright — see the\n //? cross-provider link guard in `findOrCreateOAuthUser`). Defense-in-depth\n //? against account-linking takeover under `'unified'` (SEC-21).\n if (email && provider.emailVerifiedKey) {\n const verifiedFlag = userData[provider.emailVerifiedKey];\n if (verifiedFlag === false) {\n getLogger().warn('oauth: provider email is not verified — rejecting', { provider: provider.name });\n return null;\n }\n //? Key present-and-not-false ⇒ provider positively confirmed verification.\n if (verifiedFlag !== undefined) emailVerified = true;\n }\n\n const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : undefined;\n const avatarValue = provider.avatarKey\n ? readStringField(userData, provider.avatarKey)\n : (provider.getAvatar\n ? await provider.getAvatar({ userData, avatarId: typeof avatarId === 'string' ? avatarId : '', accessToken })\n : '');\n const avatar = typeof avatarValue === 'string' ? avatarValue : '';\n\n //? Some providers (GitHub) don't return email in /userinfo — fall back to the\n //? provider's `getEmail` accessor. GitHub's accessor only ever selects a\n //? `verified === true` address (see `githubProvider.getEmail`), so an email\n //? obtained this way is treated as positively verified for the cross-provider\n //? link guard (SEC-21).\n if (!email && provider.getEmail) {\n const selectedEmail = await provider.getEmail(accessToken);\n if (!selectedEmail) {\n getLogger().warn('oauth: no email found via provider.getEmail', { provider: provider.name });\n return null;\n }\n email = selectedEmail;\n emailVerified = true;\n }\n\n //? Normalize OAuth emails through the SAME gate as credentials (trim +\n //? lowercase + `isEmail`) before they reach the adapter. Credentials emails\n //? are normalized at `normalizeCredentials`; taking the provider value\n //? verbatim meant `Victim@x.com` (OAuth) and `victim@x.com` (credentials)\n //? resolved to different rows — which under the `'unified'` account strategy\n //? (case-sensitive lookup) silently DEFEATS cross-provider linking. A\n //? present-but-malformed provider email is treated as \"no usable email\".\n if (email !== undefined) {\n const normalizedEmail = email.trim().toLowerCase();\n if (!isEmail(normalizedEmail)) {\n getLogger().warn('oauth: provider returned a malformed email', { provider: provider.name });\n return null;\n }\n email = normalizedEmail;\n }\n\n return { email, name, avatar, emailVerified, rawUserData: userData };\n};\n\ninterface ResolvedOAuthUser {\n user: SessionLayout;\n isNewUser: boolean;\n}\n\nconst findOrCreateOAuthUser = async (\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n): Promise<ResolvedOAuthUser | null> => {\n const { email, name, avatar, emailVerified } = profile;\n if (!email) return null;\n\n const preLoginResult = await dispatchHook('preLogin', { email, provider: provider.name });\n if (preLoginResult.stopped) {\n getLogger().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });\n return null;\n }\n\n const userAdapter = getUserAdapter();\n //? Under `'unified'` this links to an existing account with the same email\n //? created via ANY provider (incl. credentials); under `'per-provider'` it\n //? stays scoped to this provider (existing behavior).\n const [findError, findResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: provider.name })\n );\n if (findError) {\n getLogger().error('oauth: findByEmail failed', findError);\n return null;\n }\n\n if (findResponse?.id) {\n //? Fail-closed cross-provider account-LINK guard (SEC-21/SEC-40). Under the\n //? `'unified'` strategy `resolveUserByEmail` matches by email across every\n //? provider, so an OAuth sign-in can LINK into an account originally created\n //? by a DIFFERENT provider (incl. credentials). If the current provider did\n //? NOT positively verify the email (no `emailVerifiedKey` / `getEmail`\n //? verified-filter), an attacker who controls an unverified address equal to\n //? a victim's could take over that account. Refuse the link in that case —\n //? same-provider re-login and first-time account CREATION are unaffected, so\n //? existing flows for built-ins that DO verify (Google/GitHub/Discord) keep\n //? working. `'per-provider'` always matches within the same provider, so the\n //? cross-provider condition is never met there (no behaviour change).\n const existingProvider = findResponse.provider ?? null;\n const isCrossProviderLink = existingProvider !== null && existingProvider !== provider.name;\n if (isCrossProviderLink && !emailVerified) {\n getLogger().warn(\n 'oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)',\n { provider: provider.name, existingProvider },\n );\n return null;\n }\n\n //? LOGIN-F18 (OAuth path): same async stat lookup via the shared helper.\n const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);\n if (uploadedAvatar) {\n findResponse.avatar = uploadedAvatar;\n }\n\n const previousLogin = findResponse.lastLogin ?? null;\n const nowLogin = new Date();\n await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));\n\n return {\n user: {\n ...sanitizeUserForSession(findResponse),\n token: '',\n lastLogin: nowLogin,\n previousLogin,\n },\n isNewUser: false,\n };\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: provider.name, name });\n if (preRegisterResult.stopped) {\n getLogger().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });\n return null;\n }\n\n const createNewUser = async () => userAdapter.create({\n email,\n provider: provider.name,\n name,\n avatar,\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n const [createError, createResponse] = await tryCatch(createNewUser);\n if (createError) {\n getLogger().error('oauth: user create failed', createError);\n return null;\n }\n if (!createResponse) return null;\n\n return {\n user: { ...sanitizeUserForSession(createResponse), token: '' },\n isNewUser: true,\n };\n};\n\nconst resolvePostLoginRedirect = async ({\n fallbackUrl,\n userId,\n providerName,\n isNewUser,\n}: {\n fallbackUrl: string;\n userId: string;\n providerName: string;\n isNewUser: boolean;\n}): Promise<string> => {\n const resolver = getPostLoginRedirect();\n if (!resolver) return fallbackUrl;\n\n const [resolverError, resolved] = await tryCatch(() => resolver({\n userId,\n provider: providerName,\n isNewUser,\n defaultUrl: fallbackUrl,\n }));\n if (resolverError) {\n getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });\n return fallbackUrl;\n }\n if (resolved && isAllowedRedirectUrl(resolved)) return resolved;\n if (resolved) {\n getLogger().warn(\n `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,\n { resolved, fallbackUrl },\n );\n }\n return fallbackUrl;\n};\n\n//? Whitelist the URL path segment against registered providers and verify the\n//? request carries a URL. Returns the matched full provider + its canonical name,\n//? or null when the segment is absent, unregistered, or an incomplete provider\n//? shape. Separating this keeps the callback routing concern out of the main flow.\nconst resolveProviderFromCallback = (\n pathname: string,\n url: string | undefined,\n): { provider: FullOAuthProvider; providerName: string } | null => {\n const rawProviderSegment = pathname.split('/')[3]; // google/github/etc.\n if (!rawProviderSegment || !url) return null;\n const found = getOAuthProviders().find(p => p.name === rawProviderSegment);\n if (!found || !isFullOAuthProvider(found)) return null;\n return { provider: found, providerName: found.name };\n};\n\n//? Merge per-provider runtime extras (calendar tokens, tenant ids, etc.) into\n//? the session object IN PLACE before it is persisted. Errors are non-fatal —\n//? a failing or absent hook must not block the user from signing in (SEC-10).\nconst applyExtraSessionFields = async (\n user: SessionLayout,\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n accessToken: string,\n): Promise<void> => {\n const { extraSessionFields } = provider;\n if (!extraSessionFields) return;\n\n //? Pass the RAW provider userData (SEC-3) so the hook sees every provider\n //? claim (`sub`, tenant id, custom claims) rather than the trimmed projection.\n const [extraErr, extra] = await tryCatch(async () =>\n extraSessionFields({ userData: profile.rawUserData, accessToken }),\n );\n if (extraErr) {\n getLogger().warn(`[oauth:${provider.name}] extraSessionFields hook threw — continuing without extras`, { err: extraErr });\n return;\n }\n if (!extra) return;\n\n //? Strip framework-owned session keys before merging (SEC-10).\n const { id, token, csrfToken, password, ...safeExtra } = extra;\n if (id !== undefined || token !== undefined || csrfToken !== undefined || password !== undefined) {\n getLogger().warn(\n `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) — dropped`,\n );\n }\n Object.assign(user, safeExtra);\n};\n\n//? Assemble the fallback redirect URL for the OAuth callback by preferring the\n//? tamper-proof return URL stored server-side with the OAuth state, then the\n//? caller-supplied default, then the configured `loginRedirectUrl`, then '/'.\nconst resolveOAuthFallbackUrl = (\n stateEntry: OAuthStateEntry,\n options: { defaultRedirectUrl?: string },\n): string => {\n const stateReturnUrl =\n stateEntry.returnUrl && isAllowedRedirectUrl(stateEntry.returnUrl)\n ? stateEntry.returnUrl\n : undefined;\n\n return (\n stateReturnUrl\n ?? options.defaultRedirectUrl\n ?? getProjectConfig().loginRedirectUrl\n ?? '/'\n );\n};\n\n// Route that handles the callback from the OAuth provider\nconst loginCallback = async (\n pathname: string,\n req: IncomingMessage,\n _res: ServerResponse,\n options: { defaultRedirectUrl?: string; supersedeToken?: string } = {},\n): Promise<OAuthCallbackResult | false> => {\n const providerResolution = resolveProviderFromCallback(pathname, req.url);\n if (!providerResolution) return false;\n const { provider, providerName } = providerResolution;\n\n // req.url is guaranteed non-null here: resolveProviderFromCallback returns null when url is absent\n const queryString = (req.url ?? '').split('?')[1] ?? '';\n const params = new URLSearchParams(queryString);\n const code = params.get('code');\n const state = params.get('state');\n\n //? Browser-binding (F1): the nonce the server set as the `ls-oauth-state`\n //? cookie at the authorize step must round-trip back and match the value\n //? stored alongside the Redis state entry. A missing/mismatched cookie means\n //? this browser did NOT start the flow — reject before any token exchange.\n const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? '';\n const stateEntry = await consumeOAuthState(provider.name, state ?? '', cookieNonce);\n if (!stateEntry) {\n getLogger().warn('oauth: invalid, missing, or unbound state', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.invalidState', stage: 'oauth' });\n return false;\n }\n\n if (!code) {\n getLogger().warn('oauth: no code provided in callback url', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.noCode', stage: 'oauth' });\n return false;\n }\n\n //? PKCE (F11): replay the verifier stored with the state entry at token\n //? exchange when the provider opted in. `undefined` for non-PKCE providers,\n //? in which case the exchange is byte-identical to before.\n const accessToken = await exchangeOAuthToken(provider, code, stateEntry.codeVerifier);\n if (!accessToken) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.tokenExchangeFailed', stage: 'oauth' });\n return false;\n }\n\n const profile = await fetchOAuthProfile(provider, accessToken);\n if (!profile) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.profileFetchFailed', stage: 'oauth' });\n return false;\n }\n\n const resolved = await findOrCreateOAuthUser(provider, profile);\n if (!resolved) {\n emitLoginFailed({ provider: providerName, email: profile.email, reason: 'oauth.userResolveFailed', stage: 'oauth' });\n return false;\n }\n\n const newToken = randomBytes(32).toString('hex');\n resolved.user.token = newToken;\n\n //? Per-provider runtime extras (calendar tokens, tenant ids, etc.) are merged\n //? into the session BEFORE save. Errors are non-fatal — see applyExtraSessionFields.\n await applyExtraSessionFields(resolved.user, provider, profile, accessToken);\n\n const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted — abort the OAuth callback so authCallbackRoute\n //? returns \"Login failed\" instead of redirecting with a dead token.\n getLogger().error(`[oauth:${providerName}] saveSession failed — aborting callback`, { errorCode: saved.errorCode });\n emitLoginFailed({ provider: providerName, userId: resolved.user.id, reason: saved.errorCode, stage: 'oauth' });\n return false;\n }\n\n if (resolved.isNewUser) {\n await dispatchHook('postRegister', { userId: resolved.user.id, provider: providerName });\n }\n await dispatchHook('postLogin', {\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n token: newToken,\n });\n\n if (isDevMode()) {\n getLogger().debug(`oauth login success for user ${resolved.user.id}`);\n }\n\n const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);\n const redirectUrl = await resolvePostLoginRedirect({\n fallbackUrl,\n userId: resolved.user.id,\n providerName,\n isNewUser: resolved.isNewUser,\n });\n\n return {\n token: newToken,\n redirectUrl,\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n };\n};\n\nexport { loginWithCredentials, loginCallback, registerWithCredentials, loginWithCredentialsCore }\n","//? Implements `auth.providerAccountStrategy` (CFG-04). The knob was declared\n//? and documented but no code read it — every lookup was provider-scoped, so\n//? `'unified'` silently behaved like `'per-provider'` and produced duplicate\n//? accounts for the same email across providers.\n//?\n//? - `'per-provider'` (default): resolve a user by (email, provider). The\n//? same address via Google and GitHub is two separate User rows. No schema\n//? change required.\n//? - `'unified'`: resolve a user by email alone (across providers) so the\n//? address maps to a single User; a sign-in via a new provider LINKS to the\n//? existing account instead of creating a duplicate. Requires the User\n//? schema's `email` to be unique (see @luckystack/login README — \"Unified\n//? account strategy\").\n//?\n//? Custom `UserAdapter`s that predate this feature may not implement\n//? `findByEmailAnyProvider`. When `'unified'` is configured but the adapter\n//? lacks it, we log ONCE and fall back to provider-scoped lookup so the project\n//? degrades loudly rather than silently mis-resolving.\n\nimport { getProjectConfig, getLogger } from '@luckystack/core';\n\nimport type { UserAdapter, UserRecord } from './userAdapter';\n\nlet warnedMissingAnyProvider = false;\n\n/**\n * Resolve a user account honoring the configured `providerAccountStrategy`.\n * Use this everywhere the login flow needs \"does an account for this email\n * exist?\" — register dedupe, credentials login, and OAuth find-or-create.\n */\nexport const resolveUserByEmail = async (\n adapter: UserAdapter,\n { email, provider }: { email: string; provider: string },\n): Promise<UserRecord | null> => {\n const strategy = getProjectConfig().auth.providerAccountStrategy;\n\n if (strategy === 'unified') {\n if (adapter.findByEmailAnyProvider) {\n return adapter.findByEmailAnyProvider({ email });\n }\n if (!warnedMissingAnyProvider) {\n warnedMissingAnyProvider = true;\n getLogger().warn(\n '[login] auth.providerAccountStrategy is \"unified\" but the registered UserAdapter does not implement findByEmailAnyProvider — falling back to provider-scoped lookup (duplicate accounts per provider are still possible). Implement findByEmailAnyProvider on your adapter to enable unified accounts.',\n );\n }\n }\n\n return adapter.findByEmail({ email, provider });\n};\n\n/** Test-only: reset the one-time missing-method warning latch. */\nexport const resetAccountStrategyWarningForTests = (): void => {\n warnedMissingAnyProvider = false;\n};\n","//? Password-policy enforcement for credentials auth. Read by\n//? `registerWithCredentials` and `updatePasswordHash` to validate plaintext\n//? passwords against the consumer-tunable `projectConfig.auth.passwordPolicy`.\n//?\n//? Returns null when the password passes every check, or a reason-key\n//? (i18n-style errorCode) describing the first failure. Callers surface\n//? the reason to the client; the framework's i18n layer handles\n//? translation.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { COMMON_PASSWORDS } from './data/commonPasswords';\n\nconst UPPERCASE_REGEX = /[A-Z]/;\nconst LOWERCASE_REGEX = /[a-z]/;\nconst NUMBER_REGEX = /\\d/;\nconst SPECIAL_REGEX = /[^A-Za-z0-9]/;\n\n//? bcrypt silently truncates at 72 utf8-encoded bytes. Anything over 72 bytes\n//? hashes identically to its 72-byte prefix, so a longer password gives no\n//? extra security and actively misleads the user. Cap at 72 bytes so the\n//? stored hash always reflects the full secret the user typed.\nconst BCRYPT_MAX_BYTES = 72;\n\n/**\n * Validate `password` against the active project's password policy.\n * Returns null on success, a string reason-key on the first failure.\n *\n * The policy lives in `projectConfig.auth.passwordPolicy` and is read at\n * call time, so consumers can override defaults via `registerProjectConfig`.\n */\nexport const validatePassword = (password: string): string | null => {\n const policy = getProjectConfig().auth.passwordPolicy;\n\n if (password.length < policy.minLength) return 'login.passwordCharacterMinimum';\n //? Byte-length cap takes precedence over the policy maxLength when the policy\n //? exceeds BCRYPT_MAX_BYTES — reject before bcrypt silently truncates.\n const byteLength = Buffer.byteLength(password, 'utf8');\n if (byteLength > BCRYPT_MAX_BYTES) return 'login.passwordCharacterLimit';\n if (password.length > policy.maxLength) return 'login.passwordCharacterLimit';\n if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return 'login.passwordRequiresUppercase';\n if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return 'login.passwordRequiresLowercase';\n if (policy.requireNumber && !NUMBER_REGEX.test(password)) return 'login.passwordRequiresNumber';\n if (policy.requireSpecial && !SPECIAL_REGEX.test(password)) return 'login.passwordRequiresSpecial';\n if (policy.forbidCommon && COMMON_PASSWORDS.has(password.toLowerCase())) return 'login.passwordTooCommon';\n if (policy.customValidator) {\n const customReason = policy.customValidator(password);\n if (customReason) return customReason;\n }\n\n return null;\n};\n","//? Built-in common-password blocklist. Sourced from the public-domain\n//? SecLists Top-passwords list (Daniel Miessler), trimmed to the 200 most\n//? common entries to keep the package size small. Consumers needing a\n//? larger list (10k+ entries) can supply one through\n//? `projectConfig.auth.passwordPolicy.customValidator` and check their\n//? own data source there.\n//?\n//? Lookups are case-insensitive (the validator lower-cases before\n//? checking). Adding additional entries here is a non-breaking change.\n\nconst COMMON_PASSWORD_LIST: readonly string[] = [\n '123456', 'password', '12345678', 'qwerty', '123456789', '12345', '1234', '111111',\n '1234567', 'dragon', '123123', 'baseball', 'abc123', 'football', 'monkey', 'letmein',\n '696969', 'shadow', 'master', '666666', 'qwertyuiop', '123321', 'mustang', '1234567890',\n 'michael', '654321', 'pussy', 'superman', '1qaz2wsx', '7777777', 'fuckyou', '121212',\n '000000', 'qazwsx', '123qwe', 'killer', 'trustno1', 'jordan', 'jennifer', 'zxcvbnm',\n 'asdfgh', 'hunter', 'buster', 'soccer', 'harley', 'batman', 'andrew', 'tigger',\n 'sunshine', 'iloveyou', 'fuckme', '2000', 'charlie', 'robert', 'thomas', 'hockey',\n 'ranger', 'daniel', 'starwars', 'klaster', '112233', 'george', 'asshole', 'computer',\n 'michelle', 'jessica', 'pepper', '1111', 'zxcvbn', '555555', '11111111', '131313',\n 'freedom', '777777', 'pass', 'fuck', 'maggie', '159753', 'aaaaaa', 'ginger',\n 'princess', 'joshua', 'cheese', 'amanda', 'summer', 'love', 'ashley', '6969',\n 'nicole', 'chelsea', 'biteme', 'matthew', 'access', 'yankees', '987654321', 'dallas',\n 'austin', 'thunder', 'taylor', 'matrix', 'william', 'corvette', 'hello', 'martin',\n 'heather', 'secret', 'fucker', 'merlin', 'diamond', '1234qwer', 'gfhjkm', 'hammer',\n 'silver', 'richard', 'samantha', 'iceman', 'tigers', 'purple', 'andrea', 'horney',\n 'dakota', 'aaaaa', 'player', 'sucker', 'mercedes', 'whatever', 'orange', 'qweqwe',\n 'mickey', 'panties', 'hannah', 'fucking', 'paul', 'pakistan', 'patrick', 'martha',\n 'morgan', 'iwantu', 'lakers', 'rachel', 'slayer', 'scott', '2112', 'fish',\n 'porn', 'matt', 'qwert', 'cookie', 'eagle1', 'london', 'phoenix', 'chicago',\n 'badboy', 'braves', 'yankee', 'lover', 'barney', 'edward', 'raiders', 'green',\n 'maverick', 'rangers', 'joseph', 'maddog', 'mike', 'jasmine', 'arsenal', 'beach',\n 'tiger', 'snoopy', 'natasha', 'matthews', 'jackson', 'cumshot', 'chevy', 'gateway',\n 'gators', 'angel', 'junior', 'samson', '6666', 'amateur', 'gemini', 'apples',\n 'august', 'biscuit', '5150', 'apollo', 'parker', 'qwerty123', 'rabbit', 'angela',\n 'love123', 'rocket', 'scooby', 'beaver', 'oliver', 'walter', 'blowme', 'spider',\n '4321', 'cumshot1', 'lucky', 'helpme', 'jackie', 'monica',\n];\n\n/** Frozen Set for O(1) `.has()` lookups; entries are pre-lowercased. */\nexport const COMMON_PASSWORDS: ReadonlySet<string> = new Set(\n COMMON_PASSWORD_LIST.map((p) => p.toLowerCase()),\n);\n","//? Password-reset primitives. Used by the framework's own forgot-password\n//? flow when `ProjectConfig.auth.forgotPassword === 'framework'`, AND\n//? exported as building blocks for consumers who picked `'custom'` and want\n//? to wire their own UI / email layer without re-implementing crypto + Redis.\n//?\n//? Token format: 32 random bytes hex-encoded → 64-char URL-safe string.\n//? Storage: `<projectName>-pwreset:<sha256(token)> → userId` with a configurable\n//? TTL (`auth.passwordResetTtlSeconds`, default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): the raw token is NEVER stored — we persist only\n//? `sha256(token)` (the Redis key) via the `@luckystack/core` one-time-token\n//? primitive, so a leaked Redis keyspace can't be replayed to mint a reset.\n//? The raw token is returned to the caller exactly once for the emailed URL.\n\n// eslint-disable-next-line import-x/no-named-as-default -- bcryptjs ships both a default and namespace export; using the default mirrors its README\nimport bcrypt from 'bcryptjs';\n/* eslint-disable import-x/no-named-as-default-member -- accessing genSalt/hash/compare via the default import is the documented pattern */\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeToken, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nimport { validatePassword } from './passwordPolicy';\nimport { getUserAdapter } from './userAdapter';\n\n//? Shared key namespace for the one-time-token primitive. `formatKey('-pwreset',\n//? sha256(token))` is applied inside the primitive, so the project-name prefix /\n//? multi-tenant formatter still apply — only the SUFFIX is now the hash, not the\n//? raw token (the historical `<projectName>-pwreset:<token>` shape becomes\n//? `<projectName>-pwreset:<sha256(token)>`).\nconst PWRESET_NAMESPACE = '-pwreset';\n\n//? Per-user pointer key that records the hash-key of the currently-active reset\n//? token (LOGIN-F16). On each new issue we delete the prior hash key (if any) so\n//? at most one reset link per user is redeemable at a time — multiple live links\n//? until TTL is a session-hijack / takeover risk if the first link is intercepted.\nconst pwresetUserKey = (userId: string): string => formatKey('-pwreset-user', userId);\n\n/**\n * Create a one-time password-reset token bound to a user id. Any previously\n * issued (but not yet consumed) token for the same user is invalidated first\n * (LOGIN-F16). Stored in Redis with the configured TTL — only `sha256(token)`\n * is persisted (hash-at-rest). Returns the raw token string — caller emails it\n * to the user (typically embedded in a URL).\n */\nexport const createPasswordResetToken = async (userId: string): Promise<string> => {\n const ttl = getProjectConfig().auth.passwordResetTtlSeconds;\n\n //? Invalidate the prior active token before issuing the new one (LOGIN-F16).\n //? The pointer key holds the exact Redis key of the prior token hash, so we\n //? can delete it without possessing the raw token.\n const priorKey = await redis.get(pwresetUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(PWRESET_NAMESPACE, ttl, userId);\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n //? TTL matches the token so the pointer never outlives what it points to.\n //? `oneTimeTokenKey(namespace, rawToken)` derives the same Redis key the\n //? primitive stored — we never persist the raw token itself.\n const newHashKey = oneTimeTokenKey(PWRESET_NAMESPACE, handle.token);\n await redis.set(pwresetUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume a password-reset token. Returns the bound userId on\n * success and removes the token (one-time use, atomic GET+DEL). Returns null\n * when the token is missing, expired, malformed, or already consumed.\n */\nexport const consumePasswordResetToken = async (token: string): Promise<string | null> => {\n return consumeOneTimeToken(PWRESET_NAMESPACE, token);\n};\n\n/**\n * Bcrypt-hash a plaintext password and write it to the user record via the\n * registered user adapter. Used by the reset flow and by the settings\n * password-change flow. Validates against the active password policy\n * (`projectConfig.auth.passwordPolicy`) before hashing — throws a `PasswordPolicyError`\n * when the plaintext fails policy. Catch and surface the error code to the\n * client; framework-mode reset/change flows already do this.\n *\n * ADR — DD-LOGIN-F17: this primitive intentionally does NOT revoke sessions.\n * Session revocation is the responsibility of each calling route because the\n * right behaviour differs per use-case:\n * - `reset-password/confirmReset_v1` revokes ALL sessions (`revokeUserSessions(userId, null)`)\n * because a forgot-password reset means the old credential is compromised.\n * - `settings/changePassword_v1` revokes every OTHER session but keeps the\n * current one active (`revokeUserSessions(userId, user.token)`) so the\n * user is not kicked from the device they changed the password on.\n * - Custom consumers may want a third behaviour (e.g. re-use the existing\n * token after an admin password-force) — keeping the primitive pure lets\n * them compose it freely.\n * If you call this function directly, YOU must call `revokeUserSessions`\n * afterwards unless you have a deliberate reason not to.\n */\nexport const updatePasswordHash = async (userId: string, plaintext: string): Promise<void> => {\n const reason = validatePassword(plaintext);\n if (reason) {\n throw new PasswordPolicyError(reason);\n }\n const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await bcrypt.hash(plaintext, salt);\n const patch: Record<string, unknown> = { password: hashedPassword };\n await getUserAdapter().update(userId, patch);\n};\n\n/**\n * Thrown by `updatePasswordHash` when the supplied plaintext violates the\n * active password policy. `errorCode` matches the i18n reason key the rest\n * of the login flow uses (e.g. `login.passwordRequiresUppercase`).\n */\nexport class PasswordPolicyError extends Error {\n readonly errorCode: string;\n constructor(errorCode: string) {\n super(`Password policy violation: ${errorCode}`);\n this.name = 'PasswordPolicyError';\n this.errorCode = errorCode;\n }\n}\n\n/** Verify a plaintext password against a stored bcrypt hash. */\nexport const verifyPassword = async (plaintext: string, hash: string): Promise<boolean> => {\n return bcrypt.compare(plaintext, hash);\n};\n","//? Framework-mode forgot-password orchestration. Used only when\n//? `ProjectConfig.auth.forgotPassword === 'framework'`. Pulls in\n//? `@luckystack/email` lazily so the login package doesn't list it as a\n//? hard dep — `'custom'` and `'disabled'` modes never reach this code.\n\nimport { getProjectConfig, dispatchHook, getLogger } from '@luckystack/core';\n\nimport { loadEmailModule } from './emailModuleLoader';\nimport { createPasswordResetToken } from './passwordReset';\nimport { getUserAdapter } from './userAdapter';\n\ninterface SendResetEmailArgs {\n email: string;\n /**\n * App-facing display name used in the email greeting + subject. If omitted,\n * falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`\n * as the absolute fallback. Real consumers should set this on either the\n * call site OR via `projectConfig.auth.passwordResetBrand`.\n */\n brand?: string;\n}\n\n/**\n * Look up the user by credentials-provider email, generate a reset token,\n * and email them a reset link. Always resolves successfully regardless of\n * whether the email matched a user (anti-enumeration). Returns the result\n * of the email send for diagnostics.\n */\nexport const sendPasswordResetEmail = async ({ email, brand }: SendResetEmailArgs): Promise<{ ok: boolean; reason?: string }> => {\n const config = getProjectConfig();\n //? Log mode but NOT the raw email at start — the email is a PII field.\n getLogger().info('[forgotPassword] start', { forgotPasswordMode: config.auth.forgotPassword });\n if (config.auth.forgotPassword !== 'framework') {\n getLogger().warn('[forgotPassword] auth.forgotPassword is not \"framework\" — nothing sent', { mode: config.auth.forgotPassword });\n return { ok: false, reason: 'forgotPassword-not-framework' };\n }\n\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — the email package is an optional peer dep so its types\n // may not be resolvable at the framework's compile time.\n //? Mirror testEmail's robustness: catch a failed dynamic import instead of\n //? throwing (an uncaught throw here would bubble out of the anti-enumeration\n //? wrapper as a generic 500). Surfaces the real reason in the server log.\n const emailModule = await loadEmailModule().catch((error: unknown) => {\n getLogger().warn('[forgotPassword] failed to load @luckystack/email — is it installed?', { error: String(error) });\n return null;\n });\n if (!emailModule) {\n return { ok: false, reason: 'email-module-load-failed' };\n }\n const { sendEmail } = emailModule;\n\n const userAdapter = getUserAdapter();\n const user = await userAdapter.findByEmail({ email, provider: 'credentials' });\n //? Log found/userId without the raw email — logging email+found together is\n //? an enumeration oracle (any reader of the log can probe the user table).\n getLogger().info('[forgotPassword] credentials user lookup', { found: Boolean(user), userId: user?.id ?? null });\n\n // Anti-enumeration: always pretend it succeeded if the email isn't found.\n if (!user) {\n //? The client always gets \"success\" (anti-enumeration). This server-side\n //? line is the ONLY signal that the address simply isn't a credentials\n //? (email+password) account — by far the most common reason a reset email\n //? never arrives (OAuth-only signups and unknown/typo'd addresses have no\n //? credentials row, so there is nothing to reset).\n //? Do NOT include the email here — this line together with the email would\n //? let a log reader determine which addresses have credentials accounts.\n getLogger().warn('[forgotPassword] no credentials account for this email — nothing sent. Use the email+password account you registered with.');\n void dispatchHook('passwordResetRequested', {\n email,\n matched: false,\n });\n return { ok: true };\n }\n\n const token = await createPasswordResetToken(user.id);\n void dispatchHook('passwordResetRequested', {\n email,\n matched: true,\n userId: user.id,\n token,\n ttlSeconds: config.auth.passwordResetTtlSeconds,\n });\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: the reset token is in the URL query string\n //? (`?token=<hex>`), not in the URL fragment (`#token=<hex>`).\n //? Tradeoff: query strings appear in server access logs and Referer headers\n //? if the user's mail client renders the link via a tracking proxy. Fragment\n //? identifiers (`#`) are never sent to the server and are therefore\n //? invisible to any log / proxy in the chain — they are more private.\n //? Decision: query-string is the standard email-link pattern understood by\n //? every SPA router, and tokens here are one-time + hashed at rest (Redis\n //? holds only `sha256(token)`), so the exposure window is the TTL of a\n //? single-use credential, not a replayable secret. Logging is mitigated\n //? by not logging the full URL at the point of issue (we log `userId`, not\n //? `resetUrl`). A future `auth.tokenLinkMode: 'fragment'` option can be\n //? introduced without touching this file — callers receive the raw token\n //? and can build their own URL.\n const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;\n\n const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);\n\n //? Dispatch via the `'password-reset'` template rather than building the\n //? email inline. `@luckystack/email` ships a built-in for this name, so the\n //? out-of-the-box copy is unchanged — but a consumer can now\n //? `registerEmailTemplate('password-reset', …)` to translate or rebrand it\n //? without forking this flow (CFG-05). `adapterHint: 'transactional'` routes\n //? through a dedicated transactional sender when one is registered, else the\n //? default sender.\n const result = await sendEmail({\n to: user.email,\n template: 'password-reset',\n data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n if (result.ok) {\n getLogger().info('[forgotPassword] reset email dispatched', { userId: user.id, to: user.email });\n } else {\n getLogger().warn('[forgotPassword] reset email send FAILED', { userId: user.id, reason: result.reason });\n }\n\n return result.ok ? { ok: true } : { ok: false, reason: result.reason };\n};\n","//? Shared lazy-loader for the optional `@luckystack/email` peer dep. Both\n//? `forgotPassword.ts` and `emailChangeNotification.ts` need the SAME dynamic\n//? import + the SAME minimal `EmailModule` shape, so the interface + the\n//? `@ts-expect-error` import + the cast live here once (finding 37).\n//?\n//? IMPORTANT — this helper does NOT swallow a failed import. It resolves to the\n//? module or REJECTS exactly like the raw `import(...)` would. That preserves\n//? the two callers' DIFFERENT failure behavior verbatim:\n//? - `emailChangeNotification` awaits it directly (a load failure throws).\n//? - `forgotPassword` wraps it in `.catch(...)` (a load failure → null + warn).\n//? Centralizing the catch here would change one of those — do NOT.\n\n/** Minimal surface of `@luckystack/email` that the login orchestrators use. */\nexport interface EmailModule {\n sendEmail: (input: Record<string, unknown>) => Promise<{ ok: boolean; reason?: string }>;\n}\n\n/**\n * Dynamically import the optional `@luckystack/email` peer. Resolves to the\n * module, or REJECTS if the package is not installed (same as a bare\n * `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.\n */\nexport const loadEmailModule = (): Promise<EmailModule> =>\n // @ts-expect-error optional peer dep — installed only when the consumer wires email sending\n import('@luckystack/email') as Promise<EmailModule>;\n","//? Email-change token primitives. Mirrors `passwordReset.ts` for the\n//? confirm-via-email-link flow: user submits a new address, framework mails\n//? a tokenized URL to the NEW address, click-through confirms and updates\n//? `User.email` + revokes all sessions.\n//?\n//? Token format: 32 random bytes hex-encoded -> 64-char URL-safe string.\n//? Storage: `<projectName>-email-change:<sha256(token)>` -> JSON payload\n//? `{ userId: string; newEmail: string }`\n//? TTL: `auth.emailChangeTtlSeconds` (default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): only `sha256(token)` is persisted (the Redis key)\n//? via the `@luckystack/core` one-time-token primitive, so a leaked Redis\n//? keyspace can't be replayed to confirm an email change. The raw token is\n//? returned to the caller exactly once for the emailed URL.\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeTokenJson, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nconst EMAIL_CHANGE_NAMESPACE = '-email-change';\n\n//? Per-user pointer key for prior-token invalidation (LOGIN-F16), mirroring the\n//? same pattern used for password-reset tokens. Stored as a formatted key so the\n//? multi-tenant key formatter applies uniformly.\nconst emailChangeUserKey = (userId: string): string => formatKey('-email-change-user', userId);\n\nexport interface EmailChangePayload {\n userId: string;\n newEmail: string;\n}\n\n/**\n * Create a one-time email-change token bound to a user id + the new email\n * address. Any previously issued (but not yet consumed) token for the same\n * user is invalidated first (LOGIN-F16) so at most one confirmation link per\n * user is redeemable. Stored in Redis with the configured TTL — only\n * `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —\n * caller emails the token (embedded in a URL) to the NEW address so the\n * confirmation click proves ownership of the new mailbox.\n */\nexport const createEmailChangeToken = async (userId: string, newEmail: string): Promise<string> => {\n const ttl = getProjectConfig().auth.emailChangeTtlSeconds;\n\n //? Invalidate the prior active email-change token (LOGIN-F16). Without this,\n //? multiple links could be outstanding concurrently — a link sent to an\n //? attacker-controlled address (via a race or a compromised session) would\n //? remain redeemable even after the user re-requests the flow.\n const priorKey = await redis.get(emailChangeUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(\n EMAIL_CHANGE_NAMESPACE,\n ttl,\n { userId, newEmail } satisfies EmailChangePayload,\n );\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n const newHashKey = oneTimeTokenKey(EMAIL_CHANGE_NAMESPACE, handle.token);\n await redis.set(emailChangeUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume an email-change token. Returns the bound\n * `{ userId, newEmail }` on success and removes the token (one-time use, atomic\n * GET+DEL). Returns null when the token is missing, expired, malformed, or\n * already consumed.\n */\nexport const consumeEmailChangeToken = async (token: string): Promise<EmailChangePayload | null> => {\n const parsed = await consumeOneTimeTokenJson<EmailChangePayload>(EMAIL_CHANGE_NAMESPACE, token);\n if (\n !parsed\n || typeof parsed !== 'object'\n || typeof parsed.userId !== 'string'\n || typeof parsed.newEmail !== 'string'\n ) {\n return null;\n }\n return parsed;\n};\n","//? Framework-mode \"email change confirmation\" orchestration. Renders the\n//? confirmation email + sends it to the NEW address (proves the user can\n//? receive at the new mailbox). Mirrors the lazy-import pattern from\n//? `forgotPassword.ts` so `@luckystack/email` stays an optional peer.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { createEmailChangeToken } from './emailChange';\nimport { loadEmailModule } from './emailModuleLoader';\n\ninterface SendEmailChangeArgs {\n userId: string;\n newEmail: string;\n /** Optional display name used in the email greeting (falls back to \"there\"). */\n userName?: string | null;\n /**\n * App-facing display name used in the email greeting + subject. Falls back\n * to `projectConfig.auth.passwordResetBrand` (the shared brand label), then\n * to `'LuckyStack'` as the absolute fallback.\n */\n brand?: string;\n}\n\n/**\n * Mint a one-shot email-change token and send a confirmation email to the\n * NEW address with the tokenized URL. Returns the send result for diagnostics\n * + the token itself (callers can audit-log it through the\n * `postEmailChangeRequested` hook).\n */\nexport const sendEmailChangeConfirmation = async (\n { userId, newEmail, userName, brand }: SendEmailChangeArgs,\n): Promise<{ ok: boolean; reason?: string; token: string }> => {\n const config = getProjectConfig();\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — `@luckystack/email` is an optional peer dep. A failed load\n //? rejects here (unlike forgotPassword's catch); this caller's behavior is\n //? unchanged — the rejection bubbles out exactly as the inline import did.\n const { sendEmail } = await loadEmailModule();\n\n const token = await createEmailChangeToken(userId, newEmail);\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: same query-string tradeoff as the password-reset\n //? flow (see `forgotPassword.ts`). Tokens are one-time + hashed at rest;\n //? the raw token is never persisted. The confirm URL is only sent to the\n //? NEW address (verifying mailbox ownership), so it never appears in an\n //? access log that an attacker of the OLD address could read.\n const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;\n const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);\n\n //? Dispatch via the built-in `'email-change'` template so consumers can\n //? override the copy with `registerEmailTemplate('email-change', …)` without\n //? forking (CFG-05). Default copy is unchanged.\n const result = await sendEmail({\n to: newEmail,\n template: 'email-change',\n data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };\n};\n"],"mappings":"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","names":["getLogger","getProjectConfig","getProjectConfig","getLogger","getProjectConfig","getProjectConfig","getProjectConfig","getLogger","validationError","response","bcrypt","getProjectConfig","formatKey","formatKey","getProjectConfig","bcrypt","hash","getProjectConfig","dispatchHook","getLogger","getProjectConfig","getLogger","dispatchHook","getProjectConfig","issueOneTimeToken","formatKey","redis","oneTimeTokenKey","getProjectConfig","getProjectConfig"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/authLockout.ts","../src/redirectResolver.ts","../src/login.ts","../src/accountStrategy.ts","../src/passwordPolicy.ts","../src/data/commonPasswords.ts","../src/passwordReset.ts","../src/forgotPassword.ts","../src/emailModuleLoader.ts","../src/emailChange.ts","../src/emailChangeNotification.ts"],"sourcesContent":["// Registers module augmentations on @luckystack/core for auth/session hooks.\nimport './hookPayloads';\n\n//? Decoupling seam (0.2.0): register this package's session implementation into\n//? @luckystack/core so api/sync/presence/server read sessions through core's\n//? null-safe accessors WITHOUT importing @luckystack/login directly. This is\n//? what makes login a genuine OPTIONAL package — when it isn't installed nothing\n//? registers and the accessors return null (unauthenticated). Side-effect at\n//? module load: any app that imports @luckystack/login (its overlay, LoginForm,\n//? config) wires this before the first request is served.\nimport { registerSessionProvider } from '@luckystack/core';\nimport { saveSession as _saveSession, getSession as _getSession, deleteSession as _deleteSession } from './session';\nimport { logout as _logout } from './logout';\nregisterSessionProvider({\n getSession: _getSession,\n saveSession: _saveSession,\n deleteSession: _deleteSession,\n logout: ({ token, socket, userId, skipSessionDelete }) =>\n _logout({ token, socket, userId: userId ?? null, skipSessionDelete }),\n});\n\n//? Subscribe the per-account brute-force lockout to the `loginFailed` hook at\n//? boot (F7). Idempotent + cheap; the recorder no-ops unless\n//? `rateLimiting.auth.enabled` is set, so this is safe to wire unconditionally.\nimport { registerAuthLockoutHook } from './authLockout';\nregisterAuthLockoutHook();\n\nexport type { BaseSessionLayout, SessionLocation, AuthProps } from './sessionLayout';\nexport type {\n PreLoginPayload,\n PostLoginPayload,\n PreRegisterPayload,\n PostRegisterPayload,\n PreLogoutPayload,\n PostLogoutPayload,\n PreSessionCreatePayload,\n PostSessionCreatePayload,\n PreSessionDeletePayload,\n PostSessionDeletePayload,\n PreEmailChangePayload,\n PostEmailChangeRequestedPayload,\n PostEmailChangedPayload,\n LoginFailedPayload,\n PreAccountDeletePayload,\n PostAccountDeletePayload,\n} from './hookPayloads';\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor } from './session';\nexport { registerSessionAdapter, getSessionAdapter, redisSessionAdapter } from './sessionAdapter';\nexport type { SessionAdapter } from './sessionAdapter';\nexport { registerSessionSanitizer, getSessionSanitizer } from './sessionSanitizer';\nexport type { SessionSanitizer } from './sessionSanitizer';\nexport { loginWithCredentials, loginCallback, createOAuthState, registerWithCredentials, loginWithCredentialsCore } from './login';\nexport type {\n CredentialsLoginResult,\n CredentialsLoginSuccess,\n CredentialsLoginFailure,\n OAuthCallbackResult,\n} from './login';\nexport type { CreateOAuthStateResult } from './login';\nexport { OAUTH_STATE_COOKIE_NAME } from './login';\nexport { logout } from './logout';\n\n// Per-account brute-force lockout (F7). The hook is auto-registered at boot;\n// these are exported for consumers who want to query/clear a lock explicitly\n// (e.g. an admin \"unlock account\" action) or build their own surface on top.\nexport { isAccountLocked, recordAuthFailure, clearAuthFailures } from './authLockout';\n\n// Password-reset primitives. Used by the framework's `framework`-mode\n// forgot-password flow AND exported for consumers who picked `'custom'`.\nexport {\n createPasswordResetToken,\n consumePasswordResetToken,\n updatePasswordHash,\n verifyPassword,\n PasswordPolicyError,\n} from './passwordReset';\nexport { validatePassword } from './passwordPolicy';\nexport { sendPasswordResetEmail } from './forgotPassword';\n\n// Email-change primitives. Used by the framework's settings flow to confirm\n// a new email address before applying it (token mailed to NEW address proves\n// ownership of the new mailbox).\nexport {\n createEmailChangeToken,\n consumeEmailChangeToken,\n} from './emailChange';\nexport type { EmailChangePayload } from './emailChange';\nexport { sendEmailChangeConfirmation } from './emailChangeNotification';\n\n// OAuth provider registry + composable helpers.\nexport {\n registerOAuthProviders,\n registerOAuthProviderFactory,\n getOAuthProviders,\n isFullOAuthProvider,\n asOAuthUserData,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n} from './oauthProviders';\nexport type {\n OAuthProvider,\n CredentialsProvider,\n FullOAuthProvider,\n OAuthUserData,\n} from './oauthProviders';\n\n// User adapter registry: lets consumers swap out the Prisma User model behind auth flows.\nexport {\n registerUserAdapter,\n getUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\nexport type {\n UserAdapter,\n UserAdapterCreateInput,\n UserRecord,\n} from './userAdapter';\n\n// Post-login redirect resolver — lets consumers compute the OAuth callback\n// destination dynamically (per-user, per-tenant, per-provider).\nexport {\n registerPostLoginRedirect,\n getPostLoginRedirect,\n} from './redirectResolver';\nexport type {\n PostLoginRedirectResolver,\n PostLoginRedirectInput,\n} from './redirectResolver';\n","//? Per-account brute-force lockout (F7 / MIS-017). The generic per-IP throttle\n//? on `/auth/api/credentials` (in @luckystack/server) does NOT stop distributed\n//? credential stuffing against a SINGLE account from many IPs. This module adds\n//? a per-account failed-attempt counter, keyed independently from the api/ip\n//? limits, that locks an account after `rateLimiting.auth.maxAttempts` failures\n//? within `rateLimiting.auth.windowMs`.\n//?\n//? It is OPT-IN: when `rateLimiting.auth.enabled === false` (the default) every\n//? function here is a no-op and behaviour is byte-identical to before — only\n//? the existing per-IP throttle applies.\n//?\n//? The counter rides on the framework rate limiter (`checkRateLimit`) so it\n//? inherits the configured store (memory/redis) and multi-instance behaviour.\n//? `recordAuthFailure` increments; `isAccountLocked` reads WITHOUT incrementing\n//? (so merely checking the lock can't itself trip it); `clearAuthFailures`\n//? resets the counter on a successful login.\n\nimport {\n getProjectConfig,\n checkRateLimit,\n getRateLimitStatus,\n clearRateLimit,\n getLogger,\n registerHook,\n} from '@luckystack/core';\n\n//? DD-LOGIN-F5: build the lockout bucket key. When `requesterIp` is supplied\n//? the key is an IP+account composite (`auth:<email>:<ip>`) so a remote attacker\n//? from one IP cannot lock out an account for legitimate users on different IPs.\n//? Without an IP the key is the bare account key (`auth:<email>`) — same\n//? behaviour as before. The composite bucket is cleared on success the same way\n//? (same key is used in `clearAuthFailures`). Lower-case + trim the account\n//? identifier so `Alice@x.com` and `alice@x.com` share one lockout bucket\n//? (matches the credentials email normalization).\nconst lockoutKey = (accountKey: string, requesterIp?: string): string => {\n const base = `auth:${accountKey.trim().toLowerCase()}`;\n return requesterIp ? `${base}:${requesterIp}` : base;\n};\n\n/** Read the active auth-lockout config (returns null when the feature is off). */\nconst getAuthLockoutConfig = (): { maxAttempts: number; windowMs: number } | null => {\n const auth = getProjectConfig().rateLimiting.auth;\n if (!auth.enabled) return null;\n return { maxAttempts: auth.maxAttempts, windowMs: auth.windowMs };\n};\n\n/**\n * Whether `accountKey` is currently locked out (failed-attempt counter already\n * at/over the cap). Non-incrementing — safe to call on every login attempt.\n * Always `false` when the feature is disabled.\n * Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const isAccountLocked = async (accountKey: string, requesterIp?: string): Promise<boolean> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return false;\n const { remaining } = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);\n return remaining <= 0;\n};\n\n/**\n * Increment the failed-attempt counter for `accountKey`. Call on every failed\n * credentials login for a known/looked-up account. No-op when disabled.\n * Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const recordAuthFailure = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n const { allowed, remaining } = await checkRateLimit({\n key: lockoutKey(accountKey, requesterIp),\n limit: cfg.maxAttempts,\n windowMs: cfg.windowMs,\n });\n if (!allowed) {\n getLogger().warn('[authLockout] account temporarily locked after repeated failures', {\n remaining,\n maxAttempts: cfg.maxAttempts,\n });\n }\n};\n\n/**\n * Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL\n * login so a user who eventually gets their password right isn't penalised by\n * earlier typos). No-op when disabled.\n * Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const clearAuthFailures = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n await clearRateLimit(lockoutKey(accountKey, requesterIp));\n};\n\n//? ADR 0012 — the lockout counter must reflect GENUINE credential failures only.\n//? This is an ALLOW-LIST, not a deny-list: only a real wrong-password attempt\n//? (`login.wrongPassword`, covering both the no-account/no-hash dummy-compare\n//? path and a failed bcrypt compare) feeds the per-account counter. Everything\n//? else is excluded by construction — `preLogin` veto rejections (whose reason\n//? is a consumer-controlled `errorCode`), password-POLICY rejections (M-15),\n//? input-shape errors, the `login.accountLocked` self-trip, and infra failures\n//? (DB find / bcrypt throw, surfaced via `toReasonKey`). A deny-list let any of\n//? those — most dangerously a consumer `preLogin` veto an attacker can trigger\n//? with just a victim's email and no password — silently drive the lockout and\n//? DoS the account. Keep this allow-list narrow.\nconst COUNTING_REASONS = new Set([\n 'login.wrongPassword',\n]);\n\nlet lockoutHookRegistered = false;\n\n/**\n * Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once\n * (idempotent) and increments the failed-attempt counter only on a credentials\n * LOGIN failure that carries an email AND is a genuine wrong-password attempt\n * (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder\n * itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the\n * config at runtime is honoured without re-wiring.\n */\nexport const registerAuthLockoutHook = (): void => {\n if (lockoutHookRegistered) return;\n lockoutHookRegistered = true;\n registerHook('loginFailed', async ({ email, provider, reason, stage, requesterIp }) => {\n if (stage !== 'login' || provider !== 'credentials') return;\n if (!email || !COUNTING_REASONS.has(reason)) return;\n //? DD-LOGIN-F5: use the IP+account composite key when an IP was threaded in.\n await recordAuthFailure(email, requesterIp);\n });\n};\n","//? Post-login redirect resolver. Lets consumers compute the OAuth callback\n//? redirect URL dynamically (per-user, per-tenant, per-provider).\n//?\n//? Why a dedicated registry instead of a hook:\n//? The framework's hooks are \"stop-or-continue\" — handlers return undefined\n//? or a stop signal. They don't carry a value. A redirect-resolution\n//? handler must RETURN a string, so it lives in its own registry.\n//?\n//? Validation: the resolved URL is checked against\n//? `ProjectConfig.http.cors.allowedOrigins` (plus localhost + the bound\n//? SERVER_IP) before being used. An invalid URL falls back to\n//? `ProjectConfig.loginRedirectUrl`.\n\nexport interface PostLoginRedirectInput {\n userId: string;\n provider: string;\n isNewUser: boolean;\n defaultUrl: string;\n}\n\nexport type PostLoginRedirectResolver = (\n input: PostLoginRedirectInput,\n) => string | Promise<string>;\n\nlet activeResolver: PostLoginRedirectResolver | null = null;\n\nexport const registerPostLoginRedirect = (\n resolver: PostLoginRedirectResolver,\n): PostLoginRedirectResolver => {\n activeResolver = resolver;\n return resolver;\n};\n\nexport const getPostLoginRedirect = (): PostLoginRedirectResolver | null => activeResolver;\n","import { getOAuthProviders, isFullOAuthProvider, type FullOAuthProvider, type OAuthUserData } from './oauthProviders';\nimport { getPostLoginRedirect } from './redirectResolver';\nimport { IncomingMessage, ServerResponse } from 'node:http';\nimport { URLSearchParams } from 'node:url';\nimport { tryCatch, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger, formatKey, getProjectConfig, getCookieValue } from '@luckystack/core';\nimport bcrypt from 'bcryptjs';\nimport { randomBytes, createHash, timingSafeEqual } from 'node:crypto';\nimport { saveSession } from \"./session\"\nimport validator from 'validator';\nimport type { BaseSessionLayout as SessionLayout } from './sessionLayout';\nimport { getUserAdapter } from './userAdapter';\nimport { resolveUserByEmail } from './accountStrategy';\nimport { validatePassword } from './passwordPolicy';\nimport { isAccountLocked, clearAuthFailures } from './authLockout';\nimport path from 'node:path';\nimport { promises as fsPromises } from 'node:fs';\n\n//? Combined login/register request body. When `name` + `confirmPassword` are\n//? both present the dispatcher registers; otherwise it logs in.\ninterface LoginOrRegisterParams {\n email?: string,\n password?: string,\n name?: string,\n confirmPassword?: string,\n}\n\n//? Discriminated result of the credentials login/register surface (QUA-080).\n//? Exporting this lets `@luckystack/server`'s auth route narrow on `status`\n//? instead of casting the result to an inline shape. On `status: true`,\n//? `newToken` + `session` are always present; on `status: false` only `reason`\n//? is meaningful (the failure branch never mints a token).\nexport interface CredentialsLoginSuccess {\n status: true;\n reason: string;\n newToken: string;\n session: SessionLayout;\n}\nexport interface CredentialsLoginFailure {\n status: false;\n reason: string;\n}\nexport type CredentialsLoginResult = CredentialsLoginSuccess | CredentialsLoginFailure;\n\n//? Resolved at call time via getUploadsDir() so consumer path overrides win.\nconst uploadsFolder = (): string => getUploadsDir();\n//? Resolve at call time so dotenv/test-setup timing can't capture a stale\n//? value at module load. Routes through `projectConfig.logging.devLogs`\n//? which is the standard predicate everywhere else in the codebase.\nconst isDevMode = (): boolean => getProjectConfig().logging.devLogs;\nconst { compare, genSalt, hash } = bcrypt;\n\n//? LOGIN-F22: the dummy hash used on the user-not-found path must be generated\n//? at the CONFIGURED bcrypt rounds, not hard-pinned to cost 10. If the consumer\n//? raises `auth.bcryptRounds` (e.g. to 12), a cost-10 dummy compare finishes in\n//? ~80 ms while a real compare takes ~400 ms — the 5× difference reopens the\n//? user-enumeration timing channel we closed with the dummy compare. We lazily\n//? generate one hash per unique rounds value (cached so the per-request compare\n//? stays O(1) after the first login at a given cost) and use that hash for all\n//? subsequent not-found compares at the same cost.\nconst dummyHashCache = new Map<number, string>();\nconst getDummyBcryptHash = async (): Promise<string> => {\n const rounds = getProjectConfig().auth.bcryptRounds;\n const cached = dummyHashCache.get(rounds);\n if (cached) return cached;\n const salt = await genSalt(rounds);\n const generated = await hash(randomBytes(16).toString('hex'), salt);\n dummyHashCache.set(rounds, generated);\n return generated;\n};\n//? `validator` is a CommonJS module whose helpers hang off the default export;\n//? destructuring the default is the documented usage. The named-export warning\n//? is a false positive for this CJS interop shape.\n// eslint-disable-next-line import-x/no-named-as-default-member\nconst { escape, isEmail } = validator;\n\n//? Use the shared `getProjectName()` helper so the OAuth state Redis key\n//? prefix matches the namespace used everywhere else (sessions, activeUsers,\n//? rate-limit, password-reset). Previously this used a separate\n//? `auth.oauthStateProjectNameFallback` config field which could silently\n//? drift from `session.projectName`.\nconst getOAuthStateKey = (providerName: string, state: string): string => {\n return formatKey('-oauth-state', `${providerName}:${state}`);\n};\n\n//? Cookie the server sets at the OAuth-authorize step (F1). It carries a random\n//? nonce bound to the Redis state entry; the callback requires the cookie nonce\n//? to hash to the value stored alongside the state before it accepts the\n//? callback. This proves the SAME browser that started the flow is completing it\n//? (closes the login-CSRF / session-fixation hole where an attacker delivers\n//? their own valid `code+state` to a victim). Server + login MUST agree on this\n//? name — it is exported so `@luckystack/server`'s authorize route reuses it.\nexport const OAUTH_STATE_COOKIE_NAME = 'ls-oauth-state';\n\n//? Hash the browser-binding nonce before persisting it in Redis: the Redis\n//? state value is server-side-readable, so storing only the SHA-256 means a\n//? leaked state entry can't be replayed without the matching cookie nonce.\nconst hashStateNonce = (nonce: string): string =>\n createHash('sha256').update(nonce).digest('hex');\n\n//? Generate a PKCE verifier + its S256 challenge (RFC 7636). Only used when a\n//? provider opts in via `usePkce` — default OAuth flows are byte-identical to\n//? before. The verifier is stored server-side (in the Redis state value) and\n//? replayed at token exchange; the challenge is handed to the authorize step.\nconst base64Url = (buf: Buffer): string =>\n buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');\nconst generatePkcePair = (): { verifier: string; challenge: string } => {\n const verifier = base64Url(randomBytes(32));\n const challenge = base64Url(createHash('sha256').update(verifier).digest());\n return { verifier, challenge };\n};\n\n//? Server-side payload persisted under the Redis state key. `nonceHash` binds\n//? the flow to the initiating browser's cookie; `codeVerifier` (PKCE) is the\n//? secret replayed at token exchange.\ninterface OAuthStateEntry {\n nonceHash: string;\n codeVerifier?: string;\n //? Frontend URL to redirect to after the OAuth callback completes. Stored\n //? server-side in Redis (never echoed from the client at callback time) so it\n //? is tamper-proof. Validated against allowedOrigins + allowLocalhost before\n //? use — see isAllowedRedirectUrl. Absent when the auth initiation did not\n //? carry a return_url param (falls back to the configured loginRedirectUrl).\n returnUrl?: string;\n}\n\nexport interface CreateOAuthStateResult {\n /** Opaque `state` param put on the authorize URL + echoed back on callback. */\n state: string;\n /**\n * Random nonce the server must set as the value of the {@link OAUTH_STATE_COOKIE_NAME}\n * cookie (HttpOnly, SameSite=Lax, short TTL) at the authorize redirect. The\n * callback requires this back to accept the flow (browser-binding, F1).\n */\n stateCookie: string;\n /**\n * PKCE `code_challenge` (S256) when the provider opted into PKCE — the server\n * appends `code_challenge=<value>&code_challenge_method=S256` to the authorize\n * URL. `undefined` for non-PKCE providers (default).\n */\n codeChallenge?: string;\n}\n\nexport const createOAuthState = async (\n providerName: string,\n options?: { usePkce?: boolean; returnUrl?: string },\n): Promise<CreateOAuthStateResult | null> => {\n const state = randomBytes(32).toString('hex');\n const nonce = randomBytes(32).toString('hex');\n const key = getOAuthStateKey(providerName, state);\n\n const pkce = options?.usePkce ? generatePkcePair() : null;\n const entry: OAuthStateEntry = {\n nonceHash: hashStateNonce(nonce),\n ...(pkce ? { codeVerifier: pkce.verifier } : {}),\n ...(options?.returnUrl ? { returnUrl: options.returnUrl } : {}),\n };\n\n //? State TTL is consumer-configurable via `auth.oauthStateTtlSeconds`\n //? (default 600s / 10 min). Too short and a legitimate, slow provider\n //? round-trip expires mid-flow and the callback fails state validation\n //? (UX/soft-DoS); too long and a stolen state stays replayable. The `NX`\n //? guard keeps each state single-use even before the TTL elapses, and\n //? `consumeOAuthState` deletes it on first redemption. The value is now a\n //? JSON envelope (nonce hash + optional PKCE verifier) instead of the literal\n //? `'1'` so the flow can be bound to the initiating browser (F1) and carry a\n //? PKCE secret (F11).\n const result = await redisClient.set(\n key,\n JSON.stringify(entry),\n 'EX',\n getProjectConfig().auth.oauthStateTtlSeconds,\n 'NX',\n );\n\n if (result !== 'OK') {\n return null;\n }\n\n return {\n state,\n stateCookie: nonce,\n ...(pkce ? { codeChallenge: pkce.challenge } : {}),\n };\n};\n\n//? Atomically read + delete the state entry (single-use), then verify the\n//? browser-binding nonce. Returns the parsed entry on success (so the caller can\n//? read `codeVerifier` for the PKCE token exchange), or `null` on any failure:\n//? missing state, missing/empty cookie nonce, Redis miss, malformed payload, or\n//? a nonce that doesn't match the stored hash. `cookieNonce` is the value the\n//? server read from the {@link OAUTH_STATE_COOKIE_NAME} cookie on the callback\n//? request.\nconst consumeOAuthState = async (\n providerName: string,\n state: string,\n cookieNonce: string,\n): Promise<OAuthStateEntry | null> => {\n if (!state || !cookieNonce) {\n return null;\n }\n\n const key = getOAuthStateKey(providerName, state);\n const txResult = await redisClient.multi().get(key).del(key).exec();\n if (!txResult || txResult.length < 2) {\n return null;\n }\n\n const getResult = txResult[0];\n if (!getResult || getResult[0]) {\n return null;\n }\n\n //? Verify the DEL also succeeded (txResult[1][0] is the per-command error slot).\n //? If DEL failed the state key was not consumed — fail closed so the OAuth state\n //? cannot be replayed with the same nonce before its TTL expires (mirrors the\n //? consumeOneTimeToken hardening in @luckystack/core).\n const delResult = txResult[1];\n if (!delResult || delResult[0]) {\n return null;\n }\n\n const rawValue = getResult[1];\n if (typeof rawValue !== 'string') {\n return null;\n }\n\n const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue) as OAuthStateEntry);\n if (parseErr || !entry || typeof entry.nonceHash !== 'string') {\n return null;\n }\n\n //? Constant-time compare of the two fixed-length (SHA-256) hex digests via\n //? `timingSafeEqual` (QUA-21). Both operands are 64-char hex strings, so their\n //? byte buffers are equal-length; a length mismatch (malformed stored value)\n //? falls through to reject. A mismatch means the browser completing the flow is\n //? NOT the one that started it — reject (F1).\n const incomingHash = Buffer.from(hashStateNonce(cookieNonce));\n const storedHash = Buffer.from(entry.nonceHash);\n if (incomingHash.length !== storedHash.length || !timingSafeEqual(incomingHash, storedHash)) {\n return null;\n }\n\n return entry;\n};\n\nconst asRecord = (value: unknown): Record<string, unknown> => {\n if (value && typeof value === 'object') {\n return value as Record<string, unknown>;\n }\n return {};\n};\n\n//? Read a string-ish field from an untyped provider JSON record. Strings pass\n//? through; finite numbers / booleans are coerced (preserving the previous\n//? `String(value)` behaviour for primitive fields); objects, arrays, null and\n//? undefined collapse to `fallback` instead of stringifying to `[object Object]`.\nconst readStringField = (record: Record<string, unknown>, key: string, fallback = ''): string => {\n const value = record[key];\n if (typeof value === 'string') return value;\n if (typeof value === 'number' && Number.isFinite(value)) return String(value);\n if (typeof value === 'boolean') return String(value);\n return fallback;\n};\n\n//? Random 6-digit hex color used as the avatar placeholder for users without\n//? an uploaded image. Shared by the credentials-register and OAuth-create paths.\nconst generateAvatarFallbackColor = (): string =>\n `#${Math.floor(Math.random() * 0xFF_FF_FF).toString(16).padStart(6, \"0\")}`;\n\n//? LOGIN-F18: check whether a user has an uploaded webp avatar on disk and\n//? return the filename when found. Used by both the credentials and OAuth login\n//? paths to avoid duplicating the async stat pattern.\nconst resolveUploadedAvatar = async (userId: string): Promise<string | null> => {\n const filePath = path.join(uploadsFolder(), `${userId}.webp`);\n const [, avatarStat] = await tryCatch(() => fsPromises.stat(filePath));\n return avatarStat ? `${userId}.webp` : null;\n};\n\nconst sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {\n const { password: _password, ...safeUser } = user;\n return safeUser;\n};\n\n//? Fire-and-forget observational failure signal. Dispatched on every failed\n//? login/register/OAuth attempt so consumers can audit, feed a SIEM, or build\n//? per-account lockout (HOK-10). Never awaited and never throws — a failing\n//? handler must not change the auth outcome the user already got.\nconst emitLoginFailed = (payload: {\n email?: string;\n userId?: string;\n provider: string;\n reason: string;\n stage: 'login' | 'register' | 'oauth';\n //? DD-LOGIN-F5: optional resolved client IP — threaded from the HTTP auth\n //? route so the per-account lockout can build an IP+account composite key.\n requesterIp?: string;\n}): void => {\n void dispatchHook('loginFailed', payload);\n};\n\nconst toReasonKey = (error: unknown, fallback = 'api.internalServerError'): string => {\n if (typeof error === 'string' && error.length > 0) {\n return error;\n }\n\n if (error instanceof Error && error.message) {\n const message = error.message.toLowerCase();\n if (message.includes('authentication failed') || message.includes('scram failure')) {\n return 'api.internalServerError';\n }\n }\n\n return fallback;\n};\n\ninterface NormalizedCredentials {\n email: string;\n password: string;\n name: string | undefined;\n confirmPassword: string | undefined;\n}\n\nconst normalizeCredentials = (params: LoginOrRegisterParams): NormalizedCredentials => ({\n //? Don't HTML-escape password OR confirmPassword before bcrypt — neither\n //? reaches HTML, and they're memory-compared for equality during register.\n //? Escaping confirmPassword while leaving password raw made any password\n //? containing `& < > \" '` fail the `password !== confirmPassword` check\n //? (regression introduced in pass-1 fix #9 which only un-escaped password).\n //? Email is normalized (lowercase + trim) and validated via `isEmail`;\n //? HTML-escaping it would make legacy raw rows unfindable.\n email: (params.email ?? '').trim().toLowerCase(),\n password: params.password ?? '',\n name: params.name ? escape(params.name) : undefined,\n //? Empty-string confirmPassword must collapse to `undefined` so the\n //? register-vs-login dispatcher's truthiness check treats a blank field as\n //? \"absent\". `??` would keep the empty string (only nullish collapses), so the\n //? falsy-coercing `||` is deliberate here.\n // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional empty-string → undefined collapse\n confirmPassword: params.confirmPassword || undefined,\n});\n\n//? Shape-validate normalized credentials. `mode` decides whether the\n//? password-POLICY check runs:\n//? - `'register'` enforces the full policy (length, complexity, common-list,\n//? customValidator) — a new account MUST meet the active policy.\n//? - `'login'` does NOT run the policy (M-15). A login must accept ANY password\n//? string and let the bcrypt compare decide pass/fail. Running the policy on\n//? login is both a lockout-DoS vector (an attacker POSTing policy-violating\n//? passwords for a victim's email could otherwise feed the per-account\n//? failed-attempt counter) AND a correctness bug (tightening the policy would\n//? lock out existing users whose stored password no longer meets it). Empty /\n//? email-shape checks still run in both modes.\nconst validateCredentialsShape = (\n creds: NormalizedCredentials,\n mode: 'login' | 'register',\n): { status: false; reason: string } | null => {\n const authLimits = getProjectConfig().auth;\n if (!creds.email || !creds.password) return { status: false, reason: 'login.empty' };\n if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: 'login.emailCharacterLimit' };\n if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: 'login.nameCharacterLimit' };\n if (!isEmail(creds.email)) return { status: false, reason: 'login.invalidEmailFormat' };\n if (mode === 'register') {\n //? Full password-policy check (length, complexity, common-list, customValidator).\n //? Policy lives in `projectConfig.auth.passwordPolicy`; the deprecated\n //? `passwordMinLength`/`passwordMaxLength` top-level fields are still read\n //? from the policy's `minLength`/`maxLength` defaults. REGISTER-only — see the\n //? function doc for why the login branch skips it (M-15).\n const passwordReason = validatePassword(creds.password);\n if (passwordReason) return { status: false, reason: passwordReason };\n }\n return null;\n};\n\nconst registerWithCredentials = async (\n {\n email,\n password,\n name,\n confirmPassword,\n }: {\n email: string;\n password: string;\n name: string;\n confirmPassword: string;\n },\n options?: { supersedeToken?: string },\n): Promise<CredentialsLoginResult> => {\n if (password !== confirmPassword) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.passwordNotMatch', stage: 'register' });\n return { status: false, reason: 'login.passwordNotMatch' };\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: 'credentials', name });\n if (preRegisterResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preRegisterResult.signal.errorCode, stage: 'register' });\n return { status: false, reason: preRegisterResult.signal.errorCode };\n }\n\n //? LOGIN-F15: apply the per-account lockout check before the email-existence\n //? lookup. Without this an attacker can call the register endpoint repeatedly\n //? for a victim's email and perform unbounded `resolveUserByEmail` probes —\n //? effectively bypassing the lockout by choosing the register code path. A\n //? locked account means the credentials flow is temporarily suspended for that\n //? email; the same sentinel applies regardless of which surface (login vs\n //? register) is being used. No-op when `rateLimiting.auth.enabled` is false.\n if (await isAccountLocked(email)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'register' });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [checkEmailError, checkEmailResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (checkEmailError) {\n getLogger().error('login: findByEmail failed during register', checkEmailError);\n const reason = toReasonKey(checkEmailError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (checkEmailResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n\n const createNewUser = async () => {\n const salt = await genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await hash(password, salt);\n return await userAdapter.create({\n email,\n provider: 'credentials',\n name,\n password: hashedPassword,\n avatar: '',\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n };\n\n const [createNewUserError, createNewUserResponse] = await tryCatch(createNewUser);\n if (createNewUserError) {\n //? LOGIN-F7: treat a unique-constraint violation as a TOCTOU race — another\n //? concurrent request created this row between our findFirst and our create.\n //? Under the `'unified'` strategy (where `email @unique` is recommended) this\n //? is the expected concurrent-registration signal; return `login.emailExists`\n //? so the client can prompt the user to log in instead.\n const errorMessage =\n createNewUserError instanceof Error ? createNewUserError.message : String(createNewUserError);\n const isUniqueViolation =\n errorMessage.includes('Unique constraint') ||\n errorMessage.includes('unique constraint') ||\n // Prisma error code P2002 covers all DB engines\n (createNewUserError as { code?: unknown }).code === 'P2002';\n if (isUniqueViolation) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n const reason = toReasonKey(createNewUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (!createNewUserResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.createUserFailed', stage: 'register' });\n return { status: false, reason: 'login.createUserFailed' };\n }\n\n await dispatchHook('postRegister', { userId: createNewUserResponse.id, provider: 'credentials' });\n\n //? Auto-login after register (ARCHITECTURE_AUTH.md: both flows return\n //? `{ status, session, newToken }`). Mirrors the login branch: mint a token,\n //? persist the session, and hand the token back so the route can set the\n //? cookie / the client can store it and land on `loginRedirectUrl`.\n const newToken = randomBytes(32).toString('hex');\n const nowLogin = new Date();\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(createNewUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin: null,\n };\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Account exists but the session never persisted — surface the failure so\n //? the route does not report an authenticated state it cannot back up. The\n //? user can recover by logging in normally.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'register' });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: true, token: newToken });\n\n return {\n status: true,\n reason: 'login.userCreated',\n newToken,\n session: newUser,\n };\n};\n\nconst loginWithCredentialsCore = async (\n { email, password }: { email: string; password: string },\n //? DD-LOGIN-F5: `requesterIp` is optional so all existing callers stay\n //? compatible. The HTTP auth route resolves and passes the IP; socket paths\n //? omit it and fall back to the pure-account lockout bucket.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const requesterIp = options?.requesterIp;\n const preLoginResult = await dispatchHook('preLogin', { email, provider: 'credentials' });\n if (preLoginResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preLoginResult.signal.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: preLoginResult.signal.errorCode };\n }\n\n //? Per-account brute-force lockout (F7 / MIS-017). When the auth rate-limit\n //? slot is enabled and this account has exhausted its failed-attempt budget,\n //? refuse the attempt BEFORE any bcrypt work — distributed credential stuffing\n //? against one account (which the per-IP throttle can't see) is throttled\n //? per-account here. The counter is incremented by the registered `loginFailed`\n //? handler (see `registerAuthLockoutHook`); merely checking the lock does not\n //? increment it. No-op when `rateLimiting.auth.enabled` is false (default).\n if (await isAccountLocked(email, requesterIp)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'login', requesterIp });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [findUserError, findUserResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (findUserError) {\n getLogger().error('login: findByEmail failed', findUserError);\n const reason = toReasonKey(findUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n //? Anti-enumeration: a missing account and a missing/invalid password hash\n //? must be INDISTINGUISHABLE from a wrong password — same reason key AND\n //? similar timing. Returning a distinct `login.userNotFound` (and short-\n //? circuiting before any bcrypt work) let an attacker probe which addresses\n //? have credentials accounts via both the response key and a timing side\n //? channel. When there is no real hash to compare, run a bcrypt compare\n //? against a fixed dummy hash so the not-found path spends roughly the same\n //? CPU as a real wrong-password compare, then return the shared key.\n const passwordHash = findUserResponse?.password ?? null;\n if (!findUserResponse || !passwordHash) {\n const dummyHash = await getDummyBcryptHash();\n await tryCatch(() => compare(password, dummyHash));\n emitLoginFailed({ email, userId: findUserResponse?.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const [checkPasswordError, checkPasswordResponse] = await tryCatch(() =>\n compare(password, passwordHash)\n );\n if (checkPasswordError) {\n getLogger().error('login: bcrypt compare failed', checkPasswordError);\n const reason = toReasonKey(checkPasswordError);\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n if (!checkPasswordResponse) {\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const newToken = randomBytes(32).toString('hex');\n const previousLogin = findUserResponse.lastLogin ?? null;\n const nowLogin = new Date();\n\n // Best-effort lastLogin update — silently no-ops if the user adapter\n // (or User schema) doesn't accept the field.\n await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));\n\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(findUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin,\n };\n\n const uploadedAvatar = await resolveUploadedAvatar(newUser.id);\n if (uploadedAvatar) {\n newUser.avatar = uploadedAvatar;\n }\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted (adapter blip / preSessionCreate veto). Fail the\n //? login so the route does NOT set a cookie or delete the prior session for\n //? a token that getSession() can't resolve.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: false, token: newToken });\n //? Reset the brute-force counter on success so earlier typos don't keep a\n //? legitimate user locked out (F7). No-op when the feature is disabled.\n //? Clear the IP+account composite key (DD-LOGIN-F5) AND the bare account key\n //? so that failures recorded without an IP (e.g. from a different surface) are\n //? also cleared after a confirmed legitimate login.\n void clearAuthFailures(email, requesterIp);\n if (requesterIp) void clearAuthFailures(email);\n if (isDevMode()) {\n getLogger().debug(`credentials login success for user ${newUser.id}`);\n }\n return { status: true, reason: 'login.loggedIn', newToken, session: newUser };\n};\n\n//? Thin dispatcher: keeps the existing single-entry HTTP surface but forwards\n//? to the dedicated register / login functions. `name && confirmPassword`\n//? present in the body means the client is registering; otherwise it's a\n//? login.\nconst loginWithCredentials = async (\n params: LoginOrRegisterParams,\n //? DD-LOGIN-F5: `requesterIp` forwarded to `loginWithCredentialsCore` to\n //? enable IP+account composite lockout keys on the HTTP auth path.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const creds = normalizeCredentials(params);\n\n if (isDevMode()) {\n getLogger().debug(`credentials auth attempt for ${creds.email || 'unknown-email'}`);\n }\n\n //? Decide the branch BEFORE shape-validation so the password-POLICY check only\n //? runs on register (M-15): `name && confirmPassword` present => register;\n //? otherwise login. A login must accept any password string (the bcrypt\n //? compare is the only authority), so an attacker can't trip a victim's\n //? lockout counter by POSTing policy-violating passwords for their email.\n //? Narrowing on the destructured locals keeps `name`/`confirmPassword` typed as\n //? `string` inside the register branch (no cast needed).\n const { name, confirmPassword } = creds;\n if (name && confirmPassword) {\n const validationError = validateCredentialsShape(creds, 'register');\n if (validationError) return validationError;\n //? Public-registration gate (F18). When `auth.allowRegistration === false`\n //? (invite-only / admin-provisioned apps) refuse the register branch with a\n //? dedicated reason key BEFORE any user lookup or hook. OAuth-driven\n //? first-login account creation is governed separately by the provider flow.\n //? Default `true` keeps today's open-registration behavior.\n if (!getProjectConfig().auth.allowRegistration) {\n emitLoginFailed({ email: creds.email, provider: 'credentials', reason: 'auth.registrationDisabled', stage: 'register' });\n return { status: false, reason: 'auth.registrationDisabled' };\n }\n return registerWithCredentials({\n email: creds.email,\n password: creds.password,\n name,\n confirmPassword,\n }, options);\n }\n\n const validationError = validateCredentialsShape(creds, 'login');\n if (validationError) return validationError;\n return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);\n};\n\nexport interface OAuthCallbackResult {\n token: string;\n redirectUrl: string;\n userId: string;\n provider: string;\n isNewUser: boolean;\n}\n\nconst isAllowedRedirectUrl = (url: string): boolean => {\n if (!URL.canParse(url, 'http://placeholder')) return false;\n //? Reject relative URLs whose path starts with a backslash or contains any\n //? backslash before the same-origin shortcut (SEC-12): `new URL('/\\\\evil.com',\n //? base)` keeps the placeholder origin and would pass the same-origin check,\n //? yet browsers normalise `/\\` → `//` (protocol-relative) → open-redirect to\n //? `evil.com`. A legitimate same-origin path never needs a backslash.\n if (url.includes('\\\\')) return false;\n const parsed = new URL(url, 'http://placeholder');\n if (parsed.origin === 'http://placeholder') {\n // relative URL — same-origin, always safe\n return true;\n }\n //? `allowedOrigins` is `string[] | (origin: string) => boolean` since we\n //? added the function-resolver variant. Branch on the shape.\n //? Defensive default at a security boundary (redirect-origin validation):\n //? the type is non-nullable, but a malformed consumer config could still\n //? hand us `undefined` at runtime, so the `?? []` fail-closed guard stays.\n //? `allowLocalhost` is the dev convenience that accepts any http://localhost:*\n //? origin without needing an explicit allowedOrigins entry. Safe in production\n //? because allowLocalhost defaults to false there.\n const cfg = getProjectConfig();\n if (cfg.http.cors.allowLocalhost && parsed.hostname === 'localhost') return true;\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime-defensive guard at a security boundary\n const allowed = cfg.http.cors.allowedOrigins ?? [];\n if (typeof allowed === 'function') {\n return allowed(parsed.origin);\n }\n return allowed.some((origin) => {\n if (!URL.canParse(origin)) return false;\n return new URL(origin).origin === parsed.origin;\n });\n};\n\nconst exchangeOAuthToken = async (\n provider: FullOAuthProvider,\n code: string,\n codeVerifier?: string,\n): Promise<string | null> => {\n //? `redirect_uri` is pinned to the registered, immutable `provider.callbackURL`\n //? — the SAME static config value the authorization request is built from.\n //? It is never derived from the incoming request, so there is no per-request\n //? drift to re-validate against (the authorization-time value and the\n //? exchange-time value are guaranteed identical by construction). Persisting\n //? it into the Redis state would be a behaviour-preserving no-op here; if the\n //? callback URL ever becomes request-/tenant-derived, store it alongside the\n //? state in `createOAuthState` and compare before exchange.\n //? `code_verifier` (PKCE, F11) is included ONLY when the provider opted in and\n //? a verifier was stored with the state — for every existing flow it is\n //? `undefined` and the exchange body is unchanged.\n const values = {\n code,\n client_id: provider.clientID,\n client_secret: provider.clientSecret,\n redirect_uri: provider.callbackURL,\n grant_type: 'authorization_code',\n ...(codeVerifier ? { code_verifier: codeVerifier } : {}),\n };\n\n //? `Promise<unknown>` return annotation: `response.json()` is `any`, and an\n //? explicit `unknown` boundary type forces every caller to narrow via\n //? `asRecord(...)` instead of leaking `any` through the token-exchange seam.\n const getToken = async (): Promise<unknown> => {\n if (provider.tokenExchangeMethod === 'json') {\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: JSON.stringify(values),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n }\n //? `tokenExchangeMethod` is the closed union `'json' | 'form'`; the `json`\n //? branch returned above, so this is the exhaustive `form` case.\n const formParams = new URLSearchParams();\n formParams.append('client_id', provider.clientID);\n formParams.append('client_secret', provider.clientSecret);\n formParams.append('code', values.code);\n formParams.append('grant_type', 'authorization_code');\n formParams.append('redirect_uri', provider.callbackURL);\n if (codeVerifier) {\n formParams.append('code_verifier', codeVerifier);\n }\n\n if (isDevMode()) {\n //? NEVER stringify the raw form — it carries `client_secret` (a\n //? long-lived credential) and the single-use authorization `code`. Log\n //? only the param NAMES so the dev sees the shape without leaking secrets\n //? into the log sink.\n getLogger().debug('oauth: token-exchange form params', { paramNames: [...formParams.keys()] });\n }\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },\n body: formParams.toString(),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getToken);\n if (error) {\n getLogger().error('oauth: token exchange failed', error);\n return null;\n }\n const tokenData = asRecord(response);\n const accessToken = typeof tokenData.access_token === 'string' ? tokenData.access_token : '';\n if (!accessToken) {\n getLogger().warn('oauth: no access_token in token response', { provider: provider.name });\n return null;\n }\n return accessToken;\n};\n\ninterface OAuthProfile {\n email: string | undefined;\n name: string;\n avatar: string;\n /**\n * `true` only when the provider POSITIVELY reported the email as verified\n * (via `emailVerifiedKey`) or supplied it through a `getEmail` accessor that\n * itself filters on a verified flag (GitHub). `false` when the provider\n * exposes NO verified-email signal at all — used to fail-closed on\n * cross-provider account LINKING under the `'unified'` strategy (SEC-21/SEC-40),\n * where linking an unverified address to a victim's existing account is an\n * account-takeover vector. First-time account CREATION is unaffected.\n */\n emailVerified: boolean;\n /**\n * The RAW, un-stripped provider userInfo record. Threaded through so the\n * `extraSessionFields` hook receives every provider-specific claim (`sub`,\n * tenant id, custom claims) instead of the trimmed `{email,name,avatar}`\n * projection — the documented contract (SEC-3 / oauthProviders.ts hook docs).\n */\n rawUserData: OAuthUserData;\n}\n\nconst fetchOAuthProfile = async (\n provider: FullOAuthProvider,\n accessToken: string,\n): Promise<OAuthProfile | null> => {\n const getUserData = async (): Promise<unknown> => {\n const response = await fetch(provider.userInfoURL, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${accessToken}`,\n },\n });\n if (!response.ok) {\n getLogger().warn('oauth: userInfo fetch returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getUserData);\n if (error) {\n getLogger().error('oauth: userInfo fetch failed', error);\n return null;\n }\n\n const userData = asRecord(response);\n //? Provider responses are untyped JSON; read each field through `readString`\n //? so a non-string (object/number/missing) value falls back instead of\n //? stringifying to `[object Object]`.\n //? A present-but-empty name string falls back too (mirrors the old\n //? `String(value || 'didnt find a name')` truthy-coercion).\n const resolvedName = readStringField(userData, provider.nameKey);\n const name = resolvedName === '' ? 'didnt find a name' : resolvedName;\n const emailValue = userData[provider.emailKey];\n let email: string | undefined = typeof emailValue === 'string' ? emailValue : undefined;\n\n //? Track whether the provider POSITIVELY confirmed this email as verified.\n //? `emailVerifiedKey === true` (or any non-false value when the key is set and\n //? present) counts as verified; a provider with NO `emailVerifiedKey` exposes\n //? no signal, so the email stays `emailVerified: false` and only first-time\n //? account creation (not cross-provider linking) is allowed downstream.\n let emailVerified = false;\n\n //? Reject a provider-supplied email that the provider explicitly marks\n //? unverified (e.g. Discord's `verified: false`). A missing flag is treated\n //? as \"no signal\" (not verified, but not rejected outright — see the\n //? cross-provider link guard in `findOrCreateOAuthUser`). Defense-in-depth\n //? against account-linking takeover under `'unified'` (SEC-21).\n if (email && provider.emailVerifiedKey) {\n const verifiedFlag = userData[provider.emailVerifiedKey];\n if (verifiedFlag === false) {\n getLogger().warn('oauth: provider email is not verified — rejecting', { provider: provider.name });\n return null;\n }\n //? Key present-and-not-false ⇒ provider positively confirmed verification.\n if (verifiedFlag !== undefined) emailVerified = true;\n }\n\n const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : undefined;\n const avatarValue = provider.avatarKey\n ? readStringField(userData, provider.avatarKey)\n : (provider.getAvatar\n ? await provider.getAvatar({ userData, avatarId: typeof avatarId === 'string' ? avatarId : '', accessToken })\n : '');\n const avatar = typeof avatarValue === 'string' ? avatarValue : '';\n\n //? Some providers (GitHub) don't return email in /userinfo — fall back to the\n //? provider's `getEmail` accessor. GitHub's accessor only ever selects a\n //? `verified === true` address (see `githubProvider.getEmail`), so an email\n //? obtained this way is treated as positively verified for the cross-provider\n //? link guard (SEC-21).\n if (!email && provider.getEmail) {\n const selectedEmail = await provider.getEmail(accessToken);\n if (!selectedEmail) {\n getLogger().warn('oauth: no email found via provider.getEmail', { provider: provider.name });\n return null;\n }\n email = selectedEmail;\n emailVerified = true;\n }\n\n //? Normalize OAuth emails through the SAME gate as credentials (trim +\n //? lowercase + `isEmail`) before they reach the adapter. Credentials emails\n //? are normalized at `normalizeCredentials`; taking the provider value\n //? verbatim meant `Victim@x.com` (OAuth) and `victim@x.com` (credentials)\n //? resolved to different rows — which under the `'unified'` account strategy\n //? (case-sensitive lookup) silently DEFEATS cross-provider linking. A\n //? present-but-malformed provider email is treated as \"no usable email\".\n if (email !== undefined) {\n const normalizedEmail = email.trim().toLowerCase();\n if (!isEmail(normalizedEmail)) {\n getLogger().warn('oauth: provider returned a malformed email', { provider: provider.name });\n return null;\n }\n email = normalizedEmail;\n }\n\n return { email, name, avatar, emailVerified, rawUserData: userData };\n};\n\ninterface ResolvedOAuthUser {\n user: SessionLayout;\n isNewUser: boolean;\n}\n\nconst findOrCreateOAuthUser = async (\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n): Promise<ResolvedOAuthUser | null> => {\n const { email, name, avatar, emailVerified } = profile;\n if (!email) return null;\n\n const preLoginResult = await dispatchHook('preLogin', { email, provider: provider.name });\n if (preLoginResult.stopped) {\n getLogger().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });\n return null;\n }\n\n const userAdapter = getUserAdapter();\n //? Under `'unified'` this links to an existing account with the same email\n //? created via ANY provider (incl. credentials); under `'per-provider'` it\n //? stays scoped to this provider (existing behavior).\n const [findError, findResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: provider.name })\n );\n if (findError) {\n getLogger().error('oauth: findByEmail failed', findError);\n return null;\n }\n\n if (findResponse?.id) {\n //? Fail-closed cross-provider account-LINK guard (SEC-21/SEC-40). Under the\n //? `'unified'` strategy `resolveUserByEmail` matches by email across every\n //? provider, so an OAuth sign-in can LINK into an account originally created\n //? by a DIFFERENT provider (incl. credentials). If the current provider did\n //? NOT positively verify the email (no `emailVerifiedKey` / `getEmail`\n //? verified-filter), an attacker who controls an unverified address equal to\n //? a victim's could take over that account. Refuse the link in that case —\n //? same-provider re-login and first-time account CREATION are unaffected, so\n //? existing flows for built-ins that DO verify (Google/GitHub/Discord) keep\n //? working. `'per-provider'` always matches within the same provider, so the\n //? cross-provider condition is never met there (no behaviour change).\n const existingProvider = findResponse.provider ?? null;\n const isCrossProviderLink = existingProvider !== null && existingProvider !== provider.name;\n if (isCrossProviderLink && !emailVerified) {\n getLogger().warn(\n 'oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)',\n { provider: provider.name, existingProvider },\n );\n return null;\n }\n\n //? LOGIN-F18 (OAuth path): same async stat lookup via the shared helper.\n const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);\n if (uploadedAvatar) {\n findResponse.avatar = uploadedAvatar;\n }\n\n const previousLogin = findResponse.lastLogin ?? null;\n const nowLogin = new Date();\n await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));\n\n return {\n user: {\n ...sanitizeUserForSession(findResponse),\n token: '',\n lastLogin: nowLogin,\n previousLogin,\n },\n isNewUser: false,\n };\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: provider.name, name });\n if (preRegisterResult.stopped) {\n getLogger().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });\n return null;\n }\n\n const createNewUser = async () => userAdapter.create({\n email,\n provider: provider.name,\n name,\n avatar,\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n const [createError, createResponse] = await tryCatch(createNewUser);\n if (createError) {\n getLogger().error('oauth: user create failed', createError);\n return null;\n }\n if (!createResponse) return null;\n\n return {\n user: { ...sanitizeUserForSession(createResponse), token: '' },\n isNewUser: true,\n };\n};\n\nconst resolvePostLoginRedirect = async ({\n fallbackUrl,\n userId,\n providerName,\n isNewUser,\n}: {\n fallbackUrl: string;\n userId: string;\n providerName: string;\n isNewUser: boolean;\n}): Promise<string> => {\n const resolver = getPostLoginRedirect();\n if (!resolver) return fallbackUrl;\n\n const [resolverError, resolved] = await tryCatch(() => resolver({\n userId,\n provider: providerName,\n isNewUser,\n defaultUrl: fallbackUrl,\n }));\n if (resolverError) {\n getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });\n return fallbackUrl;\n }\n if (resolved && isAllowedRedirectUrl(resolved)) return resolved;\n if (resolved) {\n getLogger().warn(\n `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,\n { resolved, fallbackUrl },\n );\n }\n return fallbackUrl;\n};\n\n//? Whitelist the URL path segment against registered providers and verify the\n//? request carries a URL. Returns the matched full provider + its canonical name,\n//? or null when the segment is absent, unregistered, or an incomplete provider\n//? shape. Separating this keeps the callback routing concern out of the main flow.\nconst resolveProviderFromCallback = (\n pathname: string,\n url: string | undefined,\n): { provider: FullOAuthProvider; providerName: string } | null => {\n const rawProviderSegment = pathname.split('/')[3]; // google/github/etc.\n if (!rawProviderSegment || !url) return null;\n const found = getOAuthProviders().find(p => p.name === rawProviderSegment);\n if (!found || !isFullOAuthProvider(found)) return null;\n return { provider: found, providerName: found.name };\n};\n\n//? Merge per-provider runtime extras (calendar tokens, tenant ids, etc.) into\n//? the session object IN PLACE before it is persisted. Errors are non-fatal —\n//? a failing or absent hook must not block the user from signing in (SEC-10).\nconst applyExtraSessionFields = async (\n user: SessionLayout,\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n accessToken: string,\n): Promise<void> => {\n const { extraSessionFields } = provider;\n if (!extraSessionFields) return;\n\n //? Pass the RAW provider userData (SEC-3) so the hook sees every provider\n //? claim (`sub`, tenant id, custom claims) rather than the trimmed projection.\n const [extraErr, extra] = await tryCatch(async () =>\n extraSessionFields({ userData: profile.rawUserData, accessToken }),\n );\n if (extraErr) {\n getLogger().warn(`[oauth:${provider.name}] extraSessionFields hook threw — continuing without extras`, { err: extraErr });\n return;\n }\n if (!extra) return;\n\n //? Strip framework-owned session keys before merging (SEC-10).\n const { id, token, csrfToken, password, ...safeExtra } = extra;\n if (id !== undefined || token !== undefined || csrfToken !== undefined || password !== undefined) {\n getLogger().warn(\n `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) — dropped`,\n );\n }\n //? SEC: warn when extra fields appear to contain bearer credentials. Access\n //? tokens stored in the Redis session are broadcast to connected sockets via\n //? updateSession — they should not be in session fields unless intentional.\n //? The stripped set above already removes \"token\"; this covers other key names.\n const CREDENTIAL_PATTERN = /token|secret|key|auth/i;\n const suspiciousKeys = Object.keys(safeExtra).filter(k => CREDENTIAL_PATTERN.test(k));\n if (suspiciousKeys.length > 0) {\n getLogger().warn(\n `[oauth:${provider.name}] extraSessionFields contains field(s) that look like credentials (${suspiciousKeys.join(', ')}) — these will be stored in the Redis session and broadcast to connected sockets. Consider keeping them server-side only.`,\n );\n }\n Object.assign(user, safeExtra);\n};\n\n//? Assemble the fallback redirect URL for the OAuth callback by preferring the\n//? tamper-proof return URL stored server-side with the OAuth state, then the\n//? caller-supplied default, then the configured `loginRedirectUrl`, then '/'.\nconst resolveOAuthFallbackUrl = (\n stateEntry: OAuthStateEntry,\n options: { defaultRedirectUrl?: string },\n): string => {\n const stateReturnUrl =\n stateEntry.returnUrl && isAllowedRedirectUrl(stateEntry.returnUrl)\n ? stateEntry.returnUrl\n : undefined;\n\n return (\n stateReturnUrl\n ?? options.defaultRedirectUrl\n ?? getProjectConfig().loginRedirectUrl\n ?? '/'\n );\n};\n\n// Route that handles the callback from the OAuth provider\nconst loginCallback = async (\n pathname: string,\n req: IncomingMessage,\n _res: ServerResponse,\n options: { defaultRedirectUrl?: string; supersedeToken?: string } = {},\n): Promise<OAuthCallbackResult | false> => {\n const providerResolution = resolveProviderFromCallback(pathname, req.url);\n if (!providerResolution) return false;\n const { provider, providerName } = providerResolution;\n\n // req.url is guaranteed non-null here: resolveProviderFromCallback returns null when url is absent\n const queryString = (req.url ?? '').split('?')[1] ?? '';\n const params = new URLSearchParams(queryString);\n const code = params.get('code');\n const state = params.get('state');\n\n //? Browser-binding (F1): the nonce the server set as the `ls-oauth-state`\n //? cookie at the authorize step must round-trip back and match the value\n //? stored alongside the Redis state entry. A missing/mismatched cookie means\n //? this browser did NOT start the flow — reject before any token exchange.\n const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? '';\n const stateEntry = await consumeOAuthState(provider.name, state ?? '', cookieNonce);\n if (!stateEntry) {\n getLogger().warn('oauth: invalid, missing, or unbound state', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.invalidState', stage: 'oauth' });\n return false;\n }\n\n if (!code) {\n getLogger().warn('oauth: no code provided in callback url', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.noCode', stage: 'oauth' });\n return false;\n }\n\n //? PKCE (F11): replay the verifier stored with the state entry at token\n //? exchange when the provider opted in. `undefined` for non-PKCE providers,\n //? in which case the exchange is byte-identical to before.\n const accessToken = await exchangeOAuthToken(provider, code, stateEntry.codeVerifier);\n if (!accessToken) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.tokenExchangeFailed', stage: 'oauth' });\n return false;\n }\n\n const profile = await fetchOAuthProfile(provider, accessToken);\n if (!profile) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.profileFetchFailed', stage: 'oauth' });\n return false;\n }\n\n const resolved = await findOrCreateOAuthUser(provider, profile);\n if (!resolved) {\n emitLoginFailed({ provider: providerName, email: profile.email, reason: 'oauth.userResolveFailed', stage: 'oauth' });\n return false;\n }\n\n const newToken = randomBytes(32).toString('hex');\n resolved.user.token = newToken;\n\n //? Per-provider runtime extras (calendar tokens, tenant ids, etc.) are merged\n //? into the session BEFORE save. Errors are non-fatal — see applyExtraSessionFields.\n await applyExtraSessionFields(resolved.user, provider, profile, accessToken);\n\n const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted — abort the OAuth callback so authCallbackRoute\n //? returns \"Login failed\" instead of redirecting with a dead token.\n getLogger().error(`[oauth:${providerName}] saveSession failed — aborting callback`, { errorCode: saved.errorCode });\n emitLoginFailed({ provider: providerName, userId: resolved.user.id, reason: saved.errorCode, stage: 'oauth' });\n return false;\n }\n\n if (resolved.isNewUser) {\n await dispatchHook('postRegister', { userId: resolved.user.id, provider: providerName });\n }\n await dispatchHook('postLogin', {\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n token: newToken,\n });\n\n if (isDevMode()) {\n getLogger().debug(`oauth login success for user ${resolved.user.id}`);\n }\n\n const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);\n const redirectUrl = await resolvePostLoginRedirect({\n fallbackUrl,\n userId: resolved.user.id,\n providerName,\n isNewUser: resolved.isNewUser,\n });\n\n return {\n token: newToken,\n redirectUrl,\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n };\n};\n\nexport { loginWithCredentials, loginCallback, registerWithCredentials, loginWithCredentialsCore }\n","//? Implements `auth.providerAccountStrategy` (CFG-04). The knob was declared\n//? and documented but no code read it — every lookup was provider-scoped, so\n//? `'unified'` silently behaved like `'per-provider'` and produced duplicate\n//? accounts for the same email across providers.\n//?\n//? - `'per-provider'` (default): resolve a user by (email, provider). The\n//? same address via Google and GitHub is two separate User rows. No schema\n//? change required.\n//? - `'unified'`: resolve a user by email alone (across providers) so the\n//? address maps to a single User; a sign-in via a new provider LINKS to the\n//? existing account instead of creating a duplicate. Requires the User\n//? schema's `email` to be unique (see @luckystack/login README — \"Unified\n//? account strategy\").\n//?\n//? Custom `UserAdapter`s that predate this feature may not implement\n//? `findByEmailAnyProvider`. When `'unified'` is configured but the adapter\n//? lacks it, we log ONCE and fall back to provider-scoped lookup so the project\n//? degrades loudly rather than silently mis-resolving.\n\nimport { getProjectConfig, getLogger } from '@luckystack/core';\n\nimport type { UserAdapter, UserRecord } from './userAdapter';\n\nlet warnedMissingAnyProvider = false;\n\n/**\n * Resolve a user account honoring the configured `providerAccountStrategy`.\n * Use this everywhere the login flow needs \"does an account for this email\n * exist?\" — register dedupe, credentials login, and OAuth find-or-create.\n */\nexport const resolveUserByEmail = async (\n adapter: UserAdapter,\n { email, provider }: { email: string; provider: string },\n): Promise<UserRecord | null> => {\n const strategy = getProjectConfig().auth.providerAccountStrategy;\n\n if (strategy === 'unified') {\n if (adapter.findByEmailAnyProvider) {\n return adapter.findByEmailAnyProvider({ email });\n }\n if (!warnedMissingAnyProvider) {\n warnedMissingAnyProvider = true;\n getLogger().warn(\n '[login] auth.providerAccountStrategy is \"unified\" but the registered UserAdapter does not implement findByEmailAnyProvider — falling back to provider-scoped lookup (duplicate accounts per provider are still possible). Implement findByEmailAnyProvider on your adapter to enable unified accounts.',\n );\n }\n }\n\n return adapter.findByEmail({ email, provider });\n};\n\n/** Test-only: reset the one-time missing-method warning latch. */\nexport const resetAccountStrategyWarningForTests = (): void => {\n warnedMissingAnyProvider = false;\n};\n","//? Password-policy enforcement for credentials auth. Read by\n//? `registerWithCredentials` and `updatePasswordHash` to validate plaintext\n//? passwords against the consumer-tunable `projectConfig.auth.passwordPolicy`.\n//?\n//? Returns null when the password passes every check, or a reason-key\n//? (i18n-style errorCode) describing the first failure. Callers surface\n//? the reason to the client; the framework's i18n layer handles\n//? translation.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { COMMON_PASSWORDS } from './data/commonPasswords';\n\nconst UPPERCASE_REGEX = /[A-Z]/;\nconst LOWERCASE_REGEX = /[a-z]/;\nconst NUMBER_REGEX = /\\d/;\nconst SPECIAL_REGEX = /[^A-Za-z0-9]/;\n\n//? bcrypt silently truncates at 72 utf8-encoded bytes. Anything over 72 bytes\n//? hashes identically to its 72-byte prefix, so a longer password gives no\n//? extra security and actively misleads the user. Cap at 72 bytes so the\n//? stored hash always reflects the full secret the user typed.\nconst BCRYPT_MAX_BYTES = 72;\n\n/**\n * Validate `password` against the active project's password policy.\n * Returns null on success, a string reason-key on the first failure.\n *\n * The policy lives in `projectConfig.auth.passwordPolicy` and is read at\n * call time, so consumers can override defaults via `registerProjectConfig`.\n */\nexport const validatePassword = (password: string): string | null => {\n const policy = getProjectConfig().auth.passwordPolicy;\n\n if (password.length < policy.minLength) return 'login.passwordCharacterMinimum';\n //? Byte-length cap takes precedence over the policy maxLength when the policy\n //? exceeds BCRYPT_MAX_BYTES — reject before bcrypt silently truncates.\n const byteLength = Buffer.byteLength(password, 'utf8');\n if (byteLength > BCRYPT_MAX_BYTES) return 'login.passwordCharacterLimit';\n if (password.length > policy.maxLength) return 'login.passwordCharacterLimit';\n if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return 'login.passwordRequiresUppercase';\n if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return 'login.passwordRequiresLowercase';\n if (policy.requireNumber && !NUMBER_REGEX.test(password)) return 'login.passwordRequiresNumber';\n if (policy.requireSpecial && !SPECIAL_REGEX.test(password)) return 'login.passwordRequiresSpecial';\n if (policy.forbidCommon && COMMON_PASSWORDS.has(password.toLowerCase())) return 'login.passwordTooCommon';\n if (policy.customValidator) {\n const customReason = policy.customValidator(password);\n if (customReason) return customReason;\n }\n\n return null;\n};\n","//? Built-in common-password blocklist. Sourced from the public-domain\n//? SecLists Top-passwords list (Daniel Miessler), trimmed to the 200 most\n//? common entries to keep the package size small. Consumers needing a\n//? larger list (10k+ entries) can supply one through\n//? `projectConfig.auth.passwordPolicy.customValidator` and check their\n//? own data source there.\n//?\n//? Lookups are case-insensitive (the validator lower-cases before\n//? checking). Adding additional entries here is a non-breaking change.\n\nconst COMMON_PASSWORD_LIST: readonly string[] = [\n '123456', 'password', '12345678', 'qwerty', '123456789', '12345', '1234', '111111',\n '1234567', 'dragon', '123123', 'baseball', 'abc123', 'football', 'monkey', 'letmein',\n '696969', 'shadow', 'master', '666666', 'qwertyuiop', '123321', 'mustang', '1234567890',\n 'michael', '654321', 'pussy', 'superman', '1qaz2wsx', '7777777', 'fuckyou', '121212',\n '000000', 'qazwsx', '123qwe', 'killer', 'trustno1', 'jordan', 'jennifer', 'zxcvbnm',\n 'asdfgh', 'hunter', 'buster', 'soccer', 'harley', 'batman', 'andrew', 'tigger',\n 'sunshine', 'iloveyou', 'fuckme', '2000', 'charlie', 'robert', 'thomas', 'hockey',\n 'ranger', 'daniel', 'starwars', 'klaster', '112233', 'george', 'asshole', 'computer',\n 'michelle', 'jessica', 'pepper', '1111', 'zxcvbn', '555555', '11111111', '131313',\n 'freedom', '777777', 'pass', 'fuck', 'maggie', '159753', 'aaaaaa', 'ginger',\n 'princess', 'joshua', 'cheese', 'amanda', 'summer', 'love', 'ashley', '6969',\n 'nicole', 'chelsea', 'biteme', 'matthew', 'access', 'yankees', '987654321', 'dallas',\n 'austin', 'thunder', 'taylor', 'matrix', 'william', 'corvette', 'hello', 'martin',\n 'heather', 'secret', 'fucker', 'merlin', 'diamond', '1234qwer', 'gfhjkm', 'hammer',\n 'silver', 'richard', 'samantha', 'iceman', 'tigers', 'purple', 'andrea', 'horney',\n 'dakota', 'aaaaa', 'player', 'sucker', 'mercedes', 'whatever', 'orange', 'qweqwe',\n 'mickey', 'panties', 'hannah', 'fucking', 'paul', 'pakistan', 'patrick', 'martha',\n 'morgan', 'iwantu', 'lakers', 'rachel', 'slayer', 'scott', '2112', 'fish',\n 'porn', 'matt', 'qwert', 'cookie', 'eagle1', 'london', 'phoenix', 'chicago',\n 'badboy', 'braves', 'yankee', 'lover', 'barney', 'edward', 'raiders', 'green',\n 'maverick', 'rangers', 'joseph', 'maddog', 'mike', 'jasmine', 'arsenal', 'beach',\n 'tiger', 'snoopy', 'natasha', 'matthews', 'jackson', 'cumshot', 'chevy', 'gateway',\n 'gators', 'angel', 'junior', 'samson', '6666', 'amateur', 'gemini', 'apples',\n 'august', 'biscuit', '5150', 'apollo', 'parker', 'qwerty123', 'rabbit', 'angela',\n 'love123', 'rocket', 'scooby', 'beaver', 'oliver', 'walter', 'blowme', 'spider',\n '4321', 'cumshot1', 'lucky', 'helpme', 'jackie', 'monica',\n];\n\n/** Frozen Set for O(1) `.has()` lookups; entries are pre-lowercased. */\nexport const COMMON_PASSWORDS: ReadonlySet<string> = new Set(\n COMMON_PASSWORD_LIST.map((p) => p.toLowerCase()),\n);\n","//? Password-reset primitives. Used by the framework's own forgot-password\n//? flow when `ProjectConfig.auth.forgotPassword === 'framework'`, AND\n//? exported as building blocks for consumers who picked `'custom'` and want\n//? to wire their own UI / email layer without re-implementing crypto + Redis.\n//?\n//? Token format: 32 random bytes hex-encoded → 64-char URL-safe string.\n//? Storage: `<projectName>-pwreset:<sha256(token)> → userId` with a configurable\n//? TTL (`auth.passwordResetTtlSeconds`, default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): the raw token is NEVER stored — we persist only\n//? `sha256(token)` (the Redis key) via the `@luckystack/core` one-time-token\n//? primitive, so a leaked Redis keyspace can't be replayed to mint a reset.\n//? The raw token is returned to the caller exactly once for the emailed URL.\n\n// eslint-disable-next-line import-x/no-named-as-default -- bcryptjs ships both a default and namespace export; using the default mirrors its README\nimport bcrypt from 'bcryptjs';\n/* eslint-disable import-x/no-named-as-default-member -- accessing genSalt/hash/compare via the default import is the documented pattern */\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeToken, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nimport { validatePassword } from './passwordPolicy';\nimport { getUserAdapter } from './userAdapter';\n\n//? Shared key namespace for the one-time-token primitive. `formatKey('-pwreset',\n//? sha256(token))` is applied inside the primitive, so the project-name prefix /\n//? multi-tenant formatter still apply — only the SUFFIX is now the hash, not the\n//? raw token (the historical `<projectName>-pwreset:<token>` shape becomes\n//? `<projectName>-pwreset:<sha256(token)>`).\nconst PWRESET_NAMESPACE = '-pwreset';\n\n//? Per-user pointer key that records the hash-key of the currently-active reset\n//? token (LOGIN-F16). On each new issue we delete the prior hash key (if any) so\n//? at most one reset link per user is redeemable at a time — multiple live links\n//? until TTL is a session-hijack / takeover risk if the first link is intercepted.\nconst pwresetUserKey = (userId: string): string => formatKey('-pwreset-user', userId);\n\n/**\n * Create a one-time password-reset token bound to a user id. Any previously\n * issued (but not yet consumed) token for the same user is invalidated first\n * (LOGIN-F16). Stored in Redis with the configured TTL — only `sha256(token)`\n * is persisted (hash-at-rest). Returns the raw token string — caller emails it\n * to the user (typically embedded in a URL).\n */\nexport const createPasswordResetToken = async (userId: string): Promise<string> => {\n const ttl = getProjectConfig().auth.passwordResetTtlSeconds;\n\n //? Invalidate the prior active token before issuing the new one (LOGIN-F16).\n //? The pointer key holds the exact Redis key of the prior token hash, so we\n //? can delete it without possessing the raw token.\n const priorKey = await redis.get(pwresetUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(PWRESET_NAMESPACE, ttl, userId);\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n //? TTL matches the token so the pointer never outlives what it points to.\n //? `oneTimeTokenKey(namespace, rawToken)` derives the same Redis key the\n //? primitive stored — we never persist the raw token itself.\n const newHashKey = oneTimeTokenKey(PWRESET_NAMESPACE, handle.token);\n await redis.set(pwresetUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume a password-reset token. Returns the bound userId on\n * success and removes the token (one-time use, atomic GET+DEL). Returns null\n * when the token is missing, expired, malformed, or already consumed.\n */\nexport const consumePasswordResetToken = async (token: string): Promise<string | null> => {\n return consumeOneTimeToken(PWRESET_NAMESPACE, token);\n};\n\n/**\n * Bcrypt-hash a plaintext password and write it to the user record via the\n * registered user adapter. Used by the reset flow and by the settings\n * password-change flow. Validates against the active password policy\n * (`projectConfig.auth.passwordPolicy`) before hashing — throws a `PasswordPolicyError`\n * when the plaintext fails policy. Catch and surface the error code to the\n * client; framework-mode reset/change flows already do this.\n *\n * ADR — DD-LOGIN-F17: this primitive intentionally does NOT revoke sessions.\n * Session revocation is the responsibility of each calling route because the\n * right behaviour differs per use-case:\n * - `reset-password/confirmReset_v1` revokes ALL sessions (`revokeUserSessions(userId, null)`)\n * because a forgot-password reset means the old credential is compromised.\n * - `settings/changePassword_v1` revokes every OTHER session but keeps the\n * current one active (`revokeUserSessions(userId, user.token)`) so the\n * user is not kicked from the device they changed the password on.\n * - Custom consumers may want a third behaviour (e.g. re-use the existing\n * token after an admin password-force) — keeping the primitive pure lets\n * them compose it freely.\n * If you call this function directly, YOU must call `revokeUserSessions`\n * afterwards unless you have a deliberate reason not to.\n */\nexport const updatePasswordHash = async (userId: string, plaintext: string): Promise<void> => {\n const reason = validatePassword(plaintext);\n if (reason) {\n throw new PasswordPolicyError(reason);\n }\n const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await bcrypt.hash(plaintext, salt);\n const patch: Record<string, unknown> = { password: hashedPassword };\n await getUserAdapter().update(userId, patch);\n};\n\n/**\n * Thrown by `updatePasswordHash` when the supplied plaintext violates the\n * active password policy. `errorCode` matches the i18n reason key the rest\n * of the login flow uses (e.g. `login.passwordRequiresUppercase`).\n */\nexport class PasswordPolicyError extends Error {\n readonly errorCode: string;\n constructor(errorCode: string) {\n super(`Password policy violation: ${errorCode}`);\n this.name = 'PasswordPolicyError';\n this.errorCode = errorCode;\n }\n}\n\n/** Verify a plaintext password against a stored bcrypt hash. */\nexport const verifyPassword = async (plaintext: string, hash: string): Promise<boolean> => {\n return bcrypt.compare(plaintext, hash);\n};\n","//? Framework-mode forgot-password orchestration. Used only when\n//? `ProjectConfig.auth.forgotPassword === 'framework'`. Pulls in\n//? `@luckystack/email` lazily so the login package doesn't list it as a\n//? hard dep — `'custom'` and `'disabled'` modes never reach this code.\n\nimport { getProjectConfig, dispatchHook, getLogger } from '@luckystack/core';\n\nimport { loadEmailModule } from './emailModuleLoader';\nimport { createPasswordResetToken } from './passwordReset';\nimport { getUserAdapter } from './userAdapter';\n\ninterface SendResetEmailArgs {\n email: string;\n /**\n * App-facing display name used in the email greeting + subject. If omitted,\n * falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`\n * as the absolute fallback. Real consumers should set this on either the\n * call site OR via `projectConfig.auth.passwordResetBrand`.\n */\n brand?: string;\n}\n\n/**\n * Look up the user by credentials-provider email, generate a reset token,\n * and email them a reset link. Always resolves successfully regardless of\n * whether the email matched a user (anti-enumeration). Returns the result\n * of the email send for diagnostics.\n */\nexport const sendPasswordResetEmail = async ({ email, brand }: SendResetEmailArgs): Promise<{ ok: boolean; reason?: string }> => {\n const config = getProjectConfig();\n //? Log mode but NOT the raw email at start — the email is a PII field.\n getLogger().info('[forgotPassword] start', { forgotPasswordMode: config.auth.forgotPassword });\n if (config.auth.forgotPassword !== 'framework') {\n getLogger().warn('[forgotPassword] auth.forgotPassword is not \"framework\" — nothing sent', { mode: config.auth.forgotPassword });\n return { ok: false, reason: 'forgotPassword-not-framework' };\n }\n\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — the email package is an optional peer dep so its types\n // may not be resolvable at the framework's compile time.\n //? Mirror testEmail's robustness: catch a failed dynamic import instead of\n //? throwing (an uncaught throw here would bubble out of the anti-enumeration\n //? wrapper as a generic 500). Surfaces the real reason in the server log.\n const emailModule = await loadEmailModule().catch((error: unknown) => {\n getLogger().warn('[forgotPassword] failed to load @luckystack/email — is it installed?', { error: String(error) });\n return null;\n });\n if (!emailModule) {\n return { ok: false, reason: 'email-module-load-failed' };\n }\n const { sendEmail } = emailModule;\n\n const userAdapter = getUserAdapter();\n const user = await userAdapter.findByEmail({ email, provider: 'credentials' });\n //? Log found/userId without the raw email — logging email+found together is\n //? an enumeration oracle (any reader of the log can probe the user table).\n getLogger().info('[forgotPassword] credentials user lookup', { found: Boolean(user), userId: user?.id ?? null });\n\n // Anti-enumeration: always pretend it succeeded if the email isn't found.\n if (!user) {\n //? The client always gets \"success\" (anti-enumeration). This server-side\n //? line is the ONLY signal that the address simply isn't a credentials\n //? (email+password) account — by far the most common reason a reset email\n //? never arrives (OAuth-only signups and unknown/typo'd addresses have no\n //? credentials row, so there is nothing to reset).\n //? Do NOT include the email here — this line together with the email would\n //? let a log reader determine which addresses have credentials accounts.\n getLogger().warn('[forgotPassword] no credentials account for this email — nothing sent. Use the email+password account you registered with.');\n void dispatchHook('passwordResetRequested', {\n email,\n matched: false,\n });\n return { ok: true };\n }\n\n const token = await createPasswordResetToken(user.id);\n //? SEC: do NOT emit the raw token on the hook bus — hook subscribers are\n //? untrusted code and the token is a single-use credential. Consumers who need\n //? to deliver the email themselves should call `createPasswordResetToken`\n //? directly and build the URL without the framework exposing the secret here.\n void dispatchHook('passwordResetRequested', {\n email,\n matched: true,\n userId: user.id,\n ttlSeconds: config.auth.passwordResetTtlSeconds,\n });\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: the reset token is in the URL query string\n //? (`?token=<hex>`), not in the URL fragment (`#token=<hex>`).\n //? Tradeoff: query strings appear in server access logs and Referer headers\n //? if the user's mail client renders the link via a tracking proxy. Fragment\n //? identifiers (`#`) are never sent to the server and are therefore\n //? invisible to any log / proxy in the chain — they are more private.\n //? Decision: query-string is the standard email-link pattern understood by\n //? every SPA router, and tokens here are one-time + hashed at rest (Redis\n //? holds only `sha256(token)`), so the exposure window is the TTL of a\n //? single-use credential, not a replayable secret. Logging is mitigated\n //? by not logging the full URL at the point of issue (we log `userId`, not\n //? `resetUrl`). A future `auth.tokenLinkMode: 'fragment'` option can be\n //? introduced without touching this file — callers receive the raw token\n //? and can build their own URL.\n const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;\n\n const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);\n\n //? Dispatch via the `'password-reset'` template rather than building the\n //? email inline. `@luckystack/email` ships a built-in for this name, so the\n //? out-of-the-box copy is unchanged — but a consumer can now\n //? `registerEmailTemplate('password-reset', …)` to translate or rebrand it\n //? without forking this flow (CFG-05). `adapterHint: 'transactional'` routes\n //? through a dedicated transactional sender when one is registered, else the\n //? default sender.\n const result = await sendEmail({\n to: user.email,\n template: 'password-reset',\n data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n if (result.ok) {\n //? SEC: log only userId for correlation — not the email address (PII).\n getLogger().info('[forgotPassword] reset email dispatched', { userId: user.id });\n } else {\n getLogger().warn('[forgotPassword] reset email send FAILED', { userId: user.id, reason: result.reason });\n }\n\n return result.ok ? { ok: true } : { ok: false, reason: result.reason };\n};\n","//? Shared lazy-loader for the optional `@luckystack/email` peer dep. Both\n//? `forgotPassword.ts` and `emailChangeNotification.ts` need the SAME dynamic\n//? import + the SAME minimal `EmailModule` shape, so the interface + the\n//? `@ts-expect-error` import + the cast live here once (finding 37).\n//?\n//? IMPORTANT — this helper does NOT swallow a failed import. It resolves to the\n//? module or REJECTS exactly like the raw `import(...)` would. That preserves\n//? the two callers' DIFFERENT failure behavior verbatim:\n//? - `emailChangeNotification` awaits it directly (a load failure throws).\n//? - `forgotPassword` wraps it in `.catch(...)` (a load failure → null + warn).\n//? Centralizing the catch here would change one of those — do NOT.\n\n/** Minimal surface of `@luckystack/email` that the login orchestrators use. */\nexport interface EmailModule {\n sendEmail: (input: Record<string, unknown>) => Promise<{ ok: boolean; reason?: string }>;\n}\n\n/**\n * Dynamically import the optional `@luckystack/email` peer. Resolves to the\n * module, or REJECTS if the package is not installed (same as a bare\n * `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.\n */\nexport const loadEmailModule = (): Promise<EmailModule> =>\n // @ts-expect-error optional peer dep — installed only when the consumer wires email sending\n import('@luckystack/email') as Promise<EmailModule>;\n","//? Email-change token primitives. Mirrors `passwordReset.ts` for the\n//? confirm-via-email-link flow: user submits a new address, framework mails\n//? a tokenized URL to the NEW address, click-through confirms and updates\n//? `User.email` + revokes all sessions.\n//?\n//? Token format: 32 random bytes hex-encoded -> 64-char URL-safe string.\n//? Storage: `<projectName>-email-change:<sha256(token)>` -> JSON payload\n//? `{ userId: string; newEmail: string }`\n//? TTL: `auth.emailChangeTtlSeconds` (default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): only `sha256(token)` is persisted (the Redis key)\n//? via the `@luckystack/core` one-time-token primitive, so a leaked Redis\n//? keyspace can't be replayed to confirm an email change. The raw token is\n//? returned to the caller exactly once for the emailed URL.\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeTokenJson, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nconst EMAIL_CHANGE_NAMESPACE = '-email-change';\n\n//? Per-user pointer key for prior-token invalidation (LOGIN-F16), mirroring the\n//? same pattern used for password-reset tokens. Stored as a formatted key so the\n//? multi-tenant key formatter applies uniformly.\nconst emailChangeUserKey = (userId: string): string => formatKey('-email-change-user', userId);\n\nexport interface EmailChangePayload {\n userId: string;\n newEmail: string;\n}\n\n/**\n * Create a one-time email-change token bound to a user id + the new email\n * address. Any previously issued (but not yet consumed) token for the same\n * user is invalidated first (LOGIN-F16) so at most one confirmation link per\n * user is redeemable. Stored in Redis with the configured TTL — only\n * `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —\n * caller emails the token (embedded in a URL) to the NEW address so the\n * confirmation click proves ownership of the new mailbox.\n */\nexport const createEmailChangeToken = async (userId: string, newEmail: string): Promise<string> => {\n const ttl = getProjectConfig().auth.emailChangeTtlSeconds;\n\n //? Invalidate the prior active email-change token (LOGIN-F16). Without this,\n //? multiple links could be outstanding concurrently — a link sent to an\n //? attacker-controlled address (via a race or a compromised session) would\n //? remain redeemable even after the user re-requests the flow.\n const priorKey = await redis.get(emailChangeUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(\n EMAIL_CHANGE_NAMESPACE,\n ttl,\n { userId, newEmail } satisfies EmailChangePayload,\n );\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n const newHashKey = oneTimeTokenKey(EMAIL_CHANGE_NAMESPACE, handle.token);\n await redis.set(emailChangeUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume an email-change token. Returns the bound\n * `{ userId, newEmail }` on success and removes the token (one-time use, atomic\n * GET+DEL). Returns null when the token is missing, expired, malformed, or\n * already consumed.\n */\nexport const consumeEmailChangeToken = async (token: string): Promise<EmailChangePayload | null> => {\n const parsed = await consumeOneTimeTokenJson<EmailChangePayload>(EMAIL_CHANGE_NAMESPACE, token);\n if (\n !parsed\n || typeof parsed !== 'object'\n || typeof parsed.userId !== 'string'\n || typeof parsed.newEmail !== 'string'\n ) {\n return null;\n }\n return parsed;\n};\n","//? Framework-mode \"email change confirmation\" orchestration. Renders the\n//? confirmation email + sends it to the NEW address (proves the user can\n//? receive at the new mailbox). Mirrors the lazy-import pattern from\n//? `forgotPassword.ts` so `@luckystack/email` stays an optional peer.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { createEmailChangeToken } from './emailChange';\nimport { loadEmailModule } from './emailModuleLoader';\n\ninterface SendEmailChangeArgs {\n userId: string;\n newEmail: string;\n /** Optional display name used in the email greeting (falls back to \"there\"). */\n userName?: string | null;\n /**\n * App-facing display name used in the email greeting + subject. Falls back\n * to `projectConfig.auth.passwordResetBrand` (the shared brand label), then\n * to `'LuckyStack'` as the absolute fallback.\n */\n brand?: string;\n}\n\n/**\n * Mint a one-shot email-change token and send a confirmation email to the\n * NEW address with the tokenized URL. Returns the send result for diagnostics\n * + the token itself (callers can audit-log it through the\n * `postEmailChangeRequested` hook).\n */\nexport const sendEmailChangeConfirmation = async (\n { userId, newEmail, userName, brand }: SendEmailChangeArgs,\n): Promise<{ ok: boolean; reason?: string; token: string }> => {\n const config = getProjectConfig();\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — `@luckystack/email` is an optional peer dep. A failed load\n //? rejects here (unlike forgotPassword's catch); this caller's behavior is\n //? unchanged — the rejection bubbles out exactly as the inline import did.\n const { sendEmail } = await loadEmailModule();\n\n const token = await createEmailChangeToken(userId, newEmail);\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: same query-string tradeoff as the password-reset\n //? flow (see `forgotPassword.ts`). Tokens are one-time + hashed at rest;\n //? the raw token is never persisted. The confirm URL is only sent to the\n //? NEW address (verifying mailbox ownership), so it never appears in an\n //? access log that an attacker of the OLD address could read.\n const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;\n const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);\n\n //? Dispatch via the built-in `'email-change'` template so consumers can\n //? override the copy with `registerEmailTemplate('email-change', …)` without\n //? forking (CFG-05). Default copy is unchanged.\n const result = await sendEmail({\n to: newEmail,\n template: 'email-change',\n data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };\n};\n"],"mappings":"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","names":["getLogger","getProjectConfig","getProjectConfig","getLogger","getProjectConfig","getProjectConfig","getProjectConfig","getLogger","validationError","response","bcrypt","getProjectConfig","formatKey","formatKey","getProjectConfig","bcrypt","hash","getProjectConfig","dispatchHook","getLogger","getProjectConfig","getLogger","dispatchHook","getProjectConfig","issueOneTimeToken","formatKey","redis","oneTimeTokenKey","getProjectConfig","getProjectConfig"]}
package/dist/register.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  microsoftProvider,
10
10
  registerOAuthProviderFactory,
11
11
  registerUserAdapter
12
- } from "./chunk-22NFCMM7.js";
12
+ } from "./chunk-2WYYKD2D.js";
13
13
 
14
14
  // src/register.ts
15
15
  import { getProjectConfig } from "@luckystack/core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/login",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -15,7 +15,7 @@
15
15
  "url": "git+https://github.com/ItsLucky23/LuckyStack-v2.git",
16
16
  "directory": "packages/login"
17
17
  },
18
- "homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/master/packages/login#readme",
18
+ "homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/main/packages/login#readme",
19
19
  "bugs": {
20
20
  "url": "https://github.com/ItsLucky23/LuckyStack-v2/issues"
21
21
  },
@@ -59,7 +59,7 @@
59
59
  "test": "vitest run"
60
60
  },
61
61
  "dependencies": {
62
- "@luckystack/core": "^0.2.4",
62
+ "@luckystack/core": "^0.2.6",
63
63
  "bcryptjs": "^3.0.0",
64
64
  "dotenv": "^17.0.0",
65
65
  "validator": "^13.15.35"
@@ -67,7 +67,7 @@
67
67
  "peerDependencies": {
68
68
  "@prisma/client": "^6.19.0",
69
69
  "socket.io": "^4.8.0",
70
- "@luckystack/email": "^0.2.4"
70
+ "@luckystack/email": "^0.2.6"
71
71
  },
72
72
  "peerDependenciesMeta": {
73
73
  "@luckystack/email": {