@luckystack/login 0.6.7 → 0.7.1

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,390 +1,390 @@
1
- # Redirect validation
2
-
3
- Deep-dive on the post-login redirect resolver registry and the open-redirect defense that gates its output. Canonical sources: [`./src/redirectResolver.ts`](../src/redirectResolver.ts) (registry + types) and the `isAllowedRedirectUrl` / `resolvePostLoginRedirect` helpers in [`./src/login.ts`](../src/login.ts).
4
-
5
- After a successful OAuth callback, `loginCallback` needs to decide where to send the browser. The default is `projectConfig.loginRedirectUrl` (typically `/dashboard`), but most non-trivial apps want to route by user state — first-time vs returning, per-tenant landing page, per-provider deep link. The post-login redirect resolver is the supported extension point.
6
-
7
- This document covers: why it lives in its own registry (not a hook), the resolver shape, the open-redirect defense, the fallback chain, and the call-time validation rules.
8
-
9
- ---
10
-
11
- ## Why a dedicated registry instead of a hook
12
-
13
- The framework's hook bus (`registerHook` / `dispatchHook`) is "stop-or-continue" — handlers return `undefined` or a `HookStopSignal`. They do not carry a value back to the caller. The redirect resolver MUST return a string (the URL). Two options:
14
-
15
- 1. Add a "value-returning hook" variant to `@luckystack/core`. Generic, but every hook gains a return-type for one use case.
16
- 2. Define a dedicated single-slot registry for this specific need.
17
-
18
- The framework picks option 2 because:
19
-
20
- - **Single resolver wins** — there is no "chain N redirect resolvers and pick the first non-empty" scenario that makes sense. Per-tenant routing, per-user routing, per-provider routing — all three compose inside a SINGLE resolver function. Two resolvers fighting over the same login is not a feature.
21
- - **Clear ownership** — the resolver is set once at boot by the application, not registered ad-hoc by a feature package. The single-slot registry expresses that.
22
- - **Smaller surface** — no hook-bus features the resolver doesn't need (priority, stop signals, async-iterable handler arrays).
23
-
24
- ```ts
25
- // packages/login/src/redirectResolver.ts
26
- let activeResolver: PostLoginRedirectResolver | null = null;
27
-
28
- export const registerPostLoginRedirect = (resolver) => {
29
- activeResolver = resolver;
30
- return resolver;
31
- };
32
-
33
- export const getPostLoginRedirect = () => activeResolver;
34
- ```
35
-
36
- `registerPostLoginRedirect` is last-write-wins. `getPostLoginRedirect()` returns `null` when no resolver has been registered — the default state — and `loginCallback` falls back to `projectConfig.loginRedirectUrl` in that case.
37
-
38
- ## Resolver shape
39
-
40
- ```ts
41
- interface PostLoginRedirectInput {
42
- userId: string;
43
- provider: string;
44
- isNewUser: boolean;
45
- defaultUrl: string;
46
- }
47
-
48
- type PostLoginRedirectResolver = (input: PostLoginRedirectInput) => string | Promise<string>;
49
- ```
50
-
51
- | Field | Notes |
52
- | ------------ | ------------------------------------------------------------------------------------------- |
53
- | `userId` | The id of the user who just logged in. Look it up in your DB for per-user routing. |
54
- | `provider` | The OAuth provider name (`'google'`, `'github'`, `'discord'`, etc.) or `'credentials'`. |
55
- | `isNewUser` | `true` for first-time OAuth users (`postRegister` just fired), `false` for returning ones. |
56
- | `defaultUrl` | The framework's computed default — the same URL that would be used without a resolver. Use as a fallback inside the resolver. |
57
-
58
- The resolver may be sync or async. It runs INSIDE `loginCallback` between `postLogin` and the HTTP redirect — keep it fast. Database reads on the `userId` are fine; reaching out to a slow remote API will visibly delay every login.
59
-
60
- ## The open-redirect defense — `isAllowedRedirectUrl`
61
-
62
- `isAllowedRedirectUrl(url)` lives in `login.ts` and is the framework's defense against open-redirect attacks via the OAuth callback flow. Without it, a malicious link to `/auth/login/google?state=<attacker-state>&redirect_uri=https://evil.example/steal` could lure a user into authenticating and then bouncing them to a credential-stealing page.
63
-
64
- ```ts
65
- const isAllowedRedirectUrl = (url: string): boolean => {
66
- if (!URL.canParse(url, 'http://placeholder')) return false;
67
- const parsed = new URL(url, 'http://placeholder');
68
- if (parsed.origin === 'http://placeholder') {
69
- // relative URL — same-origin, always safe
70
- return true;
71
- }
72
- const allowed = getProjectConfig().http.cors.allowedOrigins ?? [];
73
- if (typeof allowed === 'function') {
74
- return allowed(parsed.origin);
75
- }
76
- return allowed.some((origin) => {
77
- if (!URL.canParse(origin)) return false;
78
- return new URL(origin).origin === parsed.origin;
79
- });
80
- };
81
- ```
82
-
83
- The rules:
84
-
85
- ### 1. Same-origin (relative URL) — always allowed
86
-
87
- ```ts
88
- '/dashboard' // ✓ relative path
89
- '/welcome?step=1' // ✓ relative path with query
90
- '/org/123/projects' // ✓ relative path with multi-segment
91
- ```
92
-
93
- The trick is parsing against a `'http://placeholder'` base — if the resulting origin matches the base, the input was a relative URL (no scheme/host of its own). Relative URLs land on the same origin as the app, so they're safe by definition.
94
-
95
- ### 2. Absolute URL — origin must match `http.cors.allowedOrigins`
96
-
97
- ```ts
98
- 'https://app.example.com/dashboard' // ✓ if 'https://app.example.com' is in allowedOrigins
99
- 'https://evil.example/steal' // ✗ unless explicitly allowed
100
- 'https://api.example.com/' // ✗ if only 'https://app.example.com' is allowed
101
- ```
102
-
103
- The check compares the parsed `origin` (scheme + host + port) of the URL to the parsed `origin` of each entry in `allowedOrigins`. String comparison wouldn't work — `https://app.example.com:443` and `https://app.example.com` should match (default port elided), and parsing both through `new URL().origin` normalizes them.
104
-
105
- ### 3. `allowedOrigins` as a function
106
-
107
- `http.cors.allowedOrigins` is typed as `string[] | (origin: string) => boolean`. The function variant lets consumers compute allowed origins dynamically — wildcard subdomains, tenant-specific origin allow-lists, integration with an internal "approved partners" service:
108
-
109
- ```ts
110
- registerProjectConfig({
111
- http: {
112
- cors: {
113
- allowedOrigins: (origin) => {
114
- // Allow any *.acme-customers.example subdomain
115
- const url = new URL(origin);
116
- if (url.hostname.endsWith('.acme-customers.example')) return true;
117
- // Allow the main app domain
118
- if (origin === 'https://app.acme.example') return true;
119
- return false;
120
- },
121
- },
122
- },
123
- });
124
- ```
125
-
126
- The function receives the already-parsed origin string (e.g. `'https://app.acme.example'`) and returns boolean. The branch in `isAllowedRedirectUrl` calls it with the same origin it would have string-compared:
127
-
128
- ```ts
129
- if (typeof allowed === 'function') {
130
- return allowed(parsed.origin);
131
- }
132
- ```
133
-
134
- ### 4. Invalid input — rejected
135
-
136
- ```ts
137
- 'not a url' // ✗ URL.canParse returns false
138
- 'javascript:alert(1)' // ✗ same-origin check fails — wrong scheme
139
- '///evil.example/x' // ✗ resolves to '//evil.example/x', not same-origin
140
- ```
141
-
142
- The `URL.canParse(url, 'http://placeholder')` first-gate handles malformed inputs. `'javascript:...'` parses fine but the origin check rejects it.
143
-
144
- ## Fallback chain — `resolvePostLoginRedirect`
145
-
146
- `resolvePostLoginRedirect` orchestrates the resolver call and the validation:
147
-
148
- ```ts
149
- const resolvePostLoginRedirect = async ({ fallbackUrl, userId, providerName, isNewUser }) => {
150
- const resolver = getPostLoginRedirect();
151
- if (!resolver) return fallbackUrl;
152
-
153
- const [resolverError, resolved] = await tryCatch(() => resolver({
154
- userId,
155
- provider: providerName,
156
- isNewUser,
157
- defaultUrl: fallbackUrl,
158
- }));
159
- if (resolverError) {
160
- getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: (resolverError as Error).message });
161
- return fallbackUrl;
162
- }
163
- if (resolved && isAllowedRedirectUrl(resolved)) return resolved;
164
- if (resolved) {
165
- getLogger().warn(
166
- `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,
167
- { resolved, fallbackUrl },
168
- );
169
- }
170
- return fallbackUrl;
171
- };
172
- ```
173
-
174
- Fallback order (from `loginCallback`):
175
-
176
- 1. **`options.defaultRedirectUrl`** — the per-call override passed by `@luckystack/server`'s callback route handler. Useful when the server adds an additional safety layer (e.g. "always send first-time users to /onboarding").
177
- 2. **`projectConfig.loginRedirectUrl`** — the project-wide default. Typically `/dashboard` or `/`. Set via `registerProjectConfig`.
178
- 3. **`'/'`** — hard fallback. Used only when both options.defaultRedirectUrl AND projectConfig.loginRedirectUrl are undefined; effectively unreachable in real deployments because the framework's `DEFAULT_PROJECT_CONFIG` populates `loginRedirectUrl`.
179
-
180
- ```ts
181
- // From loginCallback
182
- const fallbackUrl =
183
- options.defaultRedirectUrl
184
- ?? getProjectConfig().loginRedirectUrl
185
- ?? '/';
186
- ```
187
-
188
- The fallback URL is passed to the resolver as `defaultUrl` — resolvers can use it directly to "do the framework default" for cases the resolver doesn't care about.
189
-
190
- ## Resolver error handling
191
-
192
- The resolver runs inside `tryCatch`. Any thrown error is warn-logged and the function returns the fallback URL. Logins are NOT failed because of a bad resolver — the user lands on the fallback page instead.
193
-
194
- ```ts
195
- if (resolverError) {
196
- getLogger().warn(`[oauth] postLoginRedirect resolver threw`, {
197
- message: (resolverError as Error).message,
198
- });
199
- return fallbackUrl;
200
- }
201
- ```
202
-
203
- This is intentionally lenient. A resolver bug (typo, DB outage, internal API down) should not block users from signing in — at worst they land on the default page and click their way to where they were going. The warn-log gives ops the visibility they need to find and fix the bug.
204
-
205
- If the resolver returns a string that fails `isAllowedRedirectUrl`, that case is ALSO warn-logged separately:
206
-
207
- ```ts
208
- if (resolved) {
209
- getLogger().warn(
210
- `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,
211
- { resolved, fallbackUrl },
212
- );
213
- }
214
- return fallbackUrl;
215
- ```
216
-
217
- The two warn-logs are deliberately separate so a misconfigured `allowedOrigins` (returns valid URL but origin not whitelisted) is distinguishable from a resolver bug (throws an error). Different ops fixes for the two.
218
-
219
- ## Resolver patterns
220
-
221
- ### First-time vs returning
222
-
223
- ```ts
224
- import { registerPostLoginRedirect } from '@luckystack/login';
225
-
226
- registerPostLoginRedirect(({ isNewUser, defaultUrl }) => {
227
- if (isNewUser) return '/welcome';
228
- return defaultUrl;
229
- });
230
- ```
231
-
232
- The simplest case — onboard new users through a `/welcome` flow, send returning users to wherever the default sends them.
233
-
234
- ### Per-tenant landing page
235
-
236
- ```ts
237
- registerPostLoginRedirect(async ({ userId, defaultUrl }) => {
238
- const user = await prisma.user.findUnique({
239
- where: { id: userId },
240
- select: { organizationId: true },
241
- });
242
- if (!user?.organizationId) return defaultUrl;
243
- return `/org/${user.organizationId}/dashboard`;
244
- });
245
- ```
246
-
247
- Look up the user's tenant and route to a tenant-scoped URL. The relative path is always same-origin so it passes `isAllowedRedirectUrl` without needing `allowedOrigins` entries.
248
-
249
- ### Per-provider deep link
250
-
251
- ```ts
252
- registerPostLoginRedirect(({ provider, isNewUser }) => {
253
- if (provider === 'google' && isNewUser) {
254
- return '/setup/google-calendar'; // hook them to grant calendar access
255
- }
256
- if (provider === 'github') {
257
- return '/repos/connect';
258
- }
259
- return '/dashboard';
260
- });
261
- ```
262
-
263
- Different providers get different onboarding flows. Useful when each OAuth provider unlocks a different feature (calendar for Google, repos for GitHub, file storage for Microsoft Graph).
264
-
265
- ### Cross-origin enterprise app
266
-
267
- ```ts
268
- registerProjectConfig({
269
- http: {
270
- cors: {
271
- allowedOrigins: [
272
- 'https://app.acme.example',
273
- 'https://admin.acme.example',
274
- 'https://mobile.acme.example',
275
- ],
276
- },
277
- },
278
- });
279
-
280
- registerPostLoginRedirect(async ({ userId }) => {
281
- const user = await prisma.user.findUnique({
282
- where: { id: userId },
283
- select: { role: true },
284
- });
285
- if (user?.role === 'admin') return 'https://admin.acme.example/';
286
- if (user?.role === 'mobile') return 'https://mobile.acme.example/';
287
- return 'https://app.acme.example/';
288
- });
289
- ```
290
-
291
- Three domains, role-based routing. Each absolute URL is checked against `allowedOrigins` — if you forget to whitelist `https://mobile.acme.example`, the resolver's return value gets warn-logged and falls back. Maintain the allow-list and the resolver as a pair.
292
-
293
- ### Resume-where-you-left-off
294
-
295
- ```ts
296
- // 1. Frontend stores the "where they came from" URL in sessionStorage before the OAuth redirect.
297
- // 2. After login the frontend reads sessionStorage and pushes to that URL.
298
-
299
- // This package's resolver is NOT the right place for "return to the page they were on" —
300
- // the server doesn't know what the user was looking at. Wire this on the CLIENT side instead.
301
- ```
302
-
303
- The resolver runs server-side and has no knowledge of the user's browser state. "Resume where you left off" is a client-side concern.
304
-
305
- ## Threat model
306
-
307
- The open-redirect class of vulnerability is the reason this validation exists. Without it:
308
-
309
- 1. Attacker crafts a phishing email: "Sign in to ACME with your Google account: https://app.acme.example/auth/login/google?...".
310
- 2. The link goes through real ACME, the user trusts the domain, completes the OAuth flow.
311
- 3. The OAuth callback runs, and a malicious resolver / unvalidated query param sends the user to `https://evil.example/login-confirmation` — a page that looks like ACME and asks them to "re-enter your password to continue".
312
-
313
- The framework defends by:
314
-
315
- - Not accepting an unvalidated `redirect_uri` query param from the OAuth callback. The callback URL is pinned at provider registration time (`provider.callbackURL`); attackers cannot inject one.
316
- - Running the resolver's returned URL through `isAllowedRedirectUrl` before redirecting.
317
- - Falling back to a known-safe URL when the validation fails.
318
-
319
- The right mental model: `isAllowedRedirectUrl` is the "I trust this URL enough to 302 a freshly-authenticated user to it" gate. NEVER bypass it. If the resolver needs to return a cross-origin URL, register that origin in `allowedOrigins` — that's the documented extension point.
320
-
321
- ## Validation gotchas
322
-
323
- ### `localhost` is auto-allowed ONLY when `http.cors.allowLocalhost` is on
324
-
325
- When `http.cors.allowLocalhost` is `true` (a dev convenience; defaults `false` in prod), an absolute redirect whose hostname is exactly `localhost` IS auto-allowed (note: only the `localhost` hostname — `127.0.0.1` is NOT covered by this branch). With `allowLocalhost` off (the prod default) `localhost` gets no free pass. To allow a specific loopback origin regardless, add it to `allowedOrigins`:
326
-
327
- ```ts
328
- registerProjectConfig({
329
- http: {
330
- cors: {
331
- allowedOrigins: process.env.NODE_ENV === 'development'
332
- ? ['http://localhost:5173', 'http://127.0.0.1:5173']
333
- : ['https://app.example.com'],
334
- },
335
- },
336
- });
337
- ```
338
-
339
- Relative URLs (`/dashboard`) still work without the entry — they're always same-origin.
340
-
341
- ### Port mismatches
342
-
343
- `https://app.example.com:8443` and `https://app.example.com` are different origins (default port for HTTPS is 443; explicit 8443 is a different port). Whitelist the EXACT origin the resolver returns.
344
-
345
- ### Wildcards are NOT supported in the string array
346
-
347
- `allowedOrigins: ['https://*.example.com']` does NOT work — strings are compared as full origins. For wildcard subdomains, use the function variant:
348
-
349
- ```ts
350
- allowedOrigins: (origin) => {
351
- const url = new URL(origin);
352
- return url.hostname.endsWith('.example.com') && url.protocol === 'https:';
353
- },
354
- ```
355
-
356
- ### Trailing slashes
357
-
358
- `new URL('https://app.example.com/').origin === 'https://app.example.com'` (origin doesn't include the path). So `'https://app.example.com'` and `'https://app.example.com/'` are equivalent in the allow-list. Either form works.
359
-
360
- ## Diagnostic logging
361
-
362
- The validation warn-logs are intentionally noisy because spurious redirect mismatches are a clear "the resolver and the allowed-origins config don't agree" signal. In production, monitor for:
363
-
364
- ```
365
- [oauth] postLoginRedirect returned a URL not in allowed origins — falling back
366
- resolved: https://mobile.acme.example/
367
- fallbackUrl: /dashboard
368
- ```
369
-
370
- That entry means: someone added `mobile.acme.example` to the resolver but forgot the `allowedOrigins` list. Users are landing on `/dashboard` instead of where the resolver intended.
371
-
372
- ```
373
- [oauth] postLoginRedirect resolver threw
374
- message: Cannot read properties of null (reading 'organizationId')
375
- ```
376
-
377
- That entry means: the resolver's DB read returned null for a user that just signed in — race condition, soft-deleted user, missing column. Trace the userId from the surrounding `postLogin` log line.
378
-
379
- ## When NOT to use a resolver
380
-
381
- - **"Redirect to the URL they came from before login"** — that's a client-side concern (sessionStorage on the login page).
382
- - **"Send them to a generic /onboarding for first-time users"** — fine, but consider just adding the `/onboarding` route to the bundled template; if every project does this, the framework could lift it.
383
- - **"Block login when the user fails some check"** — use `preLogin` with a stop signal instead. The resolver runs AFTER the login has succeeded; there's no clean way to undo a freshly-minted session from the resolver.
384
-
385
- ## Related
386
-
387
- - [`./oauth-providers.md`](./oauth-providers.md) — `loginCallback` and where the resolver runs in the OAuth flow.
388
- - [`./hooks.md`](./hooks.md) — `preLogin` (for "block login" use cases) and `postLogin` (for side-effects that don't need to return a URL).
389
- - [`./credentials-auth.md`](./credentials-auth.md) — credentials login does NOT go through `loginCallback`, so the resolver is OAuth-only. Credentials login returns to whatever URL the login page navigates to on its own.
390
- - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
1
+ # Redirect validation
2
+
3
+ Deep-dive on the post-login redirect resolver registry and the open-redirect defense that gates its output. Canonical sources: [`./src/redirectResolver.ts`](../src/redirectResolver.ts) (registry + types) and the `isAllowedRedirectUrl` / `resolvePostLoginRedirect` helpers in [`./src/login.ts`](../src/login.ts).
4
+
5
+ After a successful OAuth callback, `loginCallback` needs to decide where to send the browser. The default is `projectConfig.loginRedirectUrl` (typically `/dashboard`), but most non-trivial apps want to route by user state — first-time vs returning, per-tenant landing page, per-provider deep link. The post-login redirect resolver is the supported extension point.
6
+
7
+ This document covers: why it lives in its own registry (not a hook), the resolver shape, the open-redirect defense, the fallback chain, and the call-time validation rules.
8
+
9
+ ---
10
+
11
+ ## Why a dedicated registry instead of a hook
12
+
13
+ The framework's hook bus (`registerHook` / `dispatchHook`) is "stop-or-continue" — handlers return `undefined` or a `HookStopSignal`. They do not carry a value back to the caller. The redirect resolver MUST return a string (the URL). Two options:
14
+
15
+ 1. Add a "value-returning hook" variant to `@luckystack/core`. Generic, but every hook gains a return-type for one use case.
16
+ 2. Define a dedicated single-slot registry for this specific need.
17
+
18
+ The framework picks option 2 because:
19
+
20
+ - **Single resolver wins** — there is no "chain N redirect resolvers and pick the first non-empty" scenario that makes sense. Per-tenant routing, per-user routing, per-provider routing — all three compose inside a SINGLE resolver function. Two resolvers fighting over the same login is not a feature.
21
+ - **Clear ownership** — the resolver is set once at boot by the application, not registered ad-hoc by a feature package. The single-slot registry expresses that.
22
+ - **Smaller surface** — no hook-bus features the resolver doesn't need (priority, stop signals, async-iterable handler arrays).
23
+
24
+ ```ts
25
+ // packages/login/src/redirectResolver.ts
26
+ let activeResolver: PostLoginRedirectResolver | null = null;
27
+
28
+ export const registerPostLoginRedirect = (resolver) => {
29
+ activeResolver = resolver;
30
+ return resolver;
31
+ };
32
+
33
+ export const getPostLoginRedirect = () => activeResolver;
34
+ ```
35
+
36
+ `registerPostLoginRedirect` is last-write-wins. `getPostLoginRedirect()` returns `null` when no resolver has been registered — the default state — and `loginCallback` falls back to `projectConfig.loginRedirectUrl` in that case.
37
+
38
+ ## Resolver shape
39
+
40
+ ```ts
41
+ interface PostLoginRedirectInput {
42
+ userId: string;
43
+ provider: string;
44
+ isNewUser: boolean;
45
+ defaultUrl: string;
46
+ }
47
+
48
+ type PostLoginRedirectResolver = (input: PostLoginRedirectInput) => string | Promise<string>;
49
+ ```
50
+
51
+ | Field | Notes |
52
+ | ------------ | ------------------------------------------------------------------------------------------- |
53
+ | `userId` | The id of the user who just logged in. Look it up in your DB for per-user routing. |
54
+ | `provider` | The OAuth provider name (`'google'`, `'github'`, `'discord'`, etc.) or `'credentials'`. |
55
+ | `isNewUser` | `true` for first-time OAuth users (`postRegister` just fired), `false` for returning ones. |
56
+ | `defaultUrl` | The framework's computed default — the same URL that would be used without a resolver. Use as a fallback inside the resolver. |
57
+
58
+ The resolver may be sync or async. It runs INSIDE `loginCallback` between `postLogin` and the HTTP redirect — keep it fast. Database reads on the `userId` are fine; reaching out to a slow remote API will visibly delay every login.
59
+
60
+ ## The open-redirect defense — `isAllowedRedirectUrl`
61
+
62
+ `isAllowedRedirectUrl(url)` lives in `login.ts` and is the framework's defense against open-redirect attacks via the OAuth callback flow. Without it, a malicious link to `/auth/login/google?state=<attacker-state>&redirect_uri=https://evil.example/steal` could lure a user into authenticating and then bouncing them to a credential-stealing page.
63
+
64
+ ```ts
65
+ const isAllowedRedirectUrl = (url: string): boolean => {
66
+ if (!URL.canParse(url, 'http://placeholder')) return false;
67
+ const parsed = new URL(url, 'http://placeholder');
68
+ if (parsed.origin === 'http://placeholder') {
69
+ // relative URL — same-origin, always safe
70
+ return true;
71
+ }
72
+ const allowed = getProjectConfig().http.cors.allowedOrigins ?? [];
73
+ if (typeof allowed === 'function') {
74
+ return allowed(parsed.origin);
75
+ }
76
+ return allowed.some((origin) => {
77
+ if (!URL.canParse(origin)) return false;
78
+ return new URL(origin).origin === parsed.origin;
79
+ });
80
+ };
81
+ ```
82
+
83
+ The rules:
84
+
85
+ ### 1. Same-origin (relative URL) — always allowed
86
+
87
+ ```ts
88
+ '/dashboard' // ✓ relative path
89
+ '/welcome?step=1' // ✓ relative path with query
90
+ '/org/123/projects' // ✓ relative path with multi-segment
91
+ ```
92
+
93
+ The trick is parsing against a `'http://placeholder'` base — if the resulting origin matches the base, the input was a relative URL (no scheme/host of its own). Relative URLs land on the same origin as the app, so they're safe by definition.
94
+
95
+ ### 2. Absolute URL — origin must match `http.cors.allowedOrigins`
96
+
97
+ ```ts
98
+ 'https://app.example.com/dashboard' // ✓ if 'https://app.example.com' is in allowedOrigins
99
+ 'https://evil.example/steal' // ✗ unless explicitly allowed
100
+ 'https://api.example.com/' // ✗ if only 'https://app.example.com' is allowed
101
+ ```
102
+
103
+ The check compares the parsed `origin` (scheme + host + port) of the URL to the parsed `origin` of each entry in `allowedOrigins`. String comparison wouldn't work — `https://app.example.com:443` and `https://app.example.com` should match (default port elided), and parsing both through `new URL().origin` normalizes them.
104
+
105
+ ### 3. `allowedOrigins` as a function
106
+
107
+ `http.cors.allowedOrigins` is typed as `string[] | (origin: string) => boolean`. The function variant lets consumers compute allowed origins dynamically — wildcard subdomains, tenant-specific origin allow-lists, integration with an internal "approved partners" service:
108
+
109
+ ```ts
110
+ registerProjectConfig({
111
+ http: {
112
+ cors: {
113
+ allowedOrigins: (origin) => {
114
+ // Allow any *.acme-customers.example subdomain
115
+ const url = new URL(origin);
116
+ if (url.hostname.endsWith('.acme-customers.example')) return true;
117
+ // Allow the main app domain
118
+ if (origin === 'https://app.acme.example') return true;
119
+ return false;
120
+ },
121
+ },
122
+ },
123
+ });
124
+ ```
125
+
126
+ The function receives the already-parsed origin string (e.g. `'https://app.acme.example'`) and returns boolean. The branch in `isAllowedRedirectUrl` calls it with the same origin it would have string-compared:
127
+
128
+ ```ts
129
+ if (typeof allowed === 'function') {
130
+ return allowed(parsed.origin);
131
+ }
132
+ ```
133
+
134
+ ### 4. Invalid input — rejected
135
+
136
+ ```ts
137
+ 'not a url' // ✗ URL.canParse returns false
138
+ 'javascript:alert(1)' // ✗ same-origin check fails — wrong scheme
139
+ '///evil.example/x' // ✗ resolves to '//evil.example/x', not same-origin
140
+ ```
141
+
142
+ The `URL.canParse(url, 'http://placeholder')` first-gate handles malformed inputs. `'javascript:...'` parses fine but the origin check rejects it.
143
+
144
+ ## Fallback chain — `resolvePostLoginRedirect`
145
+
146
+ `resolvePostLoginRedirect` orchestrates the resolver call and the validation:
147
+
148
+ ```ts
149
+ const resolvePostLoginRedirect = async ({ fallbackUrl, userId, providerName, isNewUser }) => {
150
+ const resolver = getPostLoginRedirect();
151
+ if (!resolver) return fallbackUrl;
152
+
153
+ const [resolverError, resolved] = await tryCatch(() => resolver({
154
+ userId,
155
+ provider: providerName,
156
+ isNewUser,
157
+ defaultUrl: fallbackUrl,
158
+ }));
159
+ if (resolverError) {
160
+ getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: (resolverError as Error).message });
161
+ return fallbackUrl;
162
+ }
163
+ if (resolved && isAllowedRedirectUrl(resolved)) return resolved;
164
+ if (resolved) {
165
+ getLogger().warn(
166
+ `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,
167
+ { resolved, fallbackUrl },
168
+ );
169
+ }
170
+ return fallbackUrl;
171
+ };
172
+ ```
173
+
174
+ Fallback order (from `loginCallback`):
175
+
176
+ 1. **`options.defaultRedirectUrl`** — the per-call override passed by `@luckystack/server`'s callback route handler. Useful when the server adds an additional safety layer (e.g. "always send first-time users to /onboarding").
177
+ 2. **`projectConfig.loginRedirectUrl`** — the project-wide default. Typically `/dashboard` or `/`. Set via `registerProjectConfig`.
178
+ 3. **`'/'`** — hard fallback. Used only when both options.defaultRedirectUrl AND projectConfig.loginRedirectUrl are undefined; effectively unreachable in real deployments because the framework's `DEFAULT_PROJECT_CONFIG` populates `loginRedirectUrl`.
179
+
180
+ ```ts
181
+ // From loginCallback
182
+ const fallbackUrl =
183
+ options.defaultRedirectUrl
184
+ ?? getProjectConfig().loginRedirectUrl
185
+ ?? '/';
186
+ ```
187
+
188
+ The fallback URL is passed to the resolver as `defaultUrl` — resolvers can use it directly to "do the framework default" for cases the resolver doesn't care about.
189
+
190
+ ## Resolver error handling
191
+
192
+ The resolver runs inside `tryCatch`. Any thrown error is warn-logged and the function returns the fallback URL. Logins are NOT failed because of a bad resolver — the user lands on the fallback page instead.
193
+
194
+ ```ts
195
+ if (resolverError) {
196
+ getLogger().warn(`[oauth] postLoginRedirect resolver threw`, {
197
+ message: (resolverError as Error).message,
198
+ });
199
+ return fallbackUrl;
200
+ }
201
+ ```
202
+
203
+ This is intentionally lenient. A resolver bug (typo, DB outage, internal API down) should not block users from signing in — at worst they land on the default page and click their way to where they were going. The warn-log gives ops the visibility they need to find and fix the bug.
204
+
205
+ If the resolver returns a string that fails `isAllowedRedirectUrl`, that case is ALSO warn-logged separately:
206
+
207
+ ```ts
208
+ if (resolved) {
209
+ getLogger().warn(
210
+ `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,
211
+ { resolved, fallbackUrl },
212
+ );
213
+ }
214
+ return fallbackUrl;
215
+ ```
216
+
217
+ The two warn-logs are deliberately separate so a misconfigured `allowedOrigins` (returns valid URL but origin not whitelisted) is distinguishable from a resolver bug (throws an error). Different ops fixes for the two.
218
+
219
+ ## Resolver patterns
220
+
221
+ ### First-time vs returning
222
+
223
+ ```ts
224
+ import { registerPostLoginRedirect } from '@luckystack/login';
225
+
226
+ registerPostLoginRedirect(({ isNewUser, defaultUrl }) => {
227
+ if (isNewUser) return '/welcome';
228
+ return defaultUrl;
229
+ });
230
+ ```
231
+
232
+ The simplest case — onboard new users through a `/welcome` flow, send returning users to wherever the default sends them.
233
+
234
+ ### Per-tenant landing page
235
+
236
+ ```ts
237
+ registerPostLoginRedirect(async ({ userId, defaultUrl }) => {
238
+ const user = await prisma.user.findUnique({
239
+ where: { id: userId },
240
+ select: { organizationId: true },
241
+ });
242
+ if (!user?.organizationId) return defaultUrl;
243
+ return `/org/${user.organizationId}/dashboard`;
244
+ });
245
+ ```
246
+
247
+ Look up the user's tenant and route to a tenant-scoped URL. The relative path is always same-origin so it passes `isAllowedRedirectUrl` without needing `allowedOrigins` entries.
248
+
249
+ ### Per-provider deep link
250
+
251
+ ```ts
252
+ registerPostLoginRedirect(({ provider, isNewUser }) => {
253
+ if (provider === 'google' && isNewUser) {
254
+ return '/setup/google-calendar'; // hook them to grant calendar access
255
+ }
256
+ if (provider === 'github') {
257
+ return '/repos/connect';
258
+ }
259
+ return '/dashboard';
260
+ });
261
+ ```
262
+
263
+ Different providers get different onboarding flows. Useful when each OAuth provider unlocks a different feature (calendar for Google, repos for GitHub, file storage for Microsoft Graph).
264
+
265
+ ### Cross-origin enterprise app
266
+
267
+ ```ts
268
+ registerProjectConfig({
269
+ http: {
270
+ cors: {
271
+ allowedOrigins: [
272
+ 'https://app.acme.example',
273
+ 'https://admin.acme.example',
274
+ 'https://mobile.acme.example',
275
+ ],
276
+ },
277
+ },
278
+ });
279
+
280
+ registerPostLoginRedirect(async ({ userId }) => {
281
+ const user = await prisma.user.findUnique({
282
+ where: { id: userId },
283
+ select: { role: true },
284
+ });
285
+ if (user?.role === 'admin') return 'https://admin.acme.example/';
286
+ if (user?.role === 'mobile') return 'https://mobile.acme.example/';
287
+ return 'https://app.acme.example/';
288
+ });
289
+ ```
290
+
291
+ Three domains, role-based routing. Each absolute URL is checked against `allowedOrigins` — if you forget to whitelist `https://mobile.acme.example`, the resolver's return value gets warn-logged and falls back. Maintain the allow-list and the resolver as a pair.
292
+
293
+ ### Resume-where-you-left-off
294
+
295
+ ```ts
296
+ // 1. Frontend stores the "where they came from" URL in sessionStorage before the OAuth redirect.
297
+ // 2. After login the frontend reads sessionStorage and pushes to that URL.
298
+
299
+ // This package's resolver is NOT the right place for "return to the page they were on" —
300
+ // the server doesn't know what the user was looking at. Wire this on the CLIENT side instead.
301
+ ```
302
+
303
+ The resolver runs server-side and has no knowledge of the user's browser state. "Resume where you left off" is a client-side concern.
304
+
305
+ ## Threat model
306
+
307
+ The open-redirect class of vulnerability is the reason this validation exists. Without it:
308
+
309
+ 1. Attacker crafts a phishing email: "Sign in to ACME with your Google account: https://app.acme.example/auth/login/google?...".
310
+ 2. The link goes through real ACME, the user trusts the domain, completes the OAuth flow.
311
+ 3. The OAuth callback runs, and a malicious resolver / unvalidated query param sends the user to `https://evil.example/login-confirmation` — a page that looks like ACME and asks them to "re-enter your password to continue".
312
+
313
+ The framework defends by:
314
+
315
+ - Not accepting an unvalidated `redirect_uri` query param from the OAuth callback. The callback URL is pinned at provider registration time (`provider.callbackURL`); attackers cannot inject one.
316
+ - Running the resolver's returned URL through `isAllowedRedirectUrl` before redirecting.
317
+ - Falling back to a known-safe URL when the validation fails.
318
+
319
+ The right mental model: `isAllowedRedirectUrl` is the "I trust this URL enough to 302 a freshly-authenticated user to it" gate. NEVER bypass it. If the resolver needs to return a cross-origin URL, register that origin in `allowedOrigins` — that's the documented extension point.
320
+
321
+ ## Validation gotchas
322
+
323
+ ### `localhost` is auto-allowed ONLY when `http.cors.allowLocalhost` is on
324
+
325
+ When `http.cors.allowLocalhost` is `true` (a dev convenience; defaults `false` in prod), an absolute redirect whose hostname is exactly `localhost` IS auto-allowed (note: only the `localhost` hostname — `127.0.0.1` is NOT covered by this branch). With `allowLocalhost` off (the prod default) `localhost` gets no free pass. To allow a specific loopback origin regardless, add it to `allowedOrigins`:
326
+
327
+ ```ts
328
+ registerProjectConfig({
329
+ http: {
330
+ cors: {
331
+ allowedOrigins: process.env.NODE_ENV === 'development'
332
+ ? ['http://localhost:5173', 'http://127.0.0.1:5173']
333
+ : ['https://app.example.com'],
334
+ },
335
+ },
336
+ });
337
+ ```
338
+
339
+ Relative URLs (`/dashboard`) still work without the entry — they're always same-origin.
340
+
341
+ ### Port mismatches
342
+
343
+ `https://app.example.com:8443` and `https://app.example.com` are different origins (default port for HTTPS is 443; explicit 8443 is a different port). Whitelist the EXACT origin the resolver returns.
344
+
345
+ ### Wildcards are NOT supported in the string array
346
+
347
+ `allowedOrigins: ['https://*.example.com']` does NOT work — strings are compared as full origins. For wildcard subdomains, use the function variant:
348
+
349
+ ```ts
350
+ allowedOrigins: (origin) => {
351
+ const url = new URL(origin);
352
+ return url.hostname.endsWith('.example.com') && url.protocol === 'https:';
353
+ },
354
+ ```
355
+
356
+ ### Trailing slashes
357
+
358
+ `new URL('https://app.example.com/').origin === 'https://app.example.com'` (origin doesn't include the path). So `'https://app.example.com'` and `'https://app.example.com/'` are equivalent in the allow-list. Either form works.
359
+
360
+ ## Diagnostic logging
361
+
362
+ The validation warn-logs are intentionally noisy because spurious redirect mismatches are a clear "the resolver and the allowed-origins config don't agree" signal. In production, monitor for:
363
+
364
+ ```
365
+ [oauth] postLoginRedirect returned a URL not in allowed origins — falling back
366
+ resolved: https://mobile.acme.example/
367
+ fallbackUrl: /dashboard
368
+ ```
369
+
370
+ That entry means: someone added `mobile.acme.example` to the resolver but forgot the `allowedOrigins` list. Users are landing on `/dashboard` instead of where the resolver intended.
371
+
372
+ ```
373
+ [oauth] postLoginRedirect resolver threw
374
+ message: Cannot read properties of null (reading 'organizationId')
375
+ ```
376
+
377
+ That entry means: the resolver's DB read returned null for a user that just signed in — race condition, soft-deleted user, missing column. Trace the userId from the surrounding `postLogin` log line.
378
+
379
+ ## When NOT to use a resolver
380
+
381
+ - **"Redirect to the URL they came from before login"** — that's a client-side concern (sessionStorage on the login page).
382
+ - **"Send them to a generic /onboarding for first-time users"** — fine, but consider just adding the `/onboarding` route to the bundled template; if every project does this, the framework could lift it.
383
+ - **"Block login when the user fails some check"** — use `preLogin` with a stop signal instead. The resolver runs AFTER the login has succeeded; there's no clean way to undo a freshly-minted session from the resolver.
384
+
385
+ ## Related
386
+
387
+ - [`./oauth-providers.md`](./oauth-providers.md) — `loginCallback` and where the resolver runs in the OAuth flow.
388
+ - [`./hooks.md`](./hooks.md) — `preLogin` (for "block login" use cases) and `postLogin` (for side-effects that don't need to return a URL).
389
+ - [`./credentials-auth.md`](./credentials-auth.md) — credentials login does NOT go through `loginCallback`, so the resolver is OAuth-only. Credentials login returns to whatever URL the login page navigates to on its own.
390
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).