@warlock.js/auth 5.12.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +88 -86
- package/esm/commands/auth-cleanup-command.mjs +1 -1
- package/esm/commands/auth-cleanup-command.mjs.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/contracts/providers.d.mts +101 -0
- package/esm/contracts/providers.d.mts.map +1 -0
- package/esm/contracts/types.d.mts +94 -1
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/errors/auth-provider-sdk-missing.error.d.mts +17 -0
- package/esm/errors/auth-provider-sdk-missing.error.d.mts.map +1 -0
- package/esm/errors/auth-provider-sdk-missing.error.mjs +22 -0
- package/esm/errors/auth-provider-sdk-missing.error.mjs.map +1 -0
- package/esm/errors/email-not-verified.error.d.mts +16 -0
- package/esm/errors/email-not-verified.error.d.mts.map +1 -0
- package/esm/errors/email-not-verified.error.mjs +20 -0
- package/esm/errors/email-not-verified.error.mjs.map +1 -0
- package/esm/errors/index.d.mts +7 -0
- package/esm/errors/index.mjs +9 -0
- package/esm/errors/invalid-one-time-token.error.d.mts +17 -0
- package/esm/errors/invalid-one-time-token.error.d.mts.map +1 -0
- package/esm/errors/invalid-one-time-token.error.mjs +21 -0
- package/esm/errors/invalid-one-time-token.error.mjs.map +1 -0
- package/esm/errors/invalid-passkey.error.d.mts +18 -0
- package/esm/errors/invalid-passkey.error.d.mts.map +1 -0
- package/esm/errors/invalid-passkey.error.mjs +23 -0
- package/esm/errors/invalid-passkey.error.mjs.map +1 -0
- package/esm/errors/invalid-provider-callback.error.d.mts +18 -0
- package/esm/errors/invalid-provider-callback.error.d.mts.map +1 -0
- package/esm/errors/invalid-provider-callback.error.mjs +23 -0
- package/esm/errors/invalid-provider-callback.error.mjs.map +1 -0
- package/esm/errors/notifications-unavailable.error.d.mts +15 -0
- package/esm/errors/notifications-unavailable.error.d.mts.map +1 -0
- package/esm/errors/notifications-unavailable.error.mjs +20 -0
- package/esm/errors/notifications-unavailable.error.mjs.map +1 -0
- package/esm/errors/provider-email-not-verified.error.d.mts +16 -0
- package/esm/errors/provider-email-not-verified.error.d.mts.map +1 -0
- package/esm/errors/provider-email-not-verified.error.mjs +20 -0
- package/esm/errors/provider-email-not-verified.error.mjs.map +1 -0
- package/esm/index.d.mts +27 -3
- package/esm/index.mjs +27 -1
- package/esm/middleware/index.d.mts +4 -1
- package/esm/middleware/index.mjs +3 -0
- package/esm/middleware/one-time-token-throttle.middleware.d.mts +36 -0
- package/esm/middleware/one-time-token-throttle.middleware.d.mts.map +1 -0
- package/esm/middleware/one-time-token-throttle.middleware.mjs +53 -0
- package/esm/middleware/one-time-token-throttle.middleware.mjs.map +1 -0
- package/esm/middleware/otp-throttle.middleware.d.mts +30 -0
- package/esm/middleware/otp-throttle.middleware.d.mts.map +1 -0
- package/esm/middleware/otp-throttle.middleware.mjs +45 -0
- package/esm/middleware/otp-throttle.middleware.mjs.map +1 -0
- package/esm/middleware/require-verified-email.middleware.d.mts +18 -0
- package/esm/middleware/require-verified-email.middleware.d.mts.map +1 -0
- package/esm/middleware/require-verified-email.middleware.mjs +24 -0
- package/esm/middleware/require-verified-email.middleware.mjs.map +1 -0
- package/esm/models/access-token/access-token.model.d.mts +8 -0
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +3 -0
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/index.d.mts +3 -1
- package/esm/models/index.d.mts.map +1 -1
- package/esm/models/index.mjs +16 -1
- package/esm/models/index.mjs.map +1 -1
- package/esm/models/one-time-token/index.d.mts +1 -0
- package/esm/models/one-time-token/index.mjs +3 -0
- package/esm/models/one-time-token/migration.mjs +31 -0
- package/esm/models/one-time-token/migration.mjs.map +1 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts +173 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -0
- package/esm/models/one-time-token/one-time-token.model.mjs +151 -0
- package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -0
- package/esm/models/passkey-credential/index.d.mts +1 -0
- package/esm/models/passkey-credential/index.mjs +3 -0
- package/esm/models/passkey-credential/migration.mjs +25 -0
- package/esm/models/passkey-credential/migration.mjs.map +1 -0
- package/esm/models/passkey-credential/passkey-credential.model.d.mts +108 -0
- package/esm/models/passkey-credential/passkey-credential.model.d.mts.map +1 -0
- package/esm/models/passkey-credential/passkey-credential.model.mjs +70 -0
- package/esm/models/passkey-credential/passkey-credential.model.mjs.map +1 -0
- package/esm/models/provider-account/index.d.mts +1 -0
- package/esm/models/provider-account/index.mjs +3 -0
- package/esm/models/provider-account/migration.mjs +25 -0
- package/esm/models/provider-account/migration.mjs.map +1 -0
- package/esm/models/provider-account/provider-account.model.d.mts +75 -0
- package/esm/models/provider-account/provider-account.model.d.mts.map +1 -0
- package/esm/models/provider-account/provider-account.model.mjs +55 -0
- package/esm/models/provider-account/provider-account.model.mjs.map +1 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/otp/index.d.mts +1 -0
- package/esm/otp/index.mjs +3 -0
- package/esm/otp/otp.d.mts +38 -0
- package/esm/otp/otp.d.mts.map +1 -0
- package/esm/otp/otp.mjs +105 -0
- package/esm/otp/otp.mjs.map +1 -0
- package/esm/passkeys/index.d.mts +3 -0
- package/esm/passkeys/index.mjs +4 -0
- package/esm/passkeys/passkey-authentication.d.mts +33 -0
- package/esm/passkeys/passkey-authentication.d.mts.map +1 -0
- package/esm/passkeys/passkey-authentication.mjs +93 -0
- package/esm/passkeys/passkey-authentication.mjs.map +1 -0
- package/esm/passkeys/passkey-challenges.mjs +53 -0
- package/esm/passkeys/passkey-challenges.mjs.map +1 -0
- package/esm/passkeys/passkey-registration.d.mts +29 -0
- package/esm/passkeys/passkey-registration.d.mts.map +1 -0
- package/esm/passkeys/passkey-registration.mjs +88 -0
- package/esm/passkeys/passkey-registration.mjs.map +1 -0
- package/esm/passkeys/simplewebauthn.d.mts +21 -0
- package/esm/passkeys/simplewebauthn.d.mts.map +1 -0
- package/esm/passkeys/simplewebauthn.mjs +13 -0
- package/esm/passkeys/simplewebauthn.mjs.map +1 -0
- package/esm/providers/google-provider.d.mts +40 -0
- package/esm/providers/google-provider.d.mts.map +1 -0
- package/esm/providers/google-provider.mjs +105 -0
- package/esm/providers/google-provider.mjs.map +1 -0
- package/esm/providers/index.d.mts +2 -0
- package/esm/providers/index.mjs +4 -0
- package/esm/providers/provider-login.d.mts +43 -0
- package/esm/providers/provider-login.d.mts.map +1 -0
- package/esm/providers/provider-login.mjs +86 -0
- package/esm/providers/provider-login.mjs.map +1 -0
- package/esm/providers/provider-state-cookie.mjs +63 -0
- package/esm/providers/provider-state-cookie.mjs.map +1 -0
- package/esm/providers/resolve-provider-user.mjs +45 -0
- package/esm/providers/resolve-provider-user.mjs.map +1 -0
- package/esm/services/auth-config.mjs +72 -0
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +18 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +28 -1
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/email-verification.d.mts +35 -0
- package/esm/services/email-verification.d.mts.map +1 -0
- package/esm/services/email-verification.mjs +55 -0
- package/esm/services/email-verification.mjs.map +1 -0
- package/esm/services/index.d.mts +4 -1
- package/esm/services/index.mjs +3 -0
- package/esm/services/one-time-tokens.d.mts +32 -0
- package/esm/services/one-time-tokens.d.mts.map +1 -0
- package/esm/services/one-time-tokens.mjs +56 -0
- package/esm/services/one-time-tokens.mjs.map +1 -0
- package/esm/services/optional-peer.mjs +37 -0
- package/esm/services/optional-peer.mjs.map +1 -0
- package/esm/services/password-reset.d.mts +34 -0
- package/esm/services/password-reset.d.mts.map +1 -0
- package/esm/services/password-reset.mjs +81 -0
- package/esm/services/password-reset.mjs.map +1 -0
- package/esm/services/token-notifications.mjs +87 -0
- package/esm/services/token-notifications.mjs.map +1 -0
- package/esm/utils/auth-error-codes.d.mts +39 -1
- package/esm/utils/auth-error-codes.d.mts.map +1 -1
- package/esm/utils/auth-error-codes.mjs +33 -0
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/esm/utils/index.mjs +1 -0
- package/esm/utils/safe-equal.d.mts +6 -0
- package/esm/utils/safe-equal.d.mts.map +1 -0
- package/esm/utils/safe-equal.mjs +13 -0
- package/esm/utils/safe-equal.mjs.map +1 -0
- package/llms-full.txt +313 -10
- package/llms.txt +2 -0
- package/package.json +21 -7
- package/skills/login-with-providers/SKILL.md +154 -0
- package/skills/manage-tokens/SKILL.md +7 -1
- package/skills/register-user/SKILL.md +6 -8
- package/skills/run-auth-commands/SKILL.md +1 -1
- package/skills/verify-email-and-reset-password/SKILL.md +137 -0
package/package.json
CHANGED
|
@@ -12,12 +12,26 @@
|
|
|
12
12
|
"ms": "^2.1.3"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@warlock.js/fs": "5.
|
|
16
|
-
"@warlock.js/cache": "5.
|
|
17
|
-
"@warlock.js/cascade": "5.
|
|
18
|
-
"@warlock.js/core": "5.
|
|
19
|
-
"@warlock.js/logger": "5.
|
|
20
|
-
"@warlock.js/seal": "5.
|
|
15
|
+
"@warlock.js/fs": "5.14.0",
|
|
16
|
+
"@warlock.js/cache": "5.14.0",
|
|
17
|
+
"@warlock.js/cascade": "5.14.0",
|
|
18
|
+
"@warlock.js/core": "5.14.0",
|
|
19
|
+
"@warlock.js/logger": "5.14.0",
|
|
20
|
+
"@warlock.js/seal": "5.14.0",
|
|
21
|
+
"@warlock.js/notifications": "5.14.0",
|
|
22
|
+
"@simplewebauthn/server": "^13.1.0",
|
|
23
|
+
"jose": "^6.1.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"@warlock.js/notifications": {
|
|
27
|
+
"optional": true
|
|
28
|
+
},
|
|
29
|
+
"@simplewebauthn/server": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"jose": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
21
35
|
},
|
|
22
36
|
"repository": {
|
|
23
37
|
"type": "git",
|
|
@@ -33,7 +47,7 @@
|
|
|
33
47
|
],
|
|
34
48
|
"author": "hassanzohdy",
|
|
35
49
|
"license": "MIT",
|
|
36
|
-
"version": "5.
|
|
50
|
+
"version": "5.14.0",
|
|
37
51
|
"type": "module",
|
|
38
52
|
"main": "./esm/index.mjs",
|
|
39
53
|
"module": "./esm/index.mjs",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: login-with-providers
|
|
3
|
+
description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Login with Google, passkeys, or a phone code
|
|
7
|
+
|
|
8
|
+
Services only, as with password login: you write the routes. Each `verify`/`complete` call returns the same `LoginResult` as `authService.login`, so the next step is the same too:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
const { user, tokens } = await completeProviderLogin(User, "google", request, response);
|
|
12
|
+
authService.setAuthCookie(response, tokens.accessToken); // cookie session, or return tokens as JSON
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`authService.completeLogin(user, deviceInfo?)` is that shared ending. It applies `auth.canAuthenticate` (403) and issues tokens. Call it only for a user you have already authenticated.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
| Method | Command | Installs |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| Google | `warlock add auth-google` | `jose` |
|
|
22
|
+
| Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
|
|
23
|
+
| Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
|
|
24
|
+
|
|
25
|
+
The SDKs are optional peers, loaded with `import()` only when a method runs. If one is missing, the call throws `AuthProviderSdkMissingError` (500), and the message names the `warlock add` command. Run the migrations: `authMigrations` now includes `provider_accounts` and `passkey_credentials`, and `one_time_tokens` gained an `attempts` column.
|
|
26
|
+
|
|
27
|
+
## Google
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// src/config/auth.ts
|
|
31
|
+
providers: {
|
|
32
|
+
google: {
|
|
33
|
+
clientId: env("GOOGLE_CLIENT_ID"),
|
|
34
|
+
clientSecret: env("GOOGLE_CLIENT_SECRET"),
|
|
35
|
+
redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
|
|
36
|
+
// scopes: ["openid", "email", "profile"],
|
|
37
|
+
},
|
|
38
|
+
// emailField: "email",
|
|
39
|
+
// createUser: async (profile, Model) => Model.create({ ... }),
|
|
40
|
+
},
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
router.get("/auth/google", async ({ response }) =>
|
|
45
|
+
response.redirect(await startProviderLogin(response, "google")),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
router.get("/auth/google/callback", async ({ request, response }) => {
|
|
49
|
+
const { tokens } = await completeProviderLogin(User, "google", request, response);
|
|
50
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
51
|
+
return response.redirect("/");
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Client side, the button is just a link. It must be a top-level navigation, not a `fetch`, so the state cookie goes out and comes back:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<a href="/auth/google">Continue with Google</a>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `state`, `nonce` and the PKCE verifier go into a signed, 10-minute, HttpOnly `auth_provider_state` cookie (SameSite=Lax, so it survives Google's redirect back). The callback clears the cookie, so a started login can complete only once.
|
|
62
|
+
- The callback rejects, with `InvalidProviderCallbackError` (400, `EC009`): a missing, forged or expired cookie, a `state` mismatch, a `?error=` from Google, a failed code exchange, and an id_token with a bad signature, `iss`, `aud`, `exp` or `nonce`. `error.reason` says which. The response message is generic.
|
|
63
|
+
- **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
|
|
64
|
+
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
65
|
+
|
|
66
|
+
## Passkeys
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// Register (logged in)
|
|
74
|
+
router.post("/auth/passkeys/register/options", async ({ request, response }) =>
|
|
75
|
+
response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
|
|
76
|
+
{ middleware: [authMiddleware("user")] });
|
|
77
|
+
router.post("/auth/passkeys/register", async ({ request, response }) => {
|
|
78
|
+
await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
|
|
79
|
+
return response.success({ registered: true });
|
|
80
|
+
}, { middleware: [authMiddleware("user")] });
|
|
81
|
+
|
|
82
|
+
// Log in
|
|
83
|
+
router.post("/auth/passkeys/login/options", async ({ response }) =>
|
|
84
|
+
response.success(await generatePasskeyAuthenticationOptions()));
|
|
85
|
+
router.post("/auth/passkeys/login", async ({ request, response }) => {
|
|
86
|
+
const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
|
|
87
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
88
|
+
return response.success({ ok: true });
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Browser:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
96
|
+
|
|
97
|
+
const post = (url: string, body?: unknown) =>
|
|
98
|
+
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
|
99
|
+
.then((r) => r.json());
|
|
100
|
+
|
|
101
|
+
// register
|
|
102
|
+
const regOptions = await post("/auth/passkeys/register/options");
|
|
103
|
+
await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
|
|
104
|
+
|
|
105
|
+
// log in
|
|
106
|
+
const authOptions = await post("/auth/passkeys/login/options");
|
|
107
|
+
await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
(Unwrap `response.success`'s envelope if your app wraps payloads.)
|
|
111
|
+
|
|
112
|
+
- Challenges live in `one_time_tokens` as SHA-256 hashes, expire after 5m (`auth.passkeys.challengeExpiresIn`), and are **consumed before verification**. A replayed challenge fails, and so does one whose first verification failed.
|
|
113
|
+
- The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
|
|
114
|
+
- The signature counter must advance. A counter that did not move past the stored value (when either is non-zero) is rejected as a cloned authenticator. The new counter is saved with a compare-and-set.
|
|
115
|
+
- Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
|
|
116
|
+
|
|
117
|
+
## Phone code (OTP)
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
router.post("/auth/otp/request", async ({ request, response }) => {
|
|
125
|
+
await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
|
|
126
|
+
return response.success({ message: "If that number is registered, a code is on its way." });
|
|
127
|
+
}, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
|
|
128
|
+
|
|
129
|
+
router.post("/auth/otp/verify", async ({ request, response }) => {
|
|
130
|
+
const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
|
|
131
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
132
|
+
return response.success({ ok: true });
|
|
133
|
+
}, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- Codes have 6 digits and are stored in `one_time_tokens` (purpose `otp`) as a salted HMAC keyed from your access-token secret, never as plain SHA-256, which is trivially reversible for 6 digits. A new request invalidates the previous code.
|
|
137
|
+
- Every verify counts an attempt atomically first. After `maxAttempts` the code is invalidated, even if the right code comes next, and even under concurrent guesses.
|
|
138
|
+
- Delivery: `notify.channel(channel).send(phone, { body, code, expiresAt })`. Register that channel in `config/notifications.ts`, or set `auth.otp.send(phone, message, channel)`. `auth.otp.message(code)` changes the text.
|
|
139
|
+
- **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
|
|
140
|
+
- CSRF: `verifyOtp` is a POST. If the route uses cookie auth, the 5.12 Origin check applies. Otherwise there is no ambient credential to ride.
|
|
141
|
+
|
|
142
|
+
## Gotchas
|
|
143
|
+
|
|
144
|
+
- `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
|
|
145
|
+
- One provider identity links to one account, of one user type.
|
|
146
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
|
|
147
|
+
- The passkey specs run the real `@simplewebauthn/server` against a software ES256 authenticator, and the consume-once, OTP attempt cap, counter compare-and-set and provider-link uniqueness are proven on real MongoDB and Postgres. Google's token endpoint is still mocked, so test a real Google client before you ship.
|
|
148
|
+
- A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
|
|
149
|
+
|
|
150
|
+
## See also
|
|
151
|
+
|
|
152
|
+
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
|
|
153
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
|
|
154
|
+
- [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
|
|
@@ -120,10 +120,16 @@ When issuing a new refresh token, the service counts active tokens for the user
|
|
|
120
120
|
|
|
121
121
|
```ts
|
|
122
122
|
const cleaned = await authService.cleanupExpiredTokens();
|
|
123
|
-
// Returns: number of expired refresh tokens removed (also purges expired access-token rows
|
|
123
|
+
// Returns: number of expired refresh tokens removed (also purges expired access-token rows
|
|
124
|
+
// and hard-deletes expired or consumed one_time_tokens rows).
|
|
124
125
|
// Fires "token.expired" event per token + "cleanup.completed" with the count.
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
Every purge is a hard delete on every driver, including MongoDB. `AccessToken`, `RefreshToken`,
|
|
129
|
+
`OneTimeToken` and `PasskeyCredential` all declare `static deleteStrategy = "permanent"`, so a
|
|
130
|
+
purge never copies credential material into a `*Trash` collection despite MongoDB's driver
|
|
131
|
+
default being `"trash"`.
|
|
132
|
+
|
|
127
133
|
Run this periodically via the scheduler:
|
|
128
134
|
|
|
129
135
|
```ts
|
|
@@ -87,22 +87,20 @@ Without this, `JSON.stringify(user)` in your response leaks the hash.
|
|
|
87
87
|
|
|
88
88
|
## Email verification flow (extending registration)
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Since 5.13 auth ships this. Declare `emailVerifiedAt: v.date().optional()` on the schema, then send the verification after creating the user:
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
verification_token: Random.string(64),
|
|
97
|
-
});
|
|
93
|
+
import { authService, sendEmailVerification } from "@warlock.js/auth";
|
|
94
|
+
|
|
95
|
+
const user = await User.create({ ...data, password: await hashPassword(data.password) });
|
|
98
96
|
|
|
99
|
-
await
|
|
97
|
+
await sendEmailVerification(user); // hashed, single-use, 24h token via @warlock.js/notifications
|
|
100
98
|
|
|
101
99
|
const tokens = await authService.createTokenPair(user);
|
|
102
100
|
return response.successCreate({ user, tokens });
|
|
103
101
|
```
|
|
104
102
|
|
|
105
|
-
|
|
103
|
+
Gate routes that need a confirmed address with `requireVerifiedEmail()` after `authMiddleware`. See [`@warlock.js/auth/verify-email-and-reset-password/SKILL.md`](@warlock.js/auth/verify-email-and-reset-password/SKILL.md).
|
|
106
104
|
|
|
107
105
|
## Side effects via auth events
|
|
108
106
|
|
|
@@ -46,7 +46,7 @@ Run it once when setting up a new project. Each developer typically runs it loca
|
|
|
46
46
|
npx warlock auth.cleanup
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows
|
|
49
|
+
Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows and hard-deletes expired or consumed `one_time_tokens` rows (verification/reset tokens, OTP codes, passkey challenges). Fires `token.expired` per refresh token and `cleanup.completed` once.
|
|
50
50
|
|
|
51
51
|
Schedule it periodically. Two common shapes:
|
|
52
52
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-email-and-reset-password
|
|
3
|
+
description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verify email + reset password
|
|
7
|
+
|
|
8
|
+
Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
|
|
13
|
+
2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
|
|
14
|
+
3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
export const userSchema = v.object({
|
|
18
|
+
// ...
|
|
19
|
+
emailVerifiedAt: v.date().optional(),
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
|
|
24
|
+
|
|
25
|
+
## Tokens
|
|
26
|
+
|
|
27
|
+
- 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
|
|
28
|
+
- **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
|
|
29
|
+
- **Expiring**: verification `24h`, reset `60m` by default.
|
|
30
|
+
- **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
|
|
31
|
+
- A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
|
|
32
|
+
- Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
|
|
33
|
+
|
|
34
|
+
## Email verification
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
|
|
38
|
+
import { type RequestHandler } from "@warlock.js/core";
|
|
39
|
+
|
|
40
|
+
// after User.create(...) in your register controller
|
|
41
|
+
await sendEmailVerification(user);
|
|
42
|
+
|
|
43
|
+
export const verifyEmailController: RequestHandler = async ({ request, response }) => {
|
|
44
|
+
const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
|
|
45
|
+
return response.success({ user });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
router.post("/auth/verify-email", verifyEmailController, {
|
|
49
|
+
middleware: [tokenConsumeThrottleMiddleware()],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.post("/auth/verify-email/resend", resendController, {
|
|
53
|
+
middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
|
|
57
|
+
router.post("/orders", createOrder, {
|
|
58
|
+
middleware: [authMiddleware("user"), requireVerifiedEmail()],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`isEmailVerified(user)` answers the same question in code.
|
|
63
|
+
|
|
64
|
+
## Password reset
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
|
|
68
|
+
|
|
69
|
+
export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
|
|
70
|
+
await requestPasswordReset(User, request.input("email"));
|
|
71
|
+
// Same answer whether or not the account exists.
|
|
72
|
+
return response.success({ message: "If that account exists, we sent a reset link." });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const resetPasswordController: RequestHandler = async ({ request, response }) => {
|
|
76
|
+
// Validate strength FIRST — the token is consumed before the password is written.
|
|
77
|
+
await resetPassword(request.input("token"), request.input("password"));
|
|
78
|
+
return response.success({ message: "Password updated. Please log in again." });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
router.post("/auth/forgot-password", forgotPasswordController, {
|
|
82
|
+
middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
|
|
83
|
+
});
|
|
84
|
+
router.post("/auth/reset-password", resetPasswordController, {
|
|
85
|
+
middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
|
|
90
|
+
|
|
91
|
+
**Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
|
|
92
|
+
|
|
93
|
+
## Throttling
|
|
94
|
+
|
|
95
|
+
Both presets wrap `loginThrottleMiddleware` and accept its options:
|
|
96
|
+
|
|
97
|
+
| Preset | Tracks | Counts | Default |
|
|
98
|
+
| --- | --- | --- | --- |
|
|
99
|
+
| `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
|
|
100
|
+
| `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
|
|
101
|
+
|
|
102
|
+
## Configuration (`src/config/auth.ts`)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const authConfigurations: AuthConfigurations = {
|
|
106
|
+
// ...
|
|
107
|
+
verification: {
|
|
108
|
+
expiresIn: "24h",
|
|
109
|
+
field: "emailVerifiedAt",
|
|
110
|
+
url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
|
|
111
|
+
notification: myVerificationNotification, // optional replacement
|
|
112
|
+
},
|
|
113
|
+
passwordReset: {
|
|
114
|
+
expiresIn: "60m",
|
|
115
|
+
identifierField: "email",
|
|
116
|
+
url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
|
|
117
|
+
notification: myResetNotification,
|
|
118
|
+
setPassword: async (user, plain) => { /* optional */ },
|
|
119
|
+
},
|
|
120
|
+
oneTimeToken: { model: AppOneTimeToken }, // optional model override
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
|
|
125
|
+
|
|
126
|
+
## Gotchas
|
|
127
|
+
|
|
128
|
+
- **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
|
|
129
|
+
- **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
|
|
130
|
+
- **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
|
|
131
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
|
|
132
|
+
|
|
133
|
+
## See also
|
|
134
|
+
|
|
135
|
+
- [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
|
|
136
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
|
|
137
|
+
- [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
|