@luckystack/login 0.10.1 → 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.
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +6 -1
- package/README.md +7 -235
- package/dist/accountStrategy.d.ts +12 -0
- package/dist/authLockout.d.ts +29 -0
- package/dist/data/commonPasswords.d.ts +2 -0
- package/dist/emailChange.d.ts +21 -0
- package/dist/emailChangeNotification.d.ts +24 -0
- package/dist/emailCodeLogin.d.ts +19 -0
- package/dist/emailModuleLoader.d.ts +13 -0
- package/dist/emailOtp.d.ts +33 -0
- package/dist/forgotPassword.d.ts +21 -0
- package/dist/hookPayloads.d.ts +151 -0
- package/dist/index.d.ts +33 -951
- package/dist/login.d.ts +96 -0
- package/dist/logout.d.ts +7 -0
- package/dist/oauthProviders.d.ts +171 -0
- package/dist/passwordPolicy.d.ts +8 -0
- package/dist/passwordReset.d.ts +48 -0
- package/dist/redirectResolver.d.ts +19 -0
- package/dist/register.d.ts +1 -2
- package/dist/session.d.ts +73 -0
- package/dist/sessionAdapter.d.ts +66 -0
- package/dist/sessionLayout.d.ts +1 -0
- package/dist/sessionSanitizer.d.ts +19 -0
- package/dist/totp.d.ts +38 -0
- package/dist/twoFactor.d.ts +60 -0
- package/dist/userAdapter.d.ts +68 -0
- package/docs/credentials-auth.md +3 -217
- package/docs/hooks.md +3 -442
- package/docs/logout-flow.md +3 -233
- package/docs/oauth-providers.md +2 -437
- package/docs/password-reset.md +2 -332
- package/docs/redirect-validation.md +4 -389
- package/docs/session-management.md +3 -282
- package/docs/user-adapter.md +2 -350
- package/package.json +4 -4
- package/CLAUDE.md +0 -197
package/AGENTS.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @luckystack/login — design context
|
|
2
|
+
|
|
3
|
+
Login handles credentials, OAuth, sessions and their lifecycle; consuming applications own identity storage and may register user/session adapters. Do not store persistent user data in sessions. Sanitize session fields before sending them to browsers; never log full tokens or return password hashes. Source: `src/sessionSanitizer.ts`, `src/session.ts`, `src/userAdapter.ts`.
|
|
4
|
+
|
|
5
|
+
Keep account linking a deliberate product/security choice: changing from provider-scoped accounts to unified email accounts requires database uniqueness and verified identity assumptions (`src/accountStrategy.ts`). Password policy belongs to registration/change/reset, not failed login counting against existing passwords (`src/login.ts`, `src/passwordPolicy.ts`). Keep password-reset and email-change secrets single-use and hashed at rest (`../core/src/oneTimeToken.ts`). An unknown reset email must not change visible behavior (`src/forgotPassword.ts`).
|
|
6
|
+
|
|
7
|
+
OAuth state protects the callback, and post-login redirects must be validated against trusted origins, including any return URL carried in state. Never turn an untrusted redirect into an authenticated bounce; never silently broaden CORS to make one work. Cookie sessions require CSRF and carefully scoped cross-origin rules. Source: `src/login.ts`, `src/redirectResolver.ts`, `../server/src/httpRoutes/authCallbackRoute.ts`. See `docs/redirect-validation.md`, `docs/session-management.md`, `../server/AGENTS.md`.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,12 @@ All notable changes to `@luckystack/login` are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.11.0] - 2026-10-14
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Type declarations are emitted per source file by TypeScript 7 (`tsc -b`) instead of one bundled `.d.ts` per entry, so the framework build no longer runs package by package. The exported types are unchanged; relative imports inside the declarations carry `.js`, so `moduleResolution: "node16"`/`"nodenext"` projects resolve them as before.
|
|
13
|
+
- Package design guidance now ships as `AGENTS.md`, not `CLAUDE.md`. Handwritten docs focus on rationale and safety, retaining a minimal existing-project email-code/2FA adoption checklist including persistence and encryption-key prerequisites.
|
|
9
14
|
|
|
10
15
|
## [0.10.0] - 2026-09-03
|
|
11
16
|
|
package/README.md
CHANGED
|
@@ -1,240 +1,12 @@
|
|
|
1
1
|
# @luckystack/login
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authentication and session lifecycle for LuckyStack, with credential, OAuth and adapter-backed identity options. Install with `npm install @luckystack/login socket.io`; add optional `@prisma/client` for the default user adapter and `@luckystack/email` for framework-managed email flows. Register user/session adapters before handling logins. Consult `package.json`, `src/register.ts` and `src/userAdapter.ts` for integration requirements.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Before switching an existing app to unified provider accounts: reconcile duplicate emails and dependent rows; enforce email uniqueness in the database (application checks cannot close the registration race); ensure a custom user adapter implements cross-provider email lookup; then enable the strategy. Changing cookie policy requires a CSRF/origin review. OAuth redirects must pass trusted-origin validation. Password-reset responses must not reveal whether an account exists; never expose tokens or hashes in sessions. See [design context](./AGENTS.md), [session safety](./docs/session-management.md), [redirect trust](./docs/redirect-validation.md) and [account strategy](./docs/user-adapter.md).
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
npm install @luckystack/login socket.io
|
|
9
|
-
```
|
|
7
|
+
## Existing-project email-code / 2FA adoption
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
import { loginWithCredentials, getSession, logout } from '@luckystack/login';
|
|
17
|
-
import { registerHook } from '@luckystack/core';
|
|
18
|
-
|
|
19
|
-
// Block login for unverified users:
|
|
20
|
-
registerHook('preLogin', async ({ email }) => {
|
|
21
|
-
const user = await prisma.user.findFirst({ where: { email } });
|
|
22
|
-
if (user && !user.verified) {
|
|
23
|
-
return { stop: true, errorCode: 'login.notVerified' };
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// Inside an /auth route handler:
|
|
28
|
-
const result = await loginWithCredentials({ email, password });
|
|
29
|
-
// → { status: true, reason, newToken, session } on success
|
|
30
|
-
// → { status: false, reason } on failure (including hook-stop with the hook's errorCode)
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
The default `redisSessionAdapter` stores sessions under `${PROJECT_NAME}-session:<token>` and applies sliding expiration (every authenticated read extends the TTL by `ProjectConfig.session.expiryDays`). Register a custom `SessionAdapter` when sessions should live in another store.
|
|
34
|
-
|
|
35
|
-
## Hooks
|
|
36
|
-
|
|
37
|
-
`pre*` hooks fire before the side-effect and may return a `HookStopSignal` to abort. `post*` hooks fire after success. Payload types live in `./hookPayloads.ts` and are merged into `@luckystack/core`'s `HookPayloads` via module augmentation.
|
|
38
|
-
|
|
39
|
-
| Hook | Aborts | Fires from |
|
|
40
|
-
| --- | --- | --- |
|
|
41
|
-
| `preLogin` / `postLogin` | yes | `loginWithCredentials`, `loginCallback` |
|
|
42
|
-
| `preRegister` / `postRegister` | yes | `loginWithCredentials` (register branch), `loginCallback` (new OAuth user) |
|
|
43
|
-
| `preLogout` / `postLogout` | yes | `logout` |
|
|
44
|
-
| `preSessionCreate` / `postSessionCreate` | yes | `saveSession({ newUser: true })` |
|
|
45
|
-
| `preSessionDelete` / `postSessionDelete` | yes | `deleteSession` |
|
|
46
|
-
|
|
47
|
-
## OAuth provider registry
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
import {
|
|
51
|
-
registerOAuthProviders,
|
|
52
|
-
googleProvider,
|
|
53
|
-
githubProvider,
|
|
54
|
-
credentialsProvider,
|
|
55
|
-
} from '@luckystack/login';
|
|
56
|
-
|
|
57
|
-
registerOAuthProviders([
|
|
58
|
-
credentialsProvider(),
|
|
59
|
-
googleProvider({
|
|
60
|
-
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
61
|
-
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
62
|
-
callbackUrl: `http://localhost:80/auth/callback/google`,
|
|
63
|
-
}),
|
|
64
|
-
githubProvider({
|
|
65
|
-
clientId: process.env.GITHUB_CLIENT_ID,
|
|
66
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
67
|
-
callbackUrl: `http://localhost:80/auth/callback/github`,
|
|
68
|
-
}),
|
|
69
|
-
]);
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Built-in helpers: `googleProvider`, `githubProvider`, `discordProvider`, `facebookProvider`, `microsoftProvider`, `credentialsProvider`. Each non-credentials helper takes `{ clientId, clientSecret, callbackUrl }` and returns a fully-typed `FullOAuthProvider` with the provider's authorize/token/userinfo URLs and standard scopes prefilled. Pass anything that satisfies the `OAuthProvider` interface to register custom providers — see [`packages/login/src/oauthProviders.ts`](./src/oauthProviders.ts) for the canonical shape.
|
|
73
|
-
|
|
74
|
-
### Provider options
|
|
75
|
-
|
|
76
|
-
Every helper accepts the base shape plus optional overrides for self-hosted instances and provider-specific tunables.
|
|
77
|
-
|
|
78
|
-
| Option | Applies to | Default | When to override |
|
|
79
|
-
| --- | --- | --- | --- |
|
|
80
|
-
| `clientId` | all | — | Required. The OAuth app's client identifier. |
|
|
81
|
-
| `clientSecret` | all | — | Required. The OAuth app's client secret. |
|
|
82
|
-
| `callbackUrl` | all | — | Required. Must match the URL registered with the provider — your BACKEND origin + `/auth/callback/<name>` (dev `http://localhost:80/auth/callback/<name>`, prod `https://your-domain.com/auth/callback/<name>`). |
|
|
83
|
-
| `endpoints?.authorizationURL` | all | provider-default | GitHub Enterprise host, Microsoft custom-tenant authorize URL, internal auth proxy, etc. |
|
|
84
|
-
| `endpoints?.tokenExchangeURL` | all | provider-default | Same use cases as `authorizationURL`. |
|
|
85
|
-
| `endpoints?.userInfoURL` | all | provider-default | Self-hosted GitHub Enterprise / Microsoft Graph mirror, etc. |
|
|
86
|
-
| `apiVersion?` | `facebookProvider`, `microsoftProvider` | facebook: `v18.0`, microsoft: `v2.0` | Pin to a known-good Graph API version when the upstream rolls out breaking changes. |
|
|
87
|
-
| `tenant?` | `microsoftProvider` | `'common'` | Restrict logins to a specific Azure AD tenant ID. Use a UUID or `'organizations'` / `'consumers'`. |
|
|
88
|
-
| `graphApiVersion?` | `microsoftProvider` | `'v1.0'` | Pin the Microsoft Graph version used to fetch the user profile (separate from the OAuth `apiVersion`). |
|
|
89
|
-
|
|
90
|
-
Example — self-hosted GitHub Enterprise:
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
githubProvider({
|
|
94
|
-
clientId: process.env.GITHUB_CLIENT_ID,
|
|
95
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
96
|
-
callbackUrl: `http://localhost:80/auth/callback/github`,
|
|
97
|
-
endpoints: {
|
|
98
|
-
authorizationURL: 'https://github.acme.example/login/oauth/authorize',
|
|
99
|
-
tokenExchangeURL: 'https://github.acme.example/login/oauth/access_token',
|
|
100
|
-
userInfoURL: 'https://github.acme.example/api/v3/user',
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Example — single-tenant Microsoft Entra ID:
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
microsoftProvider({
|
|
109
|
-
clientId: process.env.MICROSOFT_CLIENT_ID,
|
|
110
|
-
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
|
111
|
-
callbackUrl: `http://localhost:80/auth/callback/microsoft`,
|
|
112
|
-
tenant: process.env.MICROSOFT_TENANT_ID, // 'common' for any tenant; UUID for a single tenant.
|
|
113
|
-
apiVersion: 'v2.0', // OAuth endpoint version
|
|
114
|
-
graphApiVersion: 'v1.0', // Graph endpoint version
|
|
115
|
-
});
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## User adapter
|
|
119
|
-
|
|
120
|
-
By default the package reads / writes the Prisma `User` model directly. To bind auth flows to a different table (multi-tenant, alternative ORM, soft-deleted users, etc.), register your own `UserAdapter`:
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
import { registerUserAdapter, defaultPrismaUserAdapter } from '@luckystack/login';
|
|
124
|
-
|
|
125
|
-
registerUserAdapter({
|
|
126
|
-
...defaultPrismaUserAdapter,
|
|
127
|
-
findByEmail: async (email) => {
|
|
128
|
-
const user = await prisma.user.findFirst({ where: { email, deletedAt: null } });
|
|
129
|
-
return user;
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
## Account strategy: per-provider vs unified
|
|
135
|
-
|
|
136
|
-
`auth.providerAccountStrategy` (in `registerProjectConfig`) controls how the same email address is treated across sign-in providers:
|
|
137
|
-
|
|
138
|
-
| Strategy | Behavior | Schema |
|
|
139
|
-
|---|---|---|
|
|
140
|
-
| `'per-provider'` (default) | `sam@x.com` via Google and via GitHub are **two separate `User` rows**. Lookups are scoped to `(email, provider)`. | `@@unique([email, provider])` recommended. |
|
|
141
|
-
| `'unified'` | `sam@x.com` maps to **one `User` row**; signing in via a new provider links to the existing account (credentials login, OAuth find-or-create, and register dedupe all resolve by email alone). | `email` must be `@unique`. |
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
registerProjectConfig({ auth: { providerAccountStrategy: 'unified' } });
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**Migrating an existing project to `'unified'`** (the strategy reads accounts by email irrespective of provider, so the DB must enforce one row per email):
|
|
148
|
-
|
|
149
|
-
1. **Dedupe existing rows.** If you previously ran `'per-provider'`, the same email may already exist under multiple providers. Merge or remove duplicates so each email appears once. (Pick the row to keep — usually the credentials account or the earliest — repoint related rows, delete the rest.)
|
|
150
|
-
2. **Make `email` unique** in `prisma/schema.prisma`:
|
|
151
|
-
```prisma
|
|
152
|
-
model User {
|
|
153
|
-
// ...
|
|
154
|
-
email String @unique // was: email String (+ optional @@unique([email, provider]))
|
|
155
|
-
provider String // now records the ORIGINAL signup provider only
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
Then `prisma migrate` (or `db push`). The DB constraint closes the registration race that the application-level check alone cannot.
|
|
159
|
-
3. **No code change is required** beyond the config flag — the default `UserAdapter` already implements `findByEmailAnyProvider`. A **custom** `UserAdapter` must add `findByEmailAnyProvider({ email })` (resolve by email, ignoring provider); if it doesn't, the framework logs a one-time warning and falls back to provider-scoped lookup so the misconfiguration is visible rather than silent.
|
|
160
|
-
|
|
161
|
-
## Post-login redirect
|
|
162
|
-
|
|
163
|
-
Compute the OAuth callback destination dynamically (per-user, per-tenant, per-provider):
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
import { registerPostLoginRedirect } from '@luckystack/login';
|
|
167
|
-
|
|
168
|
-
registerPostLoginRedirect(async ({ user, provider }) => {
|
|
169
|
-
if (user.organizationId) return `/org/${user.organizationId}`;
|
|
170
|
-
return '/welcome';
|
|
171
|
-
});
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
## Password reset primitives
|
|
175
|
-
|
|
176
|
-
Used by the `forgotPassword: 'framework'` mode and exported for `'custom'` consumers who want to drive their own flow:
|
|
177
|
-
|
|
178
|
-
```ts
|
|
179
|
-
import {
|
|
180
|
-
createPasswordResetToken,
|
|
181
|
-
consumePasswordResetToken,
|
|
182
|
-
updatePasswordHash,
|
|
183
|
-
verifyPassword,
|
|
184
|
-
sendPasswordResetEmail, // requires @luckystack/email registered
|
|
185
|
-
} from '@luckystack/login';
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
`sendPasswordResetEmail` is a no-op when `@luckystack/email` has not been registered, so you can keep the import unconditionally.
|
|
189
|
-
|
|
190
|
-
## Public API
|
|
191
|
-
|
|
192
|
-
| Export | Purpose |
|
|
193
|
-
| --- | --- |
|
|
194
|
-
| `loginWithCredentials(params)` | Combined login/register dispatcher. Routes to `register*` or `login*` based on the body shape (presence of `confirmPassword`). Used by the HTTP `/auth/api/credentials` route. |
|
|
195
|
-
| `registerWithCredentials({ email, password, name, confirmPassword })` | Register-only entry point. Use this when you wire a custom auth surface that bypasses the dispatcher's body-shape branching. |
|
|
196
|
-
| `loginWithCredentialsCore({ email, password })` | Login-only entry point. Same idea as `registerWithCredentials`. |
|
|
197
|
-
| `loginCallback(pathname, req, res)` | OAuth state-exchange handler — wired to `/auth/callback/<provider>` by `@luckystack/server`. |
|
|
198
|
-
| `createOAuthState(providerName)` | Issue a CSRF state token (Redis, NX, TTL from project config). |
|
|
199
|
-
| `logout({ token, socket, userId })` | End a single socket's session. |
|
|
200
|
-
| `saveSession(token, user, newUser?)` | Write through the active `SessionAdapter` + broadcast to existing connections. |
|
|
201
|
-
| `getSession(token)` | Read + slide expiration through the active adapter. Dispatches `preSessionRefresh` / `postSessionRefresh` around the adapter TTL refresh. |
|
|
202
|
-
| `deleteSession(token)` | Hard delete + clean up active-tokens set. |
|
|
203
|
-
| `getAllSessions()` | Admin utility — scans all sessions. |
|
|
204
|
-
| `revokeUserSessions(userId)` | Force-logout every active session for a user. |
|
|
205
|
-
| `sessionKeyFor(token)` / `activeUsersKeyFor(userId)` | Centralized Redis-key builders (`{projectName}-session:{token}` / `{projectName}-activeUsers:{userId}`). Use these when you read or scan session data from outside `@luckystack/login` so the key shape stays in lockstep. |
|
|
206
|
-
| `registerOAuthProviders(list)` / `getOAuthProviders()` / `isFullOAuthProvider(p)` | OAuth registry. |
|
|
207
|
-
| `googleProvider`, `githubProvider`, `discordProvider`, `facebookProvider`, `microsoftProvider`, `credentialsProvider` | Built-in provider factories. |
|
|
208
|
-
| `registerUserAdapter(adapter)` / `getUserAdapter()` / `isUserAdapterRegistered()` / `defaultPrismaUserAdapter` | Pluggable user store. |
|
|
209
|
-
| `registerSessionAdapter(adapter)` / `getSessionAdapter()` / `redisSessionAdapter` | Pluggable session storage; Redis is the default. |
|
|
210
|
-
| `registerPostLoginRedirect(resolver)` / `getPostLoginRedirect()` | Dynamic redirect resolution. |
|
|
211
|
-
| `createPasswordResetToken`, `consumePasswordResetToken`, `updatePasswordHash`, `verifyPassword`, `sendPasswordResetEmail` | Password-reset primitives. |
|
|
212
|
-
|
|
213
|
-
Types: `BaseSessionLayout`, `SessionLocation`, `AuthProps` (re-exported from `@luckystack/core`); `OAuthProvider`, `CredentialsProvider`, `FullOAuthProvider`, `UserAdapter`, `UserAdapterCreateInput`, `UserRecord`, `PostLoginRedirectResolver`, `PostLoginRedirectInput`, plus all `Pre*Payload` / `Post*Payload` types.
|
|
214
|
-
|
|
215
|
-
## Stored-XSS warning: OAuth `name` fields
|
|
216
|
-
|
|
217
|
-
`loginCallback` reads the user's display name straight from the OAuth provider's profile response and stores it on the `User.name` column unsanitized. That is fine for plain text rendering and for the framework's avatar-fallback initials, but **becomes stored XSS the moment a consumer renders the name as raw HTML** (e.g. `dangerouslySetInnerHTML`, an HTML email body, a server-rendered widget). Two recommended mitigations on the consumer side:
|
|
218
|
-
|
|
219
|
-
- Render names with React text nodes (the default; safe).
|
|
220
|
-
- Strip or escape any `<` / `>` characters before injecting names into HTML emails or non-React surfaces.
|
|
221
|
-
|
|
222
|
-
If your project has no such surfaces this is informational. The framework intentionally does not silently mutate the field because some apps need exact-match search across providers.
|
|
223
|
-
|
|
224
|
-
## Related architecture docs
|
|
225
|
-
|
|
226
|
-
- [`docs/ARCHITECTURE_AUTH.md`](../../docs/ARCHITECTURE_AUTH.md) — OAuth + credentials lifecycle, allowed-origin checks, role guards.
|
|
227
|
-
- [`docs/ARCHITECTURE_SESSION.md`](../../docs/ARCHITECTURE_SESSION.md) — session adapters, default Redis layout, sliding expiration, and single-session enforcement.
|
|
228
|
-
- [`docs/ARCHITECTURE_EMAIL.md`](../../docs/ARCHITECTURE_EMAIL.md) — forgot-password modes (`framework` / `custom` / `disabled`).
|
|
229
|
-
|
|
230
|
-
## Dependencies
|
|
231
|
-
|
|
232
|
-
- Runtime: `@luckystack/core`, `bcryptjs`, `validator`, `dotenv`
|
|
233
|
-
- Required peer: `socket.io@^4.8.0`
|
|
234
|
-
- Optional peers: `@prisma/client@^6.19.0` (default user adapter) and `@luckystack/email` (only when `forgotPassword: 'framework'`). The package lazy-imports optional peers; every other API works without them.
|
|
235
|
-
|
|
236
|
-
When using the default Prisma `UserAdapter`, your schema must include a `User` model with at least: `id`, `email`, `provider` (enum `PROVIDERS`), `password` (nullable), `name`, `avatar`, `avatarFallback`, `admin`, `language`. See [`prisma/schema.prisma`](../../prisma/schema.prisma) for the canonical shape. A custom `UserAdapter` and `SessionAdapter` can bind auth to another data layer and session store.
|
|
237
|
-
|
|
238
|
-
## License
|
|
239
|
-
|
|
240
|
-
MIT — see [LICENSE](../../LICENSE).
|
|
9
|
+
1. Obtain feature approval before opting into `auth.emailCodeLogin: true` and/or `auth.twoFactor: 'optional'`. Register a real email sender for email-code login and any email fallback; console delivery is not sufficient.
|
|
10
|
+
2. Before 2FA enrollment, add persistence for `twoFactorEnabled` (initially false), nullable `totpSecret` and nullable `recoveryCodes` to the user store. For the default Prisma adapter the fields are `Boolean @default(false)`, `String?` and `Json?` respectively. Review/apply the database change with authorization and regenerate the client; custom adapters must support these fields in `src/userAdapter.ts`.
|
|
11
|
+
3. **Set a high-entropy secret `TOTP_ENCRYPTION_KEY` before enrollment. Without it new TOTP secrets are stored unencrypted.** Keep it outside Git/logs. During rotation retain previous values in `TOTP_ENCRYPTION_LEGACY_KEYS` (a JSON array of strings) until old records are migrated; deleting them early can lock users out. See `src/twoFactor.ts` for the installed keyring contract.
|
|
12
|
+
4. Run `npx luckystack update --app`, review/merge UI `.new` sidecars without overwriting consumer edits, then verify enrollment, login, recovery and rotation in an authorized environment. Schema and real secrets remain manually owned.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { UserAdapter, UserRecord } from './userAdapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a user account honoring the configured `providerAccountStrategy`.
|
|
4
|
+
* Use this everywhere the login flow needs "does an account for this email
|
|
5
|
+
* exist?" — register dedupe, credentials login, and OAuth find-or-create.
|
|
6
|
+
*/
|
|
7
|
+
export declare const resolveUserByEmail: (adapter: UserAdapter, { email, provider }: {
|
|
8
|
+
email: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
}) => Promise<UserRecord | null>;
|
|
11
|
+
/** Test-only: reset the one-time missing-method warning latch. */
|
|
12
|
+
export declare const resetAccountStrategyWarningForTests: () => void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `accountKey` is currently locked out (failed-attempt counter already
|
|
3
|
+
* at/over the cap). Non-incrementing — safe to call on every login attempt.
|
|
4
|
+
* Always `false` when the feature is disabled.
|
|
5
|
+
* Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).
|
|
6
|
+
*/
|
|
7
|
+
export declare const isAccountLocked: (accountKey: string, requesterIp?: string) => Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Increment the failed-attempt counter for `accountKey`. Call on every failed
|
|
10
|
+
* credentials login for a known/looked-up account. No-op when disabled.
|
|
11
|
+
* Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).
|
|
12
|
+
*/
|
|
13
|
+
export declare const recordAuthFailure: (accountKey: string, requesterIp?: string) => Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL
|
|
16
|
+
* login so a user who eventually gets their password right isn't penalised by
|
|
17
|
+
* earlier typos). No-op when disabled.
|
|
18
|
+
* Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).
|
|
19
|
+
*/
|
|
20
|
+
export declare const clearAuthFailures: (accountKey: string, requesterIp?: string) => Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once
|
|
23
|
+
* (idempotent) and increments the failed-attempt counter only on a credentials
|
|
24
|
+
* LOGIN failure that carries an email AND is a genuine wrong-password attempt
|
|
25
|
+
* (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder
|
|
26
|
+
* itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the
|
|
27
|
+
* config at runtime is honoured without re-wiring.
|
|
28
|
+
*/
|
|
29
|
+
export declare const registerAuthLockoutHook: () => void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface EmailChangePayload {
|
|
2
|
+
userId: string;
|
|
3
|
+
newEmail: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Create a one-time email-change token bound to a user id + the new email
|
|
7
|
+
* address. Any previously issued (but not yet consumed) token for the same
|
|
8
|
+
* user is invalidated first (LOGIN-F16) so at most one confirmation link per
|
|
9
|
+
* user is redeemable. Stored in Redis with the configured TTL — only
|
|
10
|
+
* `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —
|
|
11
|
+
* caller emails the token (embedded in a URL) to the NEW address so the
|
|
12
|
+
* confirmation click proves ownership of the new mailbox.
|
|
13
|
+
*/
|
|
14
|
+
export declare const createEmailChangeToken: (userId: string, newEmail: string) => Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Validate and consume an email-change token. Returns the bound
|
|
17
|
+
* `{ userId, newEmail }` on success and removes the token (one-time use, atomic
|
|
18
|
+
* GET+DEL). Returns null when the token is missing, expired, malformed, or
|
|
19
|
+
* already consumed.
|
|
20
|
+
*/
|
|
21
|
+
export declare const consumeEmailChangeToken: (token: string) => Promise<EmailChangePayload | null>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface SendEmailChangeArgs {
|
|
2
|
+
userId: string;
|
|
3
|
+
newEmail: string;
|
|
4
|
+
/** Optional display name used in the email greeting (falls back to "there"). */
|
|
5
|
+
userName?: string | null;
|
|
6
|
+
/**
|
|
7
|
+
* App-facing display name used in the email greeting + subject. Falls back
|
|
8
|
+
* to `projectConfig.auth.passwordResetBrand` (the shared brand label), then
|
|
9
|
+
* to `'LuckyStack'` as the absolute fallback.
|
|
10
|
+
*/
|
|
11
|
+
brand?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Mint a one-shot email-change token and send a confirmation email to the
|
|
15
|
+
* NEW address with the tokenized URL. Returns the send result for diagnostics
|
|
16
|
+
* + the token itself (callers can audit-log it through the
|
|
17
|
+
* `postEmailChangeRequested` hook).
|
|
18
|
+
*/
|
|
19
|
+
export declare const sendEmailChangeConfirmation: ({ userId, newEmail, userName, brand }: SendEmailChangeArgs) => Promise<{
|
|
20
|
+
ok: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
token: string;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CredentialsLoginResult } from './login.js';
|
|
2
|
+
export interface RequestEmailLoginCodeInput {
|
|
3
|
+
email: string;
|
|
4
|
+
requesterIp?: string;
|
|
5
|
+
}
|
|
6
|
+
export type RequestEmailLoginCodeResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const requestEmailLoginCode: ({ email, requesterIp }: RequestEmailLoginCodeInput) => Promise<RequestEmailLoginCodeResult>;
|
|
13
|
+
export interface VerifyEmailLoginCodeInput {
|
|
14
|
+
email: string;
|
|
15
|
+
code: string;
|
|
16
|
+
supersedeToken?: string;
|
|
17
|
+
requesterIp?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const verifyEmailLoginCode: (input: VerifyEmailLoginCodeInput) => Promise<CredentialsLoginResult>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Minimal surface of `@luckystack/email` that the login orchestrators use. */
|
|
2
|
+
export interface EmailModule {
|
|
3
|
+
sendEmail: (input: Record<string, unknown>) => Promise<{
|
|
4
|
+
ok: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Dynamically import the optional `@luckystack/email` peer. Resolves to the
|
|
10
|
+
* module, or REJECTS if the package is not installed (same as a bare
|
|
11
|
+
* `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const loadEmailModule: () => Promise<EmailModule>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type EmailOtpPurpose = 'login' | '2fa';
|
|
2
|
+
export declare const generateNumericCode: (digits: number) => string;
|
|
3
|
+
export interface IssueEmailCodeInput {
|
|
4
|
+
purpose: EmailOtpPurpose;
|
|
5
|
+
/** Email address (login) or userId (2fa). */
|
|
6
|
+
identity: string;
|
|
7
|
+
ttlSeconds: number;
|
|
8
|
+
digits: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Mint + store a fresh code for (purpose, identity). Any previously active
|
|
12
|
+
* code for the same slot is replaced, and its attempt counter reset. Returns
|
|
13
|
+
* the RAW code exactly once — the caller emails it; only sha256(code) is
|
|
14
|
+
* stored (hash-at-rest, ADR 0010 spirit).
|
|
15
|
+
*/
|
|
16
|
+
export declare const issueEmailCode: ({ purpose, identity, ttlSeconds, digits }: IssueEmailCodeInput) => Promise<string>;
|
|
17
|
+
export type EmailCodeVerdict = 'valid' | 'invalid' | 'expired' | 'locked';
|
|
18
|
+
export interface VerifyEmailCodeInput {
|
|
19
|
+
purpose: EmailOtpPurpose;
|
|
20
|
+
identity: string;
|
|
21
|
+
code: string;
|
|
22
|
+
/** Wrong-code attempts before the code is burned (counter is server-side). */
|
|
23
|
+
maxAttempts: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Verify + consume. Verdicts: 'valid' (code matched, slot consumed),
|
|
27
|
+
* 'invalid' (wrong code, attempt recorded), 'locked' (attempt budget spent —
|
|
28
|
+
* the active code is burned so guessing can't continue), 'expired' (no active
|
|
29
|
+
* code: TTL passed, never issued, or already consumed).
|
|
30
|
+
*/
|
|
31
|
+
export declare const verifyEmailCode: ({ purpose, identity, code, maxAttempts }: VerifyEmailCodeInput) => Promise<EmailCodeVerdict>;
|
|
32
|
+
/** Drop any active code + counter for the slot (e.g. after a completed login). */
|
|
33
|
+
export declare const clearEmailCode: (purpose: EmailOtpPurpose, identity: string) => Promise<void>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface SendResetEmailArgs {
|
|
2
|
+
email: string;
|
|
3
|
+
/**
|
|
4
|
+
* App-facing display name used in the email greeting + subject. If omitted,
|
|
5
|
+
* falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`
|
|
6
|
+
* as the absolute fallback. Real consumers should set this on either the
|
|
7
|
+
* call site OR via `projectConfig.auth.passwordResetBrand`.
|
|
8
|
+
*/
|
|
9
|
+
brand?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Look up the user by credentials-provider email, generate a reset token,
|
|
13
|
+
* and email them a reset link. Always resolves successfully regardless of
|
|
14
|
+
* whether the email matched a user (anti-enumeration). Returns the result
|
|
15
|
+
* of the email send for diagnostics.
|
|
16
|
+
*/
|
|
17
|
+
export declare const sendPasswordResetEmail: ({ email, brand }: SendResetEmailArgs) => Promise<{
|
|
18
|
+
ok: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module augmentation: extends `@luckystack/core`'s `HookPayloads` interface
|
|
3
|
+
* with auth and session lifecycle hooks owned by this package.
|
|
4
|
+
*
|
|
5
|
+
* This file is picked up by the TypeScript compiler because it is in the
|
|
6
|
+
* tsconfig `include` path. A side-effect `import './hookPayloads';` in
|
|
7
|
+
* `index.ts` makes the dependency explicit for readers even though TS would
|
|
8
|
+
* merge the declaration regardless.
|
|
9
|
+
*/
|
|
10
|
+
import type { BaseSessionLayout } from './sessionLayout.js';
|
|
11
|
+
export interface PreLoginPayload {
|
|
12
|
+
email: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PostLoginPayload {
|
|
16
|
+
userId: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
isNewUser: boolean;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PreRegisterPayload {
|
|
22
|
+
email: string;
|
|
23
|
+
provider: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PostRegisterPayload {
|
|
27
|
+
userId: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
}
|
|
30
|
+
export interface PreLogoutPayload {
|
|
31
|
+
userId: string | null;
|
|
32
|
+
token: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface PostLogoutPayload {
|
|
35
|
+
userId: string | null;
|
|
36
|
+
token: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface PreSessionCreatePayload {
|
|
39
|
+
token: string;
|
|
40
|
+
user: BaseSessionLayout;
|
|
41
|
+
persistent: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface PostSessionCreatePayload {
|
|
44
|
+
token: string;
|
|
45
|
+
user: BaseSessionLayout;
|
|
46
|
+
persistent: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PreSessionDeletePayload {
|
|
49
|
+
token: string;
|
|
50
|
+
userId: string | null;
|
|
51
|
+
}
|
|
52
|
+
export interface PostSessionDeletePayload {
|
|
53
|
+
token: string;
|
|
54
|
+
userId: string | null;
|
|
55
|
+
}
|
|
56
|
+
export interface PasswordResetRequestedPayload {
|
|
57
|
+
/** Email the user typed in the reset form. May or may not match a real user. */
|
|
58
|
+
email: string;
|
|
59
|
+
/** True when a matching user was found and an email is being sent. */
|
|
60
|
+
matched: boolean;
|
|
61
|
+
/** The userId, when matched. */
|
|
62
|
+
userId?: string;
|
|
63
|
+
/** Token TTL in seconds. */
|
|
64
|
+
ttlSeconds?: number;
|
|
65
|
+
}
|
|
66
|
+
export interface PasswordResetCompletedPayload {
|
|
67
|
+
/** User whose password was reset via the forgot-password flow. */
|
|
68
|
+
userId: string;
|
|
69
|
+
/** Whether the framework auto-revoked all other active sessions. */
|
|
70
|
+
revokedOtherSessions: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface PasswordChangedPayload {
|
|
73
|
+
/** User whose password was changed via the in-session change flow. */
|
|
74
|
+
userId: string;
|
|
75
|
+
/** True if the user verified their current password successfully. */
|
|
76
|
+
verifiedCurrent: boolean;
|
|
77
|
+
/** Whether sessions other than the current one were revoked. */
|
|
78
|
+
revokedOtherSessions: boolean;
|
|
79
|
+
}
|
|
80
|
+
export interface PrePasswordResetCompletedPayload {
|
|
81
|
+
/** User whose password is about to be reset via the forgot-password flow. */
|
|
82
|
+
userId: string;
|
|
83
|
+
}
|
|
84
|
+
export interface PrePasswordChangedPayload {
|
|
85
|
+
/** User whose password is about to be changed via the in-session flow. */
|
|
86
|
+
userId: string;
|
|
87
|
+
/** True if the user verified their current password successfully. */
|
|
88
|
+
verifiedCurrent: boolean;
|
|
89
|
+
}
|
|
90
|
+
export interface PreEmailChangePayload {
|
|
91
|
+
userId: string;
|
|
92
|
+
currentEmail: string;
|
|
93
|
+
newEmail: string;
|
|
94
|
+
}
|
|
95
|
+
export interface PostEmailChangeRequestedPayload {
|
|
96
|
+
userId: string;
|
|
97
|
+
currentEmail: string;
|
|
98
|
+
newEmail: string;
|
|
99
|
+
sent?: boolean;
|
|
100
|
+
}
|
|
101
|
+
export interface PostEmailChangedPayload {
|
|
102
|
+
userId: string;
|
|
103
|
+
oldEmail: string;
|
|
104
|
+
newEmail: string;
|
|
105
|
+
}
|
|
106
|
+
export interface LoginFailedPayload {
|
|
107
|
+
/** Email the attempt was for, when available. */
|
|
108
|
+
email?: string;
|
|
109
|
+
/** Resolved userId, when the attempt matched a real account. */
|
|
110
|
+
userId?: string;
|
|
111
|
+
/** Auth provider (e.g. `credentials`, `google`). */
|
|
112
|
+
provider: string;
|
|
113
|
+
/** i18n reason key describing why the attempt failed. */
|
|
114
|
+
reason: string;
|
|
115
|
+
/** Which flow the failure occurred in. */
|
|
116
|
+
stage: 'login' | 'register' | 'oauth';
|
|
117
|
+
requesterIp?: string;
|
|
118
|
+
}
|
|
119
|
+
export interface PreAccountDeletePayload {
|
|
120
|
+
userId: string;
|
|
121
|
+
email?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface PostAccountDeletePayload {
|
|
124
|
+
userId: string;
|
|
125
|
+
email?: string;
|
|
126
|
+
}
|
|
127
|
+
declare module '@luckystack/core' {
|
|
128
|
+
interface HookPayloads {
|
|
129
|
+
preLogin: PreLoginPayload;
|
|
130
|
+
postLogin: PostLoginPayload;
|
|
131
|
+
preRegister: PreRegisterPayload;
|
|
132
|
+
postRegister: PostRegisterPayload;
|
|
133
|
+
preLogout: PreLogoutPayload;
|
|
134
|
+
postLogout: PostLogoutPayload;
|
|
135
|
+
preSessionCreate: PreSessionCreatePayload;
|
|
136
|
+
postSessionCreate: PostSessionCreatePayload;
|
|
137
|
+
preSessionDelete: PreSessionDeletePayload;
|
|
138
|
+
postSessionDelete: PostSessionDeletePayload;
|
|
139
|
+
passwordResetRequested: PasswordResetRequestedPayload;
|
|
140
|
+
prePasswordResetCompleted: PrePasswordResetCompletedPayload;
|
|
141
|
+
passwordResetCompleted: PasswordResetCompletedPayload;
|
|
142
|
+
prePasswordChanged: PrePasswordChangedPayload;
|
|
143
|
+
passwordChanged: PasswordChangedPayload;
|
|
144
|
+
preEmailChange: PreEmailChangePayload;
|
|
145
|
+
postEmailChangeRequested: PostEmailChangeRequestedPayload;
|
|
146
|
+
postEmailChanged: PostEmailChangedPayload;
|
|
147
|
+
loginFailed: LoginFailedPayload;
|
|
148
|
+
preAccountDelete: PreAccountDeletePayload;
|
|
149
|
+
postAccountDelete: PostAccountDeletePayload;
|
|
150
|
+
}
|
|
151
|
+
}
|