@luckystack/login 0.1.4 → 0.1.5

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.
@@ -1,431 +1,431 @@
1
- # OAuth providers
2
-
3
- Deep-dive on the OAuth provider registry, built-in helpers, and the contract for plugging in custom providers. The canonical source is [`./src/oauthProviders.ts`](../src/oauthProviders.ts).
4
-
5
- `@luckystack/login` ships an OAuth 2.0 authorization-code flow with PKCE-equivalent state validation. Built-in helpers cover Google, GitHub, Discord, Facebook, and Microsoft. Anything else (Okta, Apple, X / Twitter, Auth0, Keycloak, custom enterprise SSO, self-hosted Gitea) plugs in as a raw `FullOAuthProvider` object.
6
-
7
- ---
8
-
9
- ## The registry
10
-
11
- The active provider list is stored in a module-level variable in `oauthProviders.ts`:
12
-
13
- ```ts
14
- let registeredProviders: OAuthProvider[] = [{ name: 'credentials' }];
15
-
16
- export const registerOAuthProviders = (providers: OAuthProvider[]): OAuthProvider[] => {
17
- registeredProviders = providers;
18
- return registeredProviders;
19
- };
20
-
21
- export const getOAuthProviders = (): OAuthProvider[] => registeredProviders;
22
- ```
23
-
24
- `registerOAuthProviders([...])` **replaces** the active list — it is not additive. The default list contains only `{ name: 'credentials' }`, which means in a fresh project with no registration call:
25
-
26
- - `loginWithCredentials` keeps working (the `'credentials'` entry is still present).
27
- - Every `/auth/callback/<provider>` request returns `false` because no matching provider is in the list.
28
-
29
- Register at boot, before the first login request:
30
-
31
- ```ts
32
- // luckystack/server/index.ts (installer side)
33
- import {
34
- registerOAuthProviders,
35
- credentialsProvider,
36
- googleProvider,
37
- githubProvider,
38
- } from '@luckystack/login';
39
-
40
- registerOAuthProviders([
41
- credentialsProvider(),
42
- googleProvider({
43
- clientId: process.env.GOOGLE_CLIENT_ID,
44
- clientSecret: process.env.GOOGLE_CLIENT_SECRET,
45
- callbackUrl: `${process.env.DNS}/auth/callback/google`,
46
- }),
47
- githubProvider({
48
- clientId: process.env.GITHUB_CLIENT_ID,
49
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
50
- callbackUrl: `${process.env.DNS}/auth/callback/github`,
51
- }),
52
- ]);
53
- ```
54
-
55
- If you omit `credentialsProvider()`, the credentials endpoint will reject all requests because the dispatcher looks the provider up by name.
56
-
57
- ## CSRF state — `createOAuthState`
58
-
59
- OAuth 2.0 requires a `state` parameter on the `/authorize` request that the provider echoes back to the callback. `@luckystack/login` issues this state from Redis:
60
-
61
- ```ts
62
- export const createOAuthState = async (providerName: string): Promise<string | null> => {
63
- const state = randomBytes(32).toString('hex');
64
- const key = `${getProjectName()}-oauth-state:${providerName}:${state}`;
65
- const result = await redis.set(key, '1', 'EX', auth.oauthStateTtlSeconds, 'NX');
66
- if (result !== 'OK') return null;
67
- return state;
68
- };
69
- ```
70
-
71
- Key points:
72
-
73
- - 32 random bytes → 64-char hex.
74
- - Key namespace: `${projectName}-oauth-state:<provider>:<state>`. Uses the shared `getProjectName()` helper so the namespace matches sessions / activeUsers / rate-limit / password-reset (no drift between keys).
75
- - TTL: `auth.oauthStateTtlSeconds` (default 600 = 10 minutes).
76
- - `NX` flag prevents collisions — if a random collision happened (astronomically unlikely with 256 bits of entropy), the function returns `null` and the caller retries.
77
-
78
- The state is consumed atomically inside `loginCallback`:
79
-
80
- ```ts
81
- const consumeOAuthState = async (provider, state) => {
82
- const txResult = await redis.multi().get(key).del(key).exec();
83
- // ...returns true iff the GET returned '1'.
84
- };
85
- ```
86
-
87
- This is a one-time-use redemption — replaying the same `state` on a second callback fails. Combined with the short TTL it defeats both CSRF and replay attacks on the callback.
88
-
89
- ## `OAuthProvider` discriminated union
90
-
91
- ```ts
92
- export type OAuthProvider = CredentialsProvider | FullOAuthProvider;
93
-
94
- export interface CredentialsProvider {
95
- name: 'credentials';
96
- }
97
-
98
- export interface FullOAuthProvider {
99
- name: string;
100
- clientID: string;
101
- clientSecret: string;
102
- callbackURL: string;
103
- authorizationURL: string;
104
- tokenExchangeURL: string;
105
- tokenExchangeMethod: 'json' | 'form';
106
- userInfoURL: string;
107
- scope: string[];
108
- nameKey: string;
109
- emailKey: string;
110
- avatarKey?: string;
111
- avatarCodeKey: string;
112
- getEmail?: (accessToken: string) => Promise<string | false | undefined>;
113
- getAvatar?: (params: {
114
- userData: OAuthUserData;
115
- avatarId?: string;
116
- accessToken: string;
117
- }) => string | undefined | Promise<string | undefined>;
118
- extraSessionFields?: (params: {
119
- userData: OAuthUserData;
120
- accessToken: string;
121
- }) => Promise<Record<string, unknown>> | Record<string, unknown>;
122
- }
123
- ```
124
-
125
- `isFullOAuthProvider(provider)` is the type guard that separates the two branches:
126
-
127
- ```ts
128
- export const isFullOAuthProvider = (provider: OAuthProvider): provider is FullOAuthProvider =>
129
- provider.name !== 'credentials';
130
- ```
131
-
132
- `loginCallback` calls this guard first and bails when the lookup resolves to `{ name: 'credentials' }` — that entry exists only to keep the credentials endpoint enabled in the same list.
133
-
134
- ### Field reference
135
-
136
- | Field | Purpose |
137
- | --------------------- | --------------------------------------------------------------------------------------------------------- |
138
- | `name` | URL slug used in `/auth/callback/<name>` and stored on `User.provider`. |
139
- | `clientID` | OAuth app client id. |
140
- | `clientSecret` | OAuth app secret. |
141
- | `callbackURL` | Must match the URL registered with the provider, normally `${DNS}/auth/callback/<name>`. |
142
- | `authorizationURL` | Where to redirect the user to authorize (start of the flow). |
143
- | `tokenExchangeURL` | POST endpoint for exchanging `code` → `access_token`. |
144
- | `tokenExchangeMethod` | `'json'` (Google) or `'form'` (Discord, Facebook, Microsoft). GitHub accepts either; we use `'json'`. |
145
- | `userInfoURL` | GET endpoint that returns the user profile JSON given a bearer token. |
146
- | `scope` | Array of scopes requested at authorize-time. Joined with spaces by the consumer-side `/auth/login` route. |
147
- | `nameKey` | Top-level key in the userinfo response that holds the display name (`'name'` / `'login'` / etc.). |
148
- | `emailKey` | Top-level key in the userinfo response that holds the email. |
149
- | `avatarKey` | Direct-URL variant — `userData[avatarKey]` IS the avatar URL. |
150
- | `avatarCodeKey` | Compose-URL variant — `userData[avatarCodeKey]` is an id that `getAvatar` turns into a URL. |
151
- | `getEmail` | Optional fallback when `userData[emailKey]` is missing (GitHub `/user/emails`). |
152
- | `getAvatar` | Optional resolver for `avatarCodeKey` providers (Discord, Microsoft). |
153
- | `extraSessionFields` | Optional hook that returns a record to merge onto the session BEFORE `saveSession`. |
154
-
155
- `OAuthUserData = Record<string, unknown>` is the framework's representation of the userinfo response. `asOAuthUserData(value)` is a safe cast helper for use inside `getAvatar` / `extraSessionFields` callbacks:
156
-
157
- ```ts
158
- export const asOAuthUserData = (value: unknown): OAuthUserData => {
159
- if (value && typeof value === 'object') {
160
- return value as OAuthUserData;
161
- }
162
- return {};
163
- };
164
- ```
165
-
166
- ## `OAuthHelperInput` — the common factory input
167
-
168
- Every built-in factory accepts the same base shape with optional override slots:
169
-
170
- ```ts
171
- interface OAuthHelperInput {
172
- clientId: string | undefined;
173
- clientSecret: string | undefined;
174
- callbackUrl: string;
175
- endpoints?: {
176
- authorizationURL?: string;
177
- tokenExchangeURL?: string;
178
- userInfoURL?: string;
179
- };
180
- extraScopes?: string[];
181
- extraSessionFields?: FullOAuthProvider['extraSessionFields'];
182
- }
183
- ```
184
-
185
- Why each slot exists:
186
-
187
- - **`endpoints?.*`** — Self-hosted GitHub Enterprise, single-tenant Azure with a custom hostname, internal auth proxies. The defaults match the public hosted services; overrides give you the same provider shape pointed at a different host.
188
- - **`extraScopes`** — Add to (not replace) the default scopes. Use for "give me read access to the user's calendar / drive / repos" flows. Merged via `mergeScopes` with `Set`-based deduplication so passing a default scope is idempotent.
189
- - **`extraSessionFields`** — Per-provider runtime extras (calendar tokens, tenant IDs, organization claims) merged into the session BEFORE the first `saveSession`.
190
-
191
- `clientId` / `clientSecret` are typed as `string | undefined` because the most common call site is `process.env.X_CLIENT_ID`. Each factory calls `requireString(value, label)` and throws a descriptive `Error` at register time if either is empty. That error surfaces at boot, so a missing OAuth env var fails the server before the first request.
192
-
193
- ## Built-in factories
194
-
195
- ### `credentialsProvider()`
196
-
197
- ```ts
198
- credentialsProvider(): CredentialsProvider // { name: 'credentials' }
199
- ```
200
-
201
- The "no-OAuth" entry. Include it in your `registerOAuthProviders` call when you want the credentials endpoint active alongside the OAuth providers.
202
-
203
- ### `googleProvider(input)`
204
-
205
- ```ts
206
- googleProvider({
207
- clientId, clientSecret, callbackUrl,
208
- endpoints?, extraScopes?, extraSessionFields?,
209
- });
210
- ```
211
-
212
- Defaults:
213
-
214
- | Field | Value |
215
- | --------------------- | ---------------------------------------------------------------------- |
216
- | `name` | `'google'` |
217
- | `authorizationURL` | `'https://accounts.google.com/o/oauth2/v2/auth'` |
218
- | `tokenExchangeURL` | `'https://oauth2.googleapis.com/token'` |
219
- | `tokenExchangeMethod` | `'json'` |
220
- | `userInfoURL` | `'https://www.googleapis.com/oauth2/v1/userinfo'` |
221
- | `scope` | `['userinfo.profile', 'userinfo.email']` (full URLs in the source) |
222
- | `nameKey` | `'name'` |
223
- | `emailKey` | `'email'` |
224
- | `avatarKey` | `'picture'` (direct URL) |
225
-
226
- ### `githubProvider(input)`
227
-
228
- | Field | Value |
229
- | --------------------- | ---------------------------------------------------------------------------------------------- |
230
- | `name` | `'github'` |
231
- | `authorizationURL` | `'https://github.com/login/oauth/authorize'` |
232
- | `tokenExchangeURL` | `'https://github.com/login/oauth/access_token'` |
233
- | `tokenExchangeMethod` | `'json'` |
234
- | `userInfoURL` | `'https://api.github.com/user'` |
235
- | `scope` | `['read:user', 'user:email']` |
236
- | `nameKey` | `'login'` (GitHub username — `name` can be null for users who never set one) |
237
- | `emailKey` | `'email'` |
238
- | `avatarKey` | `'avatar_url'` |
239
- | `getEmail` | Falls back to `GET /user/emails` and picks the entry with `primary: true`, then the first one. |
240
-
241
- GitHub returns `email: null` from `/user` when the user has hidden their email from their profile. `getEmail` fetches `/user/emails` (which requires the `user:email` scope) and selects an address.
242
-
243
- ### `discordProvider(input)`
244
-
245
- | Field | Value |
246
- | --------------------- | ------------------------------------------------------------------ |
247
- | `name` | `'discord'` |
248
- | `authorizationURL` | `'https://discord.com/oauth2/authorize'` |
249
- | `tokenExchangeURL` | `'https://discord.com/api/oauth2/token'` |
250
- | `tokenExchangeMethod` | `'form'` |
251
- | `userInfoURL` | `'https://discord.com/api/users/@me'` |
252
- | `scope` | `['identify', 'email']` |
253
- | `nameKey` | `'username'` |
254
- | `emailKey` | `'email'` |
255
- | `avatarCodeKey` | `'avatar'` (a hash, not a URL) |
256
- | `getAvatar` | Builds `cdn.discordapp.com/avatars/<userId>/<hash>.{png,gif}`. |
257
-
258
- `getAvatar` picks `.gif` when the hash starts with `a_` (animated avatars).
259
-
260
- ### `facebookProvider(input)`
261
-
262
- ```ts
263
- interface FacebookProviderInput extends OAuthHelperInput {
264
- apiVersion?: string; // defaults to 'v18.0'
265
- }
266
- ```
267
-
268
- | Field | Value |
269
- | --------------------- | ---------------------------------------------------------------------------------------- |
270
- | `name` | `'facebook'` |
271
- | `authorizationURL` | `https://www.facebook.com/${apiVersion}/dialog/oauth` |
272
- | `tokenExchangeURL` | `https://graph.facebook.com/${apiVersion}/oauth/access_token` |
273
- | `tokenExchangeMethod` | `'form'` |
274
- | `userInfoURL` | `'https://graph.facebook.com/me?fields=id,name,email,picture.type(large)'` |
275
- | `scope` | `['public_profile', 'email']` |
276
- | `nameKey` | `'name'` |
277
- | `emailKey` | `'email'` |
278
- | `getAvatar` | Reads `userData.picture.data.url` (Graph returns a nested object when you ask for it). |
279
-
280
- `apiVersion` is exposed because Meta deprecates Graph API versions on a rolling schedule. Pin the version explicitly if you want to lock against unannounced breaking changes.
281
-
282
- ### `microsoftProvider(input)`
283
-
284
- ```ts
285
- interface MicrosoftProviderInput extends OAuthHelperInput {
286
- tenant?: string; // defaults to 'common'
287
- apiVersion?: string; // defaults to 'v2.0'
288
- graphApiVersion?: string; // defaults to 'v1.0'
289
- }
290
- ```
291
-
292
- | Field | Value |
293
- | --------------------- | ------------------------------------------------------------------------------------------------------- |
294
- | `name` | `'microsoft'` |
295
- | `authorizationURL` | `https://login.microsoftonline.com/${tenant}/oauth2/${apiVersion}/authorize` |
296
- | `tokenExchangeURL` | `https://login.microsoftonline.com/${tenant}/oauth2/${apiVersion}/token` |
297
- | `tokenExchangeMethod` | `'form'` |
298
- | `userInfoURL` | `https://graph.microsoft.com/${graphApiVersion}/me` |
299
- | `scope` | `['openid', 'profile', 'email', 'User.Read']` |
300
- | `nameKey` | `'displayName'` |
301
- | `emailKey` | `'mail'` |
302
- | `avatarCodeKey` | `'id'` |
303
- | `getAvatar` | Fetches `/users/${id}/photo/$value` with the bearer token, inlines bytes as a `data:image/...` URL. |
304
- | `getEmail` | Falls back to a second `/me` call that picks `mail` then `userPrincipalName`. |
305
-
306
- Microsoft Graph's `/photo/$value` endpoint requires bearer auth, so we can't store the URL on the user record — a browser `<img>` would 401. `getAvatar` fetches the bytes server-side and produces a `data:` URL, which is then stored on `User.avatar` and served like any other inline avatar.
307
-
308
- `tenant` accepts:
309
-
310
- - A UUID (`'b3f1...'`) — single-tenant Azure AD.
311
- - `'common'` (default) — multi-tenant + personal accounts.
312
- - `'organizations'` — multi-tenant work/school only.
313
- - `'consumers'` — personal accounts only.
314
-
315
- > Note: as of 2026-05-14 the Microsoft flow is implemented but has not been end-to-end verified against a live Azure AD tenant. The token-exchange URL, the Graph `/me` shape, and the photo data-URL pipeline are based on the official Microsoft docs and pattern-matched against Google/GitHub. The first consumer to wire a real Azure tenant should report any rough edges so the note can be dropped.
316
-
317
- ## End-to-end OAuth flow
318
-
319
- The pieces glue together in `loginCallback` (see [`./src/login.ts`](../src/login.ts)):
320
-
321
- 1. **Authorize redirect** — `/auth/login/<provider>` (wired by `@luckystack/server`) calls `createOAuthState(provider)` and redirects the browser to `provider.authorizationURL?client_id=...&redirect_uri=...&state=...&scope=...&response_type=code`.
322
- 2. **User authorizes** — the user accepts on the provider's UI; the provider redirects back to `callbackURL?code=...&state=...`.
323
- 3. **State validation** — `loginCallback` calls `consumeOAuthState(provider.name, state)`. Invalid or missing state → return `false`.
324
- 4. **Token exchange** — `exchangeOAuthToken(provider, code)` POSTs to `tokenExchangeURL`. JSON or form body depending on `tokenExchangeMethod`. Reads `access_token` from the response.
325
- 5. **Userinfo fetch** — `fetchOAuthProfile(provider, accessToken)`. Pulls `name` via `nameKey`, `email` via `emailKey`. Calls `getAvatar` / `getEmail` for providers that need post-processing.
326
- 6. **Find-or-create** — `findOrCreateOAuthUser(provider, profile)`:
327
- - Dispatches `preLogin`. Stop signal → return `null` (which becomes `false` upstream).
328
- - `userAdapter.findByEmail({ email, provider: provider.name })`.
329
- - Returning user → dispatch `postLogin`; update `lastLogin`; return `{ user, isNewUser: false }`.
330
- - New user → dispatch `preRegister` (can stop); `userAdapter.create(...)`; return `{ user, isNewUser: true }`.
331
- 7. **Mint session token** — 32 bytes hex, attached to `resolved.user.token`.
332
- 8. **`extraSessionFields`** — if the provider defined one, run it and merge the result onto the session. Errors are warn-logged and skipped (a missing extra is not worth blocking login).
333
- 9. **`saveSession(token, user, true)`** — runs `preSessionCreate` / `postSessionCreate`, single-session enforcement, CSRF minting, broadcast.
334
- 10. **`postRegister` (new only) + `postLogin`** — fired with `{ userId, provider, isNewUser, token }`.
335
- 11. **Resolve redirect** — `resolvePostLoginRedirect` calls the registered post-login resolver (if any), validates the URL against `http.cors.allowedOrigins`, falls back to `loginRedirectUrl` or `'/'`. See [`./redirect-validation.md`](./redirect-validation.md).
336
- 12. **Return** — `{ token, redirectUrl, userId, provider, isNewUser }`. `@luckystack/server` writes the token to the session cookie and 302s the browser to `redirectUrl`.
337
-
338
- ## `extraSessionFields` — per-provider runtime extras
339
-
340
- ```ts
341
- googleProvider({
342
- clientId, clientSecret, callbackUrl,
343
- extraScopes: ['https://www.googleapis.com/auth/calendar.readonly'],
344
- extraSessionFields: async ({ userData, accessToken }) => ({
345
- googleCalendarToken: accessToken,
346
- }),
347
- });
348
- ```
349
-
350
- For strict typing on the session field, augment `BaseSessionLayout`:
351
-
352
- ```ts
353
- declare module '@luckystack/core' {
354
- interface BaseSessionLayout {
355
- googleCalendarToken?: string;
356
- }
357
- }
358
- ```
359
-
360
- Important constraints:
361
-
362
- - Data lives on the Redis session record. It disappears on logout — no Prisma schema change required.
363
- - Do NOT put secrets you wouldn't want a user to see if they inspected their own session. The session is broadcast to the user's own browser; treat everything in it as "the user can see this".
364
- - Errors thrown from the hook are caught, warn-logged with `[oauth:<provider>] extraSessionFields hook threw — continuing without extras`, and login proceeds without the extras. A missing extra is preferable to a blocked login.
365
-
366
- ## Authoring a custom provider
367
-
368
- Custom providers (Okta, Apple, X, Auth0, internal SSO) plug in as raw `FullOAuthProvider` objects:
369
-
370
- ```ts
371
- import { registerOAuthProviders, type FullOAuthProvider } from '@luckystack/login';
372
-
373
- const oktaProvider = (input): FullOAuthProvider => ({
374
- name: 'okta',
375
- clientID: input.clientId,
376
- clientSecret: input.clientSecret,
377
- callbackURL: input.callbackUrl,
378
- authorizationURL: `https://${input.domain}/oauth2/v1/authorize`,
379
- tokenExchangeURL: `https://${input.domain}/oauth2/v1/token`,
380
- tokenExchangeMethod: 'form',
381
- userInfoURL: `https://${input.domain}/oauth2/v1/userinfo`,
382
- scope: ['openid', 'profile', 'email'],
383
- nameKey: 'name',
384
- emailKey: 'email',
385
- avatarKey: 'picture',
386
- avatarCodeKey: '',
387
- });
388
-
389
- registerOAuthProviders([
390
- credentialsProvider(),
391
- googleProvider({ ... }),
392
- oktaProvider({
393
- clientId: process.env.OKTA_CLIENT_ID,
394
- clientSecret: process.env.OKTA_CLIENT_SECRET,
395
- callbackUrl: `${process.env.DNS}/auth/callback/okta`,
396
- domain: 'acme.okta.com',
397
- }),
398
- ]);
399
- ```
400
-
401
- Two non-obvious things to watch:
402
-
403
- 1. **`name` must be URL-safe.** It's interpolated into the callback path (`/auth/callback/okta`).
404
- 2. **`tokenExchangeMethod`.** If the provider expects `application/x-www-form-urlencoded` for the token exchange (most do, including Okta), use `'form'`. JSON-style (Google, GitHub) is the exception.
405
-
406
- ## Self-hosted enterprise mirrors
407
-
408
- For self-hosted GitHub Enterprise / GitLab / Gitea, use the `endpoints?.*` overrides on the built-in helpers:
409
-
410
- ```ts
411
- githubProvider({
412
- clientId: process.env.GITHUB_CLIENT_ID,
413
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
414
- callbackUrl: `${process.env.DNS}/auth/callback/github`,
415
- endpoints: {
416
- authorizationURL: 'https://github.acme.example/login/oauth/authorize',
417
- tokenExchangeURL: 'https://github.acme.example/login/oauth/access_token',
418
- userInfoURL: 'https://github.acme.example/api/v3/user',
419
- },
420
- });
421
- ```
422
-
423
- The provider `name` is still `'github'` (so the callback path stays `/auth/callback/github`), but the URLs point at the enterprise host.
424
-
425
- ## Related
426
-
427
- - [`./redirect-validation.md`](./redirect-validation.md) — `isAllowedRedirectUrl` and `registerPostLoginRedirect`.
428
- - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after OAuth resolution.
429
- - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` for OAuth flows.
430
- - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store; OAuth uses the same `findByEmail` / `create` surface.
431
- - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
1
+ # OAuth providers
2
+
3
+ Deep-dive on the OAuth provider registry, built-in helpers, and the contract for plugging in custom providers. The canonical source is [`./src/oauthProviders.ts`](../src/oauthProviders.ts).
4
+
5
+ `@luckystack/login` ships an OAuth 2.0 authorization-code flow with PKCE-equivalent state validation. Built-in helpers cover Google, GitHub, Discord, Facebook, and Microsoft. Anything else (Okta, Apple, X / Twitter, Auth0, Keycloak, custom enterprise SSO, self-hosted Gitea) plugs in as a raw `FullOAuthProvider` object.
6
+
7
+ ---
8
+
9
+ ## The registry
10
+
11
+ The active provider list is stored in a module-level variable in `oauthProviders.ts`:
12
+
13
+ ```ts
14
+ let registeredProviders: OAuthProvider[] = [{ name: 'credentials' }];
15
+
16
+ export const registerOAuthProviders = (providers: OAuthProvider[]): OAuthProvider[] => {
17
+ registeredProviders = providers;
18
+ return registeredProviders;
19
+ };
20
+
21
+ export const getOAuthProviders = (): OAuthProvider[] => registeredProviders;
22
+ ```
23
+
24
+ `registerOAuthProviders([...])` **replaces** the active list — it is not additive. The default list contains only `{ name: 'credentials' }`, which means in a fresh project with no registration call:
25
+
26
+ - `loginWithCredentials` keeps working (the `'credentials'` entry is still present).
27
+ - Every `/auth/callback/<provider>` request returns `false` because no matching provider is in the list.
28
+
29
+ Register at boot, before the first login request:
30
+
31
+ ```ts
32
+ // luckystack/server/index.ts (installer side)
33
+ import {
34
+ registerOAuthProviders,
35
+ credentialsProvider,
36
+ googleProvider,
37
+ githubProvider,
38
+ } from '@luckystack/login';
39
+
40
+ registerOAuthProviders([
41
+ credentialsProvider(),
42
+ googleProvider({
43
+ clientId: process.env.GOOGLE_CLIENT_ID,
44
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
45
+ callbackUrl: `${process.env.DNS}/auth/callback/google`,
46
+ }),
47
+ githubProvider({
48
+ clientId: process.env.GITHUB_CLIENT_ID,
49
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
50
+ callbackUrl: `${process.env.DNS}/auth/callback/github`,
51
+ }),
52
+ ]);
53
+ ```
54
+
55
+ If you omit `credentialsProvider()`, the credentials endpoint will reject all requests because the dispatcher looks the provider up by name.
56
+
57
+ ## CSRF state — `createOAuthState`
58
+
59
+ OAuth 2.0 requires a `state` parameter on the `/authorize` request that the provider echoes back to the callback. `@luckystack/login` issues this state from Redis:
60
+
61
+ ```ts
62
+ export const createOAuthState = async (providerName: string): Promise<string | null> => {
63
+ const state = randomBytes(32).toString('hex');
64
+ const key = `${getProjectName()}-oauth-state:${providerName}:${state}`;
65
+ const result = await redis.set(key, '1', 'EX', auth.oauthStateTtlSeconds, 'NX');
66
+ if (result !== 'OK') return null;
67
+ return state;
68
+ };
69
+ ```
70
+
71
+ Key points:
72
+
73
+ - 32 random bytes → 64-char hex.
74
+ - Key namespace: `${projectName}-oauth-state:<provider>:<state>`. Uses the shared `getProjectName()` helper so the namespace matches sessions / activeUsers / rate-limit / password-reset (no drift between keys).
75
+ - TTL: `auth.oauthStateTtlSeconds` (default 600 = 10 minutes).
76
+ - `NX` flag prevents collisions — if a random collision happened (astronomically unlikely with 256 bits of entropy), the function returns `null` and the caller retries.
77
+
78
+ The state is consumed atomically inside `loginCallback`:
79
+
80
+ ```ts
81
+ const consumeOAuthState = async (provider, state) => {
82
+ const txResult = await redis.multi().get(key).del(key).exec();
83
+ // ...returns true iff the GET returned '1'.
84
+ };
85
+ ```
86
+
87
+ This is a one-time-use redemption — replaying the same `state` on a second callback fails. Combined with the short TTL it defeats both CSRF and replay attacks on the callback.
88
+
89
+ ## `OAuthProvider` discriminated union
90
+
91
+ ```ts
92
+ export type OAuthProvider = CredentialsProvider | FullOAuthProvider;
93
+
94
+ export interface CredentialsProvider {
95
+ name: 'credentials';
96
+ }
97
+
98
+ export interface FullOAuthProvider {
99
+ name: string;
100
+ clientID: string;
101
+ clientSecret: string;
102
+ callbackURL: string;
103
+ authorizationURL: string;
104
+ tokenExchangeURL: string;
105
+ tokenExchangeMethod: 'json' | 'form';
106
+ userInfoURL: string;
107
+ scope: string[];
108
+ nameKey: string;
109
+ emailKey: string;
110
+ avatarKey?: string;
111
+ avatarCodeKey: string;
112
+ getEmail?: (accessToken: string) => Promise<string | false | undefined>;
113
+ getAvatar?: (params: {
114
+ userData: OAuthUserData;
115
+ avatarId?: string;
116
+ accessToken: string;
117
+ }) => string | undefined | Promise<string | undefined>;
118
+ extraSessionFields?: (params: {
119
+ userData: OAuthUserData;
120
+ accessToken: string;
121
+ }) => Promise<Record<string, unknown>> | Record<string, unknown>;
122
+ }
123
+ ```
124
+
125
+ `isFullOAuthProvider(provider)` is the type guard that separates the two branches:
126
+
127
+ ```ts
128
+ export const isFullOAuthProvider = (provider: OAuthProvider): provider is FullOAuthProvider =>
129
+ provider.name !== 'credentials';
130
+ ```
131
+
132
+ `loginCallback` calls this guard first and bails when the lookup resolves to `{ name: 'credentials' }` — that entry exists only to keep the credentials endpoint enabled in the same list.
133
+
134
+ ### Field reference
135
+
136
+ | Field | Purpose |
137
+ | --------------------- | --------------------------------------------------------------------------------------------------------- |
138
+ | `name` | URL slug used in `/auth/callback/<name>` and stored on `User.provider`. |
139
+ | `clientID` | OAuth app client id. |
140
+ | `clientSecret` | OAuth app secret. |
141
+ | `callbackURL` | Must match the URL registered with the provider, normally `${DNS}/auth/callback/<name>`. |
142
+ | `authorizationURL` | Where to redirect the user to authorize (start of the flow). |
143
+ | `tokenExchangeURL` | POST endpoint for exchanging `code` → `access_token`. |
144
+ | `tokenExchangeMethod` | `'json'` (Google) or `'form'` (Discord, Facebook, Microsoft). GitHub accepts either; we use `'json'`. |
145
+ | `userInfoURL` | GET endpoint that returns the user profile JSON given a bearer token. |
146
+ | `scope` | Array of scopes requested at authorize-time. Joined with spaces by the consumer-side `/auth/login` route. |
147
+ | `nameKey` | Top-level key in the userinfo response that holds the display name (`'name'` / `'login'` / etc.). |
148
+ | `emailKey` | Top-level key in the userinfo response that holds the email. |
149
+ | `avatarKey` | Direct-URL variant — `userData[avatarKey]` IS the avatar URL. |
150
+ | `avatarCodeKey` | Compose-URL variant — `userData[avatarCodeKey]` is an id that `getAvatar` turns into a URL. |
151
+ | `getEmail` | Optional fallback when `userData[emailKey]` is missing (GitHub `/user/emails`). |
152
+ | `getAvatar` | Optional resolver for `avatarCodeKey` providers (Discord, Microsoft). |
153
+ | `extraSessionFields` | Optional hook that returns a record to merge onto the session BEFORE `saveSession`. |
154
+
155
+ `OAuthUserData = Record<string, unknown>` is the framework's representation of the userinfo response. `asOAuthUserData(value)` is a safe cast helper for use inside `getAvatar` / `extraSessionFields` callbacks:
156
+
157
+ ```ts
158
+ export const asOAuthUserData = (value: unknown): OAuthUserData => {
159
+ if (value && typeof value === 'object') {
160
+ return value as OAuthUserData;
161
+ }
162
+ return {};
163
+ };
164
+ ```
165
+
166
+ ## `OAuthHelperInput` — the common factory input
167
+
168
+ Every built-in factory accepts the same base shape with optional override slots:
169
+
170
+ ```ts
171
+ interface OAuthHelperInput {
172
+ clientId: string | undefined;
173
+ clientSecret: string | undefined;
174
+ callbackUrl: string;
175
+ endpoints?: {
176
+ authorizationURL?: string;
177
+ tokenExchangeURL?: string;
178
+ userInfoURL?: string;
179
+ };
180
+ extraScopes?: string[];
181
+ extraSessionFields?: FullOAuthProvider['extraSessionFields'];
182
+ }
183
+ ```
184
+
185
+ Why each slot exists:
186
+
187
+ - **`endpoints?.*`** — Self-hosted GitHub Enterprise, single-tenant Azure with a custom hostname, internal auth proxies. The defaults match the public hosted services; overrides give you the same provider shape pointed at a different host.
188
+ - **`extraScopes`** — Add to (not replace) the default scopes. Use for "give me read access to the user's calendar / drive / repos" flows. Merged via `mergeScopes` with `Set`-based deduplication so passing a default scope is idempotent.
189
+ - **`extraSessionFields`** — Per-provider runtime extras (calendar tokens, tenant IDs, organization claims) merged into the session BEFORE the first `saveSession`.
190
+
191
+ `clientId` / `clientSecret` are typed as `string | undefined` because the most common call site is `process.env.X_CLIENT_ID`. Each factory calls `requireString(value, label)` and throws a descriptive `Error` at register time if either is empty. That error surfaces at boot, so a missing OAuth env var fails the server before the first request.
192
+
193
+ ## Built-in factories
194
+
195
+ ### `credentialsProvider()`
196
+
197
+ ```ts
198
+ credentialsProvider(): CredentialsProvider // { name: 'credentials' }
199
+ ```
200
+
201
+ The "no-OAuth" entry. Include it in your `registerOAuthProviders` call when you want the credentials endpoint active alongside the OAuth providers.
202
+
203
+ ### `googleProvider(input)`
204
+
205
+ ```ts
206
+ googleProvider({
207
+ clientId, clientSecret, callbackUrl,
208
+ endpoints?, extraScopes?, extraSessionFields?,
209
+ });
210
+ ```
211
+
212
+ Defaults:
213
+
214
+ | Field | Value |
215
+ | --------------------- | ---------------------------------------------------------------------- |
216
+ | `name` | `'google'` |
217
+ | `authorizationURL` | `'https://accounts.google.com/o/oauth2/v2/auth'` |
218
+ | `tokenExchangeURL` | `'https://oauth2.googleapis.com/token'` |
219
+ | `tokenExchangeMethod` | `'json'` |
220
+ | `userInfoURL` | `'https://www.googleapis.com/oauth2/v1/userinfo'` |
221
+ | `scope` | `['userinfo.profile', 'userinfo.email']` (full URLs in the source) |
222
+ | `nameKey` | `'name'` |
223
+ | `emailKey` | `'email'` |
224
+ | `avatarKey` | `'picture'` (direct URL) |
225
+
226
+ ### `githubProvider(input)`
227
+
228
+ | Field | Value |
229
+ | --------------------- | ---------------------------------------------------------------------------------------------- |
230
+ | `name` | `'github'` |
231
+ | `authorizationURL` | `'https://github.com/login/oauth/authorize'` |
232
+ | `tokenExchangeURL` | `'https://github.com/login/oauth/access_token'` |
233
+ | `tokenExchangeMethod` | `'json'` |
234
+ | `userInfoURL` | `'https://api.github.com/user'` |
235
+ | `scope` | `['read:user', 'user:email']` |
236
+ | `nameKey` | `'login'` (GitHub username — `name` can be null for users who never set one) |
237
+ | `emailKey` | `'email'` |
238
+ | `avatarKey` | `'avatar_url'` |
239
+ | `getEmail` | Falls back to `GET /user/emails` and picks the entry with `primary: true`, then the first one. |
240
+
241
+ GitHub returns `email: null` from `/user` when the user has hidden their email from their profile. `getEmail` fetches `/user/emails` (which requires the `user:email` scope) and selects an address.
242
+
243
+ ### `discordProvider(input)`
244
+
245
+ | Field | Value |
246
+ | --------------------- | ------------------------------------------------------------------ |
247
+ | `name` | `'discord'` |
248
+ | `authorizationURL` | `'https://discord.com/oauth2/authorize'` |
249
+ | `tokenExchangeURL` | `'https://discord.com/api/oauth2/token'` |
250
+ | `tokenExchangeMethod` | `'form'` |
251
+ | `userInfoURL` | `'https://discord.com/api/users/@me'` |
252
+ | `scope` | `['identify', 'email']` |
253
+ | `nameKey` | `'username'` |
254
+ | `emailKey` | `'email'` |
255
+ | `avatarCodeKey` | `'avatar'` (a hash, not a URL) |
256
+ | `getAvatar` | Builds `cdn.discordapp.com/avatars/<userId>/<hash>.{png,gif}`. |
257
+
258
+ `getAvatar` picks `.gif` when the hash starts with `a_` (animated avatars).
259
+
260
+ ### `facebookProvider(input)`
261
+
262
+ ```ts
263
+ interface FacebookProviderInput extends OAuthHelperInput {
264
+ apiVersion?: string; // defaults to 'v18.0'
265
+ }
266
+ ```
267
+
268
+ | Field | Value |
269
+ | --------------------- | ---------------------------------------------------------------------------------------- |
270
+ | `name` | `'facebook'` |
271
+ | `authorizationURL` | `https://www.facebook.com/${apiVersion}/dialog/oauth` |
272
+ | `tokenExchangeURL` | `https://graph.facebook.com/${apiVersion}/oauth/access_token` |
273
+ | `tokenExchangeMethod` | `'form'` |
274
+ | `userInfoURL` | `'https://graph.facebook.com/me?fields=id,name,email,picture.type(large)'` |
275
+ | `scope` | `['public_profile', 'email']` |
276
+ | `nameKey` | `'name'` |
277
+ | `emailKey` | `'email'` |
278
+ | `getAvatar` | Reads `userData.picture.data.url` (Graph returns a nested object when you ask for it). |
279
+
280
+ `apiVersion` is exposed because Meta deprecates Graph API versions on a rolling schedule. Pin the version explicitly if you want to lock against unannounced breaking changes.
281
+
282
+ ### `microsoftProvider(input)`
283
+
284
+ ```ts
285
+ interface MicrosoftProviderInput extends OAuthHelperInput {
286
+ tenant?: string; // defaults to 'common'
287
+ apiVersion?: string; // defaults to 'v2.0'
288
+ graphApiVersion?: string; // defaults to 'v1.0'
289
+ }
290
+ ```
291
+
292
+ | Field | Value |
293
+ | --------------------- | ------------------------------------------------------------------------------------------------------- |
294
+ | `name` | `'microsoft'` |
295
+ | `authorizationURL` | `https://login.microsoftonline.com/${tenant}/oauth2/${apiVersion}/authorize` |
296
+ | `tokenExchangeURL` | `https://login.microsoftonline.com/${tenant}/oauth2/${apiVersion}/token` |
297
+ | `tokenExchangeMethod` | `'form'` |
298
+ | `userInfoURL` | `https://graph.microsoft.com/${graphApiVersion}/me` |
299
+ | `scope` | `['openid', 'profile', 'email', 'User.Read']` |
300
+ | `nameKey` | `'displayName'` |
301
+ | `emailKey` | `'mail'` |
302
+ | `avatarCodeKey` | `'id'` |
303
+ | `getAvatar` | Fetches `/users/${id}/photo/$value` with the bearer token, inlines bytes as a `data:image/...` URL. |
304
+ | `getEmail` | Falls back to a second `/me` call that picks `mail` then `userPrincipalName`. |
305
+
306
+ Microsoft Graph's `/photo/$value` endpoint requires bearer auth, so we can't store the URL on the user record — a browser `<img>` would 401. `getAvatar` fetches the bytes server-side and produces a `data:` URL, which is then stored on `User.avatar` and served like any other inline avatar.
307
+
308
+ `tenant` accepts:
309
+
310
+ - A UUID (`'b3f1...'`) — single-tenant Azure AD.
311
+ - `'common'` (default) — multi-tenant + personal accounts.
312
+ - `'organizations'` — multi-tenant work/school only.
313
+ - `'consumers'` — personal accounts only.
314
+
315
+ > Note: as of 2026-05-14 the Microsoft flow is implemented but has not been end-to-end verified against a live Azure AD tenant. The token-exchange URL, the Graph `/me` shape, and the photo data-URL pipeline are based on the official Microsoft docs and pattern-matched against Google/GitHub. The first consumer to wire a real Azure tenant should report any rough edges so the note can be dropped.
316
+
317
+ ## End-to-end OAuth flow
318
+
319
+ The pieces glue together in `loginCallback` (see [`./src/login.ts`](../src/login.ts)):
320
+
321
+ 1. **Authorize redirect** — `/auth/login/<provider>` (wired by `@luckystack/server`) calls `createOAuthState(provider)` and redirects the browser to `provider.authorizationURL?client_id=...&redirect_uri=...&state=...&scope=...&response_type=code`.
322
+ 2. **User authorizes** — the user accepts on the provider's UI; the provider redirects back to `callbackURL?code=...&state=...`.
323
+ 3. **State validation** — `loginCallback` calls `consumeOAuthState(provider.name, state)`. Invalid or missing state → return `false`.
324
+ 4. **Token exchange** — `exchangeOAuthToken(provider, code)` POSTs to `tokenExchangeURL`. JSON or form body depending on `tokenExchangeMethod`. Reads `access_token` from the response.
325
+ 5. **Userinfo fetch** — `fetchOAuthProfile(provider, accessToken)`. Pulls `name` via `nameKey`, `email` via `emailKey`. Calls `getAvatar` / `getEmail` for providers that need post-processing.
326
+ 6. **Find-or-create** — `findOrCreateOAuthUser(provider, profile)`:
327
+ - Dispatches `preLogin`. Stop signal → return `null` (which becomes `false` upstream).
328
+ - `userAdapter.findByEmail({ email, provider: provider.name })`.
329
+ - Returning user → dispatch `postLogin`; update `lastLogin`; return `{ user, isNewUser: false }`.
330
+ - New user → dispatch `preRegister` (can stop); `userAdapter.create(...)`; return `{ user, isNewUser: true }`.
331
+ 7. **Mint session token** — 32 bytes hex, attached to `resolved.user.token`.
332
+ 8. **`extraSessionFields`** — if the provider defined one, run it and merge the result onto the session. Errors are warn-logged and skipped (a missing extra is not worth blocking login).
333
+ 9. **`saveSession(token, user, true)`** — runs `preSessionCreate` / `postSessionCreate`, single-session enforcement, CSRF minting, broadcast.
334
+ 10. **`postRegister` (new only) + `postLogin`** — fired with `{ userId, provider, isNewUser, token }`.
335
+ 11. **Resolve redirect** — `resolvePostLoginRedirect` calls the registered post-login resolver (if any), validates the URL against `http.cors.allowedOrigins`, falls back to `loginRedirectUrl` or `'/'`. See [`./redirect-validation.md`](./redirect-validation.md).
336
+ 12. **Return** — `{ token, redirectUrl, userId, provider, isNewUser }`. `@luckystack/server` writes the token to the session cookie and 302s the browser to `redirectUrl`.
337
+
338
+ ## `extraSessionFields` — per-provider runtime extras
339
+
340
+ ```ts
341
+ googleProvider({
342
+ clientId, clientSecret, callbackUrl,
343
+ extraScopes: ['https://www.googleapis.com/auth/calendar.readonly'],
344
+ extraSessionFields: async ({ userData, accessToken }) => ({
345
+ googleCalendarToken: accessToken,
346
+ }),
347
+ });
348
+ ```
349
+
350
+ For strict typing on the session field, augment `BaseSessionLayout`:
351
+
352
+ ```ts
353
+ declare module '@luckystack/core' {
354
+ interface BaseSessionLayout {
355
+ googleCalendarToken?: string;
356
+ }
357
+ }
358
+ ```
359
+
360
+ Important constraints:
361
+
362
+ - Data lives on the Redis session record. It disappears on logout — no Prisma schema change required.
363
+ - Do NOT put secrets you wouldn't want a user to see if they inspected their own session. The session is broadcast to the user's own browser; treat everything in it as "the user can see this".
364
+ - Errors thrown from the hook are caught, warn-logged with `[oauth:<provider>] extraSessionFields hook threw — continuing without extras`, and login proceeds without the extras. A missing extra is preferable to a blocked login.
365
+
366
+ ## Authoring a custom provider
367
+
368
+ Custom providers (Okta, Apple, X, Auth0, internal SSO) plug in as raw `FullOAuthProvider` objects:
369
+
370
+ ```ts
371
+ import { registerOAuthProviders, type FullOAuthProvider } from '@luckystack/login';
372
+
373
+ const oktaProvider = (input): FullOAuthProvider => ({
374
+ name: 'okta',
375
+ clientID: input.clientId,
376
+ clientSecret: input.clientSecret,
377
+ callbackURL: input.callbackUrl,
378
+ authorizationURL: `https://${input.domain}/oauth2/v1/authorize`,
379
+ tokenExchangeURL: `https://${input.domain}/oauth2/v1/token`,
380
+ tokenExchangeMethod: 'form',
381
+ userInfoURL: `https://${input.domain}/oauth2/v1/userinfo`,
382
+ scope: ['openid', 'profile', 'email'],
383
+ nameKey: 'name',
384
+ emailKey: 'email',
385
+ avatarKey: 'picture',
386
+ avatarCodeKey: '',
387
+ });
388
+
389
+ registerOAuthProviders([
390
+ credentialsProvider(),
391
+ googleProvider({ ... }),
392
+ oktaProvider({
393
+ clientId: process.env.OKTA_CLIENT_ID,
394
+ clientSecret: process.env.OKTA_CLIENT_SECRET,
395
+ callbackUrl: `${process.env.DNS}/auth/callback/okta`,
396
+ domain: 'acme.okta.com',
397
+ }),
398
+ ]);
399
+ ```
400
+
401
+ Two non-obvious things to watch:
402
+
403
+ 1. **`name` must be URL-safe.** It's interpolated into the callback path (`/auth/callback/okta`).
404
+ 2. **`tokenExchangeMethod`.** If the provider expects `application/x-www-form-urlencoded` for the token exchange (most do, including Okta), use `'form'`. JSON-style (Google, GitHub) is the exception.
405
+
406
+ ## Self-hosted enterprise mirrors
407
+
408
+ For self-hosted GitHub Enterprise / GitLab / Gitea, use the `endpoints?.*` overrides on the built-in helpers:
409
+
410
+ ```ts
411
+ githubProvider({
412
+ clientId: process.env.GITHUB_CLIENT_ID,
413
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
414
+ callbackUrl: `${process.env.DNS}/auth/callback/github`,
415
+ endpoints: {
416
+ authorizationURL: 'https://github.acme.example/login/oauth/authorize',
417
+ tokenExchangeURL: 'https://github.acme.example/login/oauth/access_token',
418
+ userInfoURL: 'https://github.acme.example/api/v3/user',
419
+ },
420
+ });
421
+ ```
422
+
423
+ The provider `name` is still `'github'` (so the callback path stays `/auth/callback/github`), but the URLs point at the enterprise host.
424
+
425
+ ## Related
426
+
427
+ - [`./redirect-validation.md`](./redirect-validation.md) — `isAllowedRedirectUrl` and `registerPostLoginRedirect`.
428
+ - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after OAuth resolution.
429
+ - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` for OAuth flows.
430
+ - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store; OAuth uses the same `findByEmail` / `create` surface.
431
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).