@luckystack/login 0.4.1 → 0.5.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/CLAUDE.md CHANGED
@@ -1,194 +1,194 @@
1
- # @luckystack/login
2
-
3
- > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
- > Defers to the repository root `/CLAUDE.md` for global rules (styling, no-emojis, SOLID, error handling, etc.). This file documents what `@luckystack/login` owns, how to extend it, and where to look first.
5
-
6
- ---
7
-
8
- ## What
9
-
10
- `@luckystack/login` is the authentication layer of LuckyStack. It provides:
11
-
12
- - **Credentials auth** (email + bcrypt-hashed password) with combined login/register dispatcher.
13
- - **OAuth 2.0** via a pluggable provider registry. Built-in helpers for Google, GitHub, Discord, Facebook, Microsoft. Custom providers register as raw `OAuthProvider` objects.
14
- - **Redis-backed sessions** with sliding expiration, CSRF token minting, single-session enforcement, and a swappable `SessionAdapter` (DynamoDB, Postgres, JWT-stateless, etc.).
15
- - **Lifecycle hooks**: `preLogin` / `postLogin`, `preRegister` / `postRegister`, `preLogout` / `postLogout`, `preSessionCreate` / `postSessionCreate`, `preSessionDelete` / `postSessionDelete`, `passwordResetRequested`, `passwordResetCompleted`, `passwordChanged`. All `pre*` hooks support stop-signals (abort the side-effect with a reason key).
16
- - **Pluggable user store** via `UserAdapter` — decouples auth from a specific Prisma `User` schema.
17
- - **Password-reset primitives** (`createPasswordResetToken`, `consumePasswordResetToken`, `updatePasswordHash`, `verifyPassword`) plus the framework-mode `sendPasswordResetEmail` orchestrator (requires the optional `@luckystack/email` peer).
18
- - **Dynamic post-login redirect** resolver registry (per-user / per-tenant / per-provider OAuth landing pages).
19
-
20
- OAuth state is stored in Redis under `${projectName}-oauth-state:<provider>:<state>` with TTL `auth.oauthStateTtlSeconds`. Sessions live at `${projectName}-session:<token>`. Active-tokens-per-user set lives at `${projectName}-activeUsers:<userId>`. Password-reset and email-change tokens are **hashed at rest** (0.2.0): only `sha256(token)` is stored as the Redis key (`${projectName}-pwreset:<sha256(token)>` / `${projectName}-email-change:<sha256(token)>`) via the `@luckystack/core` one-time-token primitive (`issueOneTimeToken` / `consumeOneTimeToken`). The raw token is returned to the caller exactly once for the emailed URL, so a leaked Redis keyspace can't be replayed to mint a reset or confirm an email change.
21
-
22
- ---
23
-
24
- ## When to USE
25
-
26
- Reach for `@luckystack/login` when you need to:
27
-
28
- - Add or change an authentication entry point (HTTP route, socket handler, CLI).
29
- - Register or extend an OAuth provider (built-in or custom).
30
- - Customize the User model the framework talks to (multi-tenant, soft-delete, alternative ORM).
31
- - Plug in a non-Redis session store (DynamoDB, Postgres, signed-JWT).
32
- - Hook into login / register / logout / session lifecycle for audit, 2FA, notifications, or feature-flagging.
33
- - Implement password reset, password change, or a "sign out everywhere" flow.
34
- - Compute the OAuth callback redirect dynamically.
35
-
36
- ## When NOT to use
37
-
38
- Do NOT use this package for:
39
-
40
- - **HTTP routing.** The `/auth/api/credentials` and `/auth/callback/<provider>` routes are wired by `@luckystack/server`. This package only exposes the handler functions.
41
- - **Frontend session state.** The browser session lives in `src/_providers/SessionProvider.tsx` (installer side) — talks to the server via socket events.
42
- - **Email rendering / SMTP.** That is `@luckystack/email`. This package only orchestrates the reset-email send.
43
- - **Presence / online state.** That is `@luckystack/presence`. Login dispatches `postLogin` / `postLogout`; presence subscribes and manages disconnect timers itself.
44
- - **API rate-limiting.** That lives in `@luckystack/api`. Login only calls the framework rate limiter for the credentials endpoint.
45
- - **Storing arbitrary per-user data.** Only session-scoped runtime data (CSRF token, OAuth-provider extras via `extraSessionFields`) should live in the session. Persistent data goes on the user record via the registered `UserAdapter`.
46
-
47
- ---
48
-
49
- ## Function Index
50
-
51
- ### Credentials auth (`./src/login.ts`)
52
-
53
- - `loginWithCredentials(params)` — Combined login/register dispatcher. Inspects body shape: when `name` and `confirmPassword` are present it registers; otherwise it logs in. Returns `{ status, reason, newToken?, session? }`. Used by the HTTP `/auth/api/credentials` route. **M-15:** the password-POLICY check runs ONLY on the register branch — a login accepts any password string and lets the bcrypt compare decide, so an attacker can't lock a victim's account by POSTing policy-violating passwords for their email (the per-account lockout counter only ever sees a genuine `login.wrongPassword`). It also means tightening the policy never locks out existing users with older-but-valid passwords.
54
- - `loginWithCredentialsCore({ email, password })` — Login-only entry point. Use when you wire a custom auth surface that bypasses the dispatcher's body-shape branching. Dispatches `preLogin` / `postLogin`.
55
- - `registerWithCredentials({ email, password, name, confirmPassword })` — Register-only entry point. Dispatches `preRegister` / `postRegister`. Returns the freshly-created user (password-sanitized) on success.
56
- - `loginCallback(pathname, req, res, options?)` — OAuth state-exchange handler. Validates `state`, exchanges `code` for an access token, fetches the user profile, finds-or-creates the user via the adapter, mints a session token, dispatches `postLogin` (+ `postRegister` for new users). Returns `{ token, redirectUrl, userId, provider, isNewUser }` or `false`. Wired to `/auth/callback/<provider>` by `@luckystack/server`.
57
- - `createOAuthState(providerName)` — Issue a CSRF state token (Redis, `NX`, TTL from `auth.oauthStateTtlSeconds`). Returns the state string or `null` on collision.
58
-
59
- ### Logout (`./src/logout.ts`)
60
-
61
- - `logout({ token, socket, userId, skipSessionDelete? })` — End a single socket's session. Dispatches `preLogout` (can abort), deletes the session (unless `skipSessionDelete: true`), removes the token from the active-users set, leaves the socket room, and emits `socketEventNames.logout` to the socket. Logs a warn-stacktrace on success so spurious logouts are traceable in production.
62
-
63
- ### Sessions (`./src/session.ts`)
64
-
65
- - `saveSession(token, data, newUser?)` — Persist a session through the active `SessionAdapter`. Mints a CSRF token on first write. When `newUser === true`: dispatches `preSessionCreate` / `postSessionCreate`, runs single-session-enforcement (kicks older sessions per `session.perUser` / `maxConcurrentPerUser` / `onConflict`), and broadcasts `updateSession` to connected sockets in the token room.
66
- - `getSession(token)` — Read a session through the active adapter. Implements sliding expiration: dispatches `preSessionRefresh` (can stop the TTL extend), calls `adapter.expire(...)`, dispatches `postSessionRefresh`.
67
- - `deleteSession(token)` — Hard delete + clean up active-tokens set. Dispatches `preSessionDelete` (can abort) / `postSessionDelete`. Reuses `logout` for connected sockets to keep the disconnect flow consistent. Logs a warn-stacktrace so spurious deletes are traceable.
68
- - `getAllSessions()` — Admin walk (uses `adapter.listAll()`; returns `[]` for non-scannable adapters with a warn-log).
69
- - `revokeUserSessions(userId, exceptToken?)` — Force-logout every active session for a user, optionally keeping the caller's own token alive. Returns the count revoked.
70
- - `sessionKeyFor(token)` / `activeUsersKeyFor(userId)` — Legacy key-builders for downstream code that reads Redis directly. Prefer the adapter for new code.
71
-
72
- ### Session adapter (`./src/sessionAdapter.ts`)
73
-
74
- - `registerSessionAdapter(adapter)` — Swap out the storage backend (call once at boot, before the first login request).
75
- - `getSessionAdapter()` — Read the active adapter (defaults to `redisSessionAdapter`).
76
- - `redisSessionAdapter` — The default Redis-backed implementation. Re-registering it explicitly is equivalent to not registering anything.
77
- - `SessionAdapter` (type) — Required surface: `getRaw`, `setRaw`, `delete`, `expire`, `ttl`, `trackActive`, `untrackActive`, `listActive`. Optional: `listAll`.
78
-
79
- ### OAuth providers (`./src/oauthProviders.ts`)
80
-
81
- - `registerOAuthProviders(providers)` — Replace the active provider list. Default list contains only `{ name: 'credentials' }`.
82
- - `getOAuthProviders()` — Read the active list.
83
- - `isFullOAuthProvider(provider)` — Type guard separating credentials from full OAuth.
84
- - `asOAuthUserData(value)` — Safe cast helper used inside provider `getAvatar` / `extraSessionFields` callbacks.
85
- - `credentialsProvider()` — Returns `{ name: 'credentials' }`. Register this entry to enable the credentials endpoint.
86
- - `googleProvider(input)` / `githubProvider(input)` / `discordProvider(input)` / `facebookProvider(input)` / `microsoftProvider(input)` — Built-in factories. Each takes `{ clientId, clientSecret, callbackUrl, endpoints?, extraScopes?, extraSessionFields? }`. `facebookProvider` also accepts `apiVersion`; `microsoftProvider` also accepts `tenant`, `apiVersion`, `graphApiVersion`.
87
-
88
- ### User adapter (`./src/userAdapter.ts`)
89
-
90
- - `registerUserAdapter(adapter)` — Plug in your own user store.
91
- - `getUserAdapter()` — Read the active adapter; falls back to `defaultPrismaUserAdapter()` lazily on first call.
92
- - `isUserAdapterRegistered()` — `true` iff a custom adapter was registered.
93
- - `defaultPrismaUserAdapter()` — The framework default. Talks to `prisma.user` with the documented schema (`id`, `email`, `password?`, `provider`, `name`, `avatar`, `avatarFallback`, `language`).
94
- - `UserAdapter` / `UserAdapterCreateInput` / `UserRecord` (types) — Implement these to bind auth flows to a different table.
95
-
96
- ### Post-login redirect (`./src/redirectResolver.ts`)
97
-
98
- - `registerPostLoginRedirect(resolver)` — Register a `({ userId, provider, isNewUser, defaultUrl }) => string | Promise<string>` resolver.
99
- - `getPostLoginRedirect()` — Read the active resolver (or `null`).
100
- - The resolver's returned URL is validated against `http.cors.allowedOrigins` before use; invalid URLs fall back to `defaultUrl`.
101
-
102
- ### Password reset (`./src/passwordReset.ts`, `./src/forgotPassword.ts`, `./src/passwordPolicy.ts`)
103
-
104
- - `createPasswordResetToken(userId)` — Mint a 64-char hex token; store `userId` under `${projectName}-pwreset:<sha256(token)>` (hashed at rest, via `@luckystack/core`'s `issueOneTimeToken`) with `auth.passwordResetTtlSeconds`. Returns the RAW token.
105
- - `consumePasswordResetToken(token)` — One-time-use redemption. Returns the bound `userId` or `null`.
106
- - `updatePasswordHash(userId, plaintext)` — Validate against the active password policy, bcrypt-hash, write via `getUserAdapter().update`. Throws `PasswordPolicyError` on policy violation.
107
- - `verifyPassword(plaintext, hash)` — Bcrypt comparison helper.
108
- - `PasswordPolicyError` — Thrown by `updatePasswordHash`; carries `errorCode` matching the i18n reason keys used by the rest of the login flow.
109
- - `validatePassword(plaintext)` — Returns `null` when the policy passes, or a reason key when it fails (length, complexity, common-list, custom validator).
110
- - `sendPasswordResetEmail({ email, brand? })` — Framework-mode orchestrator. Looks up the user (credentials provider only), mints a token, lazy-imports `@luckystack/email`, and sends a transactional reset email. Always resolves "ok" when the email is not found (anti-enumeration). No-op when `auth.forgotPassword !== 'framework'`.
111
- - `createEmailChangeToken(userId, newEmail)` — Mint a one-shot token bound to `userId` + `newEmail`. Stored in Redis at `${projectName}-email-change:<sha256(token)>` (hashed at rest, via `@luckystack/core`'s `issueOneTimeToken`) with TTL from `auth.emailChangeTtlSeconds`. Returns the RAW token.
112
- - `consumeEmailChangeToken(token)` — Atomic get + del redemption. Returns `{ userId, newEmail }` on success, `null` on miss / malformed payload / expired entry.
113
- - `sendEmailChangeConfirmation({ userId, newEmail, userName?, brand? })` — Lazy-imports `@luckystack/email`, renders the confirmation template via `renderEmailLayout`, and sends to the NEW address with `adapterHint: 'transactional'`. Returns `{ ok, reason?, token }` — `token` is the minted email-change token so the caller can build the confirmation URL.
114
-
115
- ### Hook payloads (`./src/hookPayloads.ts`)
116
-
117
- Type-only module that augments `@luckystack/core`'s `HookPayloads` interface. Exported payload types: `PreLoginPayload`, `PostLoginPayload`, `PreRegisterPayload`, `PostRegisterPayload`, `PreLogoutPayload`, `PostLogoutPayload`, `PreSessionCreatePayload`, `PostSessionCreatePayload`, `PreSessionDeletePayload`, `PostSessionDeletePayload`, `PasswordResetRequestedPayload`, `PasswordResetCompletedPayload`, `PasswordChangedPayload`, `PreEmailChangePayload`, `PostEmailChangeRequestedPayload`, `PostEmailChangedPayload`.
118
-
119
- Email-change hook payloads (new):
120
-
121
- - `PreEmailChangePayload` — `{ userId, currentEmail, newEmail }`. Vetoable; returning a stop-signal from a handler aborts the change before any token is minted.
122
- - `PostEmailChangeRequestedPayload` — `{ userId, currentEmail, newEmail, sent? }`. Observational. Fires after the confirmation token is minted and the email has been queued to the NEW address. The framework does NOT alert the old address by default — subscribe to this hook (it carries `currentEmail`) to send your own "change requested" notice to the old owner. `sent: false` marks an anti-enumeration silent drop (new address already taken).
123
- - `PostEmailChangedPayload` — `{ userId, oldEmail, newEmail }`. Observational. Fires after the user's address is persisted via the `UserAdapter` AND all of the user's sessions have been revoked (forced re-login on the next request).
124
-
125
- ---
126
-
127
- ## Config keys
128
-
129
- All config keys live on `ProjectConfig` (from `@luckystack/core`). Resolved at call time via `getProjectConfig()` — no module-load capture.
130
-
131
- ### Env-driven (read by the installer / consumer, surfaced through `registerProjectConfig`)
132
-
133
- | Env var | Used by | Purpose |
134
- | --- | --- | --- |
135
- | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | `googleProvider` | OAuth app credentials. |
136
- | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | `githubProvider` | OAuth app credentials. |
137
- | `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | `discordProvider` | OAuth app credentials. |
138
- | `FACEBOOK_CLIENT_ID` / `FACEBOOK_CLIENT_SECRET` | `facebookProvider` | OAuth app credentials. |
139
- | `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | `microsoftProvider` | OAuth app credentials. |
140
- | `MICROSOFT_TENANT_ID` | `microsoftProvider` (optional) | Single-tenant Azure AD. Defaults to `'common'`. |
141
- | (callback origin) | All OAuth `callbackUrl` builders | The `callbackUrl` is the BACKEND origin + `/auth/callback/<provider>`. The scaffold derives it in `config.ts` (`oauthCallbackBase`: dev `http://localhost:80`, prod the public domain). No `DNS` env var — that was removed in 0.1.5. |
142
- | `BCRYPT_ROUNDS` | `auth.bcryptRounds` | Surfaced through project config; salt rounds for credentials hashing. |
143
-
144
- > Env-key set without the matching package installed = hard boot crash (see peer-dep guard policy). `@luckystack/email` is the optional case — login lazy-imports it and gracefully fails when `forgotPassword !== 'framework'`.
145
-
146
- ### `ProjectConfig` slots consumed
147
-
148
- | Slot | Used by | Notes |
149
- | --- | --- | --- |
150
- | `session.expiryDays` | `getSessionTtl` in `session.ts` | TTL multiplier (days → seconds). |
151
- | `session.perUser` (`'single' \| 'multiple'`) | `saveSession` | Enforcement mode. `'single'` (default) keeps one session per user. |
152
- | `session.maxConcurrentPerUser` | `saveSession` | Cap when `perUser === 'multiple'`. `null` = unlimited. |
153
- | `session.onConflict` (`'revokeOld' \| 'rejectNew'`) | `saveSession` | What to do when cap is reached. `rejectNew` is enforced at the API layer; `revokeOld` kicks the oldest. |
154
- | `session.basedToken` | `saveSession` | Inverted into `persistent` for `preSessionCreate` / `postSessionCreate` payloads. |
155
- | `session.notifyOldDeviceOnRevoke` | `saveSession` | Emits `socketEventNames.sessionReplaced` to the kicked socket before disconnect. |
156
- | `auth.bcryptRounds` | Register, password change/reset | Bcrypt salt-rounds value. |
157
- | `auth.emailMaxLength` / `auth.nameMaxLength` | `validateCredentialsShape` | Reject oversized inputs. |
158
- | `auth.passwordPolicy.*` | `validatePassword` | minLength, maxLength, requireUppercase, requireDigit, requireSpecial, common-list, customValidator. |
159
- | `auth.oauthStateTtlSeconds` | `createOAuthState` | TTL for the Redis state token. |
160
- | `auth.passwordResetTtlSeconds` | `createPasswordResetToken` / `sendPasswordResetEmail` | Reset-token TTL. |
161
- | `auth.emailChangeTtlSeconds` | `createEmailChangeToken` / `sendEmailChangeConfirmation` | Email-change confirmation token TTL. Default `3600` (1 hour). |
162
- | `auth.passwordResetBrand` | `sendPasswordResetEmail` | Fallback brand label when call site omits `brand`. |
163
- | `auth.forgotPassword` (`'framework' \| 'custom' \| 'disabled'`) | `sendPasswordResetEmail` | Gates the framework-mode email flow. |
164
- | `http.cors.allowedOrigins` | `isAllowedRedirectUrl` | Whitelist for `loginCallback` redirect validation. Accepts `string[]` or `(origin) => boolean`. |
165
- | `loginRedirectUrl` | `loginCallback` | Default post-login URL when no per-call override and no resolver match. |
166
- | `logging.devLogs` | Login flow | Gates debug-level diagnostic logs. |
167
- | `defaultLanguage` | Register paths | Initial `language` column for new users. |
168
- | `app.publicUrl` | `sendPasswordResetEmail` | Used to build the reset link host. |
169
-
170
- ---
171
-
172
- ## Peer deps
173
-
174
- | Package | Status | Notes |
175
- | --- | --- | --- |
176
- | `@luckystack/core` | hard runtime dep (`dependencies`) | Source of `redis`, `prisma`, `tryCatch`, `dispatchHook`, `getProjectConfig`, `getProjectName`, `getLogger`, `getIoInstance`, `getUploadsDir`, `socketEventNames`, `BaseSessionLayout`. |
177
- | `bcryptjs` | hard runtime dep | Password hashing + comparison. |
178
- | `validator` | hard runtime dep | Email validation + name escaping. |
179
- | `dotenv` | hard runtime dep | Env loading at boot. |
180
- | `@prisma/client` | **required peer** | Default `UserAdapter` talks to `prisma.user`. Register a custom `UserAdapter` if you cannot ship Prisma. |
181
- | `socket.io` | **required peer** | `logout` and session enforcement broadcast through the live `io` instance from `getIoInstance()`. |
182
- | `@luckystack/email` | **optional peer** | Only required when `auth.forgotPassword === 'framework'`. Lazy-imported by `sendPasswordResetEmail` — every other API works without it. |
183
-
184
- ---
185
-
186
- ## Related links
187
-
188
- - Repo root contract: [`/.claude/CLAUDE.md`](../../.claude/CLAUDE.md)
189
- - Package README: [`./README.md`](./README.md)
190
- - Architecture — auth lifecycle: [`/docs/ARCHITECTURE_AUTH.md`](../../docs/ARCHITECTURE_AUTH.md)
191
- - Architecture — sessions (Redis layout, sliding expiration, single-session enforcement): [`/docs/ARCHITECTURE_SESSION.md`](../../docs/ARCHITECTURE_SESSION.md)
192
- - Architecture — email (forgot-password modes, lazy-import pattern): [`/docs/ARCHITECTURE_EMAIL.md`](../../docs/ARCHITECTURE_EMAIL.md)
193
- - Architecture — packaging strategy: [`/docs/ARCHITECTURE_PACKAGING.md`](../../docs/ARCHITECTURE_PACKAGING.md)
194
- - Per-topic stubs (deeper how-tos): [`./docs/`](./docs/)
1
+ # @luckystack/login
2
+
3
+ > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
+ > Defers to the repository root `/CLAUDE.md` for global rules (styling, no-emojis, SOLID, error handling, etc.). This file documents what `@luckystack/login` owns, how to extend it, and where to look first.
5
+
6
+ ---
7
+
8
+ ## What
9
+
10
+ `@luckystack/login` is the authentication layer of LuckyStack. It provides:
11
+
12
+ - **Credentials auth** (email + bcrypt-hashed password) with combined login/register dispatcher.
13
+ - **OAuth 2.0** via a pluggable provider registry. Built-in helpers for Google, GitHub, Discord, Facebook, Microsoft. Custom providers register as raw `OAuthProvider` objects.
14
+ - **Redis-backed sessions** with sliding expiration, CSRF token minting, single-session enforcement, and a swappable `SessionAdapter` (DynamoDB, Postgres, JWT-stateless, etc.).
15
+ - **Lifecycle hooks**: `preLogin` / `postLogin`, `preRegister` / `postRegister`, `preLogout` / `postLogout`, `preSessionCreate` / `postSessionCreate`, `preSessionDelete` / `postSessionDelete`, `passwordResetRequested`, `passwordResetCompleted`, `passwordChanged`. All `pre*` hooks support stop-signals (abort the side-effect with a reason key).
16
+ - **Pluggable user store** via `UserAdapter` — decouples auth from a specific Prisma `User` schema.
17
+ - **Password-reset primitives** (`createPasswordResetToken`, `consumePasswordResetToken`, `updatePasswordHash`, `verifyPassword`) plus the framework-mode `sendPasswordResetEmail` orchestrator (requires the optional `@luckystack/email` peer).
18
+ - **Dynamic post-login redirect** resolver registry (per-user / per-tenant / per-provider OAuth landing pages).
19
+
20
+ OAuth state is stored in Redis under `${projectName}-oauth-state:<provider>:<state>` with TTL `auth.oauthStateTtlSeconds`. Sessions live at `${projectName}-session:<token>`. Active-tokens-per-user set lives at `${projectName}-activeUsers:<userId>`. Password-reset and email-change tokens are **hashed at rest** (0.2.0): only `sha256(token)` is stored as the Redis key (`${projectName}-pwreset:<sha256(token)>` / `${projectName}-email-change:<sha256(token)>`) via the `@luckystack/core` one-time-token primitive (`issueOneTimeToken` / `consumeOneTimeToken`). The raw token is returned to the caller exactly once for the emailed URL, so a leaked Redis keyspace can't be replayed to mint a reset or confirm an email change.
21
+
22
+ ---
23
+
24
+ ## When to USE
25
+
26
+ Reach for `@luckystack/login` when you need to:
27
+
28
+ - Add or change an authentication entry point (HTTP route, socket handler, CLI).
29
+ - Register or extend an OAuth provider (built-in or custom).
30
+ - Customize the User model the framework talks to (multi-tenant, soft-delete, alternative ORM).
31
+ - Plug in a non-Redis session store (DynamoDB, Postgres, signed-JWT).
32
+ - Hook into login / register / logout / session lifecycle for audit, 2FA, notifications, or feature-flagging.
33
+ - Implement password reset, password change, or a "sign out everywhere" flow.
34
+ - Compute the OAuth callback redirect dynamically.
35
+
36
+ ## When NOT to use
37
+
38
+ Do NOT use this package for:
39
+
40
+ - **HTTP routing.** The `/auth/api/credentials` and `/auth/callback/<provider>` routes are wired by `@luckystack/server`. This package only exposes the handler functions.
41
+ - **Frontend session state.** The browser session lives in `src/_providers/SessionProvider.tsx` (installer side) — talks to the server via socket events.
42
+ - **Email rendering / SMTP.** That is `@luckystack/email`. This package only orchestrates the reset-email send.
43
+ - **Presence / online state.** That is `@luckystack/presence`. Login dispatches `postLogin` / `postLogout`; presence subscribes and manages disconnect timers itself.
44
+ - **API rate-limiting.** That lives in `@luckystack/api`. Login only calls the framework rate limiter for the credentials endpoint.
45
+ - **Storing arbitrary per-user data.** Only session-scoped runtime data (CSRF token, OAuth-provider extras via `extraSessionFields`) should live in the session. Persistent data goes on the user record via the registered `UserAdapter`.
46
+
47
+ ---
48
+
49
+ ## Function Index
50
+
51
+ ### Credentials auth (`./src/login.ts`)
52
+
53
+ - `loginWithCredentials(params)` — Combined login/register dispatcher. Inspects body shape: when `name` and `confirmPassword` are present it registers; otherwise it logs in. Returns `{ status, reason, newToken?, session? }`. Used by the HTTP `/auth/api/credentials` route. **M-15:** the password-POLICY check runs ONLY on the register branch — a login accepts any password string and lets the bcrypt compare decide, so an attacker can't lock a victim's account by POSTing policy-violating passwords for their email (the per-account lockout counter only ever sees a genuine `login.wrongPassword`). It also means tightening the policy never locks out existing users with older-but-valid passwords.
54
+ - `loginWithCredentialsCore({ email, password })` — Login-only entry point. Use when you wire a custom auth surface that bypasses the dispatcher's body-shape branching. Dispatches `preLogin` / `postLogin`.
55
+ - `registerWithCredentials({ email, password, name, confirmPassword })` — Register-only entry point. Dispatches `preRegister` / `postRegister`. Returns the freshly-created user (password-sanitized) on success.
56
+ - `loginCallback(pathname, req, res, options?)` — OAuth state-exchange handler. Validates `state`, exchanges `code` for an access token, fetches the user profile, finds-or-creates the user via the adapter, mints a session token, dispatches `postLogin` (+ `postRegister` for new users). Returns `{ token, redirectUrl, userId, provider, isNewUser }` or `false`. Wired to `/auth/callback/<provider>` by `@luckystack/server`.
57
+ - `createOAuthState(providerName)` — Issue a CSRF state token (Redis, `NX`, TTL from `auth.oauthStateTtlSeconds`). Returns the state string or `null` on collision.
58
+
59
+ ### Logout (`./src/logout.ts`)
60
+
61
+ - `logout({ token, socket, userId, skipSessionDelete? })` — End a single socket's session. Dispatches `preLogout` (can abort), deletes the session (unless `skipSessionDelete: true`), removes the token from the active-users set, leaves the socket room, and emits `socketEventNames.logout` to the socket. Logs a warn-stacktrace on success so spurious logouts are traceable in production.
62
+
63
+ ### Sessions (`./src/session.ts`)
64
+
65
+ - `saveSession(token, data, newUser?)` — Persist a session through the active `SessionAdapter`. Mints a CSRF token on first write. When `newUser === true`: dispatches `preSessionCreate` / `postSessionCreate`, runs single-session-enforcement (kicks older sessions per `session.perUser` / `maxConcurrentPerUser` / `onConflict`), and broadcasts `updateSession` to connected sockets in the token room.
66
+ - `getSession(token)` — Read a session through the active adapter. Implements sliding expiration: dispatches `preSessionRefresh` (can stop the TTL extend), calls `adapter.expire(...)`, dispatches `postSessionRefresh`.
67
+ - `deleteSession(token)` — Hard delete + clean up active-tokens set. Dispatches `preSessionDelete` (can abort) / `postSessionDelete`. Reuses `logout` for connected sockets to keep the disconnect flow consistent. Logs a warn-stacktrace so spurious deletes are traceable.
68
+ - `getAllSessions()` — Admin walk (uses `adapter.listAll()`; returns `[]` for non-scannable adapters with a warn-log).
69
+ - `revokeUserSessions(userId, exceptToken?)` — Force-logout every active session for a user, optionally keeping the caller's own token alive. Returns the count revoked.
70
+ - `sessionKeyFor(token)` / `activeUsersKeyFor(userId)` — Legacy key-builders for downstream code that reads Redis directly. Prefer the adapter for new code.
71
+
72
+ ### Session adapter (`./src/sessionAdapter.ts`)
73
+
74
+ - `registerSessionAdapter(adapter)` — Swap out the storage backend (call once at boot, before the first login request).
75
+ - `getSessionAdapter()` — Read the active adapter (defaults to `redisSessionAdapter`).
76
+ - `redisSessionAdapter` — The default Redis-backed implementation. Re-registering it explicitly is equivalent to not registering anything.
77
+ - `SessionAdapter` (type) — Required surface: `getRaw`, `setRaw`, `delete`, `expire`, `ttl`, `trackActive`, `untrackActive`, `listActive`. Optional: `listAll`.
78
+
79
+ ### OAuth providers (`./src/oauthProviders.ts`)
80
+
81
+ - `registerOAuthProviders(providers)` — Replace the active provider list. Default list contains only `{ name: 'credentials' }`.
82
+ - `getOAuthProviders()` — Read the active list.
83
+ - `isFullOAuthProvider(provider)` — Type guard separating credentials from full OAuth.
84
+ - `asOAuthUserData(value)` — Safe cast helper used inside provider `getAvatar` / `extraSessionFields` callbacks.
85
+ - `credentialsProvider()` — Returns `{ name: 'credentials' }`. Register this entry to enable the credentials endpoint.
86
+ - `googleProvider(input)` / `githubProvider(input)` / `discordProvider(input)` / `facebookProvider(input)` / `microsoftProvider(input)` — Built-in factories. Each takes `{ clientId, clientSecret, callbackUrl, endpoints?, extraScopes?, extraSessionFields? }`. `facebookProvider` also accepts `apiVersion`; `microsoftProvider` also accepts `tenant`, `apiVersion`, `graphApiVersion`.
87
+
88
+ ### User adapter (`./src/userAdapter.ts`)
89
+
90
+ - `registerUserAdapter(adapter)` — Plug in your own user store.
91
+ - `getUserAdapter()` — Read the active adapter; falls back to `defaultPrismaUserAdapter()` lazily on first call.
92
+ - `isUserAdapterRegistered()` — `true` iff a custom adapter was registered.
93
+ - `defaultPrismaUserAdapter()` — The framework default. Talks to `prisma.user` with the documented schema (`id`, `email`, `password?`, `provider`, `name`, `avatar`, `avatarFallback`, `language`).
94
+ - `UserAdapter` / `UserAdapterCreateInput` / `UserRecord` (types) — Implement these to bind auth flows to a different table.
95
+
96
+ ### Post-login redirect (`./src/redirectResolver.ts`)
97
+
98
+ - `registerPostLoginRedirect(resolver)` — Register a `({ userId, provider, isNewUser, defaultUrl }) => string | Promise<string>` resolver.
99
+ - `getPostLoginRedirect()` — Read the active resolver (or `null`).
100
+ - The resolver's returned URL is validated against `http.cors.allowedOrigins` before use; invalid URLs fall back to `defaultUrl`.
101
+
102
+ ### Password reset (`./src/passwordReset.ts`, `./src/forgotPassword.ts`, `./src/passwordPolicy.ts`)
103
+
104
+ - `createPasswordResetToken(userId)` — Mint a 64-char hex token; store `userId` under `${projectName}-pwreset:<sha256(token)>` (hashed at rest, via `@luckystack/core`'s `issueOneTimeToken`) with `auth.passwordResetTtlSeconds`. Returns the RAW token.
105
+ - `consumePasswordResetToken(token)` — One-time-use redemption. Returns the bound `userId` or `null`.
106
+ - `updatePasswordHash(userId, plaintext)` — Validate against the active password policy, bcrypt-hash, write via `getUserAdapter().update`. Throws `PasswordPolicyError` on policy violation.
107
+ - `verifyPassword(plaintext, hash)` — Bcrypt comparison helper.
108
+ - `PasswordPolicyError` — Thrown by `updatePasswordHash`; carries `errorCode` matching the i18n reason keys used by the rest of the login flow.
109
+ - `validatePassword(plaintext)` — Returns `null` when the policy passes, or a reason key when it fails (length, complexity, common-list, custom validator).
110
+ - `sendPasswordResetEmail({ email, brand? })` — Framework-mode orchestrator. Looks up the user (credentials provider only), mints a token, lazy-imports `@luckystack/email`, and sends a transactional reset email. Always resolves "ok" when the email is not found (anti-enumeration). No-op when `auth.forgotPassword !== 'framework'`.
111
+ - `createEmailChangeToken(userId, newEmail)` — Mint a one-shot token bound to `userId` + `newEmail`. Stored in Redis at `${projectName}-email-change:<sha256(token)>` (hashed at rest, via `@luckystack/core`'s `issueOneTimeToken`) with TTL from `auth.emailChangeTtlSeconds`. Returns the RAW token.
112
+ - `consumeEmailChangeToken(token)` — Atomic get + del redemption. Returns `{ userId, newEmail }` on success, `null` on miss / malformed payload / expired entry.
113
+ - `sendEmailChangeConfirmation({ userId, newEmail, userName?, brand? })` — Lazy-imports `@luckystack/email`, renders the confirmation template via `renderEmailLayout`, and sends to the NEW address with `adapterHint: 'transactional'`. Returns `{ ok, reason?, token }` — `token` is the minted email-change token so the caller can build the confirmation URL.
114
+
115
+ ### Hook payloads (`./src/hookPayloads.ts`)
116
+
117
+ Type-only module that augments `@luckystack/core`'s `HookPayloads` interface. Exported payload types: `PreLoginPayload`, `PostLoginPayload`, `PreRegisterPayload`, `PostRegisterPayload`, `PreLogoutPayload`, `PostLogoutPayload`, `PreSessionCreatePayload`, `PostSessionCreatePayload`, `PreSessionDeletePayload`, `PostSessionDeletePayload`, `PasswordResetRequestedPayload`, `PasswordResetCompletedPayload`, `PasswordChangedPayload`, `PreEmailChangePayload`, `PostEmailChangeRequestedPayload`, `PostEmailChangedPayload`.
118
+
119
+ Email-change hook payloads (new):
120
+
121
+ - `PreEmailChangePayload` — `{ userId, currentEmail, newEmail }`. Vetoable; returning a stop-signal from a handler aborts the change before any token is minted.
122
+ - `PostEmailChangeRequestedPayload` — `{ userId, currentEmail, newEmail, sent? }`. Observational. Fires after the confirmation token is minted and the email has been queued to the NEW address. The framework does NOT alert the old address by default — subscribe to this hook (it carries `currentEmail`) to send your own "change requested" notice to the old owner. `sent: false` marks an anti-enumeration silent drop (new address already taken).
123
+ - `PostEmailChangedPayload` — `{ userId, oldEmail, newEmail }`. Observational. Fires after the user's address is persisted via the `UserAdapter` AND all of the user's sessions have been revoked (forced re-login on the next request).
124
+
125
+ ---
126
+
127
+ ## Config keys
128
+
129
+ All config keys live on `ProjectConfig` (from `@luckystack/core`). Resolved at call time via `getProjectConfig()` — no module-load capture.
130
+
131
+ ### Env-driven (read by the installer / consumer, surfaced through `registerProjectConfig`)
132
+
133
+ | Env var | Used by | Purpose |
134
+ | --- | --- | --- |
135
+ | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | `googleProvider` | OAuth app credentials. |
136
+ | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | `githubProvider` | OAuth app credentials. |
137
+ | `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | `discordProvider` | OAuth app credentials. |
138
+ | `FACEBOOK_CLIENT_ID` / `FACEBOOK_CLIENT_SECRET` | `facebookProvider` | OAuth app credentials. |
139
+ | `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | `microsoftProvider` | OAuth app credentials. |
140
+ | `MICROSOFT_TENANT_ID` | `microsoftProvider` (optional) | Single-tenant Azure AD. Defaults to `'common'`. |
141
+ | (callback origin) | All OAuth `callbackUrl` builders | The `callbackUrl` is the BACKEND origin + `/auth/callback/<provider>`. The scaffold derives it in `config.ts` (`oauthCallbackBase`: dev `http://localhost:80`, prod the public domain). No `DNS` env var — that was removed in 0.1.5. |
142
+ | `BCRYPT_ROUNDS` | `auth.bcryptRounds` | Surfaced through project config; salt rounds for credentials hashing. |
143
+
144
+ > Env-key set without the matching package installed = hard boot crash (see peer-dep guard policy). `@luckystack/email` is the optional case — login lazy-imports it and gracefully fails when `forgotPassword !== 'framework'`.
145
+
146
+ ### `ProjectConfig` slots consumed
147
+
148
+ | Slot | Used by | Notes |
149
+ | --- | --- | --- |
150
+ | `session.expiryDays` | `getSessionTtl` in `session.ts` | TTL multiplier (days → seconds). |
151
+ | `session.perUser` (`'single' \| 'multiple'`) | `saveSession` | Enforcement mode. `'single'` (default) keeps one session per user. |
152
+ | `session.maxConcurrentPerUser` | `saveSession` | Cap when `perUser === 'multiple'`. `null` = unlimited. |
153
+ | `session.onConflict` (`'revokeOld' \| 'rejectNew'`) | `saveSession` | What to do when cap is reached. `rejectNew` is enforced at the API layer; `revokeOld` kicks the oldest. |
154
+ | `session.basedToken` | `saveSession` | Inverted into `persistent` for `preSessionCreate` / `postSessionCreate` payloads. |
155
+ | `session.notifyOldDeviceOnRevoke` | `saveSession` | Emits `socketEventNames.sessionReplaced` to the kicked socket before disconnect. |
156
+ | `auth.bcryptRounds` | Register, password change/reset | Bcrypt salt-rounds value. |
157
+ | `auth.emailMaxLength` / `auth.nameMaxLength` | `validateCredentialsShape` | Reject oversized inputs. |
158
+ | `auth.passwordPolicy.*` | `validatePassword` | minLength, maxLength, requireUppercase, requireDigit, requireSpecial, common-list, customValidator. |
159
+ | `auth.oauthStateTtlSeconds` | `createOAuthState` | TTL for the Redis state token. |
160
+ | `auth.passwordResetTtlSeconds` | `createPasswordResetToken` / `sendPasswordResetEmail` | Reset-token TTL. |
161
+ | `auth.emailChangeTtlSeconds` | `createEmailChangeToken` / `sendEmailChangeConfirmation` | Email-change confirmation token TTL. Default `3600` (1 hour). |
162
+ | `auth.passwordResetBrand` | `sendPasswordResetEmail` | Fallback brand label when call site omits `brand`. |
163
+ | `auth.forgotPassword` (`'framework' \| 'custom' \| 'disabled'`) | `sendPasswordResetEmail` | Gates the framework-mode email flow. |
164
+ | `http.cors.allowedOrigins` | `isAllowedRedirectUrl` | Whitelist for `loginCallback` redirect validation. Accepts `string[]` or `(origin) => boolean`. |
165
+ | `loginRedirectUrl` | `loginCallback` | Default post-login URL when no per-call override and no resolver match. |
166
+ | `logging.devLogs` | Login flow | Gates debug-level diagnostic logs. |
167
+ | `defaultLanguage` | Register paths | Initial `language` column for new users. |
168
+ | `app.publicUrl` | `sendPasswordResetEmail` | Used to build the reset link host. |
169
+
170
+ ---
171
+
172
+ ## Peer deps
173
+
174
+ | Package | Status | Notes |
175
+ | --- | --- | --- |
176
+ | `@luckystack/core` | hard runtime dep (`dependencies`) | Source of `redis`, `prisma`, `tryCatch`, `dispatchHook`, `getProjectConfig`, `getProjectName`, `getLogger`, `getIoInstance`, `getUploadsDir`, `socketEventNames`, `BaseSessionLayout`. |
177
+ | `bcryptjs` | hard runtime dep | Password hashing + comparison. |
178
+ | `validator` | hard runtime dep | Email validation + name escaping. |
179
+ | `dotenv` | hard runtime dep | Env loading at boot. |
180
+ | `@prisma/client` | **required peer** | Default `UserAdapter` talks to `prisma.user`. Register a custom `UserAdapter` if you cannot ship Prisma. |
181
+ | `socket.io` | **required peer** | `logout` and session enforcement broadcast through the live `io` instance from `getIoInstance()`. |
182
+ | `@luckystack/email` | **optional peer** | Only required when `auth.forgotPassword === 'framework'`. Lazy-imported by `sendPasswordResetEmail` — every other API works without it. |
183
+
184
+ ---
185
+
186
+ ## Related links
187
+
188
+ - Repo root contract: [`/.claude/CLAUDE.md`](../../.claude/CLAUDE.md)
189
+ - Package README: [`./README.md`](./README.md)
190
+ - Architecture — auth lifecycle: [`/docs/ARCHITECTURE_AUTH.md`](../../docs/ARCHITECTURE_AUTH.md)
191
+ - Architecture — sessions (Redis layout, sliding expiration, single-session enforcement): [`/docs/ARCHITECTURE_SESSION.md`](../../docs/ARCHITECTURE_SESSION.md)
192
+ - Architecture — email (forgot-password modes, lazy-import pattern): [`/docs/ARCHITECTURE_EMAIL.md`](../../docs/ARCHITECTURE_EMAIL.md)
193
+ - Architecture — packaging strategy: [`/docs/ARCHITECTURE_PACKAGING.md`](../../docs/ARCHITECTURE_PACKAGING.md)
194
+ - Per-topic stubs (deeper how-tos): [`./docs/`](./docs/)