@warlock.js/auth 5.24.0 → 5.25.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 +7 -0
- package/LICENSE +21 -21
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/middleware/auth.middleware.mjs +1 -1
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/access-token/migration.mjs.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.mjs +5 -0
- package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -1
- package/esm/models/auth.model.mjs.map +1 -1
- package/esm/models/refresh-token/migration.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth-events.mjs.map +1 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +10 -9
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +1927 -1927
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +1 -1
- package/skills/customize-token-storage/SKILL.md +160 -160
- package/skills/customize-user-type/SKILL.md +168 -168
- package/skills/handle-login-and-logout/SKILL.md +179 -179
- package/skills/login-with-providers/SKILL.md +283 -283
- package/skills/manage-tokens/SKILL.md +196 -196
- package/skills/overview/SKILL.md +74 -74
- package/skills/protect-routes/SKILL.md +299 -299
- package/skills/register-user/SKILL.md +147 -147
- package/skills/run-auth-commands/SKILL.md +159 -159
- package/skills/throttle-login-attempts/SKILL.md +117 -117
- package/skills/verify-email-and-reset-password/SKILL.md +137 -137
|
@@ -1,283 +1,283 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: login-with-providers
|
|
3
|
-
description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), GitHub, Discord, LinkedIn, Apple (`form_post` callback), Facebook and X, 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", "login with GitHub", "Sign in with Apple", "login with Discord", "login with LinkedIn", "login with Facebook", "login with X", `auth.providers.github`, "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 an OAuth provider, 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
|
-
| Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
|
|
23
|
-
| GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
|
|
24
|
-
| Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
|
|
25
|
-
| Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
|
|
26
|
-
|
|
27
|
-
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.
|
|
28
|
-
|
|
29
|
-
## Google
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
// src/config/auth.ts
|
|
33
|
-
providers: {
|
|
34
|
-
google: {
|
|
35
|
-
clientId: env("GOOGLE_CLIENT_ID"),
|
|
36
|
-
clientSecret: env("GOOGLE_CLIENT_SECRET"),
|
|
37
|
-
redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
|
|
38
|
-
// scopes: ["openid", "email", "profile"],
|
|
39
|
-
},
|
|
40
|
-
// emailField: "email",
|
|
41
|
-
// createUser: async (profile, Model) => Model.create({ ... }),
|
|
42
|
-
},
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
router.get("/auth/google", async ({ response }) =>
|
|
47
|
-
response.redirect(await startProviderLogin(response, "google")),
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
router.get("/auth/google/callback", async ({ request, response }) => {
|
|
51
|
-
const { tokens } = await completeProviderLogin(User, "google", request, response);
|
|
52
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
53
|
-
return response.redirect("/");
|
|
54
|
-
});
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
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:
|
|
58
|
-
|
|
59
|
-
```tsx
|
|
60
|
-
<a href="/auth/google">Continue with Google</a>
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
- `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.
|
|
64
|
-
- 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.
|
|
65
|
-
- **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 }`.
|
|
66
|
-
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
67
|
-
|
|
68
|
-
## GitHub, Discord, LinkedIn
|
|
69
|
-
|
|
70
|
-
Same shape as Google: a config block under `auth.providers.<name>`, a GET
|
|
71
|
-
route that redirects to `startProviderLogin(response, "<name>")`, and a GET
|
|
72
|
-
callback that calls `completeProviderLogin(User, "<name>", request, response)`.
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
// src/config/auth.ts
|
|
76
|
-
providers: {
|
|
77
|
-
github: {
|
|
78
|
-
clientId: env("GITHUB_CLIENT_ID"),
|
|
79
|
-
clientSecret: env("GITHUB_CLIENT_SECRET"),
|
|
80
|
-
redirectUri: `${env("APP_URL")}/auth/github/callback`,
|
|
81
|
-
// scopes: ["read:user", "user:email"],
|
|
82
|
-
},
|
|
83
|
-
discord: {
|
|
84
|
-
clientId: env("DISCORD_CLIENT_ID"),
|
|
85
|
-
clientSecret: env("DISCORD_CLIENT_SECRET"),
|
|
86
|
-
redirectUri: `${env("APP_URL")}/auth/discord/callback`,
|
|
87
|
-
// scopes: ["identify", "email"],
|
|
88
|
-
},
|
|
89
|
-
linkedin: {
|
|
90
|
-
clientId: env("LINKEDIN_CLIENT_ID"),
|
|
91
|
-
clientSecret: env("LINKEDIN_CLIENT_SECRET"),
|
|
92
|
-
redirectUri: `${env("APP_URL")}/auth/linkedin/callback`,
|
|
93
|
-
// scopes: ["openid", "profile", "email"],
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
- **GitHub** — OAuth 2 + PKCE. `/user.email` is `null` unless public, so the email is read from `/user/emails`: only the address that is both **primary and verified** is used; otherwise the profile has no email and the unverified-email rule applies.
|
|
99
|
-
- **Discord** — OAuth 2 + PKCE; `email` comes from `/users/@me` and counts as verified only when Discord's `verified` flag is `true`.
|
|
100
|
-
- **LinkedIn** — OpenID Connect; the id_token is verified with `jose` against LinkedIn's JWKS (signature, issuer, audience, expiry, nonce). `email_verified` must be the boolean `true`.
|
|
101
|
-
- Provider names resolve by own key only — `auth.providers` inherited keys such as `toString` never resolve as a provider.
|
|
102
|
-
|
|
103
|
-
## Apple
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
// src/config/auth.ts
|
|
107
|
-
providers: {
|
|
108
|
-
apple: {
|
|
109
|
-
clientId: env("APPLE_CLIENT_ID"), // the Services ID
|
|
110
|
-
teamId: env("APPLE_TEAM_ID"),
|
|
111
|
-
keyId: env("APPLE_KEY_ID"),
|
|
112
|
-
privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents
|
|
113
|
-
redirectUri: `${env("APP_URL")}/auth/apple/callback`,
|
|
114
|
-
// scopes: ["name", "email"],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
router.get("/auth/apple", async ({ response }) =>
|
|
121
|
-
response.redirect(await startProviderLogin(response, "apple")),
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// Apple POSTs the callback (form_post) when name/email scopes are requested —
|
|
125
|
-
// this route must accept a POST body, not just a query string.
|
|
126
|
-
router.post("/auth/apple/callback", async ({ request, response }) => {
|
|
127
|
-
const { tokens } = await completeProviderLogin(User, "apple", request, response);
|
|
128
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
129
|
-
return response.redirect("/");
|
|
130
|
-
});
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
- Apple has no static client secret: a fresh ES256 JWT (`iss` = team id, `sub` = client id, `aud` = Apple, `kid` = key id) is signed with your `.p8` private key on every callback.
|
|
134
|
-
- The account name arrives only on the **first** authorization, as a `user` form field (`{"name":{"firstName","lastName"}}`) — save it then, since later logins never send it again.
|
|
135
|
-
- The email may be a private-relay address (`@privaterelay.appleid.com`); it is still a real, working address and is accepted like any other.
|
|
136
|
-
- Same rejections and linking rules as Google, through `InvalidProviderCallbackError` / `ProviderEmailNotVerifiedError`.
|
|
137
|
-
- Apple's `form_post` callback is a cross-site POST, so its state cookie is written `SameSite=None; Secure` instead of the usual `Lax` — a browser drops a `Lax` cookie there. `Secure` needs HTTPS: Apple requires it in production anyway, and for local dev you need `https://` or `http://localhost` (browsers treat `localhost` as a secure context even over plain http).
|
|
138
|
-
|
|
139
|
-
## Facebook
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
providers: {
|
|
143
|
-
facebook: {
|
|
144
|
-
clientId: env("FACEBOOK_CLIENT_ID"),
|
|
145
|
-
clientSecret: env("FACEBOOK_CLIENT_SECRET"),
|
|
146
|
-
redirectUri: `${env("APP_URL")}/auth/facebook/callback`,
|
|
147
|
-
// scopes: ["email", "public_profile"],
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
```ts
|
|
153
|
-
router.get("/auth/facebook", async ({ response }) =>
|
|
154
|
-
response.redirect(await startProviderLogin(response, "facebook")),
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
router.get("/auth/facebook/callback", async ({ request, response }) => {
|
|
158
|
-
const { tokens } = await completeProviderLogin(User, "facebook", request, response);
|
|
159
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
160
|
-
return response.redirect("/");
|
|
161
|
-
});
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
- Plain OAuth 2 (no `id_token`); the profile comes from Graph `/me?fields=id,name,email,picture`. Facebook only ever returns a confirmed address, so a present `email` counts as verified.
|
|
165
|
-
- An app without the `email` permission granted (or a user with none on file) gets no email — the same missing/unverified path as every other provider, never an invented address.
|
|
166
|
-
|
|
167
|
-
## X (Twitter)
|
|
168
|
-
|
|
169
|
-
```ts
|
|
170
|
-
providers: {
|
|
171
|
-
x: {
|
|
172
|
-
clientId: env("X_CLIENT_ID"),
|
|
173
|
-
clientSecret: env("X_CLIENT_SECRET"),
|
|
174
|
-
redirectUri: `${env("APP_URL")}/auth/x/callback`,
|
|
175
|
-
// scopes: ["tweet.read", "users.read"],
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
```ts
|
|
181
|
-
router.get("/auth/x", async ({ response }) =>
|
|
182
|
-
response.redirect(await startProviderLogin(response, "x")),
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
router.get("/auth/x/callback", async ({ request, response }) => {
|
|
186
|
-
const { tokens } = await completeProviderLogin(User, "x", request, response);
|
|
187
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
188
|
-
return response.redirect("/");
|
|
189
|
-
});
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
- OAuth 2 with PKCE, plus HTTP Basic auth (`client_id:client_secret`) at the token endpoint for the confidential client — X requires both together.
|
|
193
|
-
- X's `/2/users/me` never returns an email address at all. `email` is always `undefined`; a first login therefore always needs an existing `provider_accounts` link (create one another way first) or it is rejected the same as any missing email — X gives auth no way to invent or infer one.
|
|
194
|
-
|
|
195
|
-
## Passkeys
|
|
196
|
-
|
|
197
|
-
```ts
|
|
198
|
-
passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
```ts
|
|
202
|
-
// Register (logged in)
|
|
203
|
-
router.post("/auth/passkeys/register/options", async ({ request, response }) =>
|
|
204
|
-
response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
|
|
205
|
-
{ middleware: [authMiddleware("user")] });
|
|
206
|
-
router.post("/auth/passkeys/register", async ({ request, response }) => {
|
|
207
|
-
await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
|
|
208
|
-
return response.success({ registered: true });
|
|
209
|
-
}, { middleware: [authMiddleware("user")] });
|
|
210
|
-
|
|
211
|
-
// Log in
|
|
212
|
-
router.post("/auth/passkeys/login/options", async ({ response }) =>
|
|
213
|
-
response.success(await generatePasskeyAuthenticationOptions()));
|
|
214
|
-
router.post("/auth/passkeys/login", async ({ request, response }) => {
|
|
215
|
-
const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
|
|
216
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
217
|
-
return response.success({ ok: true });
|
|
218
|
-
});
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Browser:
|
|
222
|
-
|
|
223
|
-
```ts
|
|
224
|
-
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
225
|
-
|
|
226
|
-
const post = (url: string, body?: unknown) =>
|
|
227
|
-
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
|
228
|
-
.then((r) => r.json());
|
|
229
|
-
|
|
230
|
-
// register
|
|
231
|
-
const regOptions = await post("/auth/passkeys/register/options");
|
|
232
|
-
await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
|
|
233
|
-
|
|
234
|
-
// log in
|
|
235
|
-
const authOptions = await post("/auth/passkeys/login/options");
|
|
236
|
-
await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
(Unwrap `response.success`'s envelope if your app wraps payloads.)
|
|
240
|
-
|
|
241
|
-
- 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.
|
|
242
|
-
- The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
|
|
243
|
-
- 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.
|
|
244
|
-
- Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
|
|
245
|
-
|
|
246
|
-
## Phone code (OTP)
|
|
247
|
-
|
|
248
|
-
```ts
|
|
249
|
-
otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
```ts
|
|
253
|
-
router.post("/auth/otp/request", async ({ request, response }) => {
|
|
254
|
-
await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
|
|
255
|
-
return response.success({ message: "If that number is registered, a code is on its way." });
|
|
256
|
-
}, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
|
|
257
|
-
|
|
258
|
-
router.post("/auth/otp/verify", async ({ request, response }) => {
|
|
259
|
-
const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
|
|
260
|
-
authService.setAuthCookie(response, tokens.accessToken);
|
|
261
|
-
return response.success({ ok: true });
|
|
262
|
-
}, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
- 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.
|
|
266
|
-
- 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.
|
|
267
|
-
- 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.
|
|
268
|
-
- **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
|
|
269
|
-
- 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.
|
|
270
|
-
|
|
271
|
-
## Gotchas
|
|
272
|
-
|
|
273
|
-
- `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
|
|
274
|
-
- One provider identity links to one account, of one user type.
|
|
275
|
-
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
|
|
276
|
-
- 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.
|
|
277
|
-
- A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
|
|
278
|
-
|
|
279
|
-
## See also
|
|
280
|
-
|
|
281
|
-
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
|
|
282
|
-
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
|
|
283
|
-
- [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
|
|
1
|
+
---
|
|
2
|
+
name: login-with-providers
|
|
3
|
+
description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), GitHub, Discord, LinkedIn, Apple (`form_post` callback), Facebook and X, 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", "login with GitHub", "Sign in with Apple", "login with Discord", "login with LinkedIn", "login with Facebook", "login with X", `auth.providers.github`, "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 an OAuth provider, 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
|
+
| Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
|
|
23
|
+
| GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
|
|
24
|
+
| Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
|
|
25
|
+
| Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
## Google
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/config/auth.ts
|
|
33
|
+
providers: {
|
|
34
|
+
google: {
|
|
35
|
+
clientId: env("GOOGLE_CLIENT_ID"),
|
|
36
|
+
clientSecret: env("GOOGLE_CLIENT_SECRET"),
|
|
37
|
+
redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
|
|
38
|
+
// scopes: ["openid", "email", "profile"],
|
|
39
|
+
},
|
|
40
|
+
// emailField: "email",
|
|
41
|
+
// createUser: async (profile, Model) => Model.create({ ... }),
|
|
42
|
+
},
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
router.get("/auth/google", async ({ response }) =>
|
|
47
|
+
response.redirect(await startProviderLogin(response, "google")),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
router.get("/auth/google/callback", async ({ request, response }) => {
|
|
51
|
+
const { tokens } = await completeProviderLogin(User, "google", request, response);
|
|
52
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
53
|
+
return response.redirect("/");
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
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:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
<a href="/auth/google">Continue with Google</a>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `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.
|
|
64
|
+
- 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.
|
|
65
|
+
- **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 }`.
|
|
66
|
+
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
67
|
+
|
|
68
|
+
## GitHub, Discord, LinkedIn
|
|
69
|
+
|
|
70
|
+
Same shape as Google: a config block under `auth.providers.<name>`, a GET
|
|
71
|
+
route that redirects to `startProviderLogin(response, "<name>")`, and a GET
|
|
72
|
+
callback that calls `completeProviderLogin(User, "<name>", request, response)`.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// src/config/auth.ts
|
|
76
|
+
providers: {
|
|
77
|
+
github: {
|
|
78
|
+
clientId: env("GITHUB_CLIENT_ID"),
|
|
79
|
+
clientSecret: env("GITHUB_CLIENT_SECRET"),
|
|
80
|
+
redirectUri: `${env("APP_URL")}/auth/github/callback`,
|
|
81
|
+
// scopes: ["read:user", "user:email"],
|
|
82
|
+
},
|
|
83
|
+
discord: {
|
|
84
|
+
clientId: env("DISCORD_CLIENT_ID"),
|
|
85
|
+
clientSecret: env("DISCORD_CLIENT_SECRET"),
|
|
86
|
+
redirectUri: `${env("APP_URL")}/auth/discord/callback`,
|
|
87
|
+
// scopes: ["identify", "email"],
|
|
88
|
+
},
|
|
89
|
+
linkedin: {
|
|
90
|
+
clientId: env("LINKEDIN_CLIENT_ID"),
|
|
91
|
+
clientSecret: env("LINKEDIN_CLIENT_SECRET"),
|
|
92
|
+
redirectUri: `${env("APP_URL")}/auth/linkedin/callback`,
|
|
93
|
+
// scopes: ["openid", "profile", "email"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **GitHub** — OAuth 2 + PKCE. `/user.email` is `null` unless public, so the email is read from `/user/emails`: only the address that is both **primary and verified** is used; otherwise the profile has no email and the unverified-email rule applies.
|
|
99
|
+
- **Discord** — OAuth 2 + PKCE; `email` comes from `/users/@me` and counts as verified only when Discord's `verified` flag is `true`.
|
|
100
|
+
- **LinkedIn** — OpenID Connect; the id_token is verified with `jose` against LinkedIn's JWKS (signature, issuer, audience, expiry, nonce). `email_verified` must be the boolean `true`.
|
|
101
|
+
- Provider names resolve by own key only — `auth.providers` inherited keys such as `toString` never resolve as a provider.
|
|
102
|
+
|
|
103
|
+
## Apple
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// src/config/auth.ts
|
|
107
|
+
providers: {
|
|
108
|
+
apple: {
|
|
109
|
+
clientId: env("APPLE_CLIENT_ID"), // the Services ID
|
|
110
|
+
teamId: env("APPLE_TEAM_ID"),
|
|
111
|
+
keyId: env("APPLE_KEY_ID"),
|
|
112
|
+
privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents
|
|
113
|
+
redirectUri: `${env("APP_URL")}/auth/apple/callback`,
|
|
114
|
+
// scopes: ["name", "email"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
router.get("/auth/apple", async ({ response }) =>
|
|
121
|
+
response.redirect(await startProviderLogin(response, "apple")),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Apple POSTs the callback (form_post) when name/email scopes are requested —
|
|
125
|
+
// this route must accept a POST body, not just a query string.
|
|
126
|
+
router.post("/auth/apple/callback", async ({ request, response }) => {
|
|
127
|
+
const { tokens } = await completeProviderLogin(User, "apple", request, response);
|
|
128
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
129
|
+
return response.redirect("/");
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- Apple has no static client secret: a fresh ES256 JWT (`iss` = team id, `sub` = client id, `aud` = Apple, `kid` = key id) is signed with your `.p8` private key on every callback.
|
|
134
|
+
- The account name arrives only on the **first** authorization, as a `user` form field (`{"name":{"firstName","lastName"}}`) — save it then, since later logins never send it again.
|
|
135
|
+
- The email may be a private-relay address (`@privaterelay.appleid.com`); it is still a real, working address and is accepted like any other.
|
|
136
|
+
- Same rejections and linking rules as Google, through `InvalidProviderCallbackError` / `ProviderEmailNotVerifiedError`.
|
|
137
|
+
- Apple's `form_post` callback is a cross-site POST, so its state cookie is written `SameSite=None; Secure` instead of the usual `Lax` — a browser drops a `Lax` cookie there. `Secure` needs HTTPS: Apple requires it in production anyway, and for local dev you need `https://` or `http://localhost` (browsers treat `localhost` as a secure context even over plain http).
|
|
138
|
+
|
|
139
|
+
## Facebook
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
providers: {
|
|
143
|
+
facebook: {
|
|
144
|
+
clientId: env("FACEBOOK_CLIENT_ID"),
|
|
145
|
+
clientSecret: env("FACEBOOK_CLIENT_SECRET"),
|
|
146
|
+
redirectUri: `${env("APP_URL")}/auth/facebook/callback`,
|
|
147
|
+
// scopes: ["email", "public_profile"],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
router.get("/auth/facebook", async ({ response }) =>
|
|
154
|
+
response.redirect(await startProviderLogin(response, "facebook")),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
router.get("/auth/facebook/callback", async ({ request, response }) => {
|
|
158
|
+
const { tokens } = await completeProviderLogin(User, "facebook", request, response);
|
|
159
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
160
|
+
return response.redirect("/");
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- Plain OAuth 2 (no `id_token`); the profile comes from Graph `/me?fields=id,name,email,picture`. Facebook only ever returns a confirmed address, so a present `email` counts as verified.
|
|
165
|
+
- An app without the `email` permission granted (or a user with none on file) gets no email — the same missing/unverified path as every other provider, never an invented address.
|
|
166
|
+
|
|
167
|
+
## X (Twitter)
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
providers: {
|
|
171
|
+
x: {
|
|
172
|
+
clientId: env("X_CLIENT_ID"),
|
|
173
|
+
clientSecret: env("X_CLIENT_SECRET"),
|
|
174
|
+
redirectUri: `${env("APP_URL")}/auth/x/callback`,
|
|
175
|
+
// scopes: ["tweet.read", "users.read"],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
router.get("/auth/x", async ({ response }) =>
|
|
182
|
+
response.redirect(await startProviderLogin(response, "x")),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
router.get("/auth/x/callback", async ({ request, response }) => {
|
|
186
|
+
const { tokens } = await completeProviderLogin(User, "x", request, response);
|
|
187
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
188
|
+
return response.redirect("/");
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- OAuth 2 with PKCE, plus HTTP Basic auth (`client_id:client_secret`) at the token endpoint for the confidential client — X requires both together.
|
|
193
|
+
- X's `/2/users/me` never returns an email address at all. `email` is always `undefined`; a first login therefore always needs an existing `provider_accounts` link (create one another way first) or it is rejected the same as any missing email — X gives auth no way to invent or infer one.
|
|
194
|
+
|
|
195
|
+
## Passkeys
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// Register (logged in)
|
|
203
|
+
router.post("/auth/passkeys/register/options", async ({ request, response }) =>
|
|
204
|
+
response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
|
|
205
|
+
{ middleware: [authMiddleware("user")] });
|
|
206
|
+
router.post("/auth/passkeys/register", async ({ request, response }) => {
|
|
207
|
+
await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
|
|
208
|
+
return response.success({ registered: true });
|
|
209
|
+
}, { middleware: [authMiddleware("user")] });
|
|
210
|
+
|
|
211
|
+
// Log in
|
|
212
|
+
router.post("/auth/passkeys/login/options", async ({ response }) =>
|
|
213
|
+
response.success(await generatePasskeyAuthenticationOptions()));
|
|
214
|
+
router.post("/auth/passkeys/login", async ({ request, response }) => {
|
|
215
|
+
const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
|
|
216
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
217
|
+
return response.success({ ok: true });
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Browser:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
225
|
+
|
|
226
|
+
const post = (url: string, body?: unknown) =>
|
|
227
|
+
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
|
228
|
+
.then((r) => r.json());
|
|
229
|
+
|
|
230
|
+
// register
|
|
231
|
+
const regOptions = await post("/auth/passkeys/register/options");
|
|
232
|
+
await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
|
|
233
|
+
|
|
234
|
+
// log in
|
|
235
|
+
const authOptions = await post("/auth/passkeys/login/options");
|
|
236
|
+
await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
(Unwrap `response.success`'s envelope if your app wraps payloads.)
|
|
240
|
+
|
|
241
|
+
- 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.
|
|
242
|
+
- The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
|
|
243
|
+
- 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.
|
|
244
|
+
- Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
|
|
245
|
+
|
|
246
|
+
## Phone code (OTP)
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
router.post("/auth/otp/request", async ({ request, response }) => {
|
|
254
|
+
await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
|
|
255
|
+
return response.success({ message: "If that number is registered, a code is on its way." });
|
|
256
|
+
}, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
|
|
257
|
+
|
|
258
|
+
router.post("/auth/otp/verify", async ({ request, response }) => {
|
|
259
|
+
const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
|
|
260
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
261
|
+
return response.success({ ok: true });
|
|
262
|
+
}, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- 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.
|
|
266
|
+
- 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.
|
|
267
|
+
- 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.
|
|
268
|
+
- **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
|
|
269
|
+
- 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.
|
|
270
|
+
|
|
271
|
+
## Gotchas
|
|
272
|
+
|
|
273
|
+
- `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
|
|
274
|
+
- One provider identity links to one account, of one user type.
|
|
275
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
|
|
276
|
+
- 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.
|
|
277
|
+
- A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
|
|
278
|
+
|
|
279
|
+
## See also
|
|
280
|
+
|
|
281
|
+
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
|
|
282
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
|
|
283
|
+
- [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
|