@luckystack/login 0.14.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 +51 -0
- package/README.md +6 -0
- package/dist/{chunk-CLYMWF3A.js → chunk-CYGFR56I.js} +51 -9
- package/dist/chunk-CYGFR56I.js.map +1 -0
- package/dist/{chunk-6IGNAMXW.js → chunk-MZNCQ26U.js} +32 -6
- package/dist/chunk-MZNCQ26U.js.map +1 -0
- package/dist/emailChange.d.ts +6 -0
- package/dist/forgotPassword.d.ts +3 -2
- package/dist/hookPayloads.d.ts +5 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +204 -61
- package/dist/index.js.map +1 -1
- package/dist/logout-VTVJTFSZ.js +7 -0
- package/dist/oauthProviders.d.ts +48 -4
- package/dist/passwordReset.d.ts +9 -2
- package/dist/register.js +1 -1
- package/dist/session.d.ts +5 -3
- package/dist/sessionAdapter.d.ts +9 -0
- package/dist/userAdapter.d.ts +22 -0
- package/docs/oauth-providers.md +4 -0
- package/docs/password-reset.md +1 -1
- package/docs/user-adapter.md +2 -0
- package/package.json +3 -3
- package/dist/chunk-6IGNAMXW.js.map +0 -1
- package/dist/chunk-CLYMWF3A.js.map +0 -1
- package/dist/logout-RD727I3V.js +0 -7
- /package/dist/{logout-RD727I3V.js.map → logout-VTVJTFSZ.js.map} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,57 @@ All notable changes to `@luckystack/login` are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.16.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`FullOAuthProvider.accountIdKey`** names the userinfo field that holds the provider's stable user id. Every built-in sets it: Google `sub`, GitHub `id`, Discord `id`, Facebook `id`, Microsoft `id` (the Entra object id). A custom provider opts in by setting it (ADR 0086).
|
|
13
|
+
- **Optional `UserAdapter.findByProviderAccountId({ provider, providerAccountId })`**, plus `providerAccountId` on `UserRecord` and `UserAdapterCreateInput`. The default Prisma adapter implements it.
|
|
14
|
+
- **Optional `userId` and `providerEmail` on the `preLogin` payload.** `userId` names the existing account an OAuth login resolved to; `providerEmail` is the email the provider reports on this OAuth login.
|
|
15
|
+
- **OAuth `loginFailed` reasons for the binding refusals:** `oauth.accountIdMissing` (the userinfo response lacks the declared id), `oauth.accountBoundElsewhere` (the account with this email is bound to another id) and `oauth.emailUnverified` (an unverified email met an existing account; before 0.16 the cross-provider case reported `oauth.userResolveFailed`). A refusal about an existing account carries its `userId`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Breaking:** OAuth accounts are bound to the provider's user id (ADR 0086). A sign-in first looks up the account by provider and id; an account found that way signs in even when the email changed at the provider, as long as the provider still returns a usable email, and its stored email is kept. Only when no account has the id does the email decide: an account of the same provider, or one whose record has no `provider`, bound to a different id is refused, even with a verified email (a stored id of any type counts, compared as a string); an unbound account signs in, and is bound, only when the provider verified the email. A new account stores the id. Upgrade: add a nullable `providerAccountId` with an index on `(provider, providerAccountId)` to the user schema and run `prisma db push` or a migration; with the default adapter, OAuth logins fail until the column exists (credentials logins keep working). Existing Google, GitHub, Discord and Facebook users bind on their next login. Facebook ids are scoped to the Facebook app: moving to a new Facebook App ID means setting `providerAccountId` to null on provider `facebook` rows; they rebind on their next verified login.
|
|
20
|
+
- **Breaking:** `preLogin` `email` is now the stored email for an existing OAuth account, not the email the provider reports now, so a block by email or `userId` still holds after the user changes the email at the provider. Upgrade: a `preLogin` policy on the provider's current address (for example an allowed domain) reads `providerEmail` instead.
|
|
21
|
+
- **Breaking:** a userinfo response without the provider's declared id is refused instead of matched by email. Upgrade: if you override a built-in's `userInfoURL` (for example Facebook `fields`), keep the id field in the response.
|
|
22
|
+
- **Breaking:** (custom adapters) a `UserAdapter` without `findByProviderAccountId` keeps email matching and logs a warning once, but an unverified provider email can no longer sign in to an existing unbound account. Upgrade: implement `findByProviderAccountId` and persist `providerAccountId` from `create` and `update`.
|
|
23
|
+
- Under `providerAccountStrategy: 'unified'` an account stores only the id of the provider it was created with. Another provider still links to it by verified email and is not bound.
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
|
|
27
|
+
- **An admin of another Entra tenant can no longer sign in as an existing Microsoft user** by setting Graph `mail` to that user's address on a multi-tenant app (`tenant` `common`, `organizations` or `consumers`). This closes the residual risk ADR 0083 recorded. On such an app an existing Microsoft account signs in only once it is bound to its Microsoft id, and an unverified `mail` can never bind one, so in practice only a single-tenant app supports Microsoft sign-in.
|
|
28
|
+
- **The `microsoftProvider` boot warning for multi-tenant `tenant` values now explains** that new Microsoft users are refused and unbound accounts cannot sign in, instead of warning about the takeover.
|
|
29
|
+
- **`providerAccountId` never reaches the session or the browser.** Login strips it like the password hash, also when a registered session sanitizer throws. A project that types its session from the user model can leave it out with `Omit<User, 'password' | 'providerAccountId'>`.
|
|
30
|
+
|
|
31
|
+
## [0.15.0] - 2026-10-19
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`invalidatePasswordResetToken(userId)` and `invalidateEmailChangeToken(userId)`** cancel a user's outstanding reset or email-change link. Call them when control of the account changes; the scaffold's password reset, password change and email-change confirmation now do.
|
|
36
|
+
- **`FullOAuthProvider.alwaysUseGetEmail` and the `OAuthEmailResult` type.** A provider's `getEmail` can report whether the address is verified (ADR 0083).
|
|
37
|
+
- **Optional `SessionAdapter.setRawIfExists(token, value, ttl)`.** The Redis adapter uses `SET … XX`. Custom adapters should implement it atomically; without it the framework re-reads before writing, which makes the race window smaller but does not close it (ADR 0077).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Breaking:** (types) `FullOAuthProvider.getEmail(accessToken, userData)` may return an `OAuthEmailResult`. Custom providers that return a string keep working. Upgrade: code that calls `getEmail` directly must pass the userinfo record and handle the object form.
|
|
42
|
+
- **Breaking:** GitHub sign-in reads the user's verified email list (`/user/emails`). Upgrade: the default `user:email` scope covers it; add that scope back if you removed it.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **New users whose GitHub profile shows a public email can sign up again.** The email comes from GitHub's verified email list, and a verified public email is preferred, so existing accounts keep matching.
|
|
47
|
+
- **Microsoft sign-up works for an app tied to one organisation** (`microsoftProvider({ tenant: '<tenant id or domain>' })`). With `tenant` set to `common` (the default), `organizations` or `consumers`, brand-new Microsoft users are refused on purpose, because any tenant can set any address there; existing users still sign in (ADR 0083).
|
|
48
|
+
- **Breaking:** the Microsoft `userPrincipalName` fallback no longer counts as a verified email. Upgrade: with `allowUpnFallback: true`, a new user without `mail` can no longer get an account; existing accounts still sign in.
|
|
49
|
+
- **Forgot-password answers an existing account as fast as an unknown one and never reports a send failure.** `sendPasswordResetEmail` sends in the background and logs a failure on the server; `ok: false` now only reports a configuration problem that is the same for every address.
|
|
50
|
+
- **`auth.passwordResetPath` is used for the reset link.** It was ignored.
|
|
51
|
+
- **Turning off 2FA and regenerating recovery codes count wrong codes toward the per-account 2FA lockout** (10 per 15 minutes).
|
|
52
|
+
- **`saveSession(..., { onlyIfExists: true })` never recreates a session that was deleted in the meantime** and never re-adds its token to the active-session set (ADR 0077).
|
|
53
|
+
- **Log and error-tracker redaction masks 2FA and recovery codes** (`recoveryCode`, `totp`, `totpCode` in any spelling) once `@luckystack/login` is loaded. A bare `code` key is deliberately left alone.
|
|
54
|
+
|
|
55
|
+
### Security
|
|
56
|
+
|
|
57
|
+
- **`microsoftProvider` warns once at boot when `tenant` is `common` (the default), `organizations` or `consumers`.** Existing Microsoft accounts on such an app are still matched by an unverified `mail`, so an admin of any other tenant can sign in as an existing user (nOAuth). Set `tenant` to your organisation's tenant id or domain to close this; binding accounts to the provider's subject id would need a schema change (ADR 0083).
|
|
58
|
+
|
|
8
59
|
## [0.12.0] - 2026-10-15
|
|
9
60
|
|
|
10
61
|
### Changed
|
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@ Authentication and session lifecycle for LuckyStack, with credential, OAuth and
|
|
|
4
4
|
|
|
5
5
|
Before switching an existing app to unified provider accounts: reconcile duplicate emails and dependent rows; enforce email uniqueness in the database (application checks cannot close the registration race); ensure a custom user adapter implements cross-provider email lookup; then enable the strategy. Changing cookie policy requires a CSRF/origin review. OAuth redirects must pass trusted-origin validation. Password-reset responses must not reveal whether an account exists; never expose tokens or hashes in sessions. See [design context](./AGENTS.md), [session safety](./docs/session-management.md), [redirect trust](./docs/redirect-validation.md) and [account strategy](./docs/user-adapter.md).
|
|
6
6
|
|
|
7
|
+
## Existing-project OAuth account binding (0.16)
|
|
8
|
+
|
|
9
|
+
1. Add a nullable `providerAccountId` to the user store with an index on (`provider`, `providerAccountId`); for the default Prisma adapter `providerAccountId String?` and `@@index([provider, providerAccountId])`. Keep the index non-unique where the store rejects repeated nulls in a unique index (MongoDB, including through Prisma or MikroORM); on SQL a unique index is fine, as in the drizzle starter. Review/apply the database change with authorization and regenerate the client. Until the column exists, OAuth logins with the default adapter fail; credentials logins keep working.
|
|
10
|
+
2. A custom adapter implements `findByProviderAccountId` and persists `providerAccountId` from `create` and `update` (`src/userAdapter.ts`). Without it the framework warns once and keeps email matching.
|
|
11
|
+
3. Existing accounts bind on their next login when the provider verified the email; an unbound account whose provider email is unverified (multi-tenant Microsoft) is refused. See ADR 0086.
|
|
12
|
+
|
|
7
13
|
## Existing-project email-code / 2FA adoption
|
|
8
14
|
|
|
9
15
|
1. Obtain feature approval before opting into `auth.emailCodeLogin: true` and/or `auth.twoFactor: 'optional'`. Register a real email sender for email-code login and any email fallback; console delivery is not sufficient.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/oauthProviders.ts
|
|
2
|
-
import { tryCatch } from "@luckystack/core";
|
|
2
|
+
import { getLogger, tryCatch } from "@luckystack/core";
|
|
3
3
|
var asOAuthUserData = (value) => {
|
|
4
4
|
if (value && typeof value === "object") {
|
|
5
5
|
return value;
|
|
@@ -36,6 +36,8 @@ var googleProvider = (input) => ({
|
|
|
36
36
|
], input.extraScopes),
|
|
37
37
|
nameKey: "name",
|
|
38
38
|
emailKey: "email",
|
|
39
|
+
//? OIDC subject: stable per Google account, unlike the email.
|
|
40
|
+
accountIdKey: "sub",
|
|
39
41
|
//? Google's OIDC userinfo returns `email_verified`; reject an unverified
|
|
40
42
|
//? address so a Google account with an unverified email can't link to a
|
|
41
43
|
//? victim's existing account under the `'unified'` strategy (SEC-21).
|
|
@@ -58,12 +60,18 @@ var githubProvider = (input) => ({
|
|
|
58
60
|
scope: mergeScopes(["read:user", "user:email"], input.extraScopes),
|
|
59
61
|
nameKey: "login",
|
|
60
62
|
emailKey: "email",
|
|
63
|
+
//? Numeric user id; the `login` handle can be renamed and then claimed by someone else.
|
|
64
|
+
accountIdKey: "id",
|
|
65
|
+
//? The `/user` public `email` has no verified flag, so taking it verbatim left
|
|
66
|
+
//? every new user with a public email unverified (first-login creation
|
|
67
|
+
//? refused). Always resolve through the verified-only `/user/emails` list.
|
|
68
|
+
alwaysUseGetEmail: true,
|
|
61
69
|
avatarKey: "avatar_url",
|
|
62
70
|
avatarCodeKey: "",
|
|
63
71
|
extraSessionFields: input.extraSessionFields,
|
|
64
72
|
extraAuthorizationParams: input.extraAuthorizationParams,
|
|
65
73
|
usePkce: input.usePkce,
|
|
66
|
-
getEmail: async (accessToken) => {
|
|
74
|
+
getEmail: async (accessToken, userData) => {
|
|
67
75
|
const fetchEmails = async () => {
|
|
68
76
|
const response = await fetch("https://api.github.com/user/emails", {
|
|
69
77
|
method: "GET",
|
|
@@ -81,9 +89,12 @@ var githubProvider = (input) => ({
|
|
|
81
89
|
const [error, emails] = await tryCatch(fetchEmails);
|
|
82
90
|
if (error || !emails) return false;
|
|
83
91
|
const verified = emails.filter((entry) => entry.verified === true);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const publicEmail = typeof userData.email === "string" ? userData.email.toLowerCase() : "";
|
|
93
|
+
let mainEmail = publicEmail ? verified.find((entry) => typeof entry.email === "string" && entry.email.toLowerCase() === publicEmail)?.email : void 0;
|
|
94
|
+
if (!mainEmail) {
|
|
95
|
+
for (const entry of verified) {
|
|
96
|
+
if (entry.primary && typeof entry.email === "string") mainEmail = entry.email;
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
99
|
mainEmail ??= verified[0]?.email;
|
|
89
100
|
return mainEmail;
|
|
@@ -101,6 +112,8 @@ var discordProvider = (input) => ({
|
|
|
101
112
|
scope: mergeScopes(["identify", "email"], input.extraScopes),
|
|
102
113
|
nameKey: "username",
|
|
103
114
|
emailKey: "email",
|
|
115
|
+
//? Snowflake user id.
|
|
116
|
+
accountIdKey: "id",
|
|
104
117
|
//? Discord's /users/@me returns a `verified` boolean for the account email;
|
|
105
118
|
//? reject the address when it is explicitly unverified (SEC-21).
|
|
106
119
|
emailVerifiedKey: "verified",
|
|
@@ -130,6 +143,10 @@ var facebookProvider = (input) => {
|
|
|
130
143
|
scope: mergeScopes(["public_profile", "email"], input.extraScopes),
|
|
131
144
|
nameKey: "name",
|
|
132
145
|
emailKey: "email",
|
|
146
|
+
//? App-scoped user id. A custom `userInfoURL` must keep `id` in `fields`.
|
|
147
|
+
//? A new Facebook App ID gives every user a new id, so bound accounts are
|
|
148
|
+
//? refused until their `providerAccountId` is set to null (ADR 0086).
|
|
149
|
+
accountIdKey: "id",
|
|
133
150
|
//? Facebook's Graph `/me` only returns `email` for an address the user has
|
|
134
151
|
//? CONFIRMED with Facebook (unconfirmed emails are omitted), so there is no
|
|
135
152
|
//? separate verified flag to gate on — the presence of `email` IS the verified
|
|
@@ -150,11 +167,22 @@ var facebookProvider = (input) => {
|
|
|
150
167
|
}
|
|
151
168
|
};
|
|
152
169
|
};
|
|
170
|
+
var MICROSOFT_MULTI_TENANT_SEGMENTS = /* @__PURE__ */ new Set(["common", "organizations", "consumers"]);
|
|
171
|
+
var warnedMultiTenant = false;
|
|
172
|
+
var warnMultiTenantOnce = (tenant) => {
|
|
173
|
+
if (warnedMultiTenant) return;
|
|
174
|
+
warnedMultiTenant = true;
|
|
175
|
+
getLogger().warn(
|
|
176
|
+
`[login] microsoftProvider tenant '${tenant}' accepts accounts from any Entra tenant, so Graph mail is not verified: new Microsoft users are refused and existing accounts sign in only once bound to their Microsoft id. Set \`tenant\` to your organisation's tenant id or domain to enable Microsoft sign-in.`
|
|
177
|
+
);
|
|
178
|
+
};
|
|
153
179
|
var microsoftProvider = (input) => {
|
|
154
180
|
const tenant = input.tenant ?? "common";
|
|
155
181
|
const oauthVersion = input.apiVersion ?? "v2.0";
|
|
156
182
|
const graphVersion = input.graphApiVersion ?? "v1.0";
|
|
157
183
|
const allowUpnFallback = input.allowUpnFallback ?? false;
|
|
184
|
+
const isSingleTenant = !MICROSOFT_MULTI_TENANT_SEGMENTS.has(tenant.toLowerCase());
|
|
185
|
+
if (!isSingleTenant) warnMultiTenantOnce(tenant);
|
|
158
186
|
return {
|
|
159
187
|
name: "microsoft",
|
|
160
188
|
clientID: requireString(input.clientId, "microsoft clientId"),
|
|
@@ -167,6 +195,13 @@ var microsoftProvider = (input) => {
|
|
|
167
195
|
scope: mergeScopes(["openid", "profile", "email", "User.Read"], input.extraScopes),
|
|
168
196
|
nameKey: "displayName",
|
|
169
197
|
emailKey: "mail",
|
|
198
|
+
//? Graph `/me` `id` is the user's Entra object id: assigned by Entra, never
|
|
199
|
+
//? editable by a tenant admin (unlike `mail`), and a GUID that no other tenant
|
|
200
|
+
//? can reproduce. `/me` carries no tenant id to combine it with (ADR 0086).
|
|
201
|
+
accountIdKey: "id",
|
|
202
|
+
//? Graph `mail` has no verified flag; it is trusted only when one tenant's
|
|
203
|
+
//? admin owns it (see `MicrosoftProviderInput.tenant`).
|
|
204
|
+
emailImpliesVerified: isSingleTenant,
|
|
170
205
|
avatarCodeKey: "id",
|
|
171
206
|
extraSessionFields: input.extraSessionFields,
|
|
172
207
|
extraAuthorizationParams: input.extraAuthorizationParams,
|
|
@@ -211,10 +246,10 @@ var microsoftProvider = (input) => {
|
|
|
211
246
|
if (error || !profile) return false;
|
|
212
247
|
const record = asOAuthUserData(profile);
|
|
213
248
|
const mail = typeof record.mail === "string" ? record.mail : "";
|
|
214
|
-
if (mail) return mail;
|
|
249
|
+
if (mail) return { email: mail, verified: isSingleTenant };
|
|
215
250
|
if (allowUpnFallback) {
|
|
216
251
|
const userPrincipalName = typeof record.userPrincipalName === "string" ? record.userPrincipalName : "";
|
|
217
|
-
return userPrincipalName
|
|
252
|
+
return userPrincipalName ? { email: userPrincipalName, verified: false } : false;
|
|
218
253
|
}
|
|
219
254
|
return false;
|
|
220
255
|
}
|
|
@@ -259,6 +294,10 @@ var defaultPrismaUserAdapter = () => {
|
|
|
259
294
|
//? engine-defined — two concurrent logins for the same email can resolve to
|
|
260
295
|
//? different User rows AND the cross-provider link guard flips non-deterministically.
|
|
261
296
|
findByEmailAnyProvider: async ({ email }) => getPrismaUser().findFirst({ where: { email }, orderBy: { createdAt: "asc" } }),
|
|
297
|
+
//? The (provider, providerAccountId) index is not unique (a Mongo unique
|
|
298
|
+
//? index rejects a second null), so two concurrent first logins can bind
|
|
299
|
+
//? two rows; resolve to the oldest, like `findByEmailAnyProvider`.
|
|
300
|
+
findByProviderAccountId: async ({ provider, providerAccountId }) => getPrismaUser().findFirst({ where: { provider, providerAccountId }, orderBy: { createdAt: "asc" } }),
|
|
262
301
|
findById: async (id) => getPrismaUser().findUnique({ where: { id } }),
|
|
263
302
|
create: async (input) => getPrismaUser().create({
|
|
264
303
|
data: {
|
|
@@ -268,7 +307,10 @@ var defaultPrismaUserAdapter = () => {
|
|
|
268
307
|
password: input.password ?? null,
|
|
269
308
|
avatar: input.avatar ?? "",
|
|
270
309
|
avatarFallback: input.avatarFallback,
|
|
271
|
-
language: input.language
|
|
310
|
+
language: input.language,
|
|
311
|
+
//? Only OAuth sign-ups send it, so credentials registration keeps
|
|
312
|
+
//? working on a schema that has not added the column yet.
|
|
313
|
+
...input.providerAccountId ? { providerAccountId: input.providerAccountId } : {}
|
|
272
314
|
}
|
|
273
315
|
}),
|
|
274
316
|
update: async (id, patch) => getPrismaUser().update({ where: { id }, data: patch }),
|
|
@@ -301,4 +343,4 @@ export {
|
|
|
301
343
|
defaultPrismaUserAdapter,
|
|
302
344
|
getUserAdapter
|
|
303
345
|
};
|
|
304
|
-
//# sourceMappingURL=chunk-
|
|
346
|
+
//# sourceMappingURL=chunk-CYGFR56I.js.map
|
|
@@ -0,0 +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 { 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 * Key on the userInfo response holding the provider's stable user id (a\n * non-empty string or an integer). When set, an account is bound to\n * (provider, id) and found by it before any email lookup, so an address the\n * provider does not verify can never sign in to someone else's account\n * (ADR 0086). Every built-in sets it; a custom provider opts in. A response\n * without the id is refused rather than matched by email.\n */\n accountIdKey?: 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 //? OIDC subject: stable per Google account, unlike the email.\n accountIdKey: 'sub',\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 //? Numeric user id; the `login` handle can be renamed and then claimed by someone else.\n accountIdKey: 'id',\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 //? Snowflake user id.\n accountIdKey: 'id',\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 //? App-scoped user id. A custom `userInfoURL` must keep `id` in `fields`.\n //? A new Facebook App ID gives every user a new id, so bound accounts are\n //? refused until their `providerAccountId` is set to null (ADR 0086).\n accountIdKey: 'id',\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 Microsoft accounts already bound to their Microsoft id\n * can sign in (ADR 0086). Overriding `endpoints` does not change this; set\n * `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//? Accounts are bound to the Microsoft object id (ADR 0086), so an unverified\n//? multi-tenant `mail` can no longer sign in as someone else. It also cannot\n//? create an account or bind an existing one, so on these tenants only accounts\n//? bound earlier (under a single-tenant setup) sign in. Warn once at boot so a\n//? project on the default tenant learns why every new Microsoft login fails.\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, so Graph mail is not verified: new Microsoft users are refused and existing accounts sign in only once bound to their Microsoft id. Set \\`tenant\\` to your organisation's tenant id or domain to enable Microsoft sign-in.`,\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 `/me` `id` is the user's Entra object id: assigned by Entra, never\n //? editable by a tenant admin (unlike `mail`), and a GUID that no other tenant\n //? can reproduce. `/me` carries no tenant id to combine it with (ADR 0086).\n accountIdKey: 'id',\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, providerAccountId?, name, avatar,\n//? avatarFallback, admin, language) and is used automatically when no adapter\n//? 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 /** The OAuth provider's stable user id (ADR 0086). Absent for credentials accounts. */\n providerAccountId?: string | null;\n}\n\nexport interface UserRecord extends BaseSessionLayout {\n password?: string | null;\n provider?: string | null;\n /**\n * The stable user id of the OAuth provider in `provider` (ADR 0086). Null for\n * credentials accounts and for OAuth accounts not bound yet. Binds only the\n * row's own provider: under `'unified'` another provider that links by email\n * is not stored here.\n */\n providerAccountId?: 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 /**\n * Look up the account bound to an OAuth provider's stable user id (ADR 0086).\n * Optional so existing custom adapters keep compiling. Without it the\n * framework neither reads nor writes the binding: it warns once, refuses an\n * unverified provider email on an existing account, and otherwise matches by\n * email as before (an email change at the provider then starts a new\n * account). An adapter that implements it must also persist\n * `providerAccountId` from `create` and `update`.\n */\n findByProviderAccountId?: (params: { provider: string; providerAccountId: 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: {\n where: { email: string; provider?: string } | { provider: string; providerAccountId: string };\n orderBy?: { createdAt?: 'asc' | 'desc' };\n }) => 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 //? The (provider, providerAccountId) index is not unique (a Mongo unique\n //? index rejects a second null), so two concurrent first logins can bind\n //? two rows; resolve to the oldest, like `findByEmailAnyProvider`.\n findByProviderAccountId: async ({ provider, providerAccountId }) =>\n getPrismaUser().findFirst({ where: { provider, providerAccountId }, 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 //? Only OAuth sign-ups send it, so credentials registration keeps\n //? working on a schema that has not added the column yet.\n ...(input.providerAccountId ? { providerAccountId: input.providerAccountId } : {}),\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"]}
|
|
@@ -28,6 +28,10 @@ var redisSessionAdapter = {
|
|
|
28
28
|
async setRaw(token, value, ttlSeconds) {
|
|
29
29
|
await redis.set(sessionKey(token), value, "EX", ttlSeconds);
|
|
30
30
|
},
|
|
31
|
+
async setRawIfExists(token, value, ttlSeconds) {
|
|
32
|
+
const result = await redis.set(sessionKey(token), value, "EX", ttlSeconds, "XX");
|
|
33
|
+
return result === "OK";
|
|
34
|
+
},
|
|
31
35
|
async delete(token) {
|
|
32
36
|
await redis.del(sessionKey(token));
|
|
33
37
|
},
|
|
@@ -148,17 +152,39 @@ var saveSession = async (token, data, newUser, options) => {
|
|
|
148
152
|
const [sanitizeErr, sanitized] = tryCatchSync(() => applySessionSanitizer(data));
|
|
149
153
|
if (sanitizeErr) {
|
|
150
154
|
getLogger().error("saveSession: session sanitizer threw \u2014 falling back to password-stripped projection", sanitizeErr, { tokenPrefix: token.slice(0, 8) });
|
|
151
|
-
const {
|
|
155
|
+
const {
|
|
156
|
+
password: _password,
|
|
157
|
+
totpSecret: _totpSecret,
|
|
158
|
+
recoveryCodes: _recoveryCodes,
|
|
159
|
+
providerAccountId: _providerAccountId,
|
|
160
|
+
...safe
|
|
161
|
+
} = data;
|
|
152
162
|
persisted = safe;
|
|
153
163
|
} else if (sanitized) {
|
|
154
164
|
persisted = sanitized;
|
|
155
165
|
}
|
|
156
166
|
const ttl = getSessionTtl();
|
|
157
167
|
const { token: _stripToken, ...persistedWithoutToken } = persisted;
|
|
158
|
-
|
|
168
|
+
const serialized = JSON.stringify(persistedWithoutToken);
|
|
169
|
+
if (options?.onlyIfExists) {
|
|
170
|
+
let written;
|
|
171
|
+
if (adapter.setRawIfExists) {
|
|
172
|
+
written = await adapter.setRawIfExists(token, serialized, ttl);
|
|
173
|
+
} else {
|
|
174
|
+
written = await adapter.getRaw(token) !== null;
|
|
175
|
+
if (written) await adapter.setRaw(token, serialized, ttl);
|
|
176
|
+
}
|
|
177
|
+
if (!written) return { ok: false, errorCode: "session.notFound" };
|
|
178
|
+
} else {
|
|
179
|
+
await adapter.setRaw(token, serialized, ttl);
|
|
180
|
+
}
|
|
159
181
|
const userId = data.id;
|
|
160
182
|
if (userId) {
|
|
161
|
-
|
|
183
|
+
if (options?.onlyIfExists) {
|
|
184
|
+
if (adapter.touchActive) await adapter.touchActive(userId, ttl);
|
|
185
|
+
} else {
|
|
186
|
+
await adapter.trackActive(userId, token, ttl);
|
|
187
|
+
}
|
|
162
188
|
}
|
|
163
189
|
const { getIoInstance } = await import("@luckystack/core");
|
|
164
190
|
const io = getIoInstance({ raw: true });
|
|
@@ -179,7 +205,7 @@ var saveSession = async (token, data, newUser, options) => {
|
|
|
179
205
|
tokensToKick = previousTokens.slice(0, excess);
|
|
180
206
|
}
|
|
181
207
|
if (tokensToKick.length > 0) {
|
|
182
|
-
const { logout: logout2 } = await import("./logout-
|
|
208
|
+
const { logout: logout2 } = await import("./logout-VTVJTFSZ.js");
|
|
183
209
|
await Promise.all(tokensToKick.map(async (previousToken) => {
|
|
184
210
|
const sockets = io.sockets.adapter.rooms.get(previousToken);
|
|
185
211
|
if (sockets) {
|
|
@@ -282,7 +308,7 @@ var deleteSession = async (token, options) => {
|
|
|
282
308
|
const { getIoInstance } = await import("@luckystack/core");
|
|
283
309
|
const ioInstance = getIoInstance({ raw: true });
|
|
284
310
|
if (ioInstance && !options?.skipSocketLogout) {
|
|
285
|
-
const { logout: logout2 } = await import("./logout-
|
|
311
|
+
const { logout: logout2 } = await import("./logout-VTVJTFSZ.js");
|
|
286
312
|
const sockets = ioInstance.sockets.adapter.rooms.get(token);
|
|
287
313
|
if (sockets) {
|
|
288
314
|
await Promise.all([...sockets].map(async (socketId) => {
|
|
@@ -398,4 +424,4 @@ export {
|
|
|
398
424
|
sessionKeyFor,
|
|
399
425
|
activeUsersKeyFor
|
|
400
426
|
};
|
|
401
|
-
//# sourceMappingURL=chunk-
|
|
427
|
+
//# sourceMappingURL=chunk-MZNCQ26U.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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 {\n password: _password,\n totpSecret: _totpSecret,\n recoveryCodes: _recoveryCodes,\n providerAccountId: _providerAccountId,\n ...safe\n } = data as SessionLayout & { password?: unknown; totpSecret?: unknown; recoveryCodes?: unknown; providerAccountId?: 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/emailChange.d.ts
CHANGED
|
@@ -19,3 +19,9 @@ export declare const createEmailChangeToken: (userId: string, newEmail: string)
|
|
|
19
19
|
* already consumed.
|
|
20
20
|
*/
|
|
21
21
|
export declare const consumeEmailChangeToken: (token: string) => Promise<EmailChangePayload | null>;
|
|
22
|
+
/**
|
|
23
|
+
* Invalidate the user's outstanding email-change link, if any. Call it when
|
|
24
|
+
* control of the account changes (password reset or change): a change an
|
|
25
|
+
* attacker requested before that must not stay confirmable afterwards.
|
|
26
|
+
*/
|
|
27
|
+
export declare const invalidateEmailChangeToken: (userId: string) => Promise<void>;
|
package/dist/forgotPassword.d.ts
CHANGED
|
@@ -11,8 +11,9 @@ interface SendResetEmailArgs {
|
|
|
11
11
|
/**
|
|
12
12
|
* Look up the user by credentials-provider email, generate a reset token,
|
|
13
13
|
* and email them a reset link. Always resolves successfully regardless of
|
|
14
|
-
* whether the email matched a user (anti-enumeration).
|
|
15
|
-
*
|
|
14
|
+
* whether the email matched a user (anti-enumeration). The token and send run
|
|
15
|
+
* in the background, so a send failure is logged, not returned; `ok: false`
|
|
16
|
+
* only reports a mode/module problem that is the same for every address.
|
|
16
17
|
*/
|
|
17
18
|
export declare const sendPasswordResetEmail: ({ email, brand }: SendResetEmailArgs) => Promise<{
|
|
18
19
|
ok: boolean;
|
package/dist/hookPayloads.d.ts
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { BaseSessionLayout } from './sessionLayout.js';
|
|
11
11
|
export interface PreLoginPayload {
|
|
12
|
+
/** For an existing OAuth account its stored email, not the one the provider reports now. */
|
|
12
13
|
email: string;
|
|
13
14
|
provider: string;
|
|
15
|
+
/** The existing account an OAuth login resolved to; absent for credentials and for a new account. */
|
|
16
|
+
userId?: string;
|
|
17
|
+
/** The email the OAuth provider reports on this login, which can differ from `email` for an existing account; absent for credentials. */
|
|
18
|
+
providerEmail?: string;
|
|
14
19
|
}
|
|
15
20
|
export interface PostLoginPayload {
|
|
16
21
|
userId: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,14 +12,14 @@ export type { CreateOAuthStateResult } from './login.js';
|
|
|
12
12
|
export { OAUTH_STATE_COOKIE_NAME } from './login.js';
|
|
13
13
|
export { logout } from './logout.js';
|
|
14
14
|
export { isAccountLocked, recordAuthFailure, clearAuthFailures } from './authLockout.js';
|
|
15
|
-
export { createPasswordResetToken, consumePasswordResetToken, updatePasswordHash, verifyPassword, PasswordPolicyError, } from './passwordReset.js';
|
|
15
|
+
export { createPasswordResetToken, consumePasswordResetToken, invalidatePasswordResetToken, updatePasswordHash, verifyPassword, PasswordPolicyError, } from './passwordReset.js';
|
|
16
16
|
export { validatePassword } from './passwordPolicy.js';
|
|
17
17
|
export { sendPasswordResetEmail } from './forgotPassword.js';
|
|
18
|
-
export { createEmailChangeToken, consumeEmailChangeToken, } from './emailChange.js';
|
|
18
|
+
export { createEmailChangeToken, consumeEmailChangeToken, invalidateEmailChangeToken, } from './emailChange.js';
|
|
19
19
|
export type { EmailChangePayload } from './emailChange.js';
|
|
20
20
|
export { sendEmailChangeConfirmation } from './emailChangeNotification.js';
|
|
21
21
|
export { registerOAuthProviders, registerOAuthProviderFactory, getOAuthProviders, isFullOAuthProvider, asOAuthUserData, credentialsProvider, googleProvider, githubProvider, discordProvider, facebookProvider, microsoftProvider, } from './oauthProviders.js';
|
|
22
|
-
export type { OAuthProvider, CredentialsProvider, FullOAuthProvider, OAuthUserData, } from './oauthProviders.js';
|
|
22
|
+
export type { OAuthProvider, CredentialsProvider, FullOAuthProvider, OAuthUserData, OAuthEmailResult, } from './oauthProviders.js';
|
|
23
23
|
export { registerUserAdapter, getUserAdapter, isUserAdapterRegistered, defaultPrismaUserAdapter, } from './userAdapter.js';
|
|
24
24
|
export type { UserAdapter, UserAdapterCreateInput, UserRecord, } from './userAdapter.js';
|
|
25
25
|
export { beginTotpEnrollment, confirmTotpEnrollment, disableTwoFactor, regenerateRecoveryCodes, verifyTwoFactorChallenge, requestTwoFactorEmailCode, availableTwoFactorMethods, createTwoFactorChallengeIfRequired, } from './twoFactor.js';
|