@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.
- package/README.md +1 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +96 -3
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/logout.js +1 -1
- package/dist/routes/logout.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/refresh-carrier.d.ts +10 -0
- package/dist/utils/refresh-carrier.d.ts.map +1 -0
- package/dist/utils/refresh-carrier.js +76 -0
- package/dist/utils/refresh-carrier.js.map +1 -0
- package/dist/utils/refresh-flight.d.ts +9 -0
- package/dist/utils/refresh-flight.d.ts.map +1 -0
- package/dist/utils/refresh-flight.js +55 -0
- package/dist/utils/refresh-flight.js.map +1 -0
- package/dist/utils/tokens.d.ts +26 -1
- package/dist/utils/tokens.d.ts.map +1 -1
- package/dist/utils/tokens.js +61 -11
- package/dist/utils/tokens.js.map +1 -1
- package/dist/workers.d.ts +7 -0
- package/dist/workers.d.ts.map +1 -1
- package/dist/workers.js +15 -1
- package/dist/workers.js.map +1 -1
- package/package.json +5 -5
- package/src/middleware/auth.ts +103 -4
- package/src/routes/logout.ts +1 -1
- package/src/types.ts +23 -0
- package/src/utils/index.ts +6 -1
- package/src/utils/refresh-carrier.ts +74 -0
- package/src/utils/refresh-flight.ts +59 -0
- package/src/utils/tokens.ts +106 -11
- package/src/workers.ts +25 -1
package/src/utils/tokens.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
238
|
-
clientSecret
|
|
239
|
-
resource
|
|
240
|
-
): Promise<
|
|
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)
|
|
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))
|
|
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)
|
|
325
|
+
if (!claims) {
|
|
326
|
+
return { result: null, failure: { reason: 'claims_rejected', status: response.status } }
|
|
327
|
+
}
|
|
270
328
|
|
|
271
|
-
return
|
|
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
|
-
|
|
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)
|