@warlock.js/auth 5.15.0 → 5.17.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/esm/commands/auth-cleanup-command.mjs +1 -1
  3. package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
  4. package/esm/contracts/index.d.mts +1 -1
  5. package/esm/contracts/providers.d.mts +59 -1
  6. package/esm/contracts/providers.d.mts.map +1 -1
  7. package/esm/index.d.mts +11 -2
  8. package/esm/index.mjs +11 -1
  9. package/esm/localization/ar.translations.mjs +16 -0
  10. package/esm/localization/ar.translations.mjs.map +1 -0
  11. package/esm/localization/auth-default-translations.d.mts +10 -0
  12. package/esm/localization/auth-default-translations.d.mts.map +1 -0
  13. package/esm/localization/auth-default-translations.mjs +15 -0
  14. package/esm/localization/auth-default-translations.mjs.map +1 -0
  15. package/esm/localization/auth-translations.type.d.mts +27 -0
  16. package/esm/localization/auth-translations.type.d.mts.map +1 -0
  17. package/esm/localization/en.translations.mjs +16 -0
  18. package/esm/localization/en.translations.mjs.map +1 -0
  19. package/esm/localization/index.mjs +4 -0
  20. package/esm/localization/init.mjs +8 -0
  21. package/esm/localization/init.mjs.map +1 -0
  22. package/esm/localization/register-auth-translations.d.mts +17 -0
  23. package/esm/localization/register-auth-translations.d.mts.map +1 -0
  24. package/esm/localization/register-auth-translations.mjs +29 -0
  25. package/esm/localization/register-auth-translations.mjs.map +1 -0
  26. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  27. package/esm/middleware/auth.middleware.mjs +4 -2
  28. package/esm/middleware/auth.middleware.mjs.map +1 -1
  29. package/esm/middleware/csrf-origin-check.d.mts +9 -0
  30. package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
  31. package/esm/middleware/csrf-origin-check.mjs +12 -56
  32. package/esm/middleware/csrf-origin-check.mjs.map +1 -1
  33. package/esm/middleware/localized-login-path.mjs +48 -0
  34. package/esm/middleware/localized-login-path.mjs.map +1 -0
  35. package/esm/models/access-token/access-token.model.d.mts +7 -0
  36. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  37. package/esm/models/access-token/access-token.model.mjs +3 -0
  38. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  39. package/esm/models/one-time-token/one-time-token.model.d.mts +6 -0
  40. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
  41. package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
  42. package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
  43. package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
  44. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  45. package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
  46. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  47. package/esm/providers/apple-provider.d.mts +74 -0
  48. package/esm/providers/apple-provider.d.mts.map +1 -0
  49. package/esm/providers/apple-provider.mjs +157 -0
  50. package/esm/providers/apple-provider.mjs.map +1 -0
  51. package/esm/providers/discord-provider.d.mts +25 -0
  52. package/esm/providers/discord-provider.d.mts.map +1 -0
  53. package/esm/providers/discord-provider.mjs +84 -0
  54. package/esm/providers/discord-provider.mjs.map +1 -0
  55. package/esm/providers/facebook-provider.d.mts +36 -0
  56. package/esm/providers/facebook-provider.d.mts.map +1 -0
  57. package/esm/providers/facebook-provider.mjs +94 -0
  58. package/esm/providers/facebook-provider.mjs.map +1 -0
  59. package/esm/providers/github-provider.d.mts +31 -0
  60. package/esm/providers/github-provider.d.mts.map +1 -0
  61. package/esm/providers/github-provider.mjs +115 -0
  62. package/esm/providers/github-provider.mjs.map +1 -0
  63. package/esm/providers/index.d.mts +7 -1
  64. package/esm/providers/index.mjs +6 -0
  65. package/esm/providers/linkedin-provider.d.mts +35 -0
  66. package/esm/providers/linkedin-provider.d.mts.map +1 -0
  67. package/esm/providers/linkedin-provider.mjs +108 -0
  68. package/esm/providers/linkedin-provider.mjs.map +1 -0
  69. package/esm/providers/provider-login.d.mts +2 -1
  70. package/esm/providers/provider-login.d.mts.map +1 -1
  71. package/esm/providers/provider-login.mjs +47 -7
  72. package/esm/providers/provider-login.mjs.map +1 -1
  73. package/esm/providers/provider-state-cookie.mjs +35 -16
  74. package/esm/providers/provider-state-cookie.mjs.map +1 -1
  75. package/esm/providers/x-provider.d.mts +35 -0
  76. package/esm/providers/x-provider.d.mts.map +1 -0
  77. package/esm/providers/x-provider.mjs +101 -0
  78. package/esm/providers/x-provider.mjs.map +1 -0
  79. package/esm/services/auth-config.mjs +16 -1
  80. package/esm/services/auth-config.mjs.map +1 -1
  81. package/esm/services/auth.service.mjs +1 -1
  82. package/esm/services/generate-jwt-secret.mjs +1 -1
  83. package/llms-full.txt +1075 -765
  84. package/llms.txt +5 -5
  85. package/package.json +8 -8
  86. package/skills/auth-basics/SKILL.md +16 -1
  87. package/skills/customize-token-storage/SKILL.md +160 -122
  88. package/skills/customize-user-type/SKILL.md +168 -142
  89. package/skills/handle-login-and-logout/SKILL.md +179 -165
  90. package/skills/login-with-providers/SKILL.md +131 -2
  91. package/skills/protect-routes/SKILL.md +308 -249
  92. package/skills/register-user/SKILL.md +15 -1
  93. package/skills/run-auth-commands/SKILL.md +16 -1
  94. package/skills/throttle-login-attempts/SKILL.md +1 -1
@@ -1,165 +1,179 @@
1
- ---
2
- name: handle-login-and-logout
3
- description: 'Run the full login flow via authService.login(Model, credentials, deviceInfo?) — verify password, create access + refresh token pair, fire events. Logout via authService.logout(user, accessToken?, refreshToken?) revokes tokens. Triggers: `authService.login`, `authService.logout`, `authService.attemptLogin`, `authService.refreshTokens`, `authService.revokeAllTokens`, `authEvents`; "build a login endpoint", "POST /login controller", "logout from all devices", "verify credentials and issue tokens"; typical import `import { authService, authEvents } from "@warlock.js/auth"`. Skip: token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; sign-up — `@warlock.js/auth/register-user/SKILL.md`; competing libs `passport-local`, `next-auth` credentials.'
4
- ---
5
-
6
- # Login + logout
7
-
8
- `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
9
-
10
- ## Login — `authService.login(Model, credentials, deviceInfo?)`
11
-
12
- ```ts
13
- import { authService } from "@warlock.js/auth";
14
- import { type RequestHandler } from "@warlock.js/core";
15
- import { User } from "@/app/users/models/user.model";
16
-
17
- export const loginController: RequestHandler = async ({ request, response }) => {
18
- const result = await authService.login(User, {
19
- email: request.input("email"),
20
- password: request.input("password"),
21
- }, {
22
- userAgent: request.header("user-agent"),
23
- ip: request.ip,
24
- });
25
-
26
- if (!result) {
27
- return response.unauthorized({ error: "Invalid credentials" });
28
- }
29
-
30
- return response.success({
31
- user: result.user,
32
- tokens: result.tokens,
33
- });
34
- };
35
- ```
36
-
37
- The returned shape:
38
-
39
- ```ts
40
- {
41
- user: T, // your User subclass, hydrated
42
- tokens: {
43
- accessToken: { token: string, expiresAt: string },
44
- refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
45
- },
46
- }
47
- ```
48
-
49
- Returns `null` on failure (wrong password, user not found). The service emits `login.attempt` → `login.success` or `login.failed` events as it goes — subscribe via the auth event bus if you need an audit trail.
50
-
51
- ## What `credentials` looks like
52
-
53
- The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
54
-
55
- ```ts
56
- // Email + password
57
- authService.login(User, { email: "ada@example.com", password: "..." });
58
-
59
- // Username + password
60
- authService.login(User, { username: "ada", password: "..." });
61
-
62
- // Phone-based OTP (where password is the OTP hash)
63
- authService.login(User, { phone: "+1...", password: hashedOTP });
64
- ```
65
-
66
- For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
67
-
68
- ## Device info
69
-
70
- The optional `deviceInfo` carries metadata into the refresh token row:
71
-
72
- ```ts
73
- authService.login(User, credentials, {
74
- userAgent: request.header("user-agent"),
75
- ip: request.ip,
76
- deviceId: "...", // your client-side device fingerprint
77
- familyId: "...", // pre-existing family for token rotation, usually omitted
78
- });
79
- ```
80
-
81
- Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
82
-
83
- ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
84
-
85
- ```ts
86
- import { type RequestHandler } from "@warlock.js/core";
87
-
88
- export const logoutController: RequestHandler = async ({ request, response }) => {
89
- await authService.logout(
90
- request.locals.user!,
91
- request.authorizationValue, // access token from the Authorization header
92
- request.input("refreshToken"), // refresh token from the request body
93
- );
94
-
95
- return response.success({ message: "Logged out" });
96
- };
97
- ```
98
-
99
- The contract:
100
- - **Pass the access token** → that specific access-token row is deleted.
101
- - **Pass the refresh token** → that specific refresh-token row is revoked.
102
- - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
103
- - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
104
- - `"error"` — throws. Force the client to send the refresh token.
105
-
106
- The `revoke-all` default is the right call for most apps. If a client loses track of the refresh token, logout still works and the user has to log in fresh on every device.
107
-
108
- ## Logout-everywhere
109
-
110
- ```ts
111
- await authService.revokeAllTokens(user);
112
- // Revokes every refresh token + deletes every access token for this user.
113
- ```
114
-
115
- Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
116
-
117
- ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
118
-
119
- ```ts
120
- import { type RequestHandler } from "@warlock.js/core";
121
-
122
- export const refreshController: RequestHandler = async ({ request, response }) => {
123
- const tokens = await authService.refreshTokens(
124
- request.input("refreshToken"),
125
- { userAgent: request.header("user-agent"), ip: request.ip },
126
- );
127
-
128
- if (!tokens) {
129
- return response.unauthorized({ error: "Invalid refresh token" });
130
- }
131
-
132
- return response.success({ tokens });
133
- };
134
- ```
135
-
136
- Returns a new token pair or `null` (token expired, revoked, or replay-detected). With rotation enabled (default), the old refresh token is consumed; the new pair stays in the same "family." Replay → revoke the whole family. See [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md).
137
-
138
- ## Auth events
139
-
140
- `authEvents` is a type-safe event bus (over `@mongez/events`) that fires on every meaningful auth moment. Subscribe with `on` / `subscribe`, unsubscribe with `off` / `unsubscribeAll`:
141
-
142
- ```ts
143
- import { authEvents } from "@warlock.js/auth";
144
-
145
- authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
146
- authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
147
- authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
148
- authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
149
- authEvents.on("cleanup.completed", (count) => { /* metrics */ });
150
- ```
151
-
152
- Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
153
-
154
- ## Things NOT to do
155
-
156
- - Don't `authService.login(User, { password })` without other credentials — the password is the secret; the other fields are the lookup. A login with only a password is a logic bug.
157
- - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
158
- - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
159
- - Don't issue a new token pair without revoking the old one when rotation is enabled. `refreshTokens` does this for you — don't bypass it.
160
-
161
- ## See also
162
-
163
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
164
- - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
165
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
1
+ ---
2
+ name: handle-login-and-logout
3
+ description: 'Run the full login flow via authService.login(Model, credentials, deviceInfo?) — verify password, create access + refresh token pair, fire events. Logout via authService.logout(user, accessToken?, refreshToken?) revokes tokens. Triggers: `authService.login`, `authService.logout`, `authService.attemptLogin`, `authService.refreshTokens`, `authService.revokeAllTokens`, `authEvents`; "build a login endpoint", "POST /login controller", "logout from all devices", "verify credentials and issue tokens"; typical import `import { authService, authEvents } from "@warlock.js/auth"`. Skip: token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; sign-up — `@warlock.js/auth/register-user/SKILL.md`; competing libs `passport-local`, `next-auth` credentials.'
4
+ ---
5
+
6
+ # Login + logout
7
+
8
+ `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
9
+
10
+ ```ts title="src/app/users/models/user/user.model.ts"
11
+ import { Auth } from "@warlock.js/auth";
12
+ import { RegisterModel } from "@warlock.js/cascade";
13
+
14
+ @RegisterModel()
15
+ export class User extends Auth {
16
+ public static table = "users";
17
+
18
+ public get userType(): string {
19
+ return "user";
20
+ }
21
+ }
22
+ ```
23
+
24
+ ## Login — `authService.login(Model, credentials, deviceInfo?)`
25
+
26
+ ```ts
27
+ import { authService } from "@warlock.js/auth";
28
+ import { type RequestHandler } from "@warlock.js/core";
29
+ import { User } from "app/users/models/user/user.model";
30
+
31
+ export const loginController: RequestHandler = async ({ request, response }) => {
32
+ const result = await authService.login(User, {
33
+ email: request.input("email"),
34
+ password: request.input("password"),
35
+ }, {
36
+ userAgent: request.header("user-agent"),
37
+ ip: request.ip,
38
+ });
39
+
40
+ if (!result) {
41
+ return response.unauthorized({ error: "Invalid credentials" });
42
+ }
43
+
44
+ return response.success({
45
+ user: result.user,
46
+ tokens: result.tokens,
47
+ });
48
+ };
49
+ ```
50
+
51
+ The returned shape:
52
+
53
+ ```ts
54
+ {
55
+ user: T, // your User subclass, hydrated
56
+ tokens: {
57
+ accessToken: { token: string, expiresAt: string },
58
+ refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
59
+ },
60
+ }
61
+ ```
62
+
63
+ Returns `null` on failure (wrong password, user not found). The service emits `login.attempt` → `login.success` or `login.failed` events as it goes — subscribe via the auth event bus if you need an audit trail.
64
+
65
+ ## What `credentials` looks like
66
+
67
+ The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
68
+
69
+ ```ts
70
+ // Email + password
71
+ authService.login(User, { email: "ada@example.com", password: "..." });
72
+
73
+ // Username + password
74
+ authService.login(User, { username: "ada", password: "..." });
75
+
76
+ // Phone-based OTP (where password is the OTP hash)
77
+ authService.login(User, { phone: "+1...", password: hashedOTP });
78
+ ```
79
+
80
+ For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
81
+
82
+ ## Device info
83
+
84
+ The optional `deviceInfo` carries metadata into the refresh token row:
85
+
86
+ ```ts
87
+ authService.login(User, credentials, {
88
+ userAgent: request.header("user-agent"),
89
+ ip: request.ip,
90
+ deviceId: "...", // your client-side device fingerprint
91
+ familyId: "...", // pre-existing family for token rotation, usually omitted
92
+ });
93
+ ```
94
+
95
+ Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
96
+
97
+ ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
98
+
99
+ ```ts
100
+ import { type RequestHandler } from "@warlock.js/core";
101
+
102
+ export const logoutController: RequestHandler = async ({ request, response }) => {
103
+ await authService.logout(
104
+ request.locals.user!,
105
+ request.authorizationValue, // access token from the Authorization header
106
+ request.input("refreshToken"), // refresh token from the request body
107
+ );
108
+
109
+ return response.success({ message: "Logged out" });
110
+ };
111
+ ```
112
+
113
+ The contract:
114
+ - **Pass the access token** → that specific access-token row is deleted.
115
+ - **Pass the refresh token** → that specific refresh-token row is revoked.
116
+ - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
117
+ - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
118
+ - `"error"` — throws. Force the client to send the refresh token.
119
+
120
+ The `revoke-all` default is the right call for most apps. If a client loses track of the refresh token, logout still works and the user has to log in fresh on every device.
121
+
122
+ ## Logout-everywhere
123
+
124
+ ```ts
125
+ await authService.revokeAllTokens(user);
126
+ // Revokes every refresh token + deletes every access token for this user.
127
+ ```
128
+
129
+ Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
130
+
131
+ ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
132
+
133
+ ```ts
134
+ import { type RequestHandler } from "@warlock.js/core";
135
+
136
+ export const refreshController: RequestHandler = async ({ request, response }) => {
137
+ const tokens = await authService.refreshTokens(
138
+ request.input("refreshToken"),
139
+ { userAgent: request.header("user-agent"), ip: request.ip },
140
+ );
141
+
142
+ if (!tokens) {
143
+ return response.unauthorized({ error: "Invalid refresh token" });
144
+ }
145
+
146
+ return response.success({ tokens });
147
+ };
148
+ ```
149
+
150
+ Returns a new token pair or `null` (token expired, revoked, or replay-detected). With rotation enabled (default), the old refresh token is consumed; the new pair stays in the same "family." Replay → revoke the whole family. See [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md).
151
+
152
+ ## Auth events
153
+
154
+ `authEvents` is a type-safe event bus (over `@mongez/events`) that fires on every meaningful auth moment. Subscribe with `on` / `subscribe`, unsubscribe with `off` / `unsubscribeAll`:
155
+
156
+ ```ts
157
+ import { authEvents } from "@warlock.js/auth";
158
+
159
+ authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
160
+ authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
161
+ authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
162
+ authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
163
+ authEvents.on("cleanup.completed", (count) => { /* metrics */ });
164
+ ```
165
+
166
+ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
167
+
168
+ ## Things NOT to do
169
+
170
+ - Don't `authService.login(User, { password })` without other credentials — the password is the secret; the other fields are the lookup. A login with only a password is a logic bug.
171
+ - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
172
+ - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
173
+ - Don't issue a new token pair without revoking the old one when rotation is enabled. `refreshTokens` does this for you — don't bypass it.
174
+
175
+ ## See also
176
+
177
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
178
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
179
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: login-with-providers
3
- description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
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
4
  ---
5
5
 
6
- # Login with Google, passkeys, or a phone code
6
+ # Login with an OAuth provider, passkeys, or a phone code
7
7
 
8
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
9
 
@@ -19,6 +19,8 @@ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or r
19
19
  | Method | Command | Installs |
20
20
  | --- | --- | --- |
21
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 |
22
24
  | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
23
25
  | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
24
26
 
@@ -63,6 +65,133 @@ Client side, the button is just a link. It must be a top-level navigation, not a
63
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 }`.
64
66
  - Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
65
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
+
66
195
  ## Passkeys
67
196
 
68
197
  ```ts