@luckystack/login 0.15.0 → 0.16.0
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 +23 -0
- package/README.md +6 -0
- package/dist/{chunk-ML3OQ6R4.js → chunk-CYGFR56I.js} +24 -3
- package/dist/chunk-CYGFR56I.js.map +1 -0
- package/dist/{chunk-S7TST3X6.js → chunk-MZNCQ26U.js} +10 -4
- package/dist/chunk-MZNCQ26U.js.map +1 -0
- package/dist/hookPayloads.d.ts +5 -0
- package/dist/index.js +142 -34
- package/dist/index.js.map +1 -1
- package/dist/logout-VTVJTFSZ.js +7 -0
- package/dist/oauthProviders.d.ts +12 -2
- package/dist/register.js +1 -1
- package/dist/userAdapter.d.ts +22 -0
- package/docs/oauth-providers.md +3 -1
- package/docs/user-adapter.md +2 -0
- package/package.json +3 -3
- package/dist/chunk-ML3OQ6R4.js.map +0 -1
- package/dist/chunk-S7TST3X6.js.map +0 -1
- package/dist/logout-PO7TJ442.js +0 -7
- /package/dist/{logout-PO7TJ442.js.map → logout-VTVJTFSZ.js.map} +0 -0
package/dist/oauthProviders.d.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface FullOAuthProvider {
|
|
|
36
36
|
scope: string[];
|
|
37
37
|
nameKey: string;
|
|
38
38
|
emailKey: string;
|
|
39
|
+
/**
|
|
40
|
+
* Key on the userInfo response holding the provider's stable user id (a
|
|
41
|
+
* non-empty string or an integer). When set, an account is bound to
|
|
42
|
+
* (provider, id) and found by it before any email lookup, so an address the
|
|
43
|
+
* provider does not verify can never sign in to someone else's account
|
|
44
|
+
* (ADR 0086). Every built-in sets it; a custom provider opts in. A response
|
|
45
|
+
* without the id is refused rather than matched by email.
|
|
46
|
+
*/
|
|
47
|
+
accountIdKey?: string;
|
|
39
48
|
/**
|
|
40
49
|
* Optional key on the userInfo response holding a provider "email verified"
|
|
41
50
|
* boolean. When set AND the field is present-and-falsy, the framework rejects
|
|
@@ -172,8 +181,9 @@ export interface MicrosoftProviderInput extends OAuthHelperInput {
|
|
|
172
181
|
* user get an account: Graph `mail` is set by the tenant's admin, so under
|
|
173
182
|
* `'common'` / `'organizations'` / `'consumers'` any tenant could assert any
|
|
174
183
|
* address (the nOAuth attack). With those values the email counts as
|
|
175
|
-
* unverified, so only
|
|
176
|
-
* `endpoints` does not change this; set
|
|
184
|
+
* unverified, so only Microsoft accounts already bound to their Microsoft id
|
|
185
|
+
* can sign in (ADR 0086). Overriding `endpoints` does not change this; set
|
|
186
|
+
* `tenant`.
|
|
177
187
|
*/
|
|
178
188
|
tenant?: string;
|
|
179
189
|
/** OAuth API version segment. Default `'v2.0'`. */
|
package/dist/register.js
CHANGED
package/dist/userAdapter.d.ts
CHANGED
|
@@ -7,10 +7,19 @@ export interface UserAdapterCreateInput {
|
|
|
7
7
|
avatar?: string | null;
|
|
8
8
|
avatarFallback?: string | null;
|
|
9
9
|
language?: string | null;
|
|
10
|
+
/** The OAuth provider's stable user id (ADR 0086). Absent for credentials accounts. */
|
|
11
|
+
providerAccountId?: string | null;
|
|
10
12
|
}
|
|
11
13
|
export interface UserRecord extends BaseSessionLayout {
|
|
12
14
|
password?: string | null;
|
|
13
15
|
provider?: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* The stable user id of the OAuth provider in `provider` (ADR 0086). Null for
|
|
18
|
+
* credentials accounts and for OAuth accounts not bound yet. Binds only the
|
|
19
|
+
* row's own provider: under `'unified'` another provider that links by email
|
|
20
|
+
* is not stored here.
|
|
21
|
+
*/
|
|
22
|
+
providerAccountId?: string | null;
|
|
14
23
|
/** Most recent successful login. Mirrors the optional `lastLogin` column on User. */
|
|
15
24
|
lastLogin?: Date | null;
|
|
16
25
|
/**
|
|
@@ -45,6 +54,19 @@ export interface UserAdapter {
|
|
|
45
54
|
findByEmailAnyProvider?: (params: {
|
|
46
55
|
email: string;
|
|
47
56
|
}) => Promise<UserRecord | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Look up the account bound to an OAuth provider's stable user id (ADR 0086).
|
|
59
|
+
* Optional so existing custom adapters keep compiling. Without it the
|
|
60
|
+
* framework neither reads nor writes the binding: it warns once, refuses an
|
|
61
|
+
* unverified provider email on an existing account, and otherwise matches by
|
|
62
|
+
* email as before (an email change at the provider then starts a new
|
|
63
|
+
* account). An adapter that implements it must also persist
|
|
64
|
+
* `providerAccountId` from `create` and `update`.
|
|
65
|
+
*/
|
|
66
|
+
findByProviderAccountId?: (params: {
|
|
67
|
+
provider: string;
|
|
68
|
+
providerAccountId: string;
|
|
69
|
+
}) => Promise<UserRecord | null>;
|
|
48
70
|
findById: (id: string) => Promise<UserRecord | null>;
|
|
49
71
|
create: (input: UserAdapterCreateInput) => Promise<UserRecord>;
|
|
50
72
|
update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;
|
package/docs/oauth-providers.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Provider adapters keep protocol-specific account retrieval separate from application identity. A provider email is not automatically proof that two different provider accounts should be merged; select an account-linking strategy deliberately and enforce its database uniqueness assumptions. See `../src/oauthProviders.ts`, `../src/accountStrategy.ts`, `./user-adapter.md`.
|
|
4
4
|
|
|
5
|
-
A first sign-in creates an account only from an email the provider vouches for; otherwise anyone could squat a victim's address. GitHub's profile email carries no verified flag, so the address comes from GitHub's verified email list. Microsoft's `mail` is set by the tenant's administrator, so it is trusted only when the app is bound to one tenant: under `common`, `organizations` or `consumers` any tenant could assert any address, so new Microsoft users are refused there
|
|
5
|
+
A first sign-in creates an account only from an email the provider vouches for; otherwise anyone could squat a victim's address. GitHub's profile email carries no verified flag, so the address comes from GitHub's verified email list. Microsoft's `mail` is set by the tenant's administrator, so it is trusted only when the app is bound to one tenant: under `common`, `organizations` or `consumers` any tenant could assert any address, so new Microsoft users are refused there.
|
|
6
|
+
|
|
7
|
+
An email is not an identity, so an OAuth account is bound to the provider's stable user id (ADR 0086). Sign-in looks up that id first, so an account follows its owner when the email changes at the provider; the stored email is kept, because the provider's new value may be unverified. Only when no account carries the id does the email decide, and then an email match may sign in only if the account is unbound and the provider verified the email, which binds it; an account bound to another id is refused even with a verified email. That is what stops an admin of another Entra tenant from setting `mail` to a user's address and signing in as them. A provider without a stable id key keeps the email-only matching, so a custom provider should set one. Facebook's id is scoped to the Facebook app, so switching `facebookProvider` to a new App ID refuses every bound Facebook account until its `providerAccountId` is set to null, after which it rebinds on its next verified login. Under `'unified'` one row serves several providers but stores only its own provider's id; other providers link to it by verified email alone, as before. Because the email still finds accounts without a binding, it belongs to the provider; the reference settings flow refuses to change it rather than orphaning the account or letting a stolen session rebind it.
|
|
6
8
|
|
|
7
9
|
Bind authorization callbacks with server-generated, single-use state and validate return URLs before redirecting the newly authenticated browser. The provider callback origin and public frontend origin can differ; do not turn a relative redirect into an attacker-controlled backend URL. Extra provider session data is visible to that user's browser; keep secrets out. Provider-supplied names are untrusted text and must be safely rendered wherever used. Source: `../src/login.ts`, `../src/redirectResolver.ts`, `../../server/src/httpRoutes/authCallbackRoute.ts`.
|
package/docs/user-adapter.md
CHANGED
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
A user adapter lets authentication operate with a consumer-owned schema or storage engine, including tenant scoping and soft deletion, without making framework code import the consumer's database. The adapter is an identity boundary: apply tenant filters to every lookup and never treat an unverified provider email as universal account ownership. Source: `../src/userAdapter.ts`, `../src/accountStrategy.ts`.
|
|
4
4
|
|
|
5
|
+
An adapter that can look up and persist the provider's stable user id lets OAuth accounts be bound to it (ADR 0086). One that cannot still works, but stays on email matching with unverified emails refused on existing accounts, so implement the lookup when the store can hold the id. That id index is deliberately not unique in the Prisma and MikroORM schemas, because a MongoDB unique index rejects a second empty value; keep the lookup deterministic (oldest row first) rather than assuming uniqueness.
|
|
6
|
+
|
|
5
7
|
Provider-scoped and unified-email accounts have different uniqueness requirements. Switching an existing dataset to unified identity requires reconciling duplicates and enforcing uniqueness in storage before enabling linking; a custom adapter must implement the corresponding lookup correctly. Session sanitization remains mandatory regardless of database choice (`../src/sessionSanitizer.ts`). For signatures and current schema consult `../src/userAdapter.ts`, not a copied interface table.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/login",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"test": "vitest run"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@luckystack/core": "^0.
|
|
62
|
+
"@luckystack/core": "^0.16.0",
|
|
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.
|
|
70
|
+
"@luckystack/email": "^0.16.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependenciesMeta": {
|
|
73
73
|
"@prisma/client": {
|
|
@@ -1 +0,0 @@
|
|
|
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 { getLogger, 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\n/**\n * Explicit `getEmail` result for a provider that can return an address it did\n * NOT verify (e.g. Microsoft's `userPrincipalName` fallback). A bare string\n * return keeps the historical meaning: the accessor only returns verified\n * addresses.\n */\nexport interface OAuthEmailResult {\n email: string;\n verified: boolean;\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 \"no signal\": the profile is\n * accepted but the email counts as unverified, so first-login creation and\n * cross-provider linking are refused.\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 /**\n * Set `true` to resolve the email ONLY through `getEmail` and ignore the\n * userinfo `emailKey` value. For a provider whose userinfo email carries no\n * verified signal but which exposes a verified-email endpoint (GitHub's\n * public profile `email` vs `/user/emails`). Without it, a present userinfo\n * email skips `getEmail` and stays unverified, which refuses first-login\n * account creation.\n */\n alwaysUseGetEmail?: boolean;\n avatarKey?: string;\n avatarCodeKey: string;\n /**\n * Resolve the account email when userinfo has none (or always, with\n * `alwaysUseGetEmail`). Receives the raw userinfo record as the second\n * argument. Return a string only for an address the provider verified;\n * return an {@link OAuthEmailResult} to report an unverified address.\n */\n getEmail?: (accessToken: string, userData: OAuthUserData) => Promise<string | false | undefined | OAuthEmailResult>;\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 //? The `/user` public `email` has no verified flag, so taking it verbatim left\n //? every new user with a public email unverified (first-login creation\n //? refused). Always resolve through the verified-only `/user/emails` list.\n alwaysUseGetEmail: true,\n avatarKey: 'avatar_url',\n avatarCodeKey: '',\n extraSessionFields: input.extraSessionFields,\n extraAuthorizationParams: input.extraAuthorizationParams,\n usePkce: input.usePkce,\n getEmail: async (accessToken, userData) => {\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 //? Prefer the public profile email when it is in the verified list: accounts\n //? created before `alwaysUseGetEmail` were keyed on that address, and\n //? switching them to the primary one would orphan them.\n const publicEmail = typeof userData.email === 'string' ? userData.email.toLowerCase() : '';\n let mainEmail: string | undefined = publicEmail\n ? verified.find((entry) => typeof entry.email === 'string' && entry.email.toLowerCase() === publicEmail)?.email\n : undefined;\n if (!mainEmail) {\n for (const entry of verified) {\n if (entry.primary && typeof entry.email === 'string') mainEmail = entry.email;\n }\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 /**\n * Tenant id segment in the authorize/token URLs. Default `'common'`.\n * Only a specific tenant (your organisation's id or domain) lets a brand-new\n * user get an account: Graph `mail` is set by the tenant's admin, so under\n * `'common'` / `'organizations'` / `'consumers'` any tenant could assert any\n * address (the nOAuth attack). With those values the email counts as\n * unverified, so only existing Microsoft accounts can sign in. Overriding\n * `endpoints` does not change this; set `tenant`.\n */\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//? Tenant segments that accept accounts from many tenants (or personal\n//? accounts), so no single admin vouches for the reported `mail`.\nconst MICROSOFT_MULTI_TENANT_SEGMENTS = new Set(['common', 'organizations', 'consumers']);\n\n//? Treating multi-tenant `mail` as unverified only stops NEW accounts and\n//? cross-provider links. An EXISTING Microsoft account is still matched by\n//? (email, provider), so another tenant's admin who sets `mail` to that address\n//? signs in as its owner. Blocking that would lock out every existing\n//? multi-tenant login, so it stays a consumer decision; warn once at boot.\nlet warnedMultiTenant = false;\nconst warnMultiTenantOnce = (tenant: string): void => {\n if (warnedMultiTenant) return;\n warnedMultiTenant = true;\n getLogger().warn(\n `[login] microsoftProvider tenant '${tenant}' accepts accounts from any Entra tenant: an existing Microsoft account is matched on an unverified Graph mail, so another tenant's admin can sign in as its owner (nOAuth). Set \\`tenant\\` to your organisation's tenant id or domain to close this.`,\n );\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 const isSingleTenant = !MICROSOFT_MULTI_TENANT_SEGMENTS.has(tenant.toLowerCase());\n if (!isSingleTenant) warnMultiTenantOnce(tenant);\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 //? Graph `mail` has no verified flag; it is trusted only when one tenant's\n //? admin owns it (see `MicrosoftProviderInput.tenant`).\n emailImpliesVerified: isSingleTenant,\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 { email: mail, verified: isSingleTenant };\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. It is always reported\n //? unverified, so it never creates or links an account on its own.\n if (allowUpnFallback) {\n const userPrincipalName = typeof record.userPrincipalName === 'string' ? record.userPrincipalName : '';\n return userPrincipalName ? { email: userPrincipalName, verified: false } : 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"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/logout.ts","../src/session.ts","../src/sessionAdapter.ts","../src/sessionSanitizer.ts"],"sourcesContent":["import { Socket } from \"socket.io\";\nimport { tryCatch, socketEventNames, dispatchHook, getLogger } from '@luckystack/core';\nimport { deleteSession } from \"./session\";\n\nexport const logout = async ({ token, socket, userId, skipSessionDelete }: {\n token: string | null,\n socket: Socket | undefined,\n userId: string | null,\n skipSessionDelete?: boolean,\n}) => {\n const [error, result] = await tryCatch(async () => {\n if (!socket) {\n getLogger().warn('logout: invalid socket');\n return;\n }\n if (!token) {\n getLogger().warn('logout: no token provided');\n return;\n }\n\n const preLogoutResult = await dispatchHook('preLogout', { userId, token });\n if (preLogoutResult.stopped) {\n getLogger().warn('logout: aborted by preLogout hook', { errorCode: preLogoutResult.signal.errorCode });\n socket.emit(socketEventNames.logout, \"error\");\n return;\n }\n\n getLogger().debug(`logout: user ${userId ?? '?'}`, { tokenPrefix: token ? token.slice(0, 8) : null });\n\n if (!skipSessionDelete) {\n //? `deleteSession` handles both the key delete AND `adapter.untrackActive`\n //? through the registered SessionAdapter (LOGIN-02). A separate raw\n //? `redis.srem(activeUsersKeyFor(userId), token)` call here was a direct\n //? bypass that broke custom adapters and duplicated work the adapter already\n //? performed; removed.\n await deleteSession(token);\n }\n //? `socket.leave` returns a Promise in socket.io's adapter contract but the\n //? room-leave is fire-and-forget here (the session is already being torn\n //? down) — `void` marks it intentionally un-awaited without changing timing.\n void socket.leave(token);\n\n // Presence state (disconnectTimers, tempDisconnectedSockets) is cleaned up\n // by `@luckystack/presence`'s `postLogout` hook handler, registered at\n // server startup via `registerPresenceHooks()`. Keeps login → presence\n // a one-way dep: presence knows about login (uses getSession/deleteSession),\n // login does not know about presence.\n await dispatchHook('postLogout', { userId, token });\n return true;\n });\n if (error) {\n if (socket) {\n socket.emit(socketEventNames.logout, \"error\");\n }\n } else if (result && socket) {\n //? Opt-in logout tracing. This fires on EVERY successful logout, so an\n //? unconditional warn+stacktrace spams the server log. Set\n //? LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged out?\"\n //? bug to print the originating caller's stack; silent otherwise. The\n //? client still receives the success-emit and clears its session regardless.\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\n getLogger().warn('[session] logout success — emitting logout to socket', {\n tokenPrefix: token ? token.slice(0, 8) : null,\n userId,\n socketId: socket.id,\n stack: new Error('logout invoked').stack,\n });\n }\n socket.emit(socketEventNames.logout, \"success\");\n }\n}\n","//? @adr 0018 — Token-exposure contract: `saveSession`'s `updateSession`\n//? broadcast surfaces the session token to page JS only in `sessionBasedToken`\n//? mode; cookie mode gets the token-stripped projection. See ADR 0018.\nimport type { BaseSessionLayout as SessionLayout } from \"./sessionLayout\";\nimport { randomBytes } from 'node:crypto';\nimport {\n socketEventNames,\n dispatchHook,\n getCsrfConfig,\n getLogger,\n getProjectConfig,\n formatKey,\n tryCatch,\n tryCatchSync,\n type SessionSaveOptions,\n} from '@luckystack/core';\n\nimport { getSessionAdapter } from './sessionAdapter';\nimport { applySessionSanitizer } from './sessionSanitizer';\n\n//? Resolved at call time so project registration order doesn't matter.\n//? Session TTL comes from projectConfig and is honoured by every adapter\n//? (Redis default, or any custom adapter the consumer registers).\nconst getSessionTtl = (): number => 60 * 60 * 24 * getProjectConfig().session.expiryDays;\n\nconst isSessionLayout = (value: unknown): value is SessionLayout => {\n return value !== null && typeof value === 'object' && 'id' in value;\n};\n\nconst parseSessionLayout = (value: string): SessionLayout | null => {\n const parsed = JSON.parse(value) as unknown;\n return isSessionLayout(parsed) ? parsed : null;\n};\n\n/**\n * Save or update a user session through the active session adapter.\n *\n * @param token - The session token (unique identifier)\n * @param data - The session data to store\n * @param newUser - If true, this is a new login (triggers single-session enforcement)\n * @param options.supersedeToken - A token belonging to the SAME browser that is\n * being replaced by this login (e.g. re-login while already signed in). It is\n * excluded from single-session enforcement so the user's current device is not\n * kicked by its own new session. The caller is responsible for deleting it\n * afterwards (use `deleteSession(supersedeToken, { skipSocketLogout: true })`).\n * @param options.onlyIfExists - Update an existing session only; returns\n * `{ ok: false, errorCode: 'session.notFound' }` instead of recreating a\n * session that was deleted (logout/revoke) after the caller read it.\n */\nconst saveSession = async (\n token: string,\n data: SessionLayout,\n newUser?: boolean,\n options?: SessionSaveOptions,\n): Promise<{ ok: true } | { ok: false; errorCode: string }> => {\n //? Reject a session with an empty `id` (B30). `isSessionLayout` only checks\n //? the `id` KEY is present, so a custom adapter / caller handing us an\n //? empty-string id would mint a session that is never tracked in the\n //? activeUsers set keyed by id — making it invisible to `revokeUserSessions`\n //? / \"sign out everywhere\". A real authenticated user always has a non-empty\n //? id, so this can only reject a malformed/untrackable session.\n if (!data.id) {\n //? Log only the first 8 chars of the token (LOGIN-F2) — enough to correlate\n //? with a specific request in logs/traces without exposing a live credential.\n getLogger().error('saveSession: refusing to persist a session with an empty user id', { tokenPrefix: token.slice(0, 8) });\n return { ok: false, errorCode: 'api.internalServerError' };\n }\n\n const [error, outcome] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n if (newUser) {\n const preSessionCreateResult = await dispatchHook('preSessionCreate', {\n token,\n user: data,\n persistent: !getProjectConfig().session.basedToken,\n });\n if (preSessionCreateResult.stopped) {\n getLogger().warn(`session create aborted by preSessionCreate hook`, { errorCode: preSessionCreateResult.signal.errorCode });\n return { ok: false, errorCode: preSessionCreateResult.signal.errorCode || 'api.internalServerError' } as const;\n }\n\n //? `onConflict: 'rejectNew'` enforcement. Previously this was documented\n //? as \"enforced at the API layer\" but NO code refused the login — the new\n //? session was persisted regardless and sessions accumulated unbounded,\n //? making the documented cap a silent no-op. Enforce it HERE (before any\n //? persist) so every login surface — credentials AND OAuth — is covered by\n //? construction: when `perUser: 'multiple'` with a `maxConcurrentPerUser`\n //? cap already reached and `onConflict === 'rejectNew'`, refuse the new\n //? login with a dedicated reason key instead of kicking an existing\n //? session. `'single'` and `'revokeOld'` keep kicking (handled below).\n const cfg = getProjectConfig().session;\n const perUser = cfg.perUser;\n if (\n perUser === 'multiple' &&\n cfg.onConflict === 'rejectNew' &&\n cfg.maxConcurrentPerUser !== null &&\n data.id\n ) {\n //? SOFT-CAP (LOGIN-F4): this is a read-then-decide, not an atomic\n //? reserve, so two concurrent logins for the same user can both pass the\n //? count check and temporarily exceed `maxConcurrentPerUser` by one.\n //? A Lua SETNX atomic counter would close the gap, but at the cost of a\n //? separate primitive and significant complexity. The window is narrowly\n //? time-bounded (< round-trip latency of two simultaneous logins), the\n //? worst case is one extra session that cleans up at normal TTL, and\n //? consumers operating strict caps should keep `maxConcurrentPerUser` at\n //? a margin that absorbs the race. Documented here so no future change\n //? re-tightens without understanding the trade-off.\n const existing = await getSessionAdapter().listActive(data.id);\n const others = existing.filter((t) => t !== token && t !== options?.supersedeToken);\n if (others.length + 1 > cfg.maxConcurrentPerUser) {\n getLogger().warn('session create rejected — concurrent-session cap reached (onConflict: rejectNew)', {\n userId: data.id,\n cap: cfg.maxConcurrentPerUser,\n active: others.length,\n });\n return { ok: false, errorCode: 'login.sessionLimitReached' } as const;\n }\n }\n }\n\n //? Mint a CSRF token on first session write. Subsequent (non-new) writes\n //? preserve the existing token so the client doesn't have to re-fetch on\n //? every session update. The token is rotated on logout via `deleteSession`\n //? AND whenever a genuinely new session is established (`newUser`) — a fresh\n //? login / OAuth re-login must not inherit a CSRF token that may have been\n //? carried into `data` from a prior session, so we always mint a new one.\n //? Token length is consumer-configurable via `registerCsrfConfig({ tokenLength })`.\n if (newUser || !data.csrfToken) {\n data.csrfToken = randomBytes(getCsrfConfig().tokenLength).toString('hex');\n }\n\n //? Apply the optional session sanitizer (M7) before BOTH persist and the\n //? client broadcast, so a consumer-registered redactor strips sensitive\n //? non-password columns (2FA secrets, billing ids, internal flags) that the\n //? default `sanitizeUserForSession` (password-only) leaves on the record. A\n //? throwing sanitizer must not break login — fall back to the raw record and\n //? log. `data.id` (used below for tracking/enforcement) is read from the\n //? original record, which is unaffected.\n //? Fail-CLOSED on a throwing sanitizer (M6): the previous fallback persisted\n //? AND broadcast the RAW record, leaking exactly the sensitive columns\n //? (2FA secrets, billing ids, internal flags) the sanitizer exists to strip.\n //? On error, fall back to a minimal known-safe projection — drop `password`\n //? (always) and any field a registered sanitizer was meant to redact is at\n //? least not leaked verbatim; the user can re-login if a field they needed is\n //? missing, but we never broadcast secrets.\n let persisted = data;\n const [sanitizeErr, sanitized] = tryCatchSync(() => applySessionSanitizer(data));\n if (sanitizeErr) {\n getLogger().error('saveSession: session sanitizer threw — falling back to password-stripped projection', sanitizeErr, { tokenPrefix: token.slice(0, 8) });\n const { password: _password, totpSecret: _totpSecret, recoveryCodes: _recoveryCodes, ...safe } =\n data as SessionLayout & { password?: unknown; totpSecret?: unknown; recoveryCodes?: unknown };\n persisted = safe;\n } else if (sanitized) {\n persisted = sanitized;\n }\n\n const ttl = getSessionTtl();\n //? Strip `token` from the stored value (LOGIN-M9). The token is the Redis key\n //? itself, so embedding it in the value is redundant AND means a Redis keyspace\n //? dump / `getAllSessions` / any admin log that prints a session record exposes\n //? a live replayable credential. `getSession` re-attaches it from the lookup\n //? key (`{ ...parsed, token }`) so callers receive the expected shape.\n const { token: _stripToken, ...persistedWithoutToken } = persisted as SessionLayout & { token?: unknown };\n const serialized = JSON.stringify(persistedWithoutToken);\n if (options?.onlyIfExists) {\n //? A partial update (room join, location) read the session earlier; a\n //? logout/revoke may have deleted it since. A plain SET would bring the\n //? revoked token back with a full TTL, so write only when it still exists.\n let written: boolean;\n if (adapter.setRawIfExists) {\n written = await adapter.setRawIfExists(token, serialized, ttl);\n } else {\n written = (await adapter.getRaw(token)) !== null;\n if (written) await adapter.setRaw(token, serialized, ttl);\n }\n if (!written) return { ok: false, errorCode: 'session.notFound' } as const;\n } else {\n await adapter.setRaw(token, serialized, ttl);\n }\n\n const userId = data.id;\n\n //? Always track active tokens BEFORE the io check — `trackActive` is a\n //? Redis-only write, independent of any live socket. Gating it behind a\n //? live io meant processes without a Socket.io instance (the test harness,\n //? background workers, CLI tasks) persisted the session but never populated\n //? the active-tokens set, so `listSessions` / `revokeUserSessions` saw\n //? nothing for that user.\n //? An `onlyIfExists` update only refreshes the set TTL: the token was tracked\n //? when the session was created, and re-adding it could leave a ghost entry\n //? when a delete lands between the write above and this call.\n if (userId) {\n if (options?.onlyIfExists) {\n if (adapter.touchActive) await adapter.touchActive(userId, ttl);\n } else {\n await adapter.trackActive(userId, token, ttl);\n }\n }\n\n const { getIoInstance } = await import('@luckystack/core');\n //? `{ raw: true }`: the kick + the guard below read the per-instance room\n //? map on purpose (they pre-date the cross-instance fan-out and only see\n //? THIS process's sockets — a known limitation, see the eslint-disable\n //? comments), so bypass the dev guard meant for fan-out code.\n const io = getIoInstance({ raw: true });\n //? Socket fanout + single-session enforcement below need a live io; skip\n //? them when there isn't one. The session IS already persisted + tracked, so\n //? this is a success (background workers / CLI tasks have no io).\n if (!io) return { ok: true } as const;\n\n // Handle session-limit enforcement on new login. Logic:\n // 1. perUser = 'single' (default) →\n // kick every prior session for this user.\n // 2. perUser = 'multiple' with `maxConcurrentPerUser` cap reached:\n // `onConflict === 'revokeOld'` kicks oldest until under cap,\n // `onConflict === 'rejectNew'` already refused the login above (before\n // persisting), so by here the cap has room.\n // 3. perUser = 'multiple' with no cap → no kick.\n if (newUser && userId) {\n const sessionCfg = getProjectConfig().session;\n const effectivePerUser = sessionCfg.perUser;\n const cap = sessionCfg.maxConcurrentPerUser;\n\n const allTokens = await adapter.listActive(userId);\n //? Exclude the current token — it was just added and has no socket room yet.\n //? Also exclude `supersedeToken`: when the same browser re-logs-in, its old\n //? token is being replaced (not a separate device), so kicking it would log\n //? the user out of the very browser performing the login.\n const previousTokens = allTokens.filter(\n (t) => t !== token && t !== options?.supersedeToken,\n );\n\n let tokensToKick: string[] = [];\n if (effectivePerUser === 'single') {\n tokensToKick = previousTokens;\n } else if (cap !== null && previousTokens.length + 1 > cap && sessionCfg.onConflict === 'revokeOld') {\n // Kick oldest until under cap. Without ordering metadata in the adapter,\n // we drop the head of the list — most adapters return insertion-order\n // for set-like structures, but consumers needing strict LRU should\n // implement a custom adapter that orders deterministically.\n const excess = previousTokens.length + 1 - cap;\n tokensToKick = previousTokens.slice(0, excess);\n }\n\n if (tokensToKick.length > 0) {\n const { logout } = await import('./logout');\n\n await Promise.all(tokensToKick.map(async (previousToken) => {\n // eslint-disable-next-line luckystack/no-local-socket-enumeration -- per-instance by construction: only the replaced session's sockets on THIS instance are logged out here; sockets on other instances are not reached (known limitation, reported in the 0.10.0 handoff, not fixed here)\n const sockets = io.sockets.adapter.rooms.get(previousToken);\n if (sockets) {\n getLogger().debug(`Kicking previous session for user ${userId}`);\n // Optional UI notification before the disconnect lands.\n if (sessionCfg.notifyOldDeviceOnRevoke) {\n io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({\n reason: 'session-replaced',\n userId,\n }));\n }\n for (const socketId of sockets) {\n const socket = io.sockets.sockets.get(socketId);\n if (socket) {\n await logout({ token: previousToken, socket, userId });\n }\n }\n } else {\n // No active sockets, just clean up the adapter state\n await adapter.delete(previousToken);\n await adapter.untrackActive(userId, previousToken);\n }\n }));\n }\n }\n\n // Broadcast session updates to connected clients (sanitized copy).\n //? @adr 0018 — Token-exposure contract: NEVER broadcast the session token to\n //? page JS. In cookie mode it is the HttpOnly-cookie credential; in\n //? `sessionBasedToken` mode the client already holds it in sessionStorage\n //? (set at login / OAuth handoff). The client never consumes the token from\n //? this payload in either mode (the socket handshake reads sessionStorage /\n //? the cookie), so we always send the token-stripped projection — the same one\n //? persisted to the adapter at LOGIN-M9 — matching the CLIENT `ClientSessionLayout`.\n // eslint-disable-next-line luckystack/no-local-socket-enumeration -- per-instance guard in front of a cross-instance emit: when this session's sockets live on ANOTHER instance the update is skipped (known limitation, reported in the 0.10.0 handoff, not fixed here)\n if (io.sockets.adapter.rooms.has(token)) {\n io.to(token).emit(socketEventNames.updateSession, JSON.stringify(persistedWithoutToken));\n }\n\n if (newUser) {\n await dispatchHook('postSessionCreate', { token, user: data, persistent: !getProjectConfig().session.basedToken });\n //? Core-level observational session-lifecycle hook (CORE-40). Dispatched\n //? alongside the login-owned `postSessionCreate` so consumers (audit,\n //? presence, error-tracking) can react to a minted session via core's\n //? `registerHook('sessionCreated', …)` WITHOUT importing @luckystack/login.\n //? Fire-and-forget — the session already exists, so a handler error must\n //? not change the login outcome.\n if (userId) {\n void dispatchHook('sessionCreated', { token, userId });\n }\n }\n\n return { ok: true } as const;\n }, undefined, { fn: 'saveSession', token });\n\n if (error) {\n getLogger().error('saveSession failed', error, { tokenPrefix: token.slice(0, 8) });\n //? Surface the failure instead of swallowing it. Callers (credentials/OAuth\n //? login) MUST NOT mint a token + delete the prior session when the new\n //? session never persisted — a transient adapter blip would otherwise log\n //? the user out with no usable replacement.\n return { ok: false, errorCode: 'api.internalServerError' };\n }\n return outcome ?? { ok: false, errorCode: 'api.internalServerError' };\n};\n\n/**\n * Retrieve a user session through the active session adapter.\n *\n * @param token - The session token\n * @param options.refresh - When false, skip the sliding-expiration TTL\n * extension. Defaults to true. Pass false for read-only probes (e.g. a\n * background audit, an admin lookup) where a stolen token should NOT earn\n * extra lifetime just because the server touched it (LOGIN-F9).\n * @returns The session data or null if not found\n */\nconst getSession = async (\n token: string | null,\n options?: { refresh?: boolean },\n): Promise<SessionLayout | null> => {\n if (!token) return null;\n\n const [error, value] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n const raw = await adapter.getRaw(token);\n if (!raw) return null;\n\n const parsed = parseSessionLayout(raw);\n const userId = parsed?.id ?? null;\n\n //? Sliding expiration: each successful authenticated access extends session\n //? lifetime. `options.refresh` defaults to true to preserve the existing\n //? behaviour on the hot request path. Pass `{ refresh: false }` from\n //? background / admin probes that must not extend a potentially stolen token.\n const shouldRefresh = options?.refresh !== false;\n\n // Sliding expiration: each successful authenticated access extends session lifetime.\n const newTtl = getSessionTtl();\n const oldTtl = await adapter.ttl(token);\n const preRefreshResult = shouldRefresh\n ? await dispatchHook('preSessionRefresh', { token, userId, oldTtl, newTtl })\n : { stopped: true };\n\n //? A `preSessionRefresh` handler can stop the TTL extension (e.g. admin\n //? freezing a session pending review). When stopped, we skip the\n //? `adapter.expire` call and surface `applied: false` on the post payload\n //? so audit handlers see the same signal.\n let applied = false;\n if (!preRefreshResult.stopped) {\n applied = await adapter.expire(token, newTtl);\n //? Also refresh the activeUsers-set TTL on a sliding read. `trackActive`\n //? (which sets that TTL) runs only on saveSession, so a session kept\n //? alive purely by reads would outlive its activeUsers entry — after\n //? which `revokeUserSessions` / single-session enforcement enumerate\n //? `listActive` -> [] and silently miss the live token (a stolen token\n //? would then survive a password reset / sign-out-everywhere). Refresh\n //? the set TTL in lock-step with the session-key TTL to close that drift.\n if (applied && userId && adapter.touchActive) {\n await adapter.touchActive(userId, newTtl);\n }\n }\n if (shouldRefresh) {\n await dispatchHook('postSessionRefresh', {\n token,\n userId,\n oldTtl,\n newTtl,\n applied,\n });\n }\n\n if (!parsed) return null;\n const merged: SessionLayout = { ...parsed, token };\n return merged;\n }, undefined, { fn: 'getSession', token });\n\n if (error) {\n getLogger().error('getSession failed', error, { tokenPrefix: token.slice(0, 8) });\n return null;\n }\n return value;\n};\n\n/**\n * Delete a user session through the active adapter and notify connected\n * clients.\n *\n * @param token - The session token to delete\n * @param options.skipSocketLogout - When true, the session data + active-token\n * tracking are cleaned up and the pre/post delete hooks still fire, but NO\n * `logout` is emitted to sockets in the token's room. Use this when the SAME\n * browser is replacing its session (re-login): emitting a logout would bounce\n * that browser to the login page and null its brand-new session.\n * @returns true if successful\n */\nconst deleteSession = async (\n token: string,\n options?: { skipSocketLogout?: boolean },\n): Promise<boolean> => {\n //? Opt-in spurious-delete tracing. `deleteSession` runs on EVERY legitimate\n //? logout / revokeSession / signOutEverywhere / deleteAccount, so an\n //? unconditional warn+stacktrace drowns the signal it's meant to surface and\n //? spams dev/test output with stacktrace-shaped lines that read like errors.\n //? Set LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged\n //? out?\" bug to print the originating caller's stack; silent otherwise.\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\n getLogger().warn('[session] deleteSession invoked', {\n tokenPrefix: token.slice(0, 8),\n stack: new Error('deleteSession invoked').stack,\n });\n }\n\n const [error, ok] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n const raw = await adapter.getRaw(token);\n let resolvedUserId: string | null = null;\n\n if (raw) {\n resolvedUserId = parseSessionLayout(raw)?.id ?? null;\n }\n\n const preSessionDeleteResult = await dispatchHook('preSessionDelete', { token, userId: resolvedUserId });\n if (preSessionDeleteResult.stopped) {\n getLogger().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });\n return false;\n }\n\n //? Delete the session KEY before untracking it from the activeUsers set. If\n //? `delete` threw transiently AFTER untrackActive, the token would stay\n //? readable (still in Redis) yet enumerable-as-gone — a stolen token could\n //? ride past a sign-out-everywhere. Deleting first means a transient throw\n //? leaves the token tracked + retried by the next `revokeUserSessions` sweep\n //? instead of orphaning a still-readable session. `delete` was already\n //? unconditional (ran even when `raw` was missing), so it just moves earlier.\n await adapter.delete(token);\n\n if (raw && resolvedUserId) {\n const { getIoInstance } = await import('@luckystack/core');\n //? `{ raw: true }`: reads the per-instance room map on purpose (see the\n //? eslint-disable below), so bypass the dev guard meant for fan-out code.\n const ioInstance = getIoInstance({ raw: true });\n\n // Reuse the same logout flow as single-session enforcement.\n if (ioInstance && !options?.skipSocketLogout) {\n const { logout } = await import('./logout');\n // eslint-disable-next-line luckystack/no-local-socket-enumeration -- per-instance by construction: only this session's sockets on THIS instance are logged out here; sockets on other instances are not reached (known limitation, reported in the 0.10.0 handoff, not fixed here)\n const sockets = ioInstance.sockets.adapter.rooms.get(token);\n\n if (sockets) {\n await Promise.all([...sockets].map(async (socketId) => {\n const socket = ioInstance.sockets.sockets.get(socketId);\n if (!socket) return;\n\n await logout({\n token,\n socket,\n userId: resolvedUserId,\n skipSessionDelete: true,\n });\n }));\n }\n }\n\n await adapter.untrackActive(resolvedUserId, token);\n }\n await dispatchHook('postSessionDelete', { token, userId: resolvedUserId });\n //? Core-level observational session-lifecycle hook (CORE-40), the revoke\n //? counterpart to `sessionCreated`. Lets consumers react to a revoked\n //? session via core's `registerHook('sessionRevoked', …)` without depending\n //? on login internals. Fire-and-forget.\n void dispatchHook('sessionRevoked', { token, userId: resolvedUserId });\n return true;\n }, undefined, { fn: 'deleteSession', token });\n\n if (error) {\n getLogger().error('deleteSession failed', error, { tokenPrefix: token.slice(0, 8) });\n return false;\n }\n return ok ?? false;\n};\n\n/**\n * Get all active sessions (admin utility). Returns empty when the active\n * adapter doesn't implement enumeration (signed-JWT-stateless, log-only).\n *\n * @returns Array of all session data\n */\nconst getAllSessions = async (): Promise<SessionLayout[]> => {\n const [error, sessions] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n if (!adapter.listAll) {\n getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);\n return [];\n }\n\n const rows = await adapter.listAll();\n const parsedSessions: SessionLayout[] = [];\n for (const { raw } of rows) {\n const parsed = parseSessionLayout(raw);\n if (parsed) parsedSessions.push(parsed);\n }\n return parsedSessions;\n }, undefined, { fn: 'getAllSessions' });\n\n if (error) {\n getLogger().error('getAllSessions failed', error);\n return [];\n }\n return sessions ?? [];\n};\n\n/**\n * Revoke every active session for a user, optionally keeping one alive.\n *\n * @param userId - The user whose sessions to revoke\n * @param exceptToken - If provided, this session is left untouched. Pass the\n * caller's own token when revoking after a password change so the user\n * isn't logged out of the device they just changed it from. Pass null/omit\n * to revoke everything (account deletion, \"sign out everywhere\", etc.).\n *\n * Each revocation goes through `deleteSession`, so connected sockets are\n * told to log out via the same flow as a normal logout. Returns the number\n * of sessions actually revoked.\n */\nconst revokeUserSessions = async (userId: string, exceptToken?: string | null): Promise<number> => {\n if (!userId) return 0;\n\n const adapter = getSessionAdapter();\n const tokens = await adapter.listActive(userId);\n const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;\n\n //? Count only the sessions that were ACTUALLY revoked (M13). `deleteSession`\n //? can return `false` (preSessionDelete veto, adapter error) yet was\n //? previously counted as revoked — so \"sign out everywhere\" / post-password-\n //? reset over-reported and a token a delete failed for could survive a reset.\n //? Summing truthy results means the count reflects reality; a failed token\n //? stays live and is logged by `deleteSession` for retry.\n const results = await Promise.all(targets.map((token) => deleteSession(token)));\n return results.filter(Boolean).length;\n};\n\n//? Legacy key-builders preserved as exports for downstream code that\n//? assumed a stable Redis layout (admin tooling, dev REPL scripts). New\n//? callers should use the active SessionAdapter instead.\nconst sessionKeyFor = (token: string): string => formatKey('-session', token);\nconst activeUsersKeyFor = (userId: string): string => formatKey('-activeUsers', userId);\n\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor };\n","//? Session storage DI surface. The default adapter persists session\n//? records in Redis (same behaviour as before extraction). Consumers can\n//? plug in DynamoDB / Memcached / Postgres-table / signed-JWT-stateless\n//? adapters by implementing `SessionAdapter` and calling\n//? `registerSessionAdapter(...)` at boot.\n//?\n//? The adapter owns ONLY storage primitives. Framework-level concerns\n//? (CSRF minting, hook dispatch, socket emission, single-session\n//? enforcement) stay in session.ts and call the adapter to read/write.\n\nimport { redis, tryCatch, formatKey } from '@luckystack/core';\n\nexport interface SessionAdapter {\n /** Human-readable identifier (used in logs/diagnostics). */\n name: string;\n\n /** Read the raw stored session string for `token`. Null when missing. */\n getRaw(token: string): Promise<string | null>;\n\n /**\n * Write the raw session string for `token` with a TTL. Implementations\n * MUST honour the TTL — sessions without one would never expire.\n */\n setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;\n\n /**\n * Write the raw session string ONLY when a record for `token` still exists\n * (Redis `SET ... XX`), with a TTL. Returns false on miss and must not\n * create a record. Used by partial session updates so an update racing a\n * logout/revoke cannot resurrect the deleted session. Optional: without it\n * the framework re-reads before `setRaw`, which narrows but does not close\n * that window — implement it atomically when the backend can.\n */\n setRawIfExists?(token: string, value: string, ttlSeconds: number): Promise<boolean>;\n\n /** Delete the session record for `token`. Idempotent. */\n delete(token: string): Promise<void>;\n\n /**\n * Refresh the TTL on an existing session record (sliding-window\n * expiration). Returns true when the underlying record existed and the\n * TTL was applied, false otherwise. Implementations must not create a\n * record on miss.\n */\n expire(token: string, ttlSeconds: number): Promise<boolean>;\n\n /** Remaining TTL in seconds (or null when the record is missing). */\n ttl(token: string): Promise<number | null>;\n\n // ---- Active-tokens-per-user (for single-session enforcement and\n // revokeUserSessions). Implementations need a set-like structure keyed\n // by userId. Memcached/edge-KV adapters can serialise as a comma-list\n // if no native set type is available. ----\n\n trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;\n untrackActive(userId: string, token: string): Promise<void>;\n listActive(userId: string): Promise<string[]>;\n\n /**\n * Refresh the TTL on the active-tokens-per-user record WITHOUT adding a\n * token (sliding-window expiration parity with the session key). Called on\n * an authenticated read so a session kept alive purely by reads does not\n * outlive its activeUsers entry — which would otherwise let a stolen token\n * survive a sign-out-everywhere / password reset. Optional: backends whose\n * active-set entries do not expire independently (or that have no TTL\n * concept) can omit it. Must not create a record on miss.\n */\n touchActive?(userId: string, ttlSeconds: number): Promise<void>;\n\n /**\n * Admin walk: yield every active session record. Optional because\n * non-scannable backends (signed-JWT-stateless, log-only) cannot\n * enumerate. Callers fall back to a per-user listActive when omitted.\n *\n * **Dev/admin-only (LOGIN-F14).** Implementations scan the full keyspace and\n * load every session into memory in one call — O(all sessions) in both Redis\n * round-trips and memory. Do NOT call on the hot request path or from any\n * user-facing API. Use `listActive(userId)` for per-user lookups.\n */\n listAll?(): Promise<{ token: string; raw: string }[]>;\n}\n\n/**\n * Default adapter: Redis-backed. Key layout matches what the framework\n * has used since the first release, so registering this explicitly is\n * equivalent to not registering anything.\n *\n * Imports `redis` and `tryCatch` from `@luckystack/core`. Project name\n * + prefix are resolved at call time so projectConfig changes take\n * effect without reloading this module.\n */\n\nconst sessionKey = (token: string): string => formatKey('-session', token);\nconst activeUsersKey = (userId: string): string => formatKey('-activeUsers', userId);\n\nexport const redisSessionAdapter: SessionAdapter = {\n name: 'redis',\n\n async getRaw(token) {\n const [error, value] = await tryCatch(async () => await redis.get(sessionKey(token)));\n if (error) return null;\n return value ?? null;\n },\n\n async setRaw(token, value, ttlSeconds) {\n //? Atomic SET+EX (LOGIN-M7): a two-step SET then EXPIRE leaves a window\n //? where a crash or EXPIRE failure produces an immortal (TTL-less) key that\n //? a stolen token could use indefinitely. A single command eliminates that\n //? gap — the TTL is committed in the same atomic operation as the write.\n await redis.set(sessionKey(token), value, 'EX', ttlSeconds);\n },\n\n async setRawIfExists(token, value, ttlSeconds) {\n //? `XX` makes the existence check and the write one atomic command, so a\n //? concurrent `delete` either lands first (we write nothing) or after.\n const result = await redis.set(sessionKey(token), value, 'EX', ttlSeconds, 'XX');\n return result === 'OK';\n },\n\n async delete(token) {\n await redis.del(sessionKey(token));\n },\n\n async expire(token, ttlSeconds) {\n const result = await redis.expire(sessionKey(token), ttlSeconds);\n return result === 1;\n },\n\n async ttl(token) {\n const [error, value] = await tryCatch(async () => await redis.ttl(sessionKey(token)));\n if (error) return null;\n return typeof value === 'number' ? value : null;\n },\n\n async trackActive(userId, token, ttlSeconds) {\n const key = activeUsersKey(userId);\n await redis.sadd(key, token);\n await redis.expire(key, ttlSeconds);\n },\n\n async untrackActive(userId, token) {\n await redis.srem(activeUsersKey(userId), token);\n },\n\n async touchActive(userId, ttlSeconds) {\n //? `expire` only sets a TTL on an EXISTING key (returns 0 on miss), so this\n //? never resurrects an emptied active-set — it just keeps a live one's TTL\n //? in lock-step with the session key it is read alongside.\n await redis.expire(activeUsersKey(userId), ttlSeconds);\n },\n\n async listActive(userId) {\n const [error, value] = await tryCatch(async () => await redis.smembers(activeUsersKey(userId)));\n if (error || !Array.isArray(value)) return [];\n return value;\n },\n\n async listAll() {\n const pattern = `${formatKey('-session', '')}:*`;\n const collected: { token: string; raw: string }[] = [];\n let cursor = '0';\n\n do {\n const [scanError, scanResponse] = await tryCatch(\n async () => await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100),\n );\n if (scanError || !scanResponse) break;\n\n cursor = scanResponse[0];\n const keys = scanResponse[1];\n\n if (Array.isArray(keys) && keys.length > 0) {\n const values = await Promise.all(keys.map((k) => redis.get(k)));\n for (const [idx, k] of keys.entries()) {\n const raw = values[idx];\n if (raw) {\n collected.push({\n token: k.replace(`${formatKey('-session', '')}:`, ''),\n raw,\n });\n }\n }\n }\n } while (cursor !== '0');\n\n return collected;\n },\n};\n\nlet activeAdapter: SessionAdapter = redisSessionAdapter;\n\n/**\n * Replace the active session storage backend. Call once at boot from\n * `luckystack/server/index.ts`, BEFORE the first login request. Common\n * use cases:\n *\n * - DynamoDB / Cosmos DB for serverless / edge deployments\n * - Postgres `sessions` table when you already operate Postgres\n * - signed-JWT-stateless (the adapter writes nothing, reads from the\n * JWT, and stores activeUsers per token in a small Redis hash)\n * - in-memory mock for integration tests\n *\n * Last-write-wins: subsequent calls overwrite the active adapter.\n */\nexport const registerSessionAdapter = (adapter: SessionAdapter): void => {\n activeAdapter = adapter;\n};\n\n/** Read the currently-active session adapter (defaults to Redis). */\nexport const getSessionAdapter = (): SessionAdapter => activeAdapter;\n","//? Session-field redaction seam (M7). The default session record persisted to\n//? the store AND broadcast to the browser is the full user row minus only\n//? `password` (see `sanitizeUserForSession` in login.ts). Any other sensitive\n//? column (2FA secrets, billing ids, internal flags) would otherwise leak to\n//? the client. `preSessionCreate` is veto-only (it cannot mutate the payload),\n//? so there was no supported way to redact fields. This registry provides one:\n//? register a sanitizer that returns the session shape to persist/broadcast.\n//?\n//? The sanitizer runs on EVERY session write (saveSession), so it covers the\n//? credentials, register, and OAuth paths uniformly without each having to\n//? remember to redact. It must be PURE and SYNCHRONOUS (hot path) and must\n//? return a session object — returning the input unchanged is the no-op default.\n\nimport type { BaseSessionLayout } from './sessionLayout';\n\nexport type SessionSanitizer = (session: BaseSessionLayout) => BaseSessionLayout;\n\nlet registeredSanitizer: SessionSanitizer | null = null;\n\n/**\n * Register a redactor applied to the session record before it is persisted to\n * the store and broadcast to connected clients. Call once at boot. Last write\n * wins. Pass a function that returns a shallow copy with sensitive fields\n * stripped, e.g. `({ totpSecret, stripeCustomerId, ...safe }) => safe`.\n */\nexport const registerSessionSanitizer = (sanitizer: SessionSanitizer): void => {\n registeredSanitizer = sanitizer;\n};\n\n/** Read the active sanitizer (or `null` when none is registered). */\nexport const getSessionSanitizer = (): SessionSanitizer | null => registeredSanitizer;\n\n/**\n * Apply the registered sanitizer (no-op identity when none registered). Used by\n * `saveSession`. Defensive: a throwing sanitizer must not break login, so the\n * caller falls back to the un-sanitized record on error (logged by the caller).\n */\nexport const applySessionSanitizer = (session: BaseSessionLayout): BaseSessionLayout => {\n if (!registeredSanitizer) return session;\n return registeredSanitizer(session);\n};\n\n/** Test-only: clear the registered sanitizer between scenarios. */\nexport const resetSessionSanitizerForTests = (): void => {\n registeredSanitizer = null;\n};\n"],"mappings":"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","names":["tryCatch","socketEventNames","dispatchHook","getLogger","formatKey","tryCatch","tryCatch","logout","formatKey","tryCatch","getLogger","dispatchHook","socketEventNames"]}
|
package/dist/logout-PO7TJ442.js
DELETED
|
File without changes
|