@growth-labs/auth 0.12.0 → 0.14.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.
@@ -3,6 +3,7 @@ import { JWTExpired } from 'jose/errors'
3
3
  import type { TokenRefreshResult } from '../types.js'
4
4
  import { createClientSecretBasicAuthHeader } from './client-secret-header.js'
5
5
  import { extractIdentityId } from './identity.js'
6
+ import { singleFlightRefresh } from './refresh-flight.js'
6
7
 
7
8
  type JWKSFunction = ReturnType<typeof createLocalJWKSet>
8
9
 
@@ -231,14 +232,55 @@ function tokenResultFromClaims(
231
232
  }
232
233
  }
233
234
 
234
- export async function refreshTokens(
235
+ /**
236
+ * Why a refresh produced no tokens. `invalid_grant` is the issuer saying it does
237
+ * not recognise this refresh token — the strongest signal there is, but NOT
238
+ * proof: the issuer's token store is eventually consistent, so a token only
239
+ * seconds old can read as missing from another location. Every other reason may
240
+ * simply pass on a later request. A caller must never discard the session on
241
+ * any of them; signing in again overwrites the cookies without help.
242
+ */
243
+ export type RefreshFailureReason =
244
+ | 'invalid_grant'
245
+ | 'issuer_rejected'
246
+ | 'issuer_error'
247
+ | 'malformed_response'
248
+ | 'claims_rejected'
249
+ | 'network'
250
+ | 'not_configured'
251
+ /** The refresher returned nothing and did not say why. */
252
+ | 'unreported'
253
+
254
+ export interface RefreshFailure {
255
+ reason: RefreshFailureReason
256
+ /** HTTP status of the issuer's answer, when there was one. */
257
+ status?: number
258
+ /** The OAuth `error` code the issuer returned, when it was readable. */
259
+ error?: string
260
+ }
261
+
262
+ async function oauthErrorCode(response: Response): Promise<string | undefined> {
263
+ try {
264
+ const body: unknown = await response.json()
265
+ const code = (body as { error?: unknown } | null)?.error
266
+ return typeof code === 'string' ? code : undefined
267
+ } catch {
268
+ return undefined
269
+ }
270
+ }
271
+
272
+ interface RefreshExchange {
273
+ result: TokenRefreshResult | null
274
+ failure: RefreshFailure | null
275
+ }
276
+
277
+ async function exchangeRefreshToken(
235
278
  issuer: string,
236
279
  refreshToken: string,
237
- clientId?: string,
238
- clientSecret?: string,
239
- resource?: string,
240
- ): Promise<TokenRefreshResult | null> {
241
- if (!resource) return null
280
+ clientId: string | undefined,
281
+ clientSecret: string | undefined,
282
+ resource: string,
283
+ ): Promise<RefreshExchange> {
242
284
  try {
243
285
  const params = new URLSearchParams({
244
286
  grant_type: 'refresh_token',
@@ -260,18 +302,71 @@ export async function refreshTokens(
260
302
  body: params.toString(),
261
303
  })
262
304
 
263
- if (!response.ok) return null
305
+ if (!response.ok) {
306
+ const error = await oauthErrorCode(response)
307
+ const reason: RefreshFailureReason =
308
+ error === 'invalid_grant'
309
+ ? 'invalid_grant'
310
+ : response.status >= 500
311
+ ? 'issuer_error'
312
+ : 'issuer_rejected'
313
+ return {
314
+ result: null,
315
+ failure: { reason, status: response.status, ...(error ? { error } : {}) },
316
+ }
317
+ }
264
318
 
265
- const data = await response.json()
266
- if (!tokenResponseHasTokens(data)) return null
319
+ const data: unknown = await response.json().catch(() => null)
320
+ if (!tokenResponseHasTokens(data)) {
321
+ return { result: null, failure: { reason: 'malformed_response', status: response.status } }
322
+ }
267
323
 
268
324
  const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, resource)
269
- if (!claims) return null
325
+ if (!claims) {
326
+ return { result: null, failure: { reason: 'claims_rejected', status: response.status } }
327
+ }
270
328
 
271
- return tokenResultFromClaims(data.access_token, data.refresh_token, claims)
329
+ return {
330
+ result: tokenResultFromClaims(data.access_token, data.refresh_token, claims),
331
+ failure: null,
332
+ }
272
333
  } catch {
334
+ return { result: null, failure: { reason: 'network' } }
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Exchange a refresh token for new tokens. Concurrent calls for the same token
340
+ * (same issuer, client and resource) within one isolate share a single
341
+ * exchange and receive its outcome — see `refresh-flight.ts` for why the scope
342
+ * is the isolate and nothing is cached once the flight settles. `onFailure` is
343
+ * this caller's own reporter and fires for followers as well as the leader.
344
+ */
345
+ export async function refreshTokens(
346
+ issuer: string,
347
+ refreshToken: string,
348
+ clientId?: string,
349
+ clientSecret?: string,
350
+ resource?: string,
351
+ onFailure?: (failure: RefreshFailure) => void,
352
+ ): Promise<TokenRefreshResult | null> {
353
+ // Reporting is advisory: a callback that throws must not change the outcome.
354
+ const report = (failure: RefreshFailure): void => {
355
+ try {
356
+ onFailure?.(failure)
357
+ } catch {
358
+ /* the caller's reporter is not this function's failure */
359
+ }
360
+ }
361
+ if (!resource) {
362
+ report({ reason: 'not_configured' })
273
363
  return null
274
364
  }
365
+ const exchange = await singleFlightRefresh([issuer, clientId ?? '', resource, refreshToken], () =>
366
+ exchangeRefreshToken(issuer, refreshToken, clientId, clientSecret, resource),
367
+ )
368
+ if (exchange.failure) report(exchange.failure)
369
+ return exchange.result
275
370
  }
276
371
 
277
372
  export function decodeJwtPayload(jwt: string): Record<string, unknown> {
package/src/workers.ts CHANGED
@@ -3,6 +3,8 @@ import { getCookie, setCookie } from './utils/cookies.js'
3
3
  import { extractIdentityId } from './utils/identity.js'
4
4
  import { resolveInternalIssuer } from './utils/issuer.js'
5
5
  import { generatePKCE } from './utils/pkce.js'
6
+ import { classifyRefreshCarrier, prefetchDeferredResponse } from './utils/refresh-carrier.js'
7
+ import { singleFlightRefresh } from './utils/refresh-flight.js'
6
8
  import { generateState, verifyState } from './utils/state.js'
7
9
  import { type VerifiedClaims, verifyAccessToken } from './utils/tokens.js'
8
10
 
@@ -87,6 +89,13 @@ export interface Session {
87
89
  Principal: Principal | null
88
90
  Tokens: Tokens | null
89
91
  Refreshed: boolean
92
+ /**
93
+ * The request carried a refresh token that was deliberately not spent: it
94
+ * is a beacon or a prefetch whose response the browser would not apply, so
95
+ * the next consumable request refreshes instead (0.14.0). A guest request,
96
+ * nothing more; never clear cookies on it.
97
+ */
98
+ Deferred?: true
90
99
  }
91
100
 
92
101
  export interface WorkersAuthVariables {
@@ -671,12 +680,22 @@ async function getSessionNormalized(
671
680
  }
672
681
  }
673
682
  if (refreshToken) {
683
+ // Only a response the browser is waiting for can deliver the rotated
684
+ // token; a beacon or prefetch is answered as a guest (refresh-carrier.ts).
685
+ if (classifyRefreshCarrier(request) !== 'consumable') {
686
+ return { Principal: null, Tokens: null, Refreshed: false, Deferred: true }
687
+ }
674
688
  const form = new URLSearchParams()
675
689
  form.set('grant_type', 'refresh_token')
676
690
  form.set('refresh_token', refreshToken)
677
691
  form.set('client_id', config.clientId)
678
692
  form.set('resource', config.resource)
679
- const result = await requestTokens(config, request, form)
693
+ // Concurrent requests presenting the same token share one exchange
694
+ // (refresh-flight.ts); the leader's request resolves the client secret.
695
+ const result = await singleFlightRefresh(
696
+ [config.issuerInternal, config.clientId, config.resource, refreshToken],
697
+ () => requestTokens(config, request, form),
698
+ )
680
699
  if (result) {
681
700
  return { Principal: result.principal, Tokens: result.tokens, Refreshed: true }
682
701
  }
@@ -727,6 +746,11 @@ export function Middleware(config: WorkersAuthConfig): WorkersAuthMiddleware {
727
746
  }
728
747
  context.set('session', session)
729
748
  context.set('principal', session.Principal)
749
+ // A prefetched page rendered as a guest would be shown to the member on
750
+ // navigation as if they had signed out; a non-2xx is dropped instead.
751
+ if (session.Deferred && classifyRefreshCarrier(request) === 'prefetch') {
752
+ return prefetchDeferredResponse()
753
+ }
730
754
  if (!session.Principal && isGated(normalized, new URL(request.url).pathname)) {
731
755
  const login = new URL(normalized.loginPath, request.url)
732
756
  login.searchParams.set('redirect', new URL(request.url).pathname)