@growth-labs/auth 0.10.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/routes/api/code-request.d.ts.map +1 -1
  2. package/dist/routes/api/code-request.js +10 -4
  3. package/dist/routes/api/code-request.js.map +1 -1
  4. package/dist/routes/api/password-reset-request.d.ts.map +1 -1
  5. package/dist/routes/api/password-reset-request.js +4 -1
  6. package/dist/routes/api/password-reset-request.js.map +1 -1
  7. package/dist/routes/api/shared.d.ts +7 -0
  8. package/dist/routes/api/shared.d.ts.map +1 -1
  9. package/dist/routes/api/shared.js +15 -1
  10. package/dist/routes/api/shared.js.map +1 -1
  11. package/dist/utils/client-secret-header.d.ts +2 -0
  12. package/dist/utils/client-secret-header.d.ts.map +1 -0
  13. package/dist/utils/client-secret-header.js +8 -0
  14. package/dist/utils/client-secret-header.js.map +1 -0
  15. package/dist/utils/client-secret.d.ts +1 -1
  16. package/dist/utils/client-secret.d.ts.map +1 -1
  17. package/dist/utils/client-secret.js +1 -7
  18. package/dist/utils/client-secret.js.map +1 -1
  19. package/dist/utils/cookies.d.ts.map +1 -1
  20. package/dist/utils/cookies.js +8 -1
  21. package/dist/utils/cookies.js.map +1 -1
  22. package/dist/utils/tokens.d.ts +9 -0
  23. package/dist/utils/tokens.d.ts.map +1 -1
  24. package/dist/utils/tokens.js +33 -2
  25. package/dist/utils/tokens.js.map +1 -1
  26. package/dist/workers.d.ts +89 -0
  27. package/dist/workers.d.ts.map +1 -0
  28. package/dist/workers.js +516 -0
  29. package/dist/workers.js.map +1 -0
  30. package/package.json +22 -3
  31. package/src/routes/api/code-request.ts +12 -4
  32. package/src/routes/api/password-reset-request.ts +5 -0
  33. package/src/routes/api/shared.ts +21 -0
  34. package/src/utils/client-secret-header.ts +8 -0
  35. package/src/utils/client-secret.ts +1 -9
  36. package/src/utils/cookies.ts +6 -1
  37. package/src/utils/tokens.ts +46 -2
  38. package/src/workers.ts +758 -0
package/src/workers.ts ADDED
@@ -0,0 +1,758 @@
1
+ import { createClientSecretBasicAuthHeader } from './utils/client-secret-header.js'
2
+ import { getCookie, setCookie } from './utils/cookies.js'
3
+ import { extractIdentityId } from './utils/identity.js'
4
+ import { resolveInternalIssuer } from './utils/issuer.js'
5
+ import { generatePKCE } from './utils/pkce.js'
6
+ import { generateState, verifyState } from './utils/state.js'
7
+ import { type VerifiedClaims, verifyAccessToken } from './utils/tokens.js'
8
+
9
+ const DEFAULT_CALLBACK_PATH = '/api/auth/callback'
10
+ const DEFAULT_LOGIN_PATH = '/login'
11
+ const DEFAULT_ACCESS_TOKEN_MAX_AGE = 900
12
+ const DEFAULT_REFRESH_TOKEN_MAX_AGE = 2_592_000
13
+ const DEFAULT_TRANSACTION_MAX_AGE = 600
14
+ const DEFAULT_CLOCK_SKEW = 60
15
+
16
+ type MaybePromise<T> = T | Promise<T>
17
+
18
+ export type ClientSecretValue = Uint8Array | string
19
+ export type ClientSecretResolver = (request: Request) => MaybePromise<ClientSecretValue>
20
+
21
+ export interface ClientSecretStore {
22
+ get(): MaybePromise<ClientSecretValue | string>
23
+ }
24
+
25
+ export interface ClientSecretResolverSource {
26
+ resolve(request: Request): MaybePromise<ClientSecretValue>
27
+ }
28
+
29
+ export type WorkersClientSecret =
30
+ | Uint8Array
31
+ | ClientSecretResolver
32
+ | ClientSecretResolverSource
33
+ | ClientSecretStore
34
+
35
+ export interface WorkersAuthConfig {
36
+ issuer: string
37
+ issuerInternal?: string
38
+ clientId: string
39
+ clientSecret?: WorkersClientSecret
40
+ resource: string
41
+ callbackPath?: string
42
+ cookiePrefix: string
43
+ sessionCookieDomain?: string
44
+ providers: readonly string[]
45
+ gatedPaths?: readonly string[]
46
+ loginPath?: string
47
+ accessTokenMaxAge?: number
48
+ refreshTokenMaxAge?: number
49
+ transactionMaxAge?: number
50
+ clockSkew?: number
51
+ }
52
+
53
+ interface NormalizedWorkersAuthConfig {
54
+ issuer: string
55
+ issuerInternal: string
56
+ clientId: string
57
+ clientSecret?: WorkersClientSecret
58
+ resource: string
59
+ callbackPath: string
60
+ cookiePrefix: string
61
+ sessionCookieDomain?: string
62
+ providers: readonly string[]
63
+ gatedPaths: readonly string[]
64
+ loginPath: string
65
+ accessTokenMaxAge: number
66
+ refreshTokenMaxAge: number
67
+ transactionMaxAge: number
68
+ clockSkew: number
69
+ }
70
+
71
+ export interface Principal {
72
+ Subject: string
73
+ UserID: string
74
+ Email: string
75
+ Name: string
76
+ Image: string
77
+ Roles: string[]
78
+ Audiences: string[]
79
+ }
80
+
81
+ export interface Tokens {
82
+ AccessToken: string
83
+ RefreshToken: string
84
+ }
85
+
86
+ export interface Session {
87
+ Principal: Principal | null
88
+ Tokens: Tokens | null
89
+ Refreshed: boolean
90
+ }
91
+
92
+ export interface WorkersAuthVariables {
93
+ principal: Principal | null
94
+ session: Session
95
+ }
96
+
97
+ export interface WorkersAuthEnv {
98
+ Variables: WorkersAuthVariables
99
+ }
100
+
101
+ export interface WorkersAuthContext {
102
+ req: { readonly raw: Request }
103
+ get<Key extends keyof WorkersAuthVariables>(key: Key): WorkersAuthVariables[Key]
104
+ set<Key extends keyof WorkersAuthVariables>(key: Key, value: WorkersAuthVariables[Key]): void
105
+ redirect(location: string, status: 302): Response
106
+ res: Response
107
+ }
108
+
109
+ export type WorkersAuthMiddleware = (
110
+ context: WorkersAuthContext,
111
+ next: () => Promise<void>,
112
+ ) => Promise<Response | undefined>
113
+ export type RequestOrHonoContext = Request | Pick<WorkersAuthContext, 'req'>
114
+ export type PrincipalSource = Session | Pick<WorkersAuthContext, 'get'>
115
+
116
+ export class WorkersAuthServiceUnavailableError extends Error {
117
+ readonly code = 'AUTH_SERVICE_UNAVAILABLE'
118
+ readonly status = 503
119
+
120
+ constructor() {
121
+ super('Auth service unavailable')
122
+ this.name = 'WorkersAuthServiceUnavailableError'
123
+ }
124
+ }
125
+
126
+ export interface WorkersAuthClient {
127
+ Authorize(
128
+ requestOrContext: RequestOrHonoContext,
129
+ provider: string,
130
+ redirectPath: string,
131
+ ): Promise<Response>
132
+ Callback(requestOrContext: RequestOrHonoContext): Promise<Response>
133
+ Middleware(): WorkersAuthMiddleware
134
+ Principal(source: PrincipalSource): Principal | null
135
+ getSession(requestOrContext: RequestOrHonoContext): Promise<Session>
136
+ setSessionCookies(response: Response, tokens: Tokens): Response
137
+ clearSessionCookies(response: Response): Response
138
+ }
139
+
140
+ function normalizeIssuer(value: string, label: string): string {
141
+ let url: URL
142
+ try {
143
+ url = new URL(value)
144
+ } catch {
145
+ throw new TypeError(`${label} must be an absolute HTTP URL`)
146
+ }
147
+ if (
148
+ (url.protocol !== 'https:' && url.protocol !== 'http:') ||
149
+ url.username !== '' ||
150
+ url.password !== '' ||
151
+ (url.pathname !== '/' && url.pathname !== '') ||
152
+ url.search !== '' ||
153
+ url.hash !== ''
154
+ ) {
155
+ throw new TypeError(`${label} must be an origin without credentials, path, query, or fragment`)
156
+ }
157
+ return url.origin
158
+ }
159
+
160
+ function validateResource(value: string): string {
161
+ let url: URL
162
+ try {
163
+ url = new URL(value)
164
+ } catch {
165
+ throw new TypeError('resource must be an absolute HTTP URL')
166
+ }
167
+ if (
168
+ (url.protocol !== 'https:' && url.protocol !== 'http:') ||
169
+ url.username !== '' ||
170
+ url.password !== '' ||
171
+ url.hash !== ''
172
+ ) {
173
+ throw new TypeError('resource must be an absolute HTTP URL without credentials or fragment')
174
+ }
175
+ return value
176
+ }
177
+
178
+ function isSafeAbsolutePath(path: string): boolean {
179
+ const lower = path.toLowerCase()
180
+ if (
181
+ !path.startsWith('/') ||
182
+ path.startsWith('//') ||
183
+ path.includes('\\') ||
184
+ lower.includes('%2f') ||
185
+ lower.includes('%5c')
186
+ ) {
187
+ return false
188
+ }
189
+ try {
190
+ const parsed = new URL(path, 'https://local.invalid')
191
+ return parsed.origin === 'https://local.invalid'
192
+ } catch {
193
+ return false
194
+ }
195
+ }
196
+
197
+ function isSafeLocalRoute(path: string): boolean {
198
+ if (!isSafeAbsolutePath(path)) return false
199
+ const parsed = new URL(path, 'https://local.invalid')
200
+ return parsed.search === '' && parsed.hash === ''
201
+ }
202
+
203
+ function safeRedirect(path: string | null): string {
204
+ return path !== null && isSafeAbsolutePath(path) ? path : '/'
205
+ }
206
+
207
+ function positiveSeconds(value: number | undefined, fallback: number, label: string): number {
208
+ if (value === undefined) return fallback
209
+ if (!Number.isInteger(value) || value <= 0) {
210
+ throw new TypeError(`${label} must be a positive integer number of seconds`)
211
+ }
212
+ return value
213
+ }
214
+
215
+ function normalizeConfig(config: WorkersAuthConfig): NormalizedWorkersAuthConfig {
216
+ const issuer = normalizeIssuer(config.issuer, 'issuer')
217
+ const issuerInternal = config.issuerInternal
218
+ ? normalizeIssuer(config.issuerInternal, 'issuerInternal')
219
+ : issuer
220
+ if (typeof config.clientId !== 'string' || config.clientId.trim() === '') {
221
+ throw new TypeError('clientId is required')
222
+ }
223
+ if (typeof config.cookiePrefix !== 'string' || !/^[A-Za-z0-9_-]+$/.test(config.cookiePrefix)) {
224
+ throw new TypeError('cookiePrefix must contain only letters, numbers, underscores, or hyphens')
225
+ }
226
+ const callbackPath = config.callbackPath ?? DEFAULT_CALLBACK_PATH
227
+ const loginPath = config.loginPath ?? DEFAULT_LOGIN_PATH
228
+ if (!isSafeLocalRoute(callbackPath)) throw new TypeError('callbackPath must be a local route')
229
+ if (!isSafeLocalRoute(loginPath)) throw new TypeError('loginPath must be a local route')
230
+ if (!Array.isArray(config.providers) || config.providers.length === 0) {
231
+ throw new TypeError('at least one provider is required')
232
+ }
233
+ if (config.providers.some((provider) => typeof provider !== 'string' || provider.trim() === '')) {
234
+ throw new TypeError('providers must be non-empty strings')
235
+ }
236
+ const gatedPaths = [...(config.gatedPaths ?? [])]
237
+ for (const pattern of gatedPaths) {
238
+ const route = pattern.endsWith('/*') ? pattern.slice(0, -1) : pattern
239
+ if (!isSafeLocalRoute(route)) throw new TypeError('gatedPaths must contain local routes')
240
+ }
241
+ if (
242
+ config.sessionCookieDomain !== undefined &&
243
+ (!/^(?:\.?[A-Za-z0-9-]+)(?:\.[A-Za-z0-9-]+)*$/.test(config.sessionCookieDomain) ||
244
+ config.sessionCookieDomain.includes('..'))
245
+ ) {
246
+ throw new TypeError('sessionCookieDomain is invalid')
247
+ }
248
+ return {
249
+ issuer,
250
+ issuerInternal,
251
+ clientId: config.clientId,
252
+ clientSecret: config.clientSecret,
253
+ resource: validateResource(config.resource),
254
+ callbackPath,
255
+ cookiePrefix: config.cookiePrefix,
256
+ sessionCookieDomain: config.sessionCookieDomain,
257
+ providers: [...config.providers],
258
+ gatedPaths,
259
+ loginPath,
260
+ accessTokenMaxAge: positiveSeconds(
261
+ config.accessTokenMaxAge,
262
+ DEFAULT_ACCESS_TOKEN_MAX_AGE,
263
+ 'accessTokenMaxAge',
264
+ ),
265
+ refreshTokenMaxAge: positiveSeconds(
266
+ config.refreshTokenMaxAge,
267
+ DEFAULT_REFRESH_TOKEN_MAX_AGE,
268
+ 'refreshTokenMaxAge',
269
+ ),
270
+ transactionMaxAge: positiveSeconds(
271
+ config.transactionMaxAge,
272
+ DEFAULT_TRANSACTION_MAX_AGE,
273
+ 'transactionMaxAge',
274
+ ),
275
+ clockSkew: positiveSeconds(config.clockSkew, DEFAULT_CLOCK_SKEW, 'clockSkew'),
276
+ }
277
+ }
278
+
279
+ function requestFrom(input: RequestOrHonoContext): Request {
280
+ if (input instanceof Request) return input
281
+ if (input && input.req?.raw instanceof Request) return input.req.raw
282
+ throw new TypeError('expected a Web Request or Hono Context')
283
+ }
284
+
285
+ function cookieName(config: NormalizedWorkersAuthConfig, suffix: string): string {
286
+ return `${config.cookiePrefix}_${suffix}`
287
+ }
288
+
289
+ function setSecureCookie(
290
+ response: Response,
291
+ name: string,
292
+ value: string,
293
+ path: string,
294
+ maxAge: number,
295
+ domain?: string,
296
+ ): void {
297
+ setCookie(response, name, value, {
298
+ path,
299
+ maxAge,
300
+ httpOnly: true,
301
+ secure: true,
302
+ sameSite: 'lax',
303
+ ...(domain ? { domain } : {}),
304
+ })
305
+ }
306
+
307
+ function setTransactionCookies(
308
+ config: NormalizedWorkersAuthConfig,
309
+ response: Response,
310
+ transaction: {
311
+ verifier: string
312
+ callback: string
313
+ expires: number
314
+ state: string
315
+ redirect: string
316
+ provider: string
317
+ },
318
+ ): void {
319
+ const callbackNames = ['pkce', 'callback', 'expires'] as const
320
+ const callbackValues = [
321
+ transaction.verifier,
322
+ transaction.callback,
323
+ String(transaction.expires),
324
+ ] as const
325
+ for (let index = 0; index < callbackNames.length; index += 1) {
326
+ setSecureCookie(
327
+ response,
328
+ cookieName(config, callbackNames[index]),
329
+ callbackValues[index],
330
+ config.callbackPath,
331
+ config.transactionMaxAge,
332
+ )
333
+ }
334
+ for (const [suffix, value] of [
335
+ ['state', transaction.state],
336
+ ['redirect', transaction.redirect],
337
+ ['provider', transaction.provider],
338
+ ] as const) {
339
+ setSecureCookie(response, cookieName(config, suffix), value, '/', config.transactionMaxAge)
340
+ }
341
+ }
342
+
343
+ function clearTransactionCookies(config: NormalizedWorkersAuthConfig, response: Response): void {
344
+ for (const suffix of ['pkce', 'callback', 'expires']) {
345
+ setSecureCookie(response, cookieName(config, suffix), '', config.callbackPath, 0)
346
+ }
347
+ for (const suffix of ['state', 'redirect', 'provider']) {
348
+ setSecureCookie(response, cookieName(config, suffix), '', '/', 0)
349
+ }
350
+ }
351
+
352
+ function setSessionCookiesNormalized(
353
+ config: NormalizedWorkersAuthConfig,
354
+ response: Response,
355
+ tokens: Tokens,
356
+ ): Response {
357
+ const mutable = cloneMutableResponse(response)
358
+ appendSessionCookiesNormalized(config, mutable, tokens)
359
+ return mutable
360
+ }
361
+
362
+ function appendSessionCookiesNormalized(
363
+ config: NormalizedWorkersAuthConfig,
364
+ response: Response,
365
+ tokens: Tokens,
366
+ ): void {
367
+ setSecureCookie(
368
+ response,
369
+ cookieName(config, 'at'),
370
+ tokens.AccessToken,
371
+ '/',
372
+ config.accessTokenMaxAge,
373
+ config.sessionCookieDomain,
374
+ )
375
+ setSecureCookie(
376
+ response,
377
+ cookieName(config, 'rt'),
378
+ tokens.RefreshToken,
379
+ '/',
380
+ config.refreshTokenMaxAge,
381
+ config.sessionCookieDomain,
382
+ )
383
+ }
384
+
385
+ function clearSessionCookiesNormalized(
386
+ config: NormalizedWorkersAuthConfig,
387
+ response: Response,
388
+ ): Response {
389
+ const mutable = cloneMutableResponse(response)
390
+ setSecureCookie(mutable, cookieName(config, 'at'), '', '/', 0, config.sessionCookieDomain)
391
+ setSecureCookie(mutable, cookieName(config, 'rt'), '', '/', 0, config.sessionCookieDomain)
392
+ return mutable
393
+ }
394
+
395
+ function cloneMutableResponse(response: Response): Response {
396
+ return new Response(response.body, {
397
+ status: response.status,
398
+ statusText: response.statusText,
399
+ headers: new Headers(response.headers),
400
+ })
401
+ }
402
+
403
+ function clearTerminalTransaction(
404
+ config: NormalizedWorkersAuthConfig,
405
+ response: Response,
406
+ ): Response {
407
+ clearTransactionCookies(config, response)
408
+ return response
409
+ }
410
+
411
+ function serviceUnavailable(request: Request): Response {
412
+ const headers = {
413
+ 'Cache-Control': 'private, no-store',
414
+ Pragma: 'no-cache',
415
+ Expires: '0',
416
+ }
417
+ const acceptsJson = request.headers
418
+ .get('accept')
419
+ ?.split(',')
420
+ .some((part) => part.split(';', 1)[0].trim().toLowerCase() === 'application/json')
421
+ if (acceptsJson) {
422
+ return Response.json({ error: 'auth_service_unavailable' }, { status: 503, headers })
423
+ }
424
+ return new Response('Service unavailable', { status: 503, headers })
425
+ }
426
+
427
+ async function resolveSecret(
428
+ config: NormalizedWorkersAuthConfig,
429
+ request: Request,
430
+ ): Promise<string | null> {
431
+ const source = config.clientSecret
432
+ if (source === undefined) return null
433
+ let value: unknown
434
+ try {
435
+ if (source instanceof Uint8Array) {
436
+ value = source
437
+ } else if (typeof source === 'function') {
438
+ value = await source(request)
439
+ } else if ('resolve' in source && typeof source.resolve === 'function') {
440
+ value = await source.resolve(request)
441
+ } else if ('get' in source && typeof source.get === 'function') {
442
+ value = await source.get()
443
+ }
444
+ } catch {
445
+ throw new WorkersAuthServiceUnavailableError()
446
+ }
447
+ let secret: string
448
+ try {
449
+ secret =
450
+ value instanceof Uint8Array
451
+ ? new TextDecoder('utf-8', { fatal: true }).decode(value)
452
+ : typeof value === 'string'
453
+ ? value
454
+ : ''
455
+ } catch {
456
+ throw new WorkersAuthServiceUnavailableError()
457
+ }
458
+ if (secret.trim() === '') throw new WorkersAuthServiceUnavailableError()
459
+ return secret
460
+ }
461
+
462
+ function principalFromClaims(claims: VerifiedClaims): Principal | null {
463
+ try {
464
+ const userId = claims.userId ?? extractIdentityId(claims.sub)
465
+ return {
466
+ Subject: claims.sub,
467
+ UserID: userId,
468
+ Email: claims.email,
469
+ Name: claims.name ?? '',
470
+ Image: claims.image ?? '',
471
+ Roles: [...(claims.roles ?? [])],
472
+ Audiences: [...(claims.audiences ?? [])],
473
+ }
474
+ } catch {
475
+ return null
476
+ }
477
+ }
478
+
479
+ async function verifyToken(
480
+ config: NormalizedWorkersAuthConfig,
481
+ token: string,
482
+ ): Promise<Principal | null> {
483
+ const claims = await verifyAccessToken(config.issuer, token, {
484
+ audience: config.resource,
485
+ algorithms: ['ES256'],
486
+ jwksIssuer: resolveInternalIssuer(config),
487
+ requireUserToken: true,
488
+ clockSkew: config.clockSkew,
489
+ })
490
+ return claims ? principalFromClaims(claims) : null
491
+ }
492
+
493
+ async function requestTokens(
494
+ config: NormalizedWorkersAuthConfig,
495
+ request: Request,
496
+ form: URLSearchParams,
497
+ ): Promise<{ tokens: Tokens; principal: Principal } | null> {
498
+ const secret = await resolveSecret(config, request)
499
+ const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
500
+ if (secret !== null) {
501
+ headers.set('Authorization', createClientSecretBasicAuthHeader(config.clientId, secret))
502
+ }
503
+ let response: Response
504
+ try {
505
+ response = await fetch(`${config.issuerInternal}/token`, {
506
+ method: 'POST',
507
+ headers,
508
+ body: form.toString(),
509
+ })
510
+ } catch {
511
+ return null
512
+ }
513
+ if (!response.ok) return null
514
+ let data: unknown
515
+ try {
516
+ data = await response.json()
517
+ } catch {
518
+ return null
519
+ }
520
+ if (
521
+ typeof data !== 'object' ||
522
+ data === null ||
523
+ typeof (data as { access_token?: unknown }).access_token !== 'string' ||
524
+ typeof (data as { refresh_token?: unknown }).refresh_token !== 'string'
525
+ ) {
526
+ return null
527
+ }
528
+ const tokens: Tokens = {
529
+ AccessToken: (data as { access_token: string }).access_token,
530
+ RefreshToken: (data as { refresh_token: string }).refresh_token,
531
+ }
532
+ const principal = await verifyToken(config, tokens.AccessToken)
533
+ return principal ? { tokens, principal } : null
534
+ }
535
+
536
+ export async function Authorize(
537
+ config: WorkersAuthConfig,
538
+ requestOrContext: RequestOrHonoContext,
539
+ provider: string,
540
+ redirectPath: string,
541
+ ): Promise<Response> {
542
+ const normalized = normalizeConfig(config)
543
+ if (!normalized.providers.includes(provider)) throw new TypeError('provider is not configured')
544
+ const request = requestFrom(requestOrContext)
545
+ const requestUrl = new URL(request.url)
546
+ const callback = `${requestUrl.origin}${normalized.callbackPath}`
547
+ const { verifier, challenge } = await generatePKCE()
548
+ const state = generateState()
549
+ const redirect = safeRedirect(redirectPath)
550
+ const expires = Math.floor(Date.now() / 1000) + normalized.transactionMaxAge
551
+ const authorizationUrl = new URL(`${normalized.issuer}/authorize`)
552
+ for (const [key, value] of [
553
+ ['response_type', 'code'],
554
+ ['client_id', normalized.clientId],
555
+ ['redirect_uri', callback],
556
+ ['scope', 'openid email profile'],
557
+ ['state', state],
558
+ ['code_challenge', challenge],
559
+ ['code_challenge_method', 'S256'],
560
+ ['provider', provider === 'email-code' ? 'code' : provider],
561
+ ['resource', normalized.resource],
562
+ ] as const) {
563
+ authorizationUrl.searchParams.set(key, value)
564
+ }
565
+ const response = new Response(null, {
566
+ status: 302,
567
+ headers: { Location: authorizationUrl.toString() },
568
+ })
569
+ setTransactionCookies(normalized, response, {
570
+ verifier,
571
+ callback,
572
+ expires,
573
+ state,
574
+ redirect,
575
+ provider,
576
+ })
577
+ return response
578
+ }
579
+
580
+ export async function Callback(
581
+ config: WorkersAuthConfig,
582
+ requestOrContext: RequestOrHonoContext,
583
+ ): Promise<Response> {
584
+ const normalized = normalizeConfig(config)
585
+ const request = requestFrom(requestOrContext)
586
+ const url = new URL(request.url)
587
+ const state = url.searchParams.get('state') ?? ''
588
+ const storedState = getCookie(request, cookieName(normalized, 'state')) ?? ''
589
+ const callback = getCookie(request, cookieName(normalized, 'callback'))
590
+ const expectedCallback = `${url.origin}${normalized.callbackPath}`
591
+ const storedExpires = getCookie(request, cookieName(normalized, 'expires')) ?? ''
592
+ const expires = /^(?:0|[1-9]\d*)$/.test(storedExpires) ? Number(storedExpires) : Number.NaN
593
+ if (url.pathname !== normalized.callbackPath || !verifyState(state, storedState)) {
594
+ return new Response('Invalid state parameter', { status: 403 })
595
+ }
596
+ if (
597
+ callback !== expectedCallback ||
598
+ !Number.isSafeInteger(expires) ||
599
+ Math.floor(Date.now() / 1000) >= expires
600
+ ) {
601
+ return clearTerminalTransaction(
602
+ normalized,
603
+ new Response('Invalid state parameter', { status: 403 }),
604
+ )
605
+ }
606
+ const verifier = getCookie(request, cookieName(normalized, 'pkce'))
607
+ if (!verifier) {
608
+ return clearTerminalTransaction(
609
+ normalized,
610
+ new Response('Missing PKCE verifier', { status: 400 }),
611
+ )
612
+ }
613
+ const code = url.searchParams.get('code')
614
+ if (url.searchParams.has('error') || !code) {
615
+ return clearTerminalTransaction(
616
+ normalized,
617
+ new Response(null, {
618
+ status: 302,
619
+ headers: { Location: `${normalized.loginPath}?error=auth_failed` },
620
+ }),
621
+ )
622
+ }
623
+ const form = new URLSearchParams()
624
+ form.set('grant_type', 'authorization_code')
625
+ form.set('code', code)
626
+ form.set('code_verifier', verifier)
627
+ form.set('redirect_uri', callback)
628
+ form.set('client_id', normalized.clientId)
629
+ form.set('resource', normalized.resource)
630
+ let result: Awaited<ReturnType<typeof requestTokens>>
631
+ try {
632
+ result = await requestTokens(normalized, request, form)
633
+ } catch (error) {
634
+ if (error instanceof WorkersAuthServiceUnavailableError) {
635
+ return clearTerminalTransaction(normalized, serviceUnavailable(request))
636
+ }
637
+ throw error
638
+ }
639
+ if (!result) {
640
+ return clearTerminalTransaction(
641
+ normalized,
642
+ new Response(null, {
643
+ status: 302,
644
+ headers: { Location: `${normalized.loginPath}?error=auth_failed` },
645
+ }),
646
+ )
647
+ }
648
+ let response = new Response(null, {
649
+ status: 302,
650
+ headers: { Location: safeRedirect(getCookie(request, cookieName(normalized, 'redirect'))) },
651
+ })
652
+ response = setSessionCookiesNormalized(normalized, response, result.tokens)
653
+ clearTransactionCookies(normalized, response)
654
+ return response
655
+ }
656
+
657
+ async function getSessionNormalized(
658
+ config: NormalizedWorkersAuthConfig,
659
+ request: Request,
660
+ ): Promise<Session> {
661
+ const accessToken = getCookie(request, cookieName(config, 'at')) ?? ''
662
+ const refreshToken = getCookie(request, cookieName(config, 'rt')) ?? ''
663
+ if (accessToken) {
664
+ const principal = await verifyToken(config, accessToken)
665
+ if (principal) {
666
+ return {
667
+ Principal: principal,
668
+ Tokens: { AccessToken: accessToken, RefreshToken: refreshToken },
669
+ Refreshed: false,
670
+ }
671
+ }
672
+ }
673
+ if (refreshToken) {
674
+ const form = new URLSearchParams()
675
+ form.set('grant_type', 'refresh_token')
676
+ form.set('refresh_token', refreshToken)
677
+ form.set('client_id', config.clientId)
678
+ form.set('resource', config.resource)
679
+ const result = await requestTokens(config, request, form)
680
+ if (result) {
681
+ return { Principal: result.principal, Tokens: result.tokens, Refreshed: true }
682
+ }
683
+ }
684
+ return { Principal: null, Tokens: null, Refreshed: false }
685
+ }
686
+
687
+ export async function getSession(
688
+ config: WorkersAuthConfig,
689
+ requestOrContext: RequestOrHonoContext,
690
+ ): Promise<Session> {
691
+ return getSessionNormalized(normalizeConfig(config), requestFrom(requestOrContext))
692
+ }
693
+
694
+ export function setSessionCookies(
695
+ config: WorkersAuthConfig,
696
+ response: Response,
697
+ tokens: Tokens,
698
+ ): Response {
699
+ return setSessionCookiesNormalized(normalizeConfig(config), response, tokens)
700
+ }
701
+
702
+ export function clearSessionCookies(config: WorkersAuthConfig, response: Response): Response {
703
+ return clearSessionCookiesNormalized(normalizeConfig(config), response)
704
+ }
705
+
706
+ export function Principal(source: PrincipalSource): Principal | null {
707
+ if ('Principal' in source) return source.Principal
708
+ return source.get('principal') ?? null
709
+ }
710
+
711
+ function isGated(config: NormalizedWorkersAuthConfig, path: string): boolean {
712
+ return config.gatedPaths.some((pattern) =>
713
+ pattern.endsWith('/*') ? path.startsWith(pattern.slice(0, -1)) : path === pattern,
714
+ )
715
+ }
716
+
717
+ export function Middleware(config: WorkersAuthConfig): WorkersAuthMiddleware {
718
+ const normalized = normalizeConfig(config)
719
+ return async (context, next) => {
720
+ const request = context.req.raw
721
+ let session: Session
722
+ try {
723
+ session = await getSessionNormalized(normalized, request)
724
+ } catch (error) {
725
+ if (error instanceof WorkersAuthServiceUnavailableError) return serviceUnavailable(request)
726
+ throw error
727
+ }
728
+ context.set('session', session)
729
+ context.set('principal', session.Principal)
730
+ if (!session.Principal && isGated(normalized, new URL(request.url).pathname)) {
731
+ const login = new URL(normalized.loginPath, request.url)
732
+ login.searchParams.set('redirect', new URL(request.url).pathname)
733
+ return context.redirect(login.toString(), 302)
734
+ }
735
+ await next()
736
+ if (session.Refreshed && session.Tokens) {
737
+ context.res = cloneMutableResponse(context.res)
738
+ appendSessionCookiesNormalized(normalized, context.res, session.Tokens)
739
+ }
740
+ }
741
+ }
742
+
743
+ export function New(config: WorkersAuthConfig): WorkersAuthClient {
744
+ const normalized = normalizeConfig(config)
745
+ const stableConfig: WorkersAuthConfig = normalized
746
+ return {
747
+ Authorize: (requestOrContext, provider, redirectPath) =>
748
+ Authorize(stableConfig, requestOrContext, provider, redirectPath),
749
+ Callback: (requestOrContext) => Callback(stableConfig, requestOrContext),
750
+ Middleware: () => Middleware(stableConfig),
751
+ Principal,
752
+ getSession: (requestOrContext) => getSession(stableConfig, requestOrContext),
753
+ setSessionCookies: (response, tokens) => setSessionCookies(stableConfig, response, tokens),
754
+ clearSessionCookies: (response) => clearSessionCookies(stableConfig, response),
755
+ }
756
+ }
757
+
758
+ export const createWorkersAuth = New