@luckystack/login 0.7.3 → 0.7.5
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.
- package/CHANGELOG.md +20 -0
- package/CLAUDE.md +5 -2
- package/dist/{chunk-L5MHCPNN.js → chunk-CLYMWF3A.js} +1 -1
- package/dist/{chunk-L5MHCPNN.js.map → chunk-CLYMWF3A.js.map} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +190 -69
- package/dist/index.js.map +1 -1
- package/dist/register.js +1 -1
- package/docs/redirect-validation.md +5 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.4] - 2026-07-22
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Relative `postLoginRedirect` results are now resolved against the trusted
|
|
15
|
+
absolute frontend fallback URL. In split frontend/backend deployments,
|
|
16
|
+
`/dashboard` no longer lands on the OAuth callback's backend origin.
|
|
17
|
+
- Concurrent TOTP enrollment confirmation is serialized per user, so at most
|
|
18
|
+
one caller receives the recovery-code set that was actually persisted.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- TOTP ciphertext now carries a non-secret key id (`enc:v2`) and supports a
|
|
23
|
+
JSON decrypt-only legacy keyring through `TOTP_ENCRYPTION_LEGACY_KEYS`.
|
|
24
|
+
Successful TOTP proofs lazily migrate plaintext, legacy `gcm:` and old-key
|
|
25
|
+
rows to the current primary, enabling rotation without locking users out.
|
|
26
|
+
- Email-code issue and verification are now complete Redis Lua transactions.
|
|
27
|
+
A stale verifier can no longer authenticate with the previous generation,
|
|
28
|
+
consume the replacement's attempt budget, or delete a newly reissued code.
|
|
29
|
+
|
|
10
30
|
## [0.7.3] - 2026-07-20
|
|
11
31
|
|
|
12
32
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- **Lifecycle hooks**: `preLogin` / `postLogin`, `preRegister` / `postRegister`, `preLogout` / `postLogout`, `preSessionCreate` / `postSessionCreate`, `preSessionDelete` / `postSessionDelete`, `passwordResetRequested`, `passwordResetCompleted`, `passwordChanged`. All `pre*` hooks support stop-signals (abort the side-effect with a reason key).
|
|
16
16
|
- **Pluggable user store** via `UserAdapter` — decouples auth from a specific Prisma `User` schema.
|
|
17
17
|
- **Password-reset primitives** (`createPasswordResetToken`, `consumePasswordResetToken`, `updatePasswordHash`, `verifyPassword`) plus the framework-mode `sendPasswordResetEmail` orchestrator (requires the optional `@luckystack/email` peer).
|
|
18
|
-
- **Dynamic post-login redirect** resolver registry (per-user / per-tenant / per-provider OAuth landing pages).
|
|
18
|
+
- **Dynamic post-login redirect** resolver registry (per-user / per-tenant / per-provider OAuth landing pages). Relative resolver results inherit the trusted frontend `defaultUrl` origin, not the backend callback origin.
|
|
19
19
|
|
|
20
20
|
OAuth state is stored in Redis under `${projectName}-oauth-state:<provider>:<state>` with TTL `auth.oauthStateTtlSeconds`. Sessions live at `${projectName}-session:<token>`. Active-tokens-per-user set lives at `${projectName}-activeUsers:<userId>`. Password-reset and email-change tokens are **hashed at rest** (0.2.0): only `sha256(token)` is stored as the Redis key (`${projectName}-pwreset:<sha256(token)>` / `${projectName}-email-change:<sha256(token)>`) via the `@luckystack/core` one-time-token primitive (`issueOneTimeToken` / `consumeOneTimeToken`). The raw token is returned to the caller exactly once for the emailed URL, so a leaked Redis keyspace can't be replayed to mint a reset or confirm an email change.
|
|
21
21
|
|
|
@@ -97,7 +97,7 @@ Do NOT use this package for:
|
|
|
97
97
|
|
|
98
98
|
- `registerPostLoginRedirect(resolver)` — Register a `({ userId, provider, isNewUser, defaultUrl }) => string | Promise<string>` resolver.
|
|
99
99
|
- `getPostLoginRedirect()` — Read the active resolver (or `null`).
|
|
100
|
-
- The resolver's returned URL is validated against `http.cors.allowedOrigins` before use; invalid URLs fall back to `defaultUrl`.
|
|
100
|
+
- The resolver's returned URL is validated against `http.cors.allowedOrigins` before use; invalid URLs fall back to `defaultUrl`. A valid relative result is then resolved against an absolute `defaultUrl`, so split frontend/backend callback deployments return to the frontend origin.
|
|
101
101
|
|
|
102
102
|
### Password reset (`./src/passwordReset.ts`, `./src/forgotPassword.ts`, `./src/passwordPolicy.ts`)
|
|
103
103
|
|
|
@@ -108,6 +108,7 @@ Do NOT use this package for:
|
|
|
108
108
|
- `PasswordPolicyError` — Thrown by `updatePasswordHash`; carries `errorCode` matching the i18n reason keys used by the rest of the login flow.
|
|
109
109
|
- `validatePassword(plaintext)` — Returns `null` when the policy passes, or a reason key when it fails (length, complexity, common-list, custom validator).
|
|
110
110
|
- `sendPasswordResetEmail({ email, brand? })` — Framework-mode orchestrator. Looks up the user (credentials provider only), mints a token, lazy-imports `@luckystack/email`, and sends a transactional reset email. Always resolves "ok" when the email is not found (anti-enumeration). No-op when `auth.forgotPassword !== 'framework'`.
|
|
111
|
+
- Email-code issue/verify (`emailOtp.ts`) — passwordless login + 2FA fallback primitive. Issue (`SET code hash + reset attempts`) and verify (`read current hash + increment attempts + compare + consume`) each run as one Redis Lua transaction, so reissue and verification are generation-linearizable; a stale code cannot delete or spend the replacement.
|
|
111
112
|
- `createEmailChangeToken(userId, newEmail)` — Mint a one-shot token bound to `userId` + `newEmail`. Stored in Redis at `${projectName}-email-change:<sha256(token)>` (hashed at rest, via `@luckystack/core`'s `issueOneTimeToken`) with TTL from `auth.emailChangeTtlSeconds`. Returns the RAW token.
|
|
112
113
|
- `consumeEmailChangeToken(token)` — Atomic get + del redemption. Returns `{ userId, newEmail }` on success, `null` on miss / malformed payload / expired entry.
|
|
113
114
|
- `sendEmailChangeConfirmation({ userId, newEmail, userName?, brand? })` — Lazy-imports `@luckystack/email`, renders the confirmation template via `renderEmailLayout`, and sends to the NEW address with `adapterHint: 'transactional'`. Returns `{ ok, reason?, token }` — `token` is the minted email-change token so the caller can build the confirmation URL.
|
|
@@ -140,6 +141,8 @@ All config keys live on `ProjectConfig` (from `@luckystack/core`). Resolved at c
|
|
|
140
141
|
| `MICROSOFT_TENANT_ID` | `microsoftProvider` (optional) | Single-tenant Azure AD. Defaults to `'common'`. |
|
|
141
142
|
| (callback origin) | All OAuth `callbackUrl` builders | The `callbackUrl` is the BACKEND origin + `/auth/callback/<provider>`. The scaffold derives it in `config.ts` (`oauthCallbackBase`: dev `http://localhost:80`, prod the public domain). No `DNS` env var — that was removed in 0.1.5. |
|
|
142
143
|
| `BCRYPT_ROUNDS` | `auth.bcryptRounds` | Surfaced through project config; salt rounds for credentials hashing. |
|
|
144
|
+
| `TOTP_ENCRYPTION_KEY` | TOTP at-rest encryption | Current primary write key; new rows are `enc:v2:<key-id>:...`. |
|
|
145
|
+
| `TOTP_ENCRYPTION_LEGACY_KEYS` | TOTP key rotation | JSON array of decrypt-only previous keys. Successful TOTP proofs lazily rewrite legacy/plaintext rows under the primary. |
|
|
143
146
|
|
|
144
147
|
> Env-key set without the matching package installed = hard boot crash (see peer-dep guard policy). `@luckystack/email` is the optional case — login lazy-imports it and gracefully fails when `forgotPassword !== 'framework'`.
|
|
145
148
|
|
|
@@ -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 /**\n * Set `true` for a provider whose userinfo ONLY returns an email once the\n * address is confirmed with the provider (e.g. Facebook Graph `/me` omits\n * unconfirmed emails). Such a provider exposes no separate verified flag, so\n * the mere PRESENCE of the email is the verified signal — this opts it in so\n * both first-login CREATION and cross-provider LINK treat it as verified.\n * Mutually exclusive in practice with `emailVerifiedKey`.\n */\n emailImpliesVerified?: boolean;\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. `emailImpliesVerified` encodes that so a Facebook sign-in counts as\n //? verified for BOTH first-login creation and cross-provider linking (SEC-21 /\n //? M2 creation guard). If a consumer opts into receiving unverified emails via\n //? a future Graph field, set `emailVerifiedKey` on the returned provider instead.\n emailImpliesVerified: true,\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 * 2FA (ADR 0024): whether this user enrolled a second factor. All three 2FA\n * fields are OPTIONAL so pre-0.6 schemas/adapters keep working unchanged —\n * an adapter that never returns them simply has no 2FA-capable users.\n */\n twoFactorEnabled?: boolean | null;\n /**\n * 2FA: the TOTP shared secret (base32; `gcm:`-prefixed when encrypted at\n * rest via TOTP_ENCRYPTION_KEY). NEVER reaches the client — stripped by\n * `sanitizeUserForSession` before any session persist/broadcast.\n */\n totpSecret?: string | null;\n /** 2FA: sha256 hashes of the UNUSED one-time recovery codes (also stripped). */\n recoveryCodes?: string[] | 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 /**\n * Set `true` for a provider whose userinfo ONLY returns an email once the\n * address is confirmed with the provider (e.g. Facebook Graph `/me` omits\n * unconfirmed emails). Such a provider exposes no separate verified flag, so\n * the mere PRESENCE of the email is the verified signal — this opts it in so\n * both first-login CREATION and cross-provider LINK treat it as verified.\n * Mutually exclusive in practice with `emailVerifiedKey`.\n */\n emailImpliesVerified?: boolean;\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. `emailImpliesVerified` encodes that so a Facebook sign-in counts as\n //? verified for BOTH first-login creation and cross-provider linking (SEC-21 /\n //? M2 creation guard). If a consumer opts into receiving unverified emails via\n //? a future Graph field, set `emailVerifiedKey` on the returned provider instead.\n emailImpliesVerified: true,\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 * 2FA (ADR 0024): whether this user enrolled a second factor. All three 2FA\n * fields are OPTIONAL so pre-0.6 schemas/adapters keep working unchanged —\n * an adapter that never returns them simply has no 2FA-capable users.\n */\n twoFactorEnabled?: boolean | null;\n /**\n * 2FA: the TOTP shared secret (base32; new encrypted rows use\n * `enc:v2:<key-id>:...`, while legacy `gcm:` rows remain readable through\n * TOTP_ENCRYPTION_LEGACY_KEYS). NEVER reaches the client — stripped by\n * `sanitizeUserForSession` before any session persist/broadcast.\n */\n totpSecret?: string | null;\n /** 2FA: sha256 hashes of the UNUSED one-time recovery codes (also stripped). */\n recoveryCodes?: string[] | 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
|
@@ -326,8 +326,9 @@ interface UserRecord extends BaseSessionLayout {
|
|
|
326
326
|
*/
|
|
327
327
|
twoFactorEnabled?: boolean | null;
|
|
328
328
|
/**
|
|
329
|
-
* 2FA: the TOTP shared secret (base32;
|
|
330
|
-
*
|
|
329
|
+
* 2FA: the TOTP shared secret (base32; new encrypted rows use
|
|
330
|
+
* `enc:v2:<key-id>:...`, while legacy `gcm:` rows remain readable through
|
|
331
|
+
* TOTP_ENCRYPTION_LEGACY_KEYS). NEVER reaches the client — stripped by
|
|
331
332
|
* `sanitizeUserForSession` before any session persist/broadcast.
|
|
332
333
|
*/
|
|
333
334
|
totpSecret?: string | null;
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
registerOAuthProviderFactory,
|
|
15
15
|
registerOAuthProviders,
|
|
16
16
|
registerUserAdapter
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-CLYMWF3A.js";
|
|
18
18
|
import {
|
|
19
19
|
activeUsersKeyFor,
|
|
20
20
|
deleteSession,
|
|
@@ -139,6 +139,16 @@ var registerPostLoginRedirect = (resolver) => {
|
|
|
139
139
|
return resolver;
|
|
140
140
|
};
|
|
141
141
|
var getPostLoginRedirect = () => activeResolver;
|
|
142
|
+
var RELATIVE_BASE = "http://luckystack-relative.invalid";
|
|
143
|
+
var resolvePostLoginRedirectAgainstDefault = (candidate, defaultUrl) => {
|
|
144
|
+
if (!URL.canParse(candidate, RELATIVE_BASE)) return candidate;
|
|
145
|
+
const parsed = new URL(candidate, RELATIVE_BASE);
|
|
146
|
+
if (parsed.origin !== RELATIVE_BASE) return candidate;
|
|
147
|
+
if (!URL.canParse(defaultUrl)) return candidate;
|
|
148
|
+
const fallback = new URL(defaultUrl);
|
|
149
|
+
if (fallback.protocol !== "http:" && fallback.protocol !== "https:") return candidate;
|
|
150
|
+
return new URL(candidate, fallback).toString();
|
|
151
|
+
};
|
|
142
152
|
|
|
143
153
|
// src/login.ts
|
|
144
154
|
import { URLSearchParams as URLSearchParams2 } from "url";
|
|
@@ -987,7 +997,9 @@ var resolvePostLoginRedirect = async ({
|
|
|
987
997
|
getLogger3().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });
|
|
988
998
|
return fallbackUrl;
|
|
989
999
|
}
|
|
990
|
-
if (resolved && isAllowedRedirectUrl(resolved))
|
|
1000
|
+
if (resolved && isAllowedRedirectUrl(resolved)) {
|
|
1001
|
+
return resolvePostLoginRedirectAgainstDefault(resolved, fallbackUrl);
|
|
1002
|
+
}
|
|
991
1003
|
if (resolved) {
|
|
992
1004
|
getLogger3().warn(
|
|
993
1005
|
`[oauth] postLoginRedirect returned a URL not in allowed origins \u2014 falling back`,
|
|
@@ -1267,33 +1279,53 @@ var normalizeIdentity = (identity) => identity.trim().toLowerCase();
|
|
|
1267
1279
|
var codeKey = (purpose, identity) => formatKey4("-emailcode", `${purpose}:${normalizeIdentity(identity)}`);
|
|
1268
1280
|
var attemptsKey = (purpose, identity) => formatKey4("-emailcode-attempts", `${purpose}:${normalizeIdentity(identity)}`);
|
|
1269
1281
|
var hashCode = (code) => crypto.createHash("sha256").update(code).digest("hex");
|
|
1282
|
+
var ISSUE_CODE_SCRIPT = `
|
|
1283
|
+
redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
1284
|
+
redis.call('del', KEYS[2])
|
|
1285
|
+
return 1
|
|
1286
|
+
`;
|
|
1287
|
+
var VERIFY_CODE_SCRIPT = `
|
|
1288
|
+
local stored = redis.call('get', KEYS[1])
|
|
1289
|
+
if not stored then return 0 end
|
|
1290
|
+
local attempts = redis.call('incr', KEYS[2])
|
|
1291
|
+
local remaining = redis.call('ttl', KEYS[1])
|
|
1292
|
+
redis.call('expire', KEYS[2], remaining > 0 and remaining or 600)
|
|
1293
|
+
if attempts > tonumber(ARGV[2]) then
|
|
1294
|
+
redis.call('del', KEYS[1])
|
|
1295
|
+
redis.call('del', KEYS[2])
|
|
1296
|
+
return 3
|
|
1297
|
+
end
|
|
1298
|
+
if stored ~= ARGV[1] then return 1 end
|
|
1299
|
+
redis.call('del', KEYS[1])
|
|
1300
|
+
redis.call('del', KEYS[2])
|
|
1301
|
+
return 2
|
|
1302
|
+
`;
|
|
1270
1303
|
var generateNumericCode = (digits) => String(crypto.randomInt(0, 10 ** digits)).padStart(digits, "0");
|
|
1271
1304
|
var issueEmailCode = async ({ purpose, identity, ttlSeconds, digits }) => {
|
|
1272
1305
|
const code = generateNumericCode(digits);
|
|
1273
|
-
await redis3.
|
|
1274
|
-
|
|
1306
|
+
await redis3.eval(
|
|
1307
|
+
ISSUE_CODE_SCRIPT,
|
|
1308
|
+
2,
|
|
1309
|
+
codeKey(purpose, identity),
|
|
1310
|
+
attemptsKey(purpose, identity),
|
|
1311
|
+
hashCode(code),
|
|
1312
|
+
String(ttlSeconds)
|
|
1313
|
+
);
|
|
1275
1314
|
return code;
|
|
1276
1315
|
};
|
|
1277
1316
|
var verifyEmailCode = async ({ purpose, identity, code, maxAttempts }) => {
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
const submitted = Buffer.from(hashCode(code.trim()));
|
|
1291
|
-
const expected = Buffer.from(storedHash);
|
|
1292
|
-
const matches = submitted.length === expected.length && crypto.timingSafeEqual(submitted, expected);
|
|
1293
|
-
if (!matches) return "invalid";
|
|
1294
|
-
const deleted = await redis3.del(key);
|
|
1295
|
-
await redis3.del(counter);
|
|
1296
|
-
return deleted > 0 ? "valid" : "expired";
|
|
1317
|
+
const result = Number(await redis3.eval(
|
|
1318
|
+
VERIFY_CODE_SCRIPT,
|
|
1319
|
+
2,
|
|
1320
|
+
codeKey(purpose, identity),
|
|
1321
|
+
attemptsKey(purpose, identity),
|
|
1322
|
+
hashCode(code.trim()),
|
|
1323
|
+
String(maxAttempts)
|
|
1324
|
+
));
|
|
1325
|
+
if (result === 2) return "valid";
|
|
1326
|
+
if (result === 1) return "invalid";
|
|
1327
|
+
if (result === 3) return "locked";
|
|
1328
|
+
return "expired";
|
|
1297
1329
|
};
|
|
1298
1330
|
var clearEmailCode = async (purpose, identity) => {
|
|
1299
1331
|
await redis3.del(codeKey(purpose, identity));
|
|
@@ -1378,16 +1410,65 @@ var buildOtpauthUri = ({ secret, accountName, issuer }) => {
|
|
|
1378
1410
|
|
|
1379
1411
|
// src/twoFactor.ts
|
|
1380
1412
|
var sha256 = (value) => crypto3.createHash("sha256").update(value).digest("hex");
|
|
1381
|
-
var
|
|
1413
|
+
var LEGACY_ENCRYPTED_PREFIX = "gcm:";
|
|
1414
|
+
var VERSIONED_ENCRYPTED_PREFIX = "enc:v2:";
|
|
1415
|
+
var KEY_ID_LENGTH = 24;
|
|
1382
1416
|
var warnedPlaintextSecret = false;
|
|
1383
|
-
var
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1417
|
+
var warnedInvalidLegacyKeyRing = false;
|
|
1418
|
+
var toEncryptionKey = (raw) => ({
|
|
1419
|
+
id: crypto3.createHash("sha256").update(`luckystack-totp-key-id:${raw}`).digest("hex").slice(0, KEY_ID_LENGTH),
|
|
1420
|
+
key: crypto3.createHash("sha256").update(raw).digest()
|
|
1421
|
+
});
|
|
1422
|
+
var readLegacyEncryptionKeys = () => {
|
|
1423
|
+
const raw = process.env.TOTP_ENCRYPTION_LEGACY_KEYS;
|
|
1424
|
+
if (!raw) return [];
|
|
1425
|
+
try {
|
|
1426
|
+
const parsed = JSON.parse(raw);
|
|
1427
|
+
if (!Array.isArray(parsed)) {
|
|
1428
|
+
throw new TypeError("expected a JSON array of non-empty strings");
|
|
1429
|
+
}
|
|
1430
|
+
const keys = [];
|
|
1431
|
+
for (const value of parsed) {
|
|
1432
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1433
|
+
throw new TypeError("expected a JSON array of non-empty strings");
|
|
1434
|
+
}
|
|
1435
|
+
keys.push(value);
|
|
1436
|
+
}
|
|
1437
|
+
return keys;
|
|
1438
|
+
} catch {
|
|
1439
|
+
if (!warnedInvalidLegacyKeyRing) {
|
|
1440
|
+
warnedInvalidLegacyKeyRing = true;
|
|
1441
|
+
getLogger5().error("[2fa] TOTP_ENCRYPTION_LEGACY_KEYS must be a JSON array of non-empty strings; legacy TOTP rows cannot be decrypted until it is fixed.");
|
|
1442
|
+
}
|
|
1443
|
+
return [];
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
var encryptionKeyRing = () => {
|
|
1447
|
+
const primaryRaw = process.env.TOTP_ENCRYPTION_KEY;
|
|
1448
|
+
const primary = primaryRaw ? toEncryptionKey(primaryRaw) : null;
|
|
1449
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1450
|
+
const all = [];
|
|
1451
|
+
for (const raw of [primaryRaw, ...readLegacyEncryptionKeys()]) {
|
|
1452
|
+
if (!raw) continue;
|
|
1453
|
+
const entry = toEncryptionKey(raw);
|
|
1454
|
+
if (seen.has(entry.id)) continue;
|
|
1455
|
+
seen.add(entry.id);
|
|
1456
|
+
all.push(entry);
|
|
1457
|
+
}
|
|
1458
|
+
return { primary, all };
|
|
1459
|
+
};
|
|
1460
|
+
var decryptWithKey = (key, ivPart, tagPart, dataPart) => {
|
|
1461
|
+
try {
|
|
1462
|
+
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
|
|
1463
|
+
decipher.setAuthTag(Buffer.from(tagPart, "base64"));
|
|
1464
|
+
return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
|
|
1465
|
+
} catch {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1387
1468
|
};
|
|
1388
1469
|
var encryptTotpSecret = (secret) => {
|
|
1389
|
-
const
|
|
1390
|
-
if (!
|
|
1470
|
+
const { primary } = encryptionKeyRing();
|
|
1471
|
+
if (!primary) {
|
|
1391
1472
|
if (!warnedPlaintextSecret) {
|
|
1392
1473
|
warnedPlaintextSecret = true;
|
|
1393
1474
|
getLogger5().warn("[2fa] TOTP_ENCRYPTION_KEY is not set \u2014 TOTP secrets are stored unencrypted. Set it in .env.local to encrypt new enrollments at rest.");
|
|
@@ -1395,34 +1476,58 @@ var encryptTotpSecret = (secret) => {
|
|
|
1395
1476
|
return secret;
|
|
1396
1477
|
}
|
|
1397
1478
|
const iv = crypto3.randomBytes(12);
|
|
1398
|
-
const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
|
|
1479
|
+
const cipher = crypto3.createCipheriv("aes-256-gcm", primary.key, iv);
|
|
1399
1480
|
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
|
|
1400
|
-
return `${
|
|
1401
|
-
};
|
|
1402
|
-
var
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1481
|
+
return `${VERSIONED_ENCRYPTED_PREFIX}${primary.id}:${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${ciphertext.toString("base64")}`;
|
|
1482
|
+
};
|
|
1483
|
+
var decryptTotpSecretWithMetadata = (stored) => {
|
|
1484
|
+
const ring = encryptionKeyRing();
|
|
1485
|
+
if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX) && !stored.startsWith("enc:")) {
|
|
1486
|
+
return { secret: stored, needsReencrypt: ring.primary !== null };
|
|
1487
|
+
}
|
|
1488
|
+
if (stored.startsWith(VERSIONED_ENCRYPTED_PREFIX)) {
|
|
1489
|
+
const [keyId, ivPart2, tagPart2, dataPart2] = stored.slice(VERSIONED_ENCRYPTED_PREFIX.length).split(":");
|
|
1490
|
+
if (!keyId || !ivPart2 || !tagPart2 || !dataPart2) return null;
|
|
1491
|
+
const entry = ring.all.find((candidate) => candidate.id === keyId);
|
|
1492
|
+
if (!entry) {
|
|
1493
|
+
getLogger5().error("[2fa] TOTP secret references an unavailable encryption key id. Add the previous key to TOTP_ENCRYPTION_LEGACY_KEYS.");
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
const secret = decryptWithKey(entry.key, ivPart2, tagPart2, dataPart2);
|
|
1497
|
+
if (!secret) {
|
|
1498
|
+
getLogger5().error("[2fa] TOTP secret decryption failed (ciphertext is invalid or the configured key is wrong).");
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
return {
|
|
1502
|
+
secret,
|
|
1503
|
+
needsReencrypt: ring.primary !== null && entry.id !== ring.primary.id
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX)) {
|
|
1507
|
+
getLogger5().error("[2fa] unsupported TOTP ciphertext version.");
|
|
1407
1508
|
return null;
|
|
1408
1509
|
}
|
|
1409
|
-
const [ivPart, tagPart, dataPart] = stored.slice(
|
|
1410
|
-
if (!ivPart || !tagPart || !dataPart)
|
|
1411
|
-
|
|
1412
|
-
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
|
|
1413
|
-
decipher.setAuthTag(Buffer.from(tagPart, "base64"));
|
|
1414
|
-
return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
|
|
1415
|
-
} catch {
|
|
1416
|
-
getLogger5().error("[2fa] TOTP secret decryption failed (wrong TOTP_ENCRYPTION_KEY?).");
|
|
1510
|
+
const [ivPart, tagPart, dataPart] = stored.slice(LEGACY_ENCRYPTED_PREFIX.length).split(":");
|
|
1511
|
+
if (!ivPart || !tagPart || !dataPart || ring.all.length === 0) {
|
|
1512
|
+
getLogger5().error("[2fa] found a legacy encrypted TOTP secret but no matching primary/legacy key is configured.");
|
|
1417
1513
|
return null;
|
|
1418
1514
|
}
|
|
1515
|
+
for (const entry of ring.all) {
|
|
1516
|
+
const secret = decryptWithKey(entry.key, ivPart, tagPart, dataPart);
|
|
1517
|
+
if (secret) return { secret, needsReencrypt: ring.primary !== null };
|
|
1518
|
+
}
|
|
1519
|
+
getLogger5().error("[2fa] legacy TOTP secret decryption failed with every configured key.");
|
|
1520
|
+
return null;
|
|
1419
1521
|
};
|
|
1522
|
+
var decryptTotpSecret = (stored) => decryptTotpSecretWithMetadata(stored)?.secret ?? null;
|
|
1420
1523
|
var challengeKey = (token) => formatKey5("-2fa-challenge", sha256(token));
|
|
1421
1524
|
var challengeAttemptsKey = (token) => formatKey5("-2fa-challenge-attempts", sha256(token));
|
|
1422
1525
|
var usedTimestepKey = (userId, timestep) => formatKey5("-2fa-usedstep", `${userId}:${String(timestep)}`);
|
|
1423
1526
|
var accountFailureKey = (userId) => formatKey5("-2fa-accountfail", userId);
|
|
1424
1527
|
var RECOVERY_LEASE_MS = 5e3;
|
|
1425
1528
|
var recoveryLeaseName = (userId) => `2fa-recovery:${userId}`;
|
|
1529
|
+
var ENROLLMENT_LEASE_MS = 3e4;
|
|
1530
|
+
var enrollmentLeaseName = (userId) => `2fa-enrollment:${userId}`;
|
|
1426
1531
|
var pendingSecretKey = (userId) => formatKey5("-2fa-pending", userId);
|
|
1427
1532
|
var ACCOUNT_FAIL_LIMIT = 10;
|
|
1428
1533
|
var ACCOUNT_FAIL_WINDOW_SECONDS = 15 * 60;
|
|
@@ -1476,13 +1581,23 @@ var burnChallenge = async (token) => {
|
|
|
1476
1581
|
var verifyTotpForUser = async (user, code, enforceSingleUse) => {
|
|
1477
1582
|
if (!user.totpSecret) return false;
|
|
1478
1583
|
if (!/^\d{6}$/.test(code.trim())) return false;
|
|
1479
|
-
const
|
|
1480
|
-
if (!
|
|
1481
|
-
const result = verifyTotp({ secret, code });
|
|
1584
|
+
const decrypted = decryptTotpSecretWithMetadata(user.totpSecret);
|
|
1585
|
+
if (!decrypted) return false;
|
|
1586
|
+
const result = verifyTotp({ secret: decrypted.secret, code });
|
|
1482
1587
|
if (!result.valid || result.timestep === null) return false;
|
|
1483
|
-
if (
|
|
1484
|
-
|
|
1485
|
-
|
|
1588
|
+
if (enforceSingleUse) {
|
|
1589
|
+
const claimed = await redis4.set(usedTimestepKey(user.id, result.timestep), "1", "EX", 60 * 2, "NX");
|
|
1590
|
+
if (claimed === null) return false;
|
|
1591
|
+
}
|
|
1592
|
+
if (decrypted.needsReencrypt) {
|
|
1593
|
+
const [migrationError] = await tryCatch3(() => getUserAdapter().update(user.id, {
|
|
1594
|
+
totpSecret: encryptTotpSecret(decrypted.secret)
|
|
1595
|
+
}));
|
|
1596
|
+
if (migrationError) {
|
|
1597
|
+
getLogger5().error("[2fa] TOTP encryption-key lazy migration failed; verification succeeded but the existing ciphertext was kept.", migrationError);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return true;
|
|
1486
1601
|
};
|
|
1487
1602
|
var matchRecoveryHash = (hashes, code) => {
|
|
1488
1603
|
const submitted = Buffer.from(sha256(code.trim().toLowerCase()));
|
|
@@ -1629,24 +1744,30 @@ var generateRecoveryCodes = () => {
|
|
|
1629
1744
|
var confirmTotpEnrollment = async (user, code) => {
|
|
1630
1745
|
const blocked = enrollmentBlockedReason(user);
|
|
1631
1746
|
if (blocked) return { ok: false, reason: blocked };
|
|
1632
|
-
const
|
|
1633
|
-
if (!
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1747
|
+
const leaseToken = await acquireLease(enrollmentLeaseName(user.id), ENROLLMENT_LEASE_MS);
|
|
1748
|
+
if (!leaseToken) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1749
|
+
try {
|
|
1750
|
+
const pendingStored = await redis4.get(pendingSecretKey(user.id));
|
|
1751
|
+
if (!pendingStored) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1752
|
+
const secret = decryptTotpSecret(pendingStored);
|
|
1753
|
+
if (!secret) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1754
|
+
const result = verifyTotp({ secret, code });
|
|
1755
|
+
if (!result.valid) return { ok: false, reason: "login.twoFactorInvalidCode" };
|
|
1756
|
+
const { raw, hashes } = generateRecoveryCodes();
|
|
1757
|
+
const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
|
|
1758
|
+
totpSecret: encryptTotpSecret(secret),
|
|
1759
|
+
twoFactorEnabled: true,
|
|
1760
|
+
recoveryCodes: hashes
|
|
1761
|
+
}));
|
|
1762
|
+
if (updateError) {
|
|
1763
|
+
getLogger5().error("[2fa] enrollment persist failed \u2014 is your UserAdapter/schema missing the 2FA fields? (twoFactorEnabled, totpSecret, recoveryCodes)", updateError);
|
|
1764
|
+
return { ok: false, reason: "login.twoFactorPersistFailed" };
|
|
1765
|
+
}
|
|
1766
|
+
await redis4.del(pendingSecretKey(user.id));
|
|
1767
|
+
return { ok: true, recoveryCodes: raw };
|
|
1768
|
+
} finally {
|
|
1769
|
+
await releaseLease(enrollmentLeaseName(user.id), leaseToken);
|
|
1647
1770
|
}
|
|
1648
|
-
await redis4.del(pendingSecretKey(user.id));
|
|
1649
|
-
return { ok: true, recoveryCodes: raw };
|
|
1650
1771
|
};
|
|
1651
1772
|
var disableTwoFactor = async (user, code) => {
|
|
1652
1773
|
const viaTotp = await verifyTotpForUser(user, code, false);
|