@luckystack/login 0.10.2 → 0.11.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,68 @@
1
+ import { type BaseSessionLayout } from '@luckystack/core';
2
+ export interface UserAdapterCreateInput {
3
+ email: string;
4
+ provider: string;
5
+ name?: string | null;
6
+ password?: string | null;
7
+ avatar?: string | null;
8
+ avatarFallback?: string | null;
9
+ language?: string | null;
10
+ }
11
+ export interface UserRecord extends BaseSessionLayout {
12
+ password?: string | null;
13
+ provider?: string | null;
14
+ /** Most recent successful login. Mirrors the optional `lastLogin` column on User. */
15
+ lastLogin?: Date | null;
16
+ /**
17
+ * 2FA (ADR 0024): whether this user enrolled a second factor. All three 2FA
18
+ * fields are OPTIONAL so pre-0.6 schemas/adapters keep working unchanged —
19
+ * an adapter that never returns them simply has no 2FA-capable users.
20
+ */
21
+ twoFactorEnabled?: boolean | null;
22
+ /**
23
+ * 2FA: the TOTP shared secret (base32; new encrypted rows use
24
+ * `enc:v2:<key-id>:...`, while legacy `gcm:` rows remain readable through
25
+ * TOTP_ENCRYPTION_LEGACY_KEYS). NEVER reaches the client — stripped by
26
+ * `sanitizeUserForSession` before any session persist/broadcast.
27
+ */
28
+ totpSecret?: string | null;
29
+ /** 2FA: sha256 hashes of the UNUSED one-time recovery codes (also stripped). */
30
+ recoveryCodes?: string[] | null;
31
+ }
32
+ export interface UserAdapter {
33
+ findByEmail: (params: {
34
+ email: string;
35
+ provider: string;
36
+ }) => Promise<UserRecord | null>;
37
+ /**
38
+ * Look up a user by email IRRESPECTIVE of provider. Required only when
39
+ * `auth.providerAccountStrategy === 'unified'` — the framework resolves
40
+ * accounts by email alone so the same address maps to one User across
41
+ * providers. Optional so existing custom adapters keep compiling; when a
42
+ * project sets `'unified'` but its adapter omits this method, the framework
43
+ * logs a one-time warning and falls back to provider-scoped lookup.
44
+ */
45
+ findByEmailAnyProvider?: (params: {
46
+ email: string;
47
+ }) => Promise<UserRecord | null>;
48
+ findById: (id: string) => Promise<UserRecord | null>;
49
+ create: (input: UserAdapterCreateInput) => Promise<UserRecord>;
50
+ update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;
51
+ /**
52
+ * Hard-delete a user. Account-deletion flows (`deleteAccount_v1`) call this
53
+ * instead of reaching past the adapter to `prisma.user.delete`, so a custom
54
+ * adapter / non-Prisma store / soft-delete strategy stays in control. To
55
+ * implement a soft delete, ignore the hard delete here and instead expose it
56
+ * through `update({ deletedAt: new Date() })` from your route.
57
+ *
58
+ * Optional so existing custom adapters keep compiling; the default Prisma
59
+ * adapter implements it. Consumers calling account deletion against a custom
60
+ * adapter that omits `delete` get a clear "adapter does not support delete"
61
+ * failure rather than a silent Prisma bypass.
62
+ */
63
+ delete?: (id: string) => Promise<void>;
64
+ }
65
+ export declare const registerUserAdapter: (adapter: UserAdapter) => UserAdapter;
66
+ export declare const isUserAdapterRegistered: () => boolean;
67
+ export declare const defaultPrismaUserAdapter: () => UserAdapter;
68
+ export declare const getUserAdapter: () => UserAdapter;
@@ -1,219 +1,5 @@
1
- # Credentials auth
1
+ # Credential authentication
2
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).
3
+ Login and registration share a dispatcher for a shared form, but their security rules differ. Validate password strength when creating or replacing a password, not before checking an existing login: imposing a new policy on sign-in would lock out older valid credentials and could let attackers trigger account lockouts with policy-invalid attempts. Source: `../src/login.ts`, `../src/passwordPolicy.ts`, `../src/authLockout.ts`.
4
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).
5
+ Do not transform passwords as if they were HTML: hashing and comparison need the original bytes. Normalize identifiers and keep authentication failure messages resistant to account enumeration. Never include a stored password hash in a browser-visible session (`../src/sessionSanitizer.ts`). Render reason keys through the translation layer, not as raw user-facing text. See `../../../docs/ARCHITECTURE_AUTH.md`.