@growth-labs/auth 0.17.1 → 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 (72) 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/package.json +1 -1
  57. package/src/middleware/auth.ts +19 -3
  58. package/src/options.ts +10 -0
  59. package/src/routes/api/code-request.ts +20 -2
  60. package/src/routes/api/code-verify.ts +19 -2
  61. package/src/routes/api/rate-limit.ts +48 -0
  62. package/src/routes/api/shared.ts +3 -2
  63. package/src/routes/callback.ts +2 -1
  64. package/src/routes/flow.ts +2 -0
  65. package/src/routes/login.ts +6 -0
  66. package/src/routes/verify-code.ts +8 -2
  67. package/src/server.ts +11 -5
  68. package/src/utils/cookies.ts +2 -2
  69. package/src/utils/issuer-fetch.ts +20 -0
  70. package/src/utils/issuer.ts +11 -0
  71. package/src/utils/openauth.ts +9 -3
  72. package/src/utils/tokens.ts +22 -4
@@ -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