@growth-labs/auth 0.17.0 → 0.18.3

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 (76) hide show
  1. package/dist/middleware/auth.d.ts.map +1 -1
  2. package/dist/middleware/auth.js +15 -3
  3. package/dist/middleware/auth.js.map +1 -1
  4. package/dist/options.d.ts +66 -0
  5. package/dist/options.d.ts.map +1 -1
  6. package/dist/options.js +10 -0
  7. package/dist/options.js.map +1 -1
  8. package/dist/routes/api/code-request.d.ts.map +1 -1
  9. package/dist/routes/api/code-request.js +14 -2
  10. package/dist/routes/api/code-request.js.map +1 -1
  11. package/dist/routes/api/code-verify.d.ts.map +1 -1
  12. package/dist/routes/api/code-verify.js +19 -2
  13. package/dist/routes/api/code-verify.js.map +1 -1
  14. package/dist/routes/api/rate-limit.d.ts +4 -0
  15. package/dist/routes/api/rate-limit.d.ts.map +1 -0
  16. package/dist/routes/api/rate-limit.js +30 -0
  17. package/dist/routes/api/rate-limit.js.map +1 -0
  18. package/dist/routes/api/shared.d.ts.map +1 -1
  19. package/dist/routes/api/shared.js +3 -2
  20. package/dist/routes/api/shared.js.map +1 -1
  21. package/dist/routes/callback.d.ts.map +1 -1
  22. package/dist/routes/callback.js +2 -1
  23. package/dist/routes/callback.js.map +1 -1
  24. package/dist/routes/flow.d.ts +1 -1
  25. package/dist/routes/flow.d.ts.map +1 -1
  26. package/dist/routes/flow.js +2 -1
  27. package/dist/routes/flow.js.map +1 -1
  28. package/dist/routes/login.d.ts.map +1 -1
  29. package/dist/routes/login.js +6 -0
  30. package/dist/routes/login.js.map +1 -1
  31. package/dist/routes/verify-code.d.ts.map +1 -1
  32. package/dist/routes/verify-code.js +5 -2
  33. package/dist/routes/verify-code.js.map +1 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +5 -1
  36. package/dist/server.js.map +1 -1
  37. package/dist/utils/cookies.d.ts +1 -0
  38. package/dist/utils/cookies.d.ts.map +1 -1
  39. package/dist/utils/cookies.js +1 -1
  40. package/dist/utils/cookies.js.map +1 -1
  41. package/dist/utils/issuer-fetch.d.ts +5 -0
  42. package/dist/utils/issuer-fetch.d.ts.map +1 -0
  43. package/dist/utils/issuer-fetch.js +17 -0
  44. package/dist/utils/issuer-fetch.js.map +1 -0
  45. package/dist/utils/issuer.d.ts +2 -0
  46. package/dist/utils/issuer.d.ts.map +1 -1
  47. package/dist/utils/issuer.js +12 -0
  48. package/dist/utils/issuer.js.map +1 -1
  49. package/dist/utils/openauth.d.ts.map +1 -1
  50. package/dist/utils/openauth.js +4 -3
  51. package/dist/utils/openauth.js.map +1 -1
  52. package/dist/utils/tokens.d.ts +5 -2
  53. package/dist/utils/tokens.d.ts.map +1 -1
  54. package/dist/utils/tokens.js +7 -7
  55. package/dist/utils/tokens.js.map +1 -1
  56. package/dist/workers.d.ts.map +1 -1
  57. package/dist/workers.js +6 -1
  58. package/dist/workers.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/middleware/auth.ts +19 -3
  61. package/src/options.ts +10 -0
  62. package/src/routes/api/code-request.ts +20 -2
  63. package/src/routes/api/code-verify.ts +19 -2
  64. package/src/routes/api/rate-limit.ts +48 -0
  65. package/src/routes/api/shared.ts +3 -2
  66. package/src/routes/callback.ts +2 -1
  67. package/src/routes/flow.ts +2 -0
  68. package/src/routes/login.ts +6 -0
  69. package/src/routes/verify-code.ts +8 -2
  70. package/src/server.ts +11 -5
  71. package/src/utils/cookies.ts +2 -2
  72. package/src/utils/issuer-fetch.ts +20 -0
  73. package/src/utils/issuer.ts +11 -0
  74. package/src/utils/openauth.ts +9 -3
  75. package/src/utils/tokens.ts +22 -4
  76. package/src/workers.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/auth",
3
- "version": "0.17.0",
3
+ "version": "0.18.3",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import { getConfig } from '../config-store.js'
2
+ import { resolveIssuerFetch } from '../utils/issuer-fetch.js'
2
3
  import '../runtime-bridge.js'
3
4
  import type { AuthSessionState, User } from '../types.js'
4
5
  import { resolveClientSecret } from '../utils/client-secret.js'
@@ -32,6 +33,7 @@ export async function onRequest(
32
33
  next: () => Promise<Response>,
33
34
  ): Promise<Response> {
34
35
  const options = getConfig()
36
+ const nonConsumablePath = isGatedPath(context.url.pathname, options.nonConsumablePaths ?? [])
35
37
  const issuer = resolveInternalIssuer(options)
36
38
  const { request } = context
37
39
 
@@ -52,7 +54,8 @@ export async function onRequest(
52
54
  // below. A refresh token explicitly opts the request into this fail-closed
53
55
  // confidential-session invariant even when its access token would verify;
54
56
  // an access-token-only request never needs or reads the client secret.
55
- if (refreshToken && options.clientSecret !== undefined) {
57
+ // Configured non-consumable paths cannot refresh, so they do not need it either.
58
+ if (refreshToken && options.clientSecret !== undefined && !nonConsumablePath) {
56
59
  const secretResult = await resolveClientSecret(options.clientSecret, request)
57
60
  if (!secretResult.ok) {
58
61
  context.locals.user = null
@@ -65,7 +68,16 @@ export async function onRequest(
65
68
  }
66
69
 
67
70
  if (accessToken) {
68
- const verified = await verifyAccessToken(issuer, accessToken, { audience: options.resource })
71
+ const verified = await verifyAccessToken(
72
+ options.issuerFetch ? options.issuer : issuer,
73
+ accessToken,
74
+ {
75
+ audience: options.resource,
76
+ ...(options.issuerFetch
77
+ ? { jwksIssuer: issuer, jwksFetch: resolveIssuerFetch(options) }
78
+ : {}),
79
+ },
80
+ )
69
81
  if (verified) {
70
82
  user = {
71
83
  id: verified.sub,
@@ -84,10 +96,11 @@ export async function onRequest(
84
96
  // does not get it: the request is answered as `deferred` and the next
85
97
  // consumable request refreshes instead (refresh-carrier.ts says why).
86
98
  let refreshFailure = null as RefreshFailure | null
87
- let deferredCarrier: RefreshCarrier | null = null
99
+ let deferredCarrier: RefreshCarrier | 'path' | null = null
88
100
  if (!user && refreshToken) {
89
101
  const carrier = classifyRefreshCarrier(request)
90
102
  if (carrier !== 'consumable') deferredCarrier = carrier
103
+ else if (nonConsumablePath) deferredCarrier = 'path'
91
104
  }
92
105
 
93
106
  // Confirm before clear (refresh-rejection.ts). A refresh token the issuer
@@ -129,6 +142,9 @@ export async function onRequest(
129
142
  (failure) => {
130
143
  refreshFailure = failure
131
144
  },
145
+ ...(options.issuerFetch
146
+ ? [{ issuerFetch: resolveIssuerFetch(options), verificationIssuer: options.issuer }]
147
+ : []),
132
148
  )
133
149
  if (refreshed) {
134
150
  user = {
package/src/options.ts CHANGED
@@ -26,6 +26,14 @@ export const authOptionsSchema = z.object({
26
26
  // ─── Issuer URLs ───
27
27
  issuer: urlOrEnvMarker,
28
28
  issuerInternal: urlOrEnvMarker.optional(),
29
+ // Cloudflare service binding used for every issuer backchannel call.
30
+ issuerFetch: z.string().trim().min(1).optional(),
31
+ rateLimits: z
32
+ .object({
33
+ codeRequest: z.object({ ip: z.string().min(1), email: z.string().min(1) }),
34
+ codeVerify: z.object({ ip: z.string().min(1), email: z.string().min(1) }),
35
+ })
36
+ .optional(),
29
37
 
30
38
  // ─── Client identity ───
31
39
  clientId: stringOrEnvMarker.optional(),
@@ -55,6 +63,8 @@ export const authOptionsSchema = z.object({
55
63
 
56
64
  // ─── Path configuration ───
57
65
  gatedPaths: z.array(z.string()).default([]),
66
+ // Verify identity normally, but never rotate refresh tokens on these requests.
67
+ nonConsumablePaths: z.array(z.string()).optional(),
58
68
  loginPath: z.string().default('/login'),
59
69
  logoutRedirect: z.string().default('/'),
60
70
  callbackPath: z.string().default('/api/auth/callback'),
@@ -1,3 +1,7 @@
1
+ import { getCookie } from '../../utils/cookies.js'
2
+ import { sameOriginRedirect } from '../../utils/issuer.js'
3
+ import { withAuthFlowCookies } from '../flow.js'
4
+ import { enforceCodeRateLimit } from './rate-limit.js'
1
5
  import {
2
6
  authClient,
3
7
  failClosedOnSecretResolutionFailure,
@@ -25,7 +29,12 @@ export async function POST(context: RouteContext): Promise<Response> {
25
29
 
26
30
  const form = await readForm(context)
27
31
  const email = await readFormString(form, 'email')
28
- const redirect = form.get('redirect')?.toString()
32
+ const redirect =
33
+ form.get('redirect')?.toString() ??
34
+ getCookie(context.request, `${options.cookiePrefix}_redirect`) ??
35
+ undefined
36
+ const limited = await enforceCodeRateLimit(context, 'codeRequest', email)
37
+ if (limited) return limited
29
38
  const surface =
30
39
  form.get('auth_surface')?.toString() === 'create-account' ? 'create-account' : 'login'
31
40
  const surfacePath = surface === 'create-account' ? '/create-account/' : options.verifyCodePath
@@ -39,7 +48,7 @@ export async function POST(context: RouteContext): Promise<Response> {
39
48
  })
40
49
  }
41
50
 
42
- return statusRedirect(
51
+ const response = statusRedirect(
43
52
  context,
44
53
  result,
45
54
  surfacePath,
@@ -48,4 +57,13 @@ export async function POST(context: RouteContext): Promise<Response> {
48
57
  { email, redirect },
49
58
  'email-code',
50
59
  )
60
+ if ('error' in result) return response
61
+ return withAuthFlowCookies(
62
+ context,
63
+ options,
64
+ response,
65
+ sameOriginRedirect(redirect, context.url.origin),
66
+ 'email-code',
67
+ 900,
68
+ )
51
69
  }
@@ -1,3 +1,5 @@
1
+ import { clearAuthFlowCookies, getCookie } from '../../utils/cookies.js'
2
+ import { enforceCodeRateLimit } from './rate-limit.js'
1
3
  import {
2
4
  authClient,
3
5
  callbackRedirect,
@@ -25,20 +27,35 @@ export async function POST(context: RouteContext): Promise<Response> {
25
27
  const form = await readForm(context)
26
28
  const email = await readFormString(form, 'email')
27
29
  const code = await readFormString(form, 'code')
30
+ const limited = await enforceCodeRateLimit(context, 'codeVerify', email)
31
+ if (limited) {
32
+ clearAuthFlowCookies(limited, options)
33
+ return limited
34
+ }
28
35
  const errorPath =
29
36
  form.get('auth_surface')?.toString() === 'create-account'
30
37
  ? '/create-account/'
31
38
  : options.verifyCodePath
32
39
  const result = await authClient(context).code.verify({ email, code })
33
40
 
34
- return callbackRedirect(
41
+ const response = await callbackRedirect(
35
42
  context,
36
43
  result,
37
44
  errorPath,
38
45
  {
39
46
  email,
40
- redirect: form.get('redirect')?.toString(),
47
+ redirect:
48
+ form.get('redirect')?.toString() ??
49
+ getCookie(context.request, `${options.cookiePrefix}_redirect`) ??
50
+ undefined,
41
51
  },
42
52
  'email-code',
43
53
  )
54
+ if (!('error' in result)) return response
55
+ const mutable = new Response(response.body, {
56
+ status: response.status,
57
+ headers: new Headers(response.headers),
58
+ })
59
+ clearAuthFlowCookies(mutable, options)
60
+ return mutable
44
61
  }
@@ -0,0 +1,48 @@
1
+ import { getConfig } from '../../config-store.js'
2
+ import { getRuntimeEnv } from '../../runtime.js'
3
+ import type { RouteContext } from '../flow.js'
4
+
5
+ interface Limiter {
6
+ limit(input: { key: string }): Promise<{ success: boolean }>
7
+ }
8
+
9
+ /** Both dimensions use native rate limits; email keys contain only a SHA-256 digest. */
10
+ export async function enforceCodeRateLimit(
11
+ context: RouteContext,
12
+ operation: 'codeRequest' | 'codeVerify',
13
+ email: string,
14
+ ): Promise<Response | null> {
15
+ const bindings = getConfig().rateLimits?.[operation]
16
+ if (!bindings) return null
17
+ const env = getRuntimeEnv()
18
+ try {
19
+ const digest = await crypto.subtle.digest(
20
+ 'SHA-256',
21
+ new TextEncoder().encode(email.trim().toLowerCase()),
22
+ )
23
+ const emailKey = Array.from(new Uint8Array(digest), (byte) =>
24
+ byte.toString(16).padStart(2, '0'),
25
+ ).join('')
26
+ for (const [dimension, key] of [
27
+ ['ip', context.request.headers.get('cf-connecting-ip') || 'unknown'],
28
+ ['email', emailKey],
29
+ ] as const) {
30
+ const binding = env[bindings[dimension]] as Limiter | undefined
31
+ if (!binding || typeof binding.limit !== 'function')
32
+ throw new Error('missing rate limit binding')
33
+ const result = await binding.limit({ key: `${operation}:${dimension}:${key}` })
34
+ if (!result.success)
35
+ return Response.json(
36
+ { error: 'rate_limited' },
37
+ { status: 429, headers: { 'Cache-Control': 'private, no-store', 'Retry-After': '60' } },
38
+ )
39
+ }
40
+ return null
41
+ } catch {
42
+ console.error('@growth-labs/auth: configured code rate limiter unavailable')
43
+ return Response.json(
44
+ { error: 'auth_service_unavailable' },
45
+ { status: 503, headers: { 'Cache-Control': 'private, no-store' } },
46
+ )
47
+ }
48
+ }
@@ -19,6 +19,7 @@ import {
19
19
  import { secretResolutionFailureResponse } from '../../utils/fail-closed.js'
20
20
  import { canonicalIdentitySubject } from '../../utils/identity.js'
21
21
  import { createIdentityCookiePayload, signIdentityCookie } from '../../utils/identity-cookie.js'
22
+ import { sameOriginRedirect } from '../../utils/issuer.js'
22
23
  import { exchangeCode } from '../../utils/openauth.js'
23
24
  import { resolveWaitUntil } from '../../utils/wait-until.js'
24
25
  import { type RouteContext, readForm, redirectLocation, redirectTo } from '../flow.js'
@@ -132,7 +133,7 @@ function routeResult(
132
133
  }
133
134
 
134
135
  return Response.json(error ? { error, redirect: location } : { ok: true, redirect: location }, {
135
- status: error ? 400 : 200,
136
+ status: error === 'rate_limited' || error === 'too_many_attempts' ? 429 : error ? 400 : 200,
136
137
  })
137
138
  }
138
139
 
@@ -202,7 +203,7 @@ async function completeJsonAuth(
202
203
  const tokenResponse = exchangeResult
203
204
 
204
205
  const redirectPath = getCookie(context.request, `${options.cookiePrefix}_redirect`) || '/'
205
- const redirect = new URL(redirectPath, context.url.origin).toString()
206
+ const redirect = sameOriginRedirect(redirectPath, context.url.origin)
206
207
  const response = mutableHeaders(Response.json({ ok: true, redirect }))
207
208
 
208
209
  setTokenCookies(response, options, tokenResponse.accessToken, tokenResponse.refreshToken)
@@ -14,6 +14,7 @@ import {
14
14
  import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
15
15
  import { canonicalIdentitySubject } from '../utils/identity.js'
16
16
  import { createIdentityCookiePayload, signIdentityCookie } from '../utils/identity-cookie.js'
17
+ import { sameOriginRedirect } from '../utils/issuer.js'
17
18
  import { exchangeCode } from '../utils/openauth.js'
18
19
  import { verifyState } from '../utils/state.js'
19
20
  import { resolveWaitUntil } from '../utils/wait-until.js'
@@ -131,7 +132,7 @@ export async function GET(context: CallbackContext): Promise<Response> {
131
132
 
132
133
  // Build redirect response
133
134
  const redirectPath = getCookie(request, `${options.cookiePrefix}_redirect`) || '/'
134
- const redirect = new URL(redirectPath, url.origin).toString()
135
+ const redirect = sameOriginRedirect(redirectPath, url.origin)
135
136
  const response = wantsJson(request)
136
137
  ? Response.json({ ok: true, redirect })
137
138
  : Response.redirect(redirect, 302)
@@ -19,6 +19,7 @@ export async function withAuthFlowCookies(
19
19
  response: Response,
20
20
  redirectPath: string,
21
21
  provider?: string,
22
+ maxAge?: number,
22
23
  ): Promise<Response> {
23
24
  const authFlow = await createAuthFlow(context)
24
25
  const mutable = new Response(response.body, {
@@ -29,6 +30,7 @@ export async function withAuthFlowCookies(
29
30
  setAuthFlowCookies(mutable, options, authFlow.verifier, authFlow.state, redirectPath, provider, {
30
31
  pkcePath: '/',
31
32
  additionalPkcePaths: [options.callbackPath],
33
+ maxAge,
32
34
  })
33
35
  return mutable
34
36
  }
@@ -2,6 +2,7 @@ import { loginPageScript } from '../client/login-page.js'
2
2
  import { getConfig } from '../config-store.js'
3
3
  import { getRenderer, loginRendererPayload, renderHtmlResponse } from '../renderers.js'
4
4
  import { setAuthFlowCookies } from '../utils/cookies.js'
5
+ import { sameOriginRedirect } from '../utils/issuer.js'
5
6
  import { createAuthFlow, getAuthorizationUrl } from '../utils/openauth.js'
6
7
 
7
8
  interface LoginContext {
@@ -24,6 +25,11 @@ export async function GET(context: LoginContext): Promise<Response> {
24
25
  selectedProvider &&
25
26
  options.providers.includes(selectedProvider as (typeof options.providers)[number])
26
27
  ) {
28
+ if (selectedProvider === 'email-code' && getRenderer('verifyCode')) {
29
+ const local = new URL(options.verifyCodePath, context.url.origin)
30
+ local.searchParams.set('redirect', sameOriginRedirect(redirectPath, context.url.origin))
31
+ return Response.redirect(local, 302)
32
+ }
27
33
  const authFlow = await createAuthFlow(context)
28
34
  const issuerProvider = selectedProvider === 'email-code' ? 'code' : selectedProvider
29
35
  const { url } = await getAuthorizationUrl(context, issuerProvider, redirectPath, authFlow)
@@ -1,5 +1,7 @@
1
1
  import { getConfig } from '../config-store.js'
2
2
  import { getRenderer, renderHtmlResponse, verifyCodeRendererPayload } from '../renderers.js'
3
+ import { getCookie } from '../utils/cookies.js'
4
+ import { sameOriginRedirect } from '../utils/issuer.js'
3
5
  import { type RouteContext, withAuthFlowCookies } from './flow.js'
4
6
 
5
7
  export async function GET(context: RouteContext): Promise<Response> {
@@ -13,7 +15,11 @@ export async function GET(context: RouteContext): Promise<Response> {
13
15
  return new Response('Not found', { status: 404 })
14
16
  }
15
17
 
16
- const redirectPath = context.url.searchParams.get('redirect') || '/'
18
+ const redirectPath = sameOriginRedirect(
19
+ context.url.searchParams.get('redirect') ??
20
+ getCookie(context.request, `${options.cookiePrefix}_redirect`),
21
+ context.url.origin,
22
+ )
17
23
  const authSurface =
18
24
  context.url.pathname === '/create-account/' ||
19
25
  context.url.searchParams.get('auth_surface') === 'create-account'
@@ -30,5 +36,5 @@ export async function GET(context: RouteContext): Promise<Response> {
30
36
  }),
31
37
  ),
32
38
  )
33
- return withAuthFlowCookies(context, options, response, redirectPath, 'email-code')
39
+ return withAuthFlowCookies(context, options, response, redirectPath, 'email-code', 900)
34
40
  }
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  reportClientSecretResolutionFailure,
8
8
  } from './utils/fail-closed.js'
9
9
  import { resolveInternalIssuer } from './utils/issuer.js'
10
+ import { resolveIssuerFetch } from './utils/issuer-fetch.js'
10
11
  import { createPKCEChallenge } from './utils/pkce.js'
11
12
 
12
13
  export { ClientSecretResolutionError }
@@ -75,6 +76,8 @@ async function headersFor(
75
76
  context: AuthClientContext,
76
77
  ): Promise<Record<string, string>> {
77
78
  const headers: Record<string, string> = { 'Content-Type': 'application/json' }
79
+ const clientIp = context.request.headers.get('cf-connecting-ip')
80
+ if (clientIp) headers['X-Auth-Client-IP'] = clientIp
78
81
  const secretResult = await resolveClientSecret(options.clientSecret, context.request)
79
82
  if (!secretResult.ok) throw new ClientSecretResolutionError(secretResult.code)
80
83
  if (secretResult.secret) {
@@ -106,11 +109,14 @@ async function postJson<T extends AuthClientCodeResult | AuthClientOkResult>(
106
109
  body: Record<string, unknown>,
107
110
  ): Promise<T> {
108
111
  try {
109
- const response = await fetch(`${resolveInternalIssuer(options)}${pathname}`, {
110
- method: 'POST',
111
- headers: await headersFor(options, context),
112
- body: JSON.stringify(body),
113
- })
112
+ const response = await resolveIssuerFetch(options)(
113
+ `${resolveInternalIssuer(options)}${pathname}`,
114
+ {
115
+ method: 'POST',
116
+ headers: await headersFor(options, context),
117
+ body: JSON.stringify(body),
118
+ },
119
+ )
114
120
  const json = await readJson(response)
115
121
 
116
122
  if (typeof json.error === 'string') {
@@ -112,14 +112,14 @@ export function setAuthFlowCookies(
112
112
  state: string,
113
113
  redirectPath: string,
114
114
  provider?: string,
115
- flowOptions: { pkcePath?: string; additionalPkcePaths?: string[] } = {},
115
+ flowOptions: { pkcePath?: string; additionalPkcePaths?: string[]; maxAge?: number } = {},
116
116
  ): void {
117
117
  const baseAttrs: CookieAttributes = {
118
118
  path: '/',
119
119
  httpOnly: true,
120
120
  secure: true,
121
121
  sameSite: 'lax',
122
- maxAge: 600,
122
+ maxAge: flowOptions.maxAge ?? 600,
123
123
  }
124
124
  const pkcePaths = new Set([
125
125
  flowOptions.pkcePath ?? options.callbackPath,
@@ -0,0 +1,20 @@
1
+ import type { ResolvedAuthOptions } from '../options.js'
2
+ import { getRuntimeEnv } from '../runtime.js'
3
+ import type { IssuerFetch } from './tokens.js'
4
+
5
+ /** Resolve lazily, retaining the binding receiver and never falling back on a bad binding. */
6
+ export function resolveIssuerFetch(options: Pick<ResolvedAuthOptions, 'issuerFetch'>): IssuerFetch {
7
+ if (!options.issuerFetch) return fetch
8
+ return (input, init) => {
9
+ const binding = getRuntimeEnv()[options.issuerFetch!]
10
+ if (
11
+ typeof binding !== 'object' ||
12
+ binding === null ||
13
+ !('fetch' in binding) ||
14
+ typeof binding.fetch !== 'function'
15
+ ) {
16
+ throw new Error('@growth-labs/auth: configured issuer service binding unavailable')
17
+ }
18
+ return binding.fetch(input, init)
19
+ }
20
+ }
@@ -5,3 +5,14 @@ export function resolveInternalIssuer(
5
5
  ): string {
6
6
  return options.issuerInternal ?? options.issuer
7
7
  }
8
+
9
+ /** Redirect cookies and form return paths are untrusted input. */
10
+ export function sameOriginRedirect(value: string | null | undefined, origin: string): string {
11
+ try {
12
+ const target = new URL(value || '/', origin)
13
+ if (target.origin === origin && !target.username && !target.password) return target.toString()
14
+ } catch {
15
+ /* use the local home page */
16
+ }
17
+ return new URL('/', origin).toString()
18
+ }
@@ -6,6 +6,7 @@ import {
6
6
  resolveClientSecret,
7
7
  } from './client-secret.js'
8
8
  import { resolveInternalIssuer } from './issuer.js'
9
+ import { resolveIssuerFetch } from './issuer-fetch.js'
9
10
  import { generatePKCE } from './pkce.js'
10
11
  import { generateState } from './state.js'
11
12
  import { verifyIssuedAccessTokenClaims } from './tokens.js'
@@ -137,7 +138,7 @@ export async function exchangeCode(
137
138
  })
138
139
 
139
140
  try {
140
- const response = await fetch(`${issuer}/token`, {
141
+ const response = await resolveIssuerFetch(options)(`${issuer}/token`, {
141
142
  method: 'POST',
142
143
  headers,
143
144
  body: body.toString(),
@@ -148,7 +149,12 @@ export async function exchangeCode(
148
149
  const data = await response.json()
149
150
  if (!tokenResponseHasTokens(data)) return null
150
151
 
151
- const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, options.resource)
152
+ const claims = await verifyIssuedAccessTokenClaims(
153
+ options.issuerFetch ? options.issuer : issuer,
154
+ data.access_token,
155
+ options.resource,
156
+ { jwksIssuer: issuer, jwksFetch: resolveIssuerFetch(options) },
157
+ )
152
158
  if (!claims) return null
153
159
 
154
160
  return {
@@ -181,7 +187,7 @@ export async function revokeToken(
181
187
  }
182
188
 
183
189
  try {
184
- await fetch(`${issuer}/revoke`, {
190
+ await resolveIssuerFetch(options)(`${issuer}/revoke`, {
185
191
  method: 'POST',
186
192
  headers,
187
193
  body: new URLSearchParams({
@@ -216,9 +216,10 @@ export async function verifyIssuedAccessTokenClaims(
216
216
  issuer: string,
217
217
  token: string,
218
218
  audience: string | undefined,
219
+ options: Pick<VerifyAccessTokenOptions, 'jwksFetch' | 'jwksIssuer'> = {},
219
220
  ): Promise<TokenPayloadClaims | null> {
220
221
  if (!audience) return null
221
- const payload = await verifyAccessTokenPayload(issuer, token, { audience })
222
+ const payload = await verifyAccessTokenPayload(issuer, token, { audience, ...options })
222
223
  if (!payload) return null
223
224
  return extractTokenClaims(payload)
224
225
  }
@@ -298,6 +299,8 @@ async function exchangeRefreshToken(
298
299
  clientId: string | undefined,
299
300
  clientSecret: string | undefined,
300
301
  resource: string,
302
+ fetcher: IssuerFetch,
303
+ verificationIssuer: string,
301
304
  ): Promise<RefreshExchange> {
302
305
  try {
303
306
  const params = new URLSearchParams({
@@ -314,7 +317,7 @@ async function exchangeRefreshToken(
314
317
  headers.Authorization = createClientSecretBasicAuthHeader(clientId, clientSecret)
315
318
  }
316
319
 
317
- const response = await fetch(`${issuer}/token`, {
320
+ const response = await fetcher(`${issuer}/token`, {
318
321
  method: 'POST',
319
322
  headers,
320
323
  body: params.toString(),
@@ -339,7 +342,12 @@ async function exchangeRefreshToken(
339
342
  return { result: null, failure: { reason: 'malformed_response', status: response.status } }
340
343
  }
341
344
 
342
- const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, resource)
345
+ const claims = await verifyIssuedAccessTokenClaims(
346
+ verificationIssuer,
347
+ data.access_token,
348
+ resource,
349
+ { jwksIssuer: issuer, jwksFetch: fetcher },
350
+ )
343
351
  if (!claims) {
344
352
  return { result: null, failure: { reason: 'claims_rejected', status: response.status } }
345
353
  }
@@ -367,6 +375,7 @@ export async function refreshTokens(
367
375
  clientSecret?: string,
368
376
  resource?: string,
369
377
  onFailure?: (failure: RefreshFailure) => void,
378
+ transport: { issuerFetch?: IssuerFetch; verificationIssuer?: string } = {},
370
379
  ): Promise<TokenRefreshResult | null> {
371
380
  // Reporting is advisory: a callback that throws must not change the outcome.
372
381
  const report = (failure: RefreshFailure): void => {
@@ -382,7 +391,16 @@ export async function refreshTokens(
382
391
  }
383
392
  const exchange = await singleFlightRefresh(
384
393
  ['tokens', issuer, clientId ?? '', resource, refreshToken],
385
- () => exchangeRefreshToken(issuer, refreshToken, clientId, clientSecret, resource),
394
+ () =>
395
+ exchangeRefreshToken(
396
+ issuer,
397
+ refreshToken,
398
+ clientId,
399
+ clientSecret,
400
+ resource,
401
+ transport.issuerFetch ?? fetch,
402
+ transport.verificationIssuer ?? issuer,
403
+ ),
386
404
  )
387
405
  if (exchange.failure) report(exchange.failure)
388
406
  return exchange.result
package/src/workers.ts CHANGED
@@ -526,7 +526,12 @@ async function requestTokens(
526
526
  }
527
527
  let response: Response
528
528
  try {
529
- response = await config.issuerFetch(`${config.issuerInternal}/token`, {
529
+ // Detached call: `config.issuerFetch(...)` would invoke the default
530
+ // global fetch as a method of the config object, which Workers refuse
531
+ // with `TypeError: Illegal invocation` (0.17.0 regression for every
532
+ // consumer that leaves issuerFetch unset).
533
+ const issuerFetch = config.issuerFetch
534
+ response = await issuerFetch(`${config.issuerInternal}/token`, {
530
535
  method: 'POST',
531
536
  headers,
532
537
  body: form.toString(),