@luckystack/login 0.6.7 → 0.7.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.
@@ -0,0 +1,7 @@
1
+ import {
2
+ logout
3
+ } from "./chunk-WFPYOCFC.js";
4
+ export {
5
+ logout
6
+ };
7
+ //# sourceMappingURL=logout-OBO6AF4R.js.map
package/dist/register.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  microsoftProvider,
10
10
  registerOAuthProviderFactory,
11
11
  registerUserAdapter
12
- } from "./chunk-HJHCX2JO.js";
12
+ } from "./chunk-L5MHCPNN.js";
13
13
 
14
14
  // src/register.ts
15
15
  import { getProjectConfig, resolveEnvKey } from "@luckystack/core";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/register.ts"],"sourcesContent":["//? Side-effect `./register` entry for @luckystack/login. Auto-imported at boot by\r\n//? @luckystack/server's `bootstrapLuckyStack` when this package is installed, so\r\n//? OAuth providers + the default user adapter wire themselves from env with NO\r\n//? consumer code edit — adding an OAuth provider is just env vars + restart.\r\n//?\r\n//? (Session-provider registration lives in this package's index module load —\r\n//? `bootstrapLuckyStack` force-loads it via `getLogin()`. This register entry\r\n//? covers the parts that USED to live in the consumer\r\n//? `luckystack/login/{oauthProviders,userAdapter}.ts` overlay.)\r\n//?\r\n//? A provider becomes active (its button shows on the login form via\r\n//? `GET /auth/providers` AND its `/auth/api/<name>` route works) as soon as BOTH\r\n//? its *_CLIENT_ID and *_CLIENT_SECRET are set for the current environment:\r\n//? `DEV_<KEY>` in dev, the unprefixed `<KEY>` in prod. The callback origin comes\r\n//? from `getProjectConfig().oauthCallbackBase` (set in the consumer's config.ts)\r\n//? — the redirect URI to register with each provider is\r\n//? `<oauthCallbackBase>/auth/callback/<provider>`.\r\n//?\r\n//? A consumer overlay (`luckystack/login/*.ts`) runs AFTER this import, so a\r\n//? hand-written `registerOAuthProviders(...)` / `registerUserAdapter(...)` still\r\n//? wins (last writer).\r\n\r\nimport { getProjectConfig, resolveEnvKey } from '@luckystack/core';\r\nimport {\r\n registerOAuthProviderFactory,\r\n credentialsProvider,\r\n googleProvider,\r\n githubProvider,\r\n discordProvider,\r\n facebookProvider,\r\n microsoftProvider,\r\n type OAuthProvider,\r\n} from './oauthProviders';\r\nimport {\r\n registerUserAdapter,\r\n isUserAdapterRegistered,\r\n defaultPrismaUserAdapter,\r\n} from './userAdapter';\r\n\r\n//? Default Prisma-backed user store — guard so a consumer overlay that\r\n//? registered a custom adapter before this runs is never clobbered.\r\nif (!isUserAdapterRegistered()) {\r\n registerUserAdapter(defaultPrismaUserAdapter());\r\n}\r\n\r\n//? LOGIN-01: all config/env reads are deferred into a factory function that\r\n//? executes at first-request time, not at module-load time. This prevents\r\n//? stale-config footguns when `getProjectConfig()` is overridden after import\r\n//? (e.g. in tests or multi-step boot sequences). Module-load side effects\r\n//? in the block below are now limited to adapter registration (which is safe\r\n//? because the adapter registry itself does not depend on project config).\r\nconst buildProviders = (): OAuthProvider[] => {\r\n const dev = resolveEnvKey() !== 'production';\r\n\r\n //? Reads `DEV_<key>` in dev, the unprefixed `<key>` in prod. Empty when unset.\r\n const env = (key: string): string => (dev ? process.env[`DEV_${key}`] : process.env[key]) ?? '';\r\n\r\n //? Backend origin for the OAuth redirect URI. Prefer the explicit\r\n //? `oauthCallbackBase` slot; fall back to `app.publicUrl` so a consumer that\r\n //? only set `publicUrl` still gets a usable callback.\r\n const projectConfig = getProjectConfig();\r\n //? Empty-string slot (the default) must fall through to publicUrl. Use an\r\n //? explicit length check so `??` (which would keep the empty string) is wrong here.\r\n const configuredCallbackBase = projectConfig.oauthCallbackBase ?? '';\r\n const callbackBase = configuredCallbackBase.length > 0 ? configuredCallbackBase : projectConfig.app.publicUrl;\r\n const callback = (name: string): string => `${callbackBase}/auth/callback/${name}`;\r\n\r\n //? Credentials (email+password) is a registry entry too, gated by a single\r\n //? config flag so the registry — exposed via GET /auth/providers — is the ONE\r\n //? source the login form reads (no duplicate static `config.providers` list).\r\n const providers: OAuthProvider[] = [];\r\n if (projectConfig.auth.credentials) {\r\n providers.push(credentialsProvider());\r\n }\r\n\r\n if (env('GOOGLE_CLIENT_ID') && env('GOOGLE_CLIENT_SECRET')) {\r\n providers.push(googleProvider({\r\n clientId: env('GOOGLE_CLIENT_ID'),\r\n clientSecret: env('GOOGLE_CLIENT_SECRET'),\r\n callbackUrl: callback('google'),\r\n }));\r\n }\r\n\r\n if (env('GITHUB_CLIENT_ID') && env('GITHUB_CLIENT_SECRET')) {\r\n providers.push(githubProvider({\r\n clientId: env('GITHUB_CLIENT_ID'),\r\n clientSecret: env('GITHUB_CLIENT_SECRET'),\r\n callbackUrl: callback('github'),\r\n }));\r\n }\r\n\r\n if (env('DISCORD_CLIENT_ID') && env('DISCORD_CLIENT_SECRET')) {\r\n providers.push(discordProvider({\r\n clientId: env('DISCORD_CLIENT_ID'),\r\n clientSecret: env('DISCORD_CLIENT_SECRET'),\r\n callbackUrl: callback('discord'),\r\n }));\r\n }\r\n\r\n if (env('FACEBOOK_CLIENT_ID') && env('FACEBOOK_CLIENT_SECRET')) {\r\n providers.push(facebookProvider({\r\n clientId: env('FACEBOOK_CLIENT_ID'),\r\n clientSecret: env('FACEBOOK_CLIENT_SECRET'),\r\n callbackUrl: callback('facebook'),\r\n }));\r\n }\r\n\r\n if (env('MICROSOFT_CLIENT_ID') && env('MICROSOFT_CLIENT_SECRET')) {\r\n providers.push(microsoftProvider({\r\n clientId: env('MICROSOFT_CLIENT_ID'),\r\n clientSecret: env('MICROSOFT_CLIENT_SECRET'),\r\n callbackUrl: callback('microsoft'),\r\n tenant: env('MICROSOFT_TENANT_ID') || undefined,\r\n }));\r\n }\r\n\r\n return providers;\r\n};\r\n\r\nregisterOAuthProviderFactory(buildProviders);\r\n"],"mappings":";;;;;;;;;;;;;;AAsBA,SAAS,kBAAkB,qBAAqB;AAmBhD,IAAI,CAAC,wBAAwB,GAAG;AAC9B,sBAAoB,yBAAyB,CAAC;AAChD;AAQA,IAAM,iBAAiB,MAAuB;AAC5C,QAAM,MAAM,cAAc,MAAM;AAGhC,QAAM,MAAM,CAAC,SAAyB,MAAM,QAAQ,IAAI,OAAO,GAAG,EAAE,IAAI,QAAQ,IAAI,GAAG,MAAM;AAK7F,QAAM,gBAAgB,iBAAiB;AAGvC,QAAM,yBAAyB,cAAc,qBAAqB;AAClE,QAAM,eAAe,uBAAuB,SAAS,IAAI,yBAAyB,cAAc,IAAI;AACpG,QAAM,WAAW,CAAC,SAAyB,GAAG,YAAY,kBAAkB,IAAI;AAKhF,QAAM,YAA6B,CAAC;AACpC,MAAI,cAAc,KAAK,aAAa;AAClC,cAAU,KAAK,oBAAoB,CAAC;AAAA,EACtC;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,mBAAmB,KAAK,IAAI,uBAAuB,GAAG;AAC5D,cAAU,KAAK,gBAAgB;AAAA,MAC7B,UAAU,IAAI,mBAAmB;AAAA,MACjC,cAAc,IAAI,uBAAuB;AAAA,MACzC,aAAa,SAAS,SAAS;AAAA,IACjC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,oBAAoB,KAAK,IAAI,wBAAwB,GAAG;AAC9D,cAAU,KAAK,iBAAiB;AAAA,MAC9B,UAAU,IAAI,oBAAoB;AAAA,MAClC,cAAc,IAAI,wBAAwB;AAAA,MAC1C,aAAa,SAAS,UAAU;AAAA,IAClC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,qBAAqB,KAAK,IAAI,yBAAyB,GAAG;AAChE,cAAU,KAAK,kBAAkB;AAAA,MAC/B,UAAU,IAAI,qBAAqB;AAAA,MACnC,cAAc,IAAI,yBAAyB;AAAA,MAC3C,aAAa,SAAS,WAAW;AAAA,MACjC,QAAQ,IAAI,qBAAqB,KAAK;AAAA,IACxC,CAAC,CAAC;AAAA,EACJ;AAEA,SAAO;AACT;AAEA,6BAA6B,cAAc;","names":[]}
1
+ {"version":3,"sources":["../src/register.ts"],"sourcesContent":["//? Side-effect `./register` entry for @luckystack/login. Auto-imported at boot by\n//? @luckystack/server's `bootstrapLuckyStack` when this package is installed, so\n//? OAuth providers + the default user adapter wire themselves from env with NO\n//? consumer code edit — adding an OAuth provider is just env vars + restart.\n//?\n//? (Session-provider registration lives in this package's index module load —\n//? `bootstrapLuckyStack` force-loads it via `getLogin()`. This register entry\n//? covers the parts that USED to live in the consumer\n//? `luckystack/login/{oauthProviders,userAdapter}.ts` overlay.)\n//?\n//? A provider becomes active (its button shows on the login form via\n//? `GET /auth/providers` AND its `/auth/api/<name>` route works) as soon as BOTH\n//? its *_CLIENT_ID and *_CLIENT_SECRET are set for the current environment:\n//? `DEV_<KEY>` in dev, the unprefixed `<KEY>` in prod. The callback origin comes\n//? from `getProjectConfig().oauthCallbackBase` (set in the consumer's config.ts)\n//? — the redirect URI to register with each provider is\n//? `<oauthCallbackBase>/auth/callback/<provider>`.\n//?\n//? A consumer overlay (`luckystack/login/*.ts`) runs AFTER this import, so a\n//? hand-written `registerOAuthProviders(...)` / `registerUserAdapter(...)` still\n//? wins (last writer).\n\nimport { getProjectConfig, resolveEnvKey } from '@luckystack/core';\nimport {\n registerOAuthProviderFactory,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n type OAuthProvider,\n} from './oauthProviders';\nimport {\n registerUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\n\n//? Default Prisma-backed user store — guard so a consumer overlay that\n//? registered a custom adapter before this runs is never clobbered.\nif (!isUserAdapterRegistered()) {\n registerUserAdapter(defaultPrismaUserAdapter());\n}\n\n//? LOGIN-01: all config/env reads are deferred into a factory function that\n//? executes at first-request time, not at module-load time. This prevents\n//? stale-config footguns when `getProjectConfig()` is overridden after import\n//? (e.g. in tests or multi-step boot sequences). Module-load side effects\n//? in the block below are now limited to adapter registration (which is safe\n//? because the adapter registry itself does not depend on project config).\nconst buildProviders = (): OAuthProvider[] => {\n const dev = resolveEnvKey() !== 'production';\n\n //? Reads `DEV_<key>` in dev, the unprefixed `<key>` in prod. Empty when unset.\n const env = (key: string): string => (dev ? process.env[`DEV_${key}`] : process.env[key]) ?? '';\n\n //? Backend origin for the OAuth redirect URI. Prefer the explicit\n //? `oauthCallbackBase` slot; fall back to `app.publicUrl` so a consumer that\n //? only set `publicUrl` still gets a usable callback.\n const projectConfig = getProjectConfig();\n //? Empty-string slot (the default) must fall through to publicUrl. Use an\n //? explicit length check so `??` (which would keep the empty string) is wrong here.\n const configuredCallbackBase = projectConfig.oauthCallbackBase ?? '';\n const callbackBase = configuredCallbackBase.length > 0 ? configuredCallbackBase : projectConfig.app.publicUrl;\n const callback = (name: string): string => `${callbackBase}/auth/callback/${name}`;\n\n //? Credentials (email+password) is a registry entry too, gated by a single\n //? config flag so the registry — exposed via GET /auth/providers — is the ONE\n //? source the login form reads (no duplicate static `config.providers` list).\n const providers: OAuthProvider[] = [];\n if (projectConfig.auth.credentials) {\n providers.push(credentialsProvider());\n }\n\n if (env('GOOGLE_CLIENT_ID') && env('GOOGLE_CLIENT_SECRET')) {\n providers.push(googleProvider({\n clientId: env('GOOGLE_CLIENT_ID'),\n clientSecret: env('GOOGLE_CLIENT_SECRET'),\n callbackUrl: callback('google'),\n }));\n }\n\n if (env('GITHUB_CLIENT_ID') && env('GITHUB_CLIENT_SECRET')) {\n providers.push(githubProvider({\n clientId: env('GITHUB_CLIENT_ID'),\n clientSecret: env('GITHUB_CLIENT_SECRET'),\n callbackUrl: callback('github'),\n }));\n }\n\n if (env('DISCORD_CLIENT_ID') && env('DISCORD_CLIENT_SECRET')) {\n providers.push(discordProvider({\n clientId: env('DISCORD_CLIENT_ID'),\n clientSecret: env('DISCORD_CLIENT_SECRET'),\n callbackUrl: callback('discord'),\n }));\n }\n\n if (env('FACEBOOK_CLIENT_ID') && env('FACEBOOK_CLIENT_SECRET')) {\n providers.push(facebookProvider({\n clientId: env('FACEBOOK_CLIENT_ID'),\n clientSecret: env('FACEBOOK_CLIENT_SECRET'),\n callbackUrl: callback('facebook'),\n }));\n }\n\n if (env('MICROSOFT_CLIENT_ID') && env('MICROSOFT_CLIENT_SECRET')) {\n providers.push(microsoftProvider({\n clientId: env('MICROSOFT_CLIENT_ID'),\n clientSecret: env('MICROSOFT_CLIENT_SECRET'),\n callbackUrl: callback('microsoft'),\n tenant: env('MICROSOFT_TENANT_ID') || undefined,\n }));\n }\n\n return providers;\n};\n\nregisterOAuthProviderFactory(buildProviders);\n"],"mappings":";;;;;;;;;;;;;;AAsBA,SAAS,kBAAkB,qBAAqB;AAmBhD,IAAI,CAAC,wBAAwB,GAAG;AAC9B,sBAAoB,yBAAyB,CAAC;AAChD;AAQA,IAAM,iBAAiB,MAAuB;AAC5C,QAAM,MAAM,cAAc,MAAM;AAGhC,QAAM,MAAM,CAAC,SAAyB,MAAM,QAAQ,IAAI,OAAO,GAAG,EAAE,IAAI,QAAQ,IAAI,GAAG,MAAM;AAK7F,QAAM,gBAAgB,iBAAiB;AAGvC,QAAM,yBAAyB,cAAc,qBAAqB;AAClE,QAAM,eAAe,uBAAuB,SAAS,IAAI,yBAAyB,cAAc,IAAI;AACpG,QAAM,WAAW,CAAC,SAAyB,GAAG,YAAY,kBAAkB,IAAI;AAKhF,QAAM,YAA6B,CAAC;AACpC,MAAI,cAAc,KAAK,aAAa;AAClC,cAAU,KAAK,oBAAoB,CAAC;AAAA,EACtC;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,mBAAmB,KAAK,IAAI,uBAAuB,GAAG;AAC5D,cAAU,KAAK,gBAAgB;AAAA,MAC7B,UAAU,IAAI,mBAAmB;AAAA,MACjC,cAAc,IAAI,uBAAuB;AAAA,MACzC,aAAa,SAAS,SAAS;AAAA,IACjC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,oBAAoB,KAAK,IAAI,wBAAwB,GAAG;AAC9D,cAAU,KAAK,iBAAiB;AAAA,MAC9B,UAAU,IAAI,oBAAoB;AAAA,MAClC,cAAc,IAAI,wBAAwB;AAAA,MAC1C,aAAa,SAAS,UAAU;AAAA,IAClC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,qBAAqB,KAAK,IAAI,yBAAyB,GAAG;AAChE,cAAU,KAAK,kBAAkB;AAAA,MAC/B,UAAU,IAAI,qBAAqB;AAAA,MACnC,cAAc,IAAI,yBAAyB;AAAA,MAC3C,aAAa,SAAS,WAAW;AAAA,MACjC,QAAQ,IAAI,qBAAqB,KAAK;AAAA,IACxC,CAAC,CAAC;AAAA,EACJ;AAEA,SAAO;AACT;AAEA,6BAA6B,cAAc;","names":[]}
@@ -1,219 +1,219 @@
1
- # Credentials auth
2
-
3
- Deep-dive on the email + password authentication flow exposed by `@luckystack/login`. This document covers the dispatcher contract, input normalization, validation, the underlying register / login functions, and the hooks fired along the way. All references point at the canonical implementation in [`./src/login.ts`](../src/login.ts).
4
-
5
- ---
6
-
7
- ## The two-in-one dispatcher
8
-
9
- The HTTP `/auth/api/credentials` route — wired by `@luckystack/server` — calls a single entry point:
10
-
11
- ```ts
12
- import { loginWithCredentials } from '@luckystack/login';
13
-
14
- const result = await loginWithCredentials({
15
- email,
16
- password,
17
- name, // present only when the client is registering
18
- confirmPassword, // present only when the client is registering
19
- });
20
- ```
21
-
22
- `loginWithCredentials` is a thin dispatcher. It inspects the shape of the body to decide whether the caller is logging in or registering:
23
-
24
- ```ts
25
- if (creds.name && creds.confirmPassword) {
26
- return registerWithCredentials({ ... });
27
- }
28
- return loginWithCredentialsCore({ ... });
29
- ```
30
-
31
- The rule is intentionally minimal: presence of both `name` and `confirmPassword` triggers the register branch; any other shape (just `email` + `password`) triggers the login branch. There is no `mode` parameter, no second route, and no separate URL — one POST handles both flows so the front-end can ship one form component.
32
-
33
- When you wire a non-HTTP auth surface (CLI, custom socket event, an admin "impersonate" tool) and you already know which side you want, skip the dispatcher and call `loginWithCredentialsCore` or `registerWithCredentials` directly. The dispatcher is purely a convenience for the unified HTTP route.
34
-
35
- ## Signatures
36
-
37
- ```ts
38
- // Dispatcher
39
- const loginWithCredentials = async (params: {
40
- email?: string;
41
- password?: string;
42
- name?: string;
43
- confirmPassword?: string;
44
- }) => Promise<
45
- | { status: true; reason: string; newToken?: string; session: SessionLayout }
46
- | { status: false; reason: string }
47
- >;
48
-
49
- // Login-only entry point
50
- const loginWithCredentialsCore = async (input: {
51
- email: string;
52
- password: string;
53
- }) => Promise<
54
- | { status: true; reason: 'login.loggedIn'; newToken: string; session: SessionLayout }
55
- | { status: false; reason: string }
56
- >;
57
-
58
- // Register-only entry point
59
- const registerWithCredentials = async (input: {
60
- email: string;
61
- password: string;
62
- name: string;
63
- confirmPassword: string;
64
- }) => Promise<
65
- | { status: true; reason: 'login.userCreated'; session: SessionLayout }
66
- | { status: false; reason: string }
67
- >;
68
- ```
69
-
70
- `reason` is always an i18n key — the installer-side translator turns it into a human-readable message. Never render it raw. See [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the full list of canonical reason keys.
71
-
72
- ## Input normalization
73
-
74
- `normalizeCredentials(params)` runs first, producing a `NormalizedCredentials` object that the rest of the flow consumes. Each transformation exists for a specific reason:
75
-
76
- | Field | Transformation | Why |
77
- | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
78
- | `email` | `trim().toLowerCase()` | Case-insensitive lookup; trailing/leading whitespace stripped so a copy-paste from a chat client doesn't break the find. |
79
- | `password` | Left raw | Bcrypt operates on bytes. The password never reaches HTML, JSON-as-HTML, or a SQL string interpolation, so HTML-escaping it is harmful. |
80
- | `name` | `validator.escape(name)` when present | The name IS surfaced to the UI as text. Pre-escape `<`, `>`, `&`, `"`, `'` to neutralize stored XSS in any consumer that renders it raw. |
81
- | `confirmPassword` | Left raw | Must be byte-identical to `password` for the `password !== confirmPassword` equality check. Escaping only one half breaks special chars. |
82
-
83
- The "escape `confirmPassword` too" mistake is the subject of a long-standing comment in `normalizeCredentials` — that bug previously made any password containing `&`, `<`, `>`, `"`, or `'` fail registration silently. Don't reintroduce it.
84
-
85
- ## Shape validation
86
-
87
- `validateCredentialsShape(creds)` is the next gate. It returns `{ status: false, reason }` on the first failure, or `null` when every check passes:
88
-
89
- 1. **Empty check** — `email` and `password` must both be non-empty. Reason: `login.empty`.
90
- 2. **Email length** — capped at `auth.emailMaxLength`. Reason: `login.emailCharacterLimit`.
91
- 3. **Name length** — only checked when `name` is present (register branch). Capped at `auth.nameMaxLength`. Reason: `login.nameCharacterLimit`.
92
- 4. **Email format** — `validator.isEmail(email)` after normalization. Reason: `login.invalidEmailFormat`.
93
- 5. **Password policy** — `validatePassword(password)` from [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts). Returns a reason key (`login.passwordCharacterMinimum`, `login.passwordRequiresUppercase`, `login.passwordTooCommon`, etc.) or `null`.
94
-
95
- Length limits live in `ProjectConfig.auth` and are resolved at call time, so consumers can override them through `registerProjectConfig` without restarting the framework.
96
-
97
- ## Register flow
98
-
99
- `registerWithCredentials` is the canonical "new user" path:
100
-
101
- 1. **Confirm-password match** — `password !== confirmPassword` fails with `login.passwordNotMatch`.
102
- 2. **`preRegister` hook** — fires with `{ email, provider: 'credentials', name }`. A handler can return a `HookStopSignal` (`{ stop: true, errorCode: '...' }`) to abort; the signal's `errorCode` becomes the `reason`.
103
- 3. **Duplicate-email guard** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. A match returns `login.emailExists`.
104
- 4. **Hash the password** — `bcrypt.genSalt(auth.bcryptRounds)` → `bcrypt.hash(plaintext, salt)`. The default `bcryptRounds` is 10; tighten it on production deployments behind a sufficient CPU budget.
105
- 5. **Create the user** — `userAdapter.create({...})` with the assembled record. `avatarFallback` is a random hex color so the avatar component has something to render until the user uploads an image.
106
- 6. **`postRegister` hook** — fires with `{ userId, provider: 'credentials' }`.
107
- 7. **Return** — `{ status: true, reason: 'login.userCreated', session: sanitizeUserForSession(user) }`.
108
-
109
- Note that registration does NOT auto-login. The dispatcher returns the sanitized user record so the front-end can show a "welcome" screen, but `newToken` is undefined — the user follows the register response with an explicit login (or the front-end posts the same credentials through the login branch). Auto-login on register is something a consumer can layer on top by chaining the two calls.
110
-
111
- ## Login flow
112
-
113
- `loginWithCredentialsCore` is the "existing user" path:
114
-
115
- 1. **`preLogin` hook** — fires with `{ email, provider: 'credentials' }`. Same stop-signal contract as `preRegister`.
116
- 2. **User lookup** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. Missing user returns `login.userNotFound`.
117
- 3. **Password check** — `bcrypt.compare(plaintext, user.password)`. Mismatch returns `login.wrongPassword`. (We do not differentiate "no user" from "wrong password" in production — see [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the anti-enumeration design. The two reason keys exist for dev-mode diagnostics only.)
118
- 4. **Mint token** — 32 random bytes, hex-encoded, 64 chars total.
119
- 5. **Best-effort `lastLogin` update** — `userAdapter.update(id, { lastLogin: new Date() })` wrapped in `tryCatch`. Silently no-ops if the column doesn't exist on the consumer's schema.
120
- 6. **Build the session** — `sanitizeUserForSession(user)` strips the password hash, then `token`, `lastLogin`, `previousLogin` are layered on. The avatar resolution step below is run before saving.
121
- 7. **Avatar resolution** — if `<uploadsDir>/<userId>.webp` exists, set `session.avatar = '<userId>.webp'`. The path is resolved at call time via `getUploadsDir()` so consumer overrides win.
122
- 8. **Save the session** — `saveSession(token, user, true)`. The `true` flag triggers `preSessionCreate` / `postSessionCreate`, runs single-session enforcement, mints a CSRF token, and broadcasts the new session over Socket.io.
123
- 9. **`postLogin` hook** — `{ userId, provider: 'credentials', isNewUser: false, token }`.
124
- 10. **Return** — `{ status: true, reason: 'login.loggedIn', newToken, session }`.
125
-
126
- `isNewUser` is always `false` for credentials login. It exists in the payload because `loginCallback` (OAuth) shares the same `postLogin` hook and DOES distinguish first-time-vs-returning, and we want a single audit hook to handle both.
127
-
128
- ## Session sanitization
129
-
130
- `sanitizeUserForSession<T extends { password?: unknown }>(user)` strips the `password` key via destructuring:
131
-
132
- ```ts
133
- const sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {
134
- const { password: _password, ...safeUser } = user;
135
- return safeUser;
136
- };
137
- ```
138
-
139
- The bcrypt hash should never leave the server. The session is broadcast to clients (`socketEventNames.updateSession`), so a `password` field on it would be a stored-credential leak. Every login path runs through this helper before `saveSession`.
140
-
141
- ## `lastLogin` and `previousLogin`
142
-
143
- Both fields are surfaced on the session layout so the UI can show "Last signed in 3 days ago" without an extra round-trip:
144
-
145
- - `previousLogin` — the value of `user.lastLogin` BEFORE we update it. Equals `null` for the very first login.
146
- - `lastLogin` — the timestamp we just wrote.
147
-
148
- Both are best-effort. The framework wraps the adapter update in `tryCatch` so older schemas missing the column won't break login. If your `User` model doesn't have a `lastLogin` column, the fields will be `undefined` on the session and the UI should treat them as optional.
149
-
150
- ## Reason-key reference (credentials only)
151
-
152
- | Reason key | Meaning |
153
- | ----------------------------------- | -------------------------------------------------------------------- |
154
- | `login.empty` | Email or password was blank after normalization. |
155
- | `login.invalidEmailFormat` | `validator.isEmail` rejected the address. |
156
- | `login.emailCharacterLimit` | Email exceeded `auth.emailMaxLength`. |
157
- | `login.nameCharacterLimit` | Name exceeded `auth.nameMaxLength`. |
158
- | `login.passwordCharacterMinimum` | Password shorter than `auth.passwordPolicy.minLength`. |
159
- | `login.passwordCharacterLimit` | Password longer than `auth.passwordPolicy.maxLength`. |
160
- | `login.passwordRequiresUppercase` | Policy requires `[A-Z]`. |
161
- | `login.passwordRequiresLowercase` | Policy requires `[a-z]`. |
162
- | `login.passwordRequiresNumber` | Policy requires `\d`. |
163
- | `login.passwordRequiresSpecial` | Policy requires a non-alphanumeric character. |
164
- | `login.passwordTooCommon` | Plaintext appears in the bundled common-passwords blocklist. |
165
- | `login.passwordNotMatch` | Register branch only — `password !== confirmPassword`. |
166
- | `login.emailExists` | Register branch — `findByEmail` returned an existing user. |
167
- | `login.createUserFailed` | Adapter `create` resolved without throwing but returned falsy. |
168
- | `login.userNotFound` | Login branch — no user for `{ email, provider: 'credentials' }`. |
169
- | `login.wrongPassword` | Login branch — bcrypt compare returned false. |
170
- | `login.loggedIn` | Success — login. |
171
- | `login.userCreated` | Success — register. |
172
- | `api.internalServerError` | Adapter threw or another infrastructure error bubbled up. |
173
-
174
- Custom reason keys can be returned by `preLogin` / `preRegister` handlers via the stop-signal contract.
175
-
176
- ## When to call `loginWithCredentialsCore` / `registerWithCredentials` directly
177
-
178
- The dispatcher only exists because the HTTP route is a single POST endpoint that wants one entry point. Prefer the direct functions whenever you control the call site:
179
-
180
- - **Custom CLI** — `lucky user create --email ...` wires `registerWithCredentials` directly. No body-shape inspection needed.
181
- - **Admin "create user" panel** — `registerWithCredentials` from the admin endpoint, gated by an admin guard.
182
- - **Programmatic test setup** — your integration tests likely seed users via `registerWithCredentials` because they know the user doesn't exist yet.
183
- - **Auto-login after register** — call `registerWithCredentials` then `loginWithCredentialsCore` in sequence.
184
-
185
- The dispatcher is safe to use from anywhere — it just adds an unnecessary `if/else` when you already know the branch.
186
-
187
- ## Hook recipes
188
-
189
- ```ts
190
- // Block unverified users
191
- registerHook('preLogin', async ({ email, provider }) => {
192
- if (provider !== 'credentials') return;
193
- const user = await prisma.user.findFirst({ where: { email } });
194
- if (user && !user.verified) {
195
- return { stop: true, errorCode: 'login.notVerified' };
196
- }
197
- });
198
-
199
- // Notify on every new registration
200
- registerHook('postRegister', async ({ userId, provider }) => {
201
- await sendInternalNotification(`new ${provider} user: ${userId}`);
202
- });
203
-
204
- // Defer creating ancillary records until after the user is created
205
- registerHook('postRegister', async ({ userId }) => {
206
- await prisma.userPreferences.create({ data: { userId, theme: 'dark' } });
207
- });
208
- ```
209
-
210
- See [`./hooks.md`](./hooks.md) for the full hook reference and the stop-signal contract.
211
-
212
- ## Related
213
-
214
- - [`./password-reset.md`](./password-reset.md) — forgot-password + in-session password change.
215
- - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store.
216
- - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` payload reference.
217
- - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after a successful login.
218
- - [`./oauth-providers.md`](./oauth-providers.md) — the OAuth alternative to credentials auth.
219
- - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
1
+ # Credentials auth
2
+
3
+ Deep-dive on the email + password authentication flow exposed by `@luckystack/login`. This document covers the dispatcher contract, input normalization, validation, the underlying register / login functions, and the hooks fired along the way. All references point at the canonical implementation in [`./src/login.ts`](../src/login.ts).
4
+
5
+ ---
6
+
7
+ ## The two-in-one dispatcher
8
+
9
+ The HTTP `/auth/api/credentials` route — wired by `@luckystack/server` — calls a single entry point:
10
+
11
+ ```ts
12
+ import { loginWithCredentials } from '@luckystack/login';
13
+
14
+ const result = await loginWithCredentials({
15
+ email,
16
+ password,
17
+ name, // present only when the client is registering
18
+ confirmPassword, // present only when the client is registering
19
+ });
20
+ ```
21
+
22
+ `loginWithCredentials` is a thin dispatcher. It inspects the shape of the body to decide whether the caller is logging in or registering:
23
+
24
+ ```ts
25
+ if (creds.name && creds.confirmPassword) {
26
+ return registerWithCredentials({ ... });
27
+ }
28
+ return loginWithCredentialsCore({ ... });
29
+ ```
30
+
31
+ The rule is intentionally minimal: presence of both `name` and `confirmPassword` triggers the register branch; any other shape (just `email` + `password`) triggers the login branch. There is no `mode` parameter, no second route, and no separate URL — one POST handles both flows so the front-end can ship one form component.
32
+
33
+ When you wire a non-HTTP auth surface (CLI, custom socket event, an admin "impersonate" tool) and you already know which side you want, skip the dispatcher and call `loginWithCredentialsCore` or `registerWithCredentials` directly. The dispatcher is purely a convenience for the unified HTTP route.
34
+
35
+ ## Signatures
36
+
37
+ ```ts
38
+ // Dispatcher
39
+ const loginWithCredentials = async (params: {
40
+ email?: string;
41
+ password?: string;
42
+ name?: string;
43
+ confirmPassword?: string;
44
+ }) => Promise<
45
+ | { status: true; reason: string; newToken?: string; session: SessionLayout }
46
+ | { status: false; reason: string }
47
+ >;
48
+
49
+ // Login-only entry point
50
+ const loginWithCredentialsCore = async (input: {
51
+ email: string;
52
+ password: string;
53
+ }) => Promise<
54
+ | { status: true; reason: 'login.loggedIn'; newToken: string; session: SessionLayout }
55
+ | { status: false; reason: string }
56
+ >;
57
+
58
+ // Register-only entry point
59
+ const registerWithCredentials = async (input: {
60
+ email: string;
61
+ password: string;
62
+ name: string;
63
+ confirmPassword: string;
64
+ }) => Promise<
65
+ | { status: true; reason: 'login.userCreated'; session: SessionLayout }
66
+ | { status: false; reason: string }
67
+ >;
68
+ ```
69
+
70
+ `reason` is always an i18n key — the installer-side translator turns it into a human-readable message. Never render it raw. See [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the full list of canonical reason keys.
71
+
72
+ ## Input normalization
73
+
74
+ `normalizeCredentials(params)` runs first, producing a `NormalizedCredentials` object that the rest of the flow consumes. Each transformation exists for a specific reason:
75
+
76
+ | Field | Transformation | Why |
77
+ | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `email` | `trim().toLowerCase()` | Case-insensitive lookup; trailing/leading whitespace stripped so a copy-paste from a chat client doesn't break the find. |
79
+ | `password` | Left raw | Bcrypt operates on bytes. The password never reaches HTML, JSON-as-HTML, or a SQL string interpolation, so HTML-escaping it is harmful. |
80
+ | `name` | `validator.escape(name)` when present | The name IS surfaced to the UI as text. Pre-escape `<`, `>`, `&`, `"`, `'` to neutralize stored XSS in any consumer that renders it raw. |
81
+ | `confirmPassword` | Left raw | Must be byte-identical to `password` for the `password !== confirmPassword` equality check. Escaping only one half breaks special chars. |
82
+
83
+ The "escape `confirmPassword` too" mistake is the subject of a long-standing comment in `normalizeCredentials` — that bug previously made any password containing `&`, `<`, `>`, `"`, or `'` fail registration silently. Don't reintroduce it.
84
+
85
+ ## Shape validation
86
+
87
+ `validateCredentialsShape(creds)` is the next gate. It returns `{ status: false, reason }` on the first failure, or `null` when every check passes:
88
+
89
+ 1. **Empty check** — `email` and `password` must both be non-empty. Reason: `login.empty`.
90
+ 2. **Email length** — capped at `auth.emailMaxLength`. Reason: `login.emailCharacterLimit`.
91
+ 3. **Name length** — only checked when `name` is present (register branch). Capped at `auth.nameMaxLength`. Reason: `login.nameCharacterLimit`.
92
+ 4. **Email format** — `validator.isEmail(email)` after normalization. Reason: `login.invalidEmailFormat`.
93
+ 5. **Password policy** — `validatePassword(password)` from [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts). Returns a reason key (`login.passwordCharacterMinimum`, `login.passwordRequiresUppercase`, `login.passwordTooCommon`, etc.) or `null`.
94
+
95
+ Length limits live in `ProjectConfig.auth` and are resolved at call time, so consumers can override them through `registerProjectConfig` without restarting the framework.
96
+
97
+ ## Register flow
98
+
99
+ `registerWithCredentials` is the canonical "new user" path:
100
+
101
+ 1. **Confirm-password match** — `password !== confirmPassword` fails with `login.passwordNotMatch`.
102
+ 2. **`preRegister` hook** — fires with `{ email, provider: 'credentials', name }`. A handler can return a `HookStopSignal` (`{ stop: true, errorCode: '...' }`) to abort; the signal's `errorCode` becomes the `reason`.
103
+ 3. **Duplicate-email guard** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. A match returns `login.emailExists`.
104
+ 4. **Hash the password** — `bcrypt.genSalt(auth.bcryptRounds)` → `bcrypt.hash(plaintext, salt)`. The default `bcryptRounds` is 10; tighten it on production deployments behind a sufficient CPU budget.
105
+ 5. **Create the user** — `userAdapter.create({...})` with the assembled record. `avatarFallback` is a random hex color so the avatar component has something to render until the user uploads an image.
106
+ 6. **`postRegister` hook** — fires with `{ userId, provider: 'credentials' }`.
107
+ 7. **Return** — `{ status: true, reason: 'login.userCreated', session: sanitizeUserForSession(user) }`.
108
+
109
+ Note that registration does NOT auto-login. The dispatcher returns the sanitized user record so the front-end can show a "welcome" screen, but `newToken` is undefined — the user follows the register response with an explicit login (or the front-end posts the same credentials through the login branch). Auto-login on register is something a consumer can layer on top by chaining the two calls.
110
+
111
+ ## Login flow
112
+
113
+ `loginWithCredentialsCore` is the "existing user" path:
114
+
115
+ 1. **`preLogin` hook** — fires with `{ email, provider: 'credentials' }`. Same stop-signal contract as `preRegister`.
116
+ 2. **User lookup** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. Missing user returns `login.userNotFound`.
117
+ 3. **Password check** — `bcrypt.compare(plaintext, user.password)`. Mismatch returns `login.wrongPassword`. (We do not differentiate "no user" from "wrong password" in production — see [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the anti-enumeration design. The two reason keys exist for dev-mode diagnostics only.)
118
+ 4. **Mint token** — 32 random bytes, hex-encoded, 64 chars total.
119
+ 5. **Best-effort `lastLogin` update** — `userAdapter.update(id, { lastLogin: new Date() })` wrapped in `tryCatch`. Silently no-ops if the column doesn't exist on the consumer's schema.
120
+ 6. **Build the session** — `sanitizeUserForSession(user)` strips the password hash, then `token`, `lastLogin`, `previousLogin` are layered on. The avatar resolution step below is run before saving.
121
+ 7. **Avatar resolution** — if `<uploadsDir>/<userId>.webp` exists, set `session.avatar = '<userId>.webp'`. The path is resolved at call time via `getUploadsDir()` so consumer overrides win.
122
+ 8. **Save the session** — `saveSession(token, user, true)`. The `true` flag triggers `preSessionCreate` / `postSessionCreate`, runs single-session enforcement, mints a CSRF token, and broadcasts the new session over Socket.io.
123
+ 9. **`postLogin` hook** — `{ userId, provider: 'credentials', isNewUser: false, token }`.
124
+ 10. **Return** — `{ status: true, reason: 'login.loggedIn', newToken, session }`.
125
+
126
+ `isNewUser` is always `false` for credentials login. It exists in the payload because `loginCallback` (OAuth) shares the same `postLogin` hook and DOES distinguish first-time-vs-returning, and we want a single audit hook to handle both.
127
+
128
+ ## Session sanitization
129
+
130
+ `sanitizeUserForSession<T extends { password?: unknown }>(user)` strips the `password` key via destructuring:
131
+
132
+ ```ts
133
+ const sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {
134
+ const { password: _password, ...safeUser } = user;
135
+ return safeUser;
136
+ };
137
+ ```
138
+
139
+ The bcrypt hash should never leave the server. The session is broadcast to clients (`socketEventNames.updateSession`), so a `password` field on it would be a stored-credential leak. Every login path runs through this helper before `saveSession`.
140
+
141
+ ## `lastLogin` and `previousLogin`
142
+
143
+ Both fields are surfaced on the session layout so the UI can show "Last signed in 3 days ago" without an extra round-trip:
144
+
145
+ - `previousLogin` — the value of `user.lastLogin` BEFORE we update it. Equals `null` for the very first login.
146
+ - `lastLogin` — the timestamp we just wrote.
147
+
148
+ Both are best-effort. The framework wraps the adapter update in `tryCatch` so older schemas missing the column won't break login. If your `User` model doesn't have a `lastLogin` column, the fields will be `undefined` on the session and the UI should treat them as optional.
149
+
150
+ ## Reason-key reference (credentials only)
151
+
152
+ | Reason key | Meaning |
153
+ | ----------------------------------- | -------------------------------------------------------------------- |
154
+ | `login.empty` | Email or password was blank after normalization. |
155
+ | `login.invalidEmailFormat` | `validator.isEmail` rejected the address. |
156
+ | `login.emailCharacterLimit` | Email exceeded `auth.emailMaxLength`. |
157
+ | `login.nameCharacterLimit` | Name exceeded `auth.nameMaxLength`. |
158
+ | `login.passwordCharacterMinimum` | Password shorter than `auth.passwordPolicy.minLength`. |
159
+ | `login.passwordCharacterLimit` | Password longer than `auth.passwordPolicy.maxLength`. |
160
+ | `login.passwordRequiresUppercase` | Policy requires `[A-Z]`. |
161
+ | `login.passwordRequiresLowercase` | Policy requires `[a-z]`. |
162
+ | `login.passwordRequiresNumber` | Policy requires `\d`. |
163
+ | `login.passwordRequiresSpecial` | Policy requires a non-alphanumeric character. |
164
+ | `login.passwordTooCommon` | Plaintext appears in the bundled common-passwords blocklist. |
165
+ | `login.passwordNotMatch` | Register branch only — `password !== confirmPassword`. |
166
+ | `login.emailExists` | Register branch — `findByEmail` returned an existing user. |
167
+ | `login.createUserFailed` | Adapter `create` resolved without throwing but returned falsy. |
168
+ | `login.userNotFound` | Login branch — no user for `{ email, provider: 'credentials' }`. |
169
+ | `login.wrongPassword` | Login branch — bcrypt compare returned false. |
170
+ | `login.loggedIn` | Success — login. |
171
+ | `login.userCreated` | Success — register. |
172
+ | `api.internalServerError` | Adapter threw or another infrastructure error bubbled up. |
173
+
174
+ Custom reason keys can be returned by `preLogin` / `preRegister` handlers via the stop-signal contract.
175
+
176
+ ## When to call `loginWithCredentialsCore` / `registerWithCredentials` directly
177
+
178
+ The dispatcher only exists because the HTTP route is a single POST endpoint that wants one entry point. Prefer the direct functions whenever you control the call site:
179
+
180
+ - **Custom CLI** — `lucky user create --email ...` wires `registerWithCredentials` directly. No body-shape inspection needed.
181
+ - **Admin "create user" panel** — `registerWithCredentials` from the admin endpoint, gated by an admin guard.
182
+ - **Programmatic test setup** — your integration tests likely seed users via `registerWithCredentials` because they know the user doesn't exist yet.
183
+ - **Auto-login after register** — call `registerWithCredentials` then `loginWithCredentialsCore` in sequence.
184
+
185
+ The dispatcher is safe to use from anywhere — it just adds an unnecessary `if/else` when you already know the branch.
186
+
187
+ ## Hook recipes
188
+
189
+ ```ts
190
+ // Block unverified users
191
+ registerHook('preLogin', async ({ email, provider }) => {
192
+ if (provider !== 'credentials') return;
193
+ const user = await prisma.user.findFirst({ where: { email } });
194
+ if (user && !user.verified) {
195
+ return { stop: true, errorCode: 'login.notVerified' };
196
+ }
197
+ });
198
+
199
+ // Notify on every new registration
200
+ registerHook('postRegister', async ({ userId, provider }) => {
201
+ await sendInternalNotification(`new ${provider} user: ${userId}`);
202
+ });
203
+
204
+ // Defer creating ancillary records until after the user is created
205
+ registerHook('postRegister', async ({ userId }) => {
206
+ await prisma.userPreferences.create({ data: { userId, theme: 'dark' } });
207
+ });
208
+ ```
209
+
210
+ See [`./hooks.md`](./hooks.md) for the full hook reference and the stop-signal contract.
211
+
212
+ ## Related
213
+
214
+ - [`./password-reset.md`](./password-reset.md) — forgot-password + in-session password change.
215
+ - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store.
216
+ - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` payload reference.
217
+ - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after a successful login.
218
+ - [`./oauth-providers.md`](./oauth-providers.md) — the OAuth alternative to credentials auth.
219
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).