@warlock.js/auth 5.11.0 → 5.13.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 +65 -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 +4 -2
- package/esm/contracts/providers.d.mts +101 -0
- package/esm/contracts/providers.d.mts.map +1 -0
- package/esm/contracts/request-locals.d.mts +43 -0
- package/esm/contracts/request-locals.d.mts.map +1 -0
- package/esm/contracts/types.d.mts +159 -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 +31 -3
- package/esm/index.mjs +29 -1
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +13 -2
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/csrf-origin-check.d.mts +40 -0
- package/esm/middleware/csrf-origin-check.d.mts.map +1 -0
- package/esm/middleware/csrf-origin-check.mjs +105 -0
- package/esm/middleware/csrf-origin-check.mjs.map +1 -0
- package/esm/middleware/index.d.mts +5 -1
- package/esm/middleware/index.mjs +4 -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 +84 -0
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +59 -2
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +86 -1
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/current-user.d.mts +21 -0
- package/esm/services/current-user.d.mts.map +1 -0
- package/esm/services/current-user.mjs +24 -0
- package/esm/services/current-user.mjs.map +1 -0
- 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 +5 -1
- package/esm/services/index.mjs +4 -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 +48 -1
- package/esm/utils/auth-error-codes.d.mts.map +1 -1
- package/esm/utils/auth-error-codes.mjs +41 -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 +402 -29
- package/llms.txt +2 -0
- package/package.json +21 -7
- package/skills/handle-login-and-logout/SKILL.md +1 -1
- package/skills/login-with-providers/SKILL.md +154 -0
- package/skills/manage-tokens/SKILL.md +7 -1
- package/skills/overview/SKILL.md +1 -1
- package/skills/protect-routes/SKILL.md +71 -13
- 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/llms-full.txt
CHANGED
|
@@ -475,7 +475,7 @@ import { type RequestHandler } from "@warlock.js/core";
|
|
|
475
475
|
|
|
476
476
|
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
477
477
|
await authService.logout(
|
|
478
|
-
request.user!,
|
|
478
|
+
request.locals.user!,
|
|
479
479
|
request.authorizationValue, // access token from the Authorization header
|
|
480
480
|
request.input("refreshToken"), // refresh token from the request body
|
|
481
481
|
);
|
|
@@ -553,6 +553,164 @@ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `lo
|
|
|
553
553
|
- [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
|
|
554
554
|
|
|
555
555
|
|
|
556
|
+
## login-with-providers `@warlock.js/auth/login-with-providers/SKILL.md`
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
name: login-with-providers
|
|
560
|
+
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`.'
|
|
561
|
+
---
|
|
562
|
+
|
|
563
|
+
# Login with Google, passkeys, or a phone code
|
|
564
|
+
|
|
565
|
+
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:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
const { user, tokens } = await completeProviderLogin(User, "google", request, response);
|
|
569
|
+
authService.setAuthCookie(response, tokens.accessToken); // cookie session, or return tokens as JSON
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
`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.
|
|
573
|
+
|
|
574
|
+
## Install
|
|
575
|
+
|
|
576
|
+
| Method | Command | Installs |
|
|
577
|
+
| --- | --- | --- |
|
|
578
|
+
| Google | `warlock add auth-google` | `jose` |
|
|
579
|
+
| Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
|
|
580
|
+
| Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
|
|
581
|
+
|
|
582
|
+
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.
|
|
583
|
+
|
|
584
|
+
## Google
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
// src/config/auth.ts
|
|
588
|
+
providers: {
|
|
589
|
+
google: {
|
|
590
|
+
clientId: env("GOOGLE_CLIENT_ID"),
|
|
591
|
+
clientSecret: env("GOOGLE_CLIENT_SECRET"),
|
|
592
|
+
redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
|
|
593
|
+
// scopes: ["openid", "email", "profile"],
|
|
594
|
+
},
|
|
595
|
+
// emailField: "email",
|
|
596
|
+
// createUser: async (profile, Model) => Model.create({ ... }),
|
|
597
|
+
},
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
router.get("/auth/google", async ({ response }) =>
|
|
602
|
+
response.redirect(await startProviderLogin(response, "google")),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
router.get("/auth/google/callback", async ({ request, response }) => {
|
|
606
|
+
const { tokens } = await completeProviderLogin(User, "google", request, response);
|
|
607
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
608
|
+
return response.redirect("/");
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
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:
|
|
613
|
+
|
|
614
|
+
```tsx
|
|
615
|
+
<a href="/auth/google">Continue with Google</a>
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
- `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.
|
|
619
|
+
- 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.
|
|
620
|
+
- **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 }`.
|
|
621
|
+
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
622
|
+
|
|
623
|
+
## Passkeys
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
// Register (logged in)
|
|
631
|
+
router.post("/auth/passkeys/register/options", async ({ request, response }) =>
|
|
632
|
+
response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
|
|
633
|
+
{ middleware: [authMiddleware("user")] });
|
|
634
|
+
router.post("/auth/passkeys/register", async ({ request, response }) => {
|
|
635
|
+
await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
|
|
636
|
+
return response.success({ registered: true });
|
|
637
|
+
}, { middleware: [authMiddleware("user")] });
|
|
638
|
+
|
|
639
|
+
// Log in
|
|
640
|
+
router.post("/auth/passkeys/login/options", async ({ response }) =>
|
|
641
|
+
response.success(await generatePasskeyAuthenticationOptions()));
|
|
642
|
+
router.post("/auth/passkeys/login", async ({ request, response }) => {
|
|
643
|
+
const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
|
|
644
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
645
|
+
return response.success({ ok: true });
|
|
646
|
+
});
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
Browser:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
653
|
+
|
|
654
|
+
const post = (url: string, body?: unknown) =>
|
|
655
|
+
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
|
656
|
+
.then((r) => r.json());
|
|
657
|
+
|
|
658
|
+
// register
|
|
659
|
+
const regOptions = await post("/auth/passkeys/register/options");
|
|
660
|
+
await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
|
|
661
|
+
|
|
662
|
+
// log in
|
|
663
|
+
const authOptions = await post("/auth/passkeys/login/options");
|
|
664
|
+
await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
(Unwrap `response.success`'s envelope if your app wraps payloads.)
|
|
668
|
+
|
|
669
|
+
- 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.
|
|
670
|
+
- The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
|
|
671
|
+
- 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.
|
|
672
|
+
- Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
|
|
673
|
+
|
|
674
|
+
## Phone code (OTP)
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
router.post("/auth/otp/request", async ({ request, response }) => {
|
|
682
|
+
await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
|
|
683
|
+
return response.success({ message: "If that number is registered, a code is on its way." });
|
|
684
|
+
}, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
|
|
685
|
+
|
|
686
|
+
router.post("/auth/otp/verify", async ({ request, response }) => {
|
|
687
|
+
const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
|
|
688
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
689
|
+
return response.success({ ok: true });
|
|
690
|
+
}, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
- 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.
|
|
694
|
+
- 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.
|
|
695
|
+
- 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.
|
|
696
|
+
- **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
|
|
697
|
+
- 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.
|
|
698
|
+
|
|
699
|
+
## Gotchas
|
|
700
|
+
|
|
701
|
+
- `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
|
|
702
|
+
- One provider identity links to one account, of one user type.
|
|
703
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
|
|
704
|
+
- 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.
|
|
705
|
+
- A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
|
|
706
|
+
|
|
707
|
+
## See also
|
|
708
|
+
|
|
709
|
+
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
|
|
710
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
|
|
711
|
+
- [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
|
|
712
|
+
|
|
713
|
+
|
|
556
714
|
## manage-tokens `@warlock.js/auth/manage-tokens/SKILL.md`
|
|
557
715
|
|
|
558
716
|
---
|
|
@@ -677,10 +835,16 @@ When issuing a new refresh token, the service counts active tokens for the user
|
|
|
677
835
|
|
|
678
836
|
```ts
|
|
679
837
|
const cleaned = await authService.cleanupExpiredTokens();
|
|
680
|
-
// Returns: number of expired refresh tokens removed (also purges expired access-token rows
|
|
838
|
+
// Returns: number of expired refresh tokens removed (also purges expired access-token rows
|
|
839
|
+
// and hard-deletes expired or consumed one_time_tokens rows).
|
|
681
840
|
// Fires "token.expired" event per token + "cleanup.completed" with the count.
|
|
682
841
|
```
|
|
683
842
|
|
|
843
|
+
Every purge is a hard delete on every driver, including MongoDB. `AccessToken`, `RefreshToken`,
|
|
844
|
+
`OneTimeToken` and `PasskeyCredential` all declare `static deleteStrategy = "permanent"`, so a
|
|
845
|
+
purge never copies credential material into a `*Trash` collection despite MongoDB's driver
|
|
846
|
+
default being `"trash"`.
|
|
847
|
+
|
|
684
848
|
Run this periodically via the scheduler:
|
|
685
849
|
|
|
686
850
|
```ts
|
|
@@ -779,7 +943,7 @@ Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/l
|
|
|
779
943
|
Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
|
|
780
944
|
|
|
781
945
|
#### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
|
|
782
|
-
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
946
|
+
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
783
947
|
|
|
784
948
|
### Going deeper
|
|
785
949
|
|
|
@@ -816,7 +980,7 @@ Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `
|
|
|
816
980
|
|
|
817
981
|
---
|
|
818
982
|
name: protect-routes
|
|
819
|
-
description: 'Gate HTTP routes via authMiddleware(allowedUserType) — the argument is required and a valid token is always required: [] allows any authenticated user, a user-type restricts to those types. Sets request.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.'
|
|
983
|
+
description: 'Gate HTTP routes via authMiddleware(allowedUserType) — the argument is required and a valid token is always required: [] allows any authenticated user, a user-type restricts to those types. Sets request.locals.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.locals.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.'
|
|
820
984
|
---
|
|
821
985
|
|
|
822
986
|
# Gate routes with `authMiddleware`
|
|
@@ -854,7 +1018,7 @@ The `userType` slug must match a key in `config.auth.userType.<name>` — see [`
|
|
|
854
1018
|
On success, before your controller runs:
|
|
855
1019
|
|
|
856
1020
|
```ts
|
|
857
|
-
request.user = <hydrated user model instance>;
|
|
1021
|
+
request.locals.user = <hydrated user model instance>;
|
|
858
1022
|
request.decodedAccessToken = <decoded JWT payload>;
|
|
859
1023
|
```
|
|
860
1024
|
|
|
@@ -898,7 +1062,7 @@ Behavior, precisely:
|
|
|
898
1062
|
|
|
899
1063
|
The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthConfig` on `AuthConfigurations` (`loginPath?: string`, `returnUrlParam?: string`, default `"returnUrl"`); read at runtime through `authConfig.pageAuth`.
|
|
900
1064
|
|
|
901
|
-
>
|
|
1065
|
+
> `auth.pageAuth` is **not a second auth mechanism** — it is a convenience *adapter* over the same "not authenticated" outcome the gate already produces. It only changes the **representation** of that outcome on a page route (a browser-friendly login redirect) instead of the JSON `401`; the decision that the request is unauthenticated is unchanged. The sanctioned *generic* way to make authentication soft is the app-owned optional-auth middleware below — `pageAuth` is the built-in adapter for the common "redirect a logged-out human to /login" case, so most apps never need to hand-roll it.
|
|
902
1066
|
|
|
903
1067
|
## Reading the user in a controller
|
|
904
1068
|
|
|
@@ -906,7 +1070,7 @@ The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthCo
|
|
|
906
1070
|
import { type RequestHandler } from "@warlock.js/core";
|
|
907
1071
|
|
|
908
1072
|
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
909
|
-
const user = request.user!; // typed via your Auth subclass
|
|
1073
|
+
const user = request.locals.user!; // typed via your Auth subclass
|
|
910
1074
|
return response.success({
|
|
911
1075
|
id: user.id,
|
|
912
1076
|
email: user.get("email"),
|
|
@@ -914,7 +1078,7 @@ export const accountController: RequestHandler = async ({ request, response }) =
|
|
|
914
1078
|
};
|
|
915
1079
|
```
|
|
916
1080
|
|
|
917
|
-
Because the middleware always requires a valid token, `request.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
1081
|
+
Because the middleware always requires a valid token, `request.locals.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
918
1082
|
|
|
919
1083
|
## Route-group protection
|
|
920
1084
|
|
|
@@ -927,19 +1091,31 @@ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () =>
|
|
|
927
1091
|
|
|
928
1092
|
Every route inside the group is gated — the group's `middleware` array applies to each route in the callback. Cleaner than repeating the middleware on each route.
|
|
929
1093
|
|
|
930
|
-
##
|
|
1094
|
+
## Optional auth is an app-owned middleware (the sanctioned pattern)
|
|
931
1095
|
|
|
932
|
-
There is no "hydrate `request.user` if a token is present, otherwise continue" mode
|
|
1096
|
+
`authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
|
|
933
1097
|
|
|
934
|
-
```ts
|
|
935
|
-
import { type
|
|
1098
|
+
```ts title="src/app/middleware/optional-auth.middleware.ts"
|
|
1099
|
+
import { type Middleware } from "@warlock.js/core";
|
|
1100
|
+
import { jwt } from "@warlock.js/auth";
|
|
936
1101
|
|
|
937
|
-
|
|
1102
|
+
// The one sanctioned optional-auth shape: resolve if present, never reject.
|
|
1103
|
+
// Middleware receives the context object ({ request, response }), like a handler.
|
|
1104
|
+
export const optionalAuth: Middleware = async ({ request }) => {
|
|
938
1105
|
const token = request.authorizationValue;
|
|
939
|
-
//
|
|
1106
|
+
if (!token) return; // absent → continue anonymously; the route decides
|
|
1107
|
+
try {
|
|
1108
|
+
const decoded = await jwt.verify(token);
|
|
1109
|
+
// hydrate request.locals.user from your token/user model when valid
|
|
1110
|
+
} catch {
|
|
1111
|
+
// invalid token on an optional route → treat as anonymous, don't reject
|
|
1112
|
+
}
|
|
1113
|
+
// returning nothing (undefined) === continue to the handler
|
|
940
1114
|
};
|
|
941
1115
|
```
|
|
942
1116
|
|
|
1117
|
+
Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
|
|
1118
|
+
|
|
943
1119
|
## Custom error responses
|
|
944
1120
|
|
|
945
1121
|
The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
|
|
@@ -967,24 +1143,82 @@ definePolicy("users.create", (actor, target, ctx) =>
|
|
|
967
1143
|
ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
968
1144
|
);
|
|
969
1145
|
|
|
970
|
-
if (await can(request.user, "users.create", { resource: payload })) { /* ... */ }
|
|
1146
|
+
if (await can(request.locals.user, "users.create", { resource: payload })) { /* ... */ }
|
|
971
1147
|
```
|
|
972
1148
|
|
|
973
1149
|
Use `authMiddleware` to establish *who the caller is*, and `access` to decide *what they may do*. They compose — `access` reads the user `authMiddleware` put on the request.
|
|
974
1150
|
|
|
975
|
-
##
|
|
1151
|
+
## Cookie sessions: `authMiddleware([], "cookie:<name>")`
|
|
1152
|
+
|
|
1153
|
+
`authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
|
|
1154
|
+
|
|
1155
|
+
```ts
|
|
1156
|
+
router.get("/browser-account", accountController, {
|
|
1157
|
+
middleware: [authMiddleware([], "cookie:token")],
|
|
1158
|
+
});
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
A route is header-only or cookie-only, never both — `tokenFrom` accepts exactly one source, so there is no header/cookie precedence to define. A route that legitimately needs to accept either composes two `authMiddleware` instances against the app's own optional-auth pattern (above).
|
|
1162
|
+
|
|
1163
|
+
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
1164
|
+
|
|
1165
|
+
Reading a cookie is opt-in per route (above); *writing* one is a separate, explicit step your login/logout controller calls — `authService.login`/`logout` never set cookies implicitly, so upgrading never starts emitting `Set-Cookie` for an existing bearer-only app.
|
|
1166
|
+
|
|
1167
|
+
```ts
|
|
1168
|
+
import { authService } from "@warlock.js/auth";
|
|
1169
|
+
|
|
1170
|
+
// after issuing tokens
|
|
1171
|
+
const { user, tokens } = await authService.login(User, credentials);
|
|
1172
|
+
authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
|
|
1173
|
+
|
|
1174
|
+
// after revoking tokens
|
|
1175
|
+
await authService.logout(user, accessToken, refreshToken);
|
|
1176
|
+
authService.clearAuthCookie(response);
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
`setAuthCookie(response, token, options?)` accepts either the raw token string or an `AccessTokenOutput` (`{ token, expiresAt }`, e.g. `tokens.accessToken` from `login`) — passing the latter derives `Max-Age` from `expiresAt` automatically; pass `options.maxAge` (seconds) to override it, or a bare string with no `maxAge` for a session cookie. `clearAuthCookie(response, options?)` clears it.
|
|
1180
|
+
|
|
1181
|
+
Cookie **attribute flags are not configurable** — `HttpOnly`, `SameSite=Lax`, and `Secure` outside development come from core's `secureCookieDefaults()`, the same floor every `response.cookie()` call gets. Only the cookie's `name` (default `"access_token"`) and `path` (default `"/"`) can be set, via `options` or the `auth.cookie` config block:
|
|
1182
|
+
|
|
1183
|
+
```ts title="src/config/auth.ts"
|
|
1184
|
+
export default {
|
|
1185
|
+
userType: { user: User },
|
|
1186
|
+
cookie: {
|
|
1187
|
+
name: "access_token", // must match the `cookie:<name>` a route reads
|
|
1188
|
+
path: "/",
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
|
|
976
1194
|
|
|
977
|
-
|
|
1195
|
+
### CSRF: an automatic Origin check on cookie-authenticated writes
|
|
978
1196
|
|
|
979
|
-
|
|
1197
|
+
Cookie auth changes the CSRF picture that bearer-token auth doesn't have: a browser attaches cookies to same-site requests it didn't originate. `SameSite=Lax` already blocks the classic cross-site `<form method="post">` case, but not a same-site GET redirect chain or a client that ignores `SameSite`.
|
|
980
1198
|
|
|
981
|
-
|
|
1199
|
+
To close that gap, `authMiddleware` automatically runs a CSRF Origin check on every request whose credential came from a `cookie:` source **and** whose method is unsafe (`POST`/`PUT`/`PATCH`/`DELETE`) — there is nothing to opt into, and nothing to configure to turn it on:
|
|
1200
|
+
|
|
1201
|
+
- **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
|
|
1202
|
+
- If `Origin` is absent, `Referer`'s origin is checked the same way.
|
|
1203
|
+
- If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
|
|
1204
|
+
- **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
|
|
1205
|
+
|
|
1206
|
+
```ts title="src/config/auth.ts"
|
|
1207
|
+
export default {
|
|
1208
|
+
userType: { user: User },
|
|
1209
|
+
csrf: {
|
|
1210
|
+
allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
This is an Origin check, not a double-submit CSRF token — cheaper, and closes the gap `SameSite=Lax` leaves open, but it doesn't help a client whose privacy proxy strips both `Origin` and `Referer` (that request is rejected outright, fail-closed, rather than silently allowed). A double-submit token mechanism is deferred to a later release; track it if your threat model includes Origin-stripping clients.
|
|
982
1216
|
|
|
983
1217
|
## Things NOT to do
|
|
984
1218
|
|
|
985
1219
|
- Don't call `authMiddleware` outside route definition. It returns a function — the function is what runs per-request. Calling it once per request creates a fresh middleware on every hit (wasteful) and a fresh allowed-types Set (correctness if the input changes per call).
|
|
986
1220
|
- Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
|
|
987
|
-
- Don't trust `request.user` set by client-supplied headers. The middleware is the only place that sets it on the server — client headers can't reach this slot.
|
|
1221
|
+
- Don't trust `request.locals.user` set by client-supplied headers. The middleware is the only place that sets it on the server — client headers can't reach this slot.
|
|
988
1222
|
- Don't pass an unknown user-type to `authMiddleware("typo")`. The middleware will reject every request because the lookup fails. Test the wire-up with a real token of each user type.
|
|
989
1223
|
|
|
990
1224
|
## See also
|
|
@@ -1086,22 +1320,20 @@ Without this, `JSON.stringify(user)` in your response leaks the hash.
|
|
|
1086
1320
|
|
|
1087
1321
|
## Email verification flow (extending registration)
|
|
1088
1322
|
|
|
1089
|
-
|
|
1323
|
+
Since 5.13 auth ships this. Declare `emailVerifiedAt: v.date().optional()` on the schema, then send the verification after creating the user:
|
|
1090
1324
|
|
|
1091
1325
|
```ts
|
|
1092
|
-
|
|
1093
|
-
...data,
|
|
1094
|
-
email_verified: false,
|
|
1095
|
-
verification_token: Random.string(64),
|
|
1096
|
-
});
|
|
1326
|
+
import { authService, sendEmailVerification } from "@warlock.js/auth";
|
|
1097
1327
|
|
|
1098
|
-
await
|
|
1328
|
+
const user = await User.create({ ...data, password: await hashPassword(data.password) });
|
|
1329
|
+
|
|
1330
|
+
await sendEmailVerification(user); // hashed, single-use, 24h token via @warlock.js/notifications
|
|
1099
1331
|
|
|
1100
1332
|
const tokens = await authService.createTokenPair(user);
|
|
1101
1333
|
return response.successCreate({ user, tokens });
|
|
1102
1334
|
```
|
|
1103
1335
|
|
|
1104
|
-
|
|
1336
|
+
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).
|
|
1105
1337
|
|
|
1106
1338
|
## Side effects via auth events
|
|
1107
1339
|
|
|
@@ -1184,7 +1416,7 @@ Run it once when setting up a new project. Each developer typically runs it loca
|
|
|
1184
1416
|
npx warlock auth.cleanup
|
|
1185
1417
|
```
|
|
1186
1418
|
|
|
1187
|
-
Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows
|
|
1419
|
+
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.
|
|
1188
1420
|
|
|
1189
1421
|
Schedule it periodically. Two common shapes:
|
|
1190
1422
|
|
|
@@ -1402,3 +1634,144 @@ They compose. For a hard ceiling on request volume *and* failure-aware account l
|
|
|
1402
1634
|
- [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
|
|
1403
1635
|
|
|
1404
1636
|
|
|
1637
|
+
## verify-email-and-reset-password `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`
|
|
1638
|
+
|
|
1639
|
+
---
|
|
1640
|
+
name: verify-email-and-reset-password
|
|
1641
|
+
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`.'
|
|
1642
|
+
---
|
|
1643
|
+
|
|
1644
|
+
# Verify email + reset password
|
|
1645
|
+
|
|
1646
|
+
Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
|
|
1647
|
+
|
|
1648
|
+
## Prerequisites
|
|
1649
|
+
|
|
1650
|
+
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.
|
|
1651
|
+
2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
|
|
1652
|
+
3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
|
|
1653
|
+
|
|
1654
|
+
```ts
|
|
1655
|
+
export const userSchema = v.object({
|
|
1656
|
+
// ...
|
|
1657
|
+
emailVerifiedAt: v.date().optional(),
|
|
1658
|
+
});
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
|
|
1662
|
+
|
|
1663
|
+
## Tokens
|
|
1664
|
+
|
|
1665
|
+
- 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
|
|
1666
|
+
- **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
|
|
1667
|
+
- **Expiring**: verification `24h`, reset `60m` by default.
|
|
1668
|
+
- **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
|
|
1669
|
+
- A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
|
|
1670
|
+
- Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
|
|
1671
|
+
|
|
1672
|
+
## Email verification
|
|
1673
|
+
|
|
1674
|
+
```ts
|
|
1675
|
+
import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
|
|
1676
|
+
import { type RequestHandler } from "@warlock.js/core";
|
|
1677
|
+
|
|
1678
|
+
// after User.create(...) in your register controller
|
|
1679
|
+
await sendEmailVerification(user);
|
|
1680
|
+
|
|
1681
|
+
export const verifyEmailController: RequestHandler = async ({ request, response }) => {
|
|
1682
|
+
const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
|
|
1683
|
+
return response.success({ user });
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
router.post("/auth/verify-email", verifyEmailController, {
|
|
1687
|
+
middleware: [tokenConsumeThrottleMiddleware()],
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
router.post("/auth/verify-email/resend", resendController, {
|
|
1691
|
+
middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
// Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
|
|
1695
|
+
router.post("/orders", createOrder, {
|
|
1696
|
+
middleware: [authMiddleware("user"), requireVerifiedEmail()],
|
|
1697
|
+
});
|
|
1698
|
+
```
|
|
1699
|
+
|
|
1700
|
+
`isEmailVerified(user)` answers the same question in code.
|
|
1701
|
+
|
|
1702
|
+
## Password reset
|
|
1703
|
+
|
|
1704
|
+
```ts
|
|
1705
|
+
import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
|
|
1706
|
+
|
|
1707
|
+
export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
|
|
1708
|
+
await requestPasswordReset(User, request.input("email"));
|
|
1709
|
+
// Same answer whether or not the account exists.
|
|
1710
|
+
return response.success({ message: "If that account exists, we sent a reset link." });
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
export const resetPasswordController: RequestHandler = async ({ request, response }) => {
|
|
1714
|
+
// Validate strength FIRST — the token is consumed before the password is written.
|
|
1715
|
+
await resetPassword(request.input("token"), request.input("password"));
|
|
1716
|
+
return response.success({ message: "Password updated. Please log in again." });
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
router.post("/auth/forgot-password", forgotPasswordController, {
|
|
1720
|
+
middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
|
|
1721
|
+
});
|
|
1722
|
+
router.post("/auth/reset-password", resetPasswordController, {
|
|
1723
|
+
middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
|
|
1724
|
+
});
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
`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`.
|
|
1728
|
+
|
|
1729
|
+
**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`.
|
|
1730
|
+
|
|
1731
|
+
## Throttling
|
|
1732
|
+
|
|
1733
|
+
Both presets wrap `loginThrottleMiddleware` and accept its options:
|
|
1734
|
+
|
|
1735
|
+
| Preset | Tracks | Counts | Default |
|
|
1736
|
+
| --- | --- | --- | --- |
|
|
1737
|
+
| `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 |
|
|
1738
|
+
| `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
|
|
1739
|
+
|
|
1740
|
+
## Configuration (`src/config/auth.ts`)
|
|
1741
|
+
|
|
1742
|
+
```ts
|
|
1743
|
+
const authConfigurations: AuthConfigurations = {
|
|
1744
|
+
// ...
|
|
1745
|
+
verification: {
|
|
1746
|
+
expiresIn: "24h",
|
|
1747
|
+
field: "emailVerifiedAt",
|
|
1748
|
+
url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
|
|
1749
|
+
notification: myVerificationNotification, // optional replacement
|
|
1750
|
+
},
|
|
1751
|
+
passwordReset: {
|
|
1752
|
+
expiresIn: "60m",
|
|
1753
|
+
identifierField: "email",
|
|
1754
|
+
url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
|
|
1755
|
+
notification: myResetNotification,
|
|
1756
|
+
setPassword: async (user, plain) => { /* optional */ },
|
|
1757
|
+
},
|
|
1758
|
+
oneTimeToken: { model: AppOneTimeToken }, // optional model override
|
|
1759
|
+
};
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
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.
|
|
1763
|
+
|
|
1764
|
+
## Gotchas
|
|
1765
|
+
|
|
1766
|
+
- **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
|
|
1767
|
+
- **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.
|
|
1768
|
+
- **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.
|
|
1769
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
|
|
1770
|
+
|
|
1771
|
+
## See also
|
|
1772
|
+
|
|
1773
|
+
- [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
|
|
1774
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
|
|
1775
|
+
- [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
|
|
1776
|
+
|
|
1777
|
+
|
package/llms.txt
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
- [customize-token-storage](@warlock.js/auth/customize-token-storage/SKILL.md): (no description)
|
|
11
11
|
- [customize-user-type](@warlock.js/auth/customize-user-type/SKILL.md): (no description)
|
|
12
12
|
- [handle-login-and-logout](@warlock.js/auth/handle-login-and-logout/SKILL.md): (no description)
|
|
13
|
+
- [login-with-providers](@warlock.js/auth/login-with-providers/SKILL.md): 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`.
|
|
13
14
|
- [manage-tokens](@warlock.js/auth/manage-tokens/SKILL.md): (no description)
|
|
14
15
|
- [overview](@warlock.js/auth/overview/SKILL.md): (no description)
|
|
15
16
|
- [protect-routes](@warlock.js/auth/protect-routes/SKILL.md): (no description)
|
|
16
17
|
- [register-user](@warlock.js/auth/register-user/SKILL.md): Sign up a new user and issue the initial token pair — User.create({...password: await hashPassword(plain)}) then authService.createTokenPair(user). Triggers: `User.create`, `hashPassword`, `verifyPassword`, `authService.createTokenPair`, `toJsonColumns`, `strongPassword`, `authEvents`; "build a register endpoint", "POST /register controller", "sign up a new user", "hash password on signup", "email verification flow"; typical import `import { authService } from "@warlock.js/auth"; import { hashPassword } from "@warlock.js/core"`. Skip: login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; competing libs `bcrypt`, `bcryptjs`, `argon2`.
|
|
17
18
|
- [run-auth-commands](@warlock.js/auth/run-auth-commands/SKILL.md): Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.
|
|
18
19
|
- [throttle-login-attempts](@warlock.js/auth/throttle-login-attempts/SKILL.md): (no description)
|
|
20
|
+
- [verify-email-and-reset-password](@warlock.js/auth/verify-email-and-reset-password/SKILL.md): 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`.
|
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.13.0",
|
|
16
|
+
"@warlock.js/cache": "5.13.0",
|
|
17
|
+
"@warlock.js/cascade": "5.13.0",
|
|
18
|
+
"@warlock.js/core": "5.13.0",
|
|
19
|
+
"@warlock.js/logger": "5.13.0",
|
|
20
|
+
"@warlock.js/seal": "5.13.0",
|
|
21
|
+
"@warlock.js/notifications": "5.13.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.13.0",
|
|
37
51
|
"type": "module",
|
|
38
52
|
"main": "./esm/index.mjs",
|
|
39
53
|
"module": "./esm/index.mjs",
|
|
@@ -87,7 +87,7 @@ import { type RequestHandler } from "@warlock.js/core";
|
|
|
87
87
|
|
|
88
88
|
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
89
89
|
await authService.logout(
|
|
90
|
-
request.user!,
|
|
90
|
+
request.locals.user!,
|
|
91
91
|
request.authorizationValue, // access token from the Authorization header
|
|
92
92
|
request.input("refreshToken"), // refresh token from the request body
|
|
93
93
|
);
|