@growth-labs/auth 0.11.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/auth",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -16,6 +16,10 @@
16
16
  "types": "./dist/server.d.ts",
17
17
  "import": "./dist/server.js"
18
18
  },
19
+ "./workers": {
20
+ "types": "./dist/workers.d.ts",
21
+ "import": "./dist/workers.js"
22
+ },
19
23
  "./middleware/*": {
20
24
  "types": "./dist/middleware/*.d.ts",
21
25
  "import": "./dist/middleware/*.js",
@@ -46,22 +50,37 @@
46
50
  },
47
51
  "peerDependencies": {
48
52
  "@growth-labs/analytics": ">=0.5.1",
49
- "astro": "^6.1.10 || ^7.0.0"
53
+ "astro": "^6.1.10 || ^7.0.0",
54
+ "hono": "^4.12.25"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@growth-labs/analytics": {
58
+ "optional": true
59
+ },
60
+ "astro": {
61
+ "optional": true
62
+ },
63
+ "hono": {
64
+ "optional": true
65
+ }
50
66
  },
51
67
  "dependencies": {
52
68
  "jose": "^6.0.0",
53
69
  "zod": "^3.23.0"
54
70
  },
55
71
  "devDependencies": {
56
- "astro": "^6.4.6",
72
+ "astro": "7.3.1",
73
+ "hono": "^4.12.25",
57
74
  "typescript": "^5.8.0",
58
- "vite": "^7.3.5",
75
+ "vite": "8.0.16",
59
76
  "vitest": "^4.1.8",
60
- "@growth-labs/analytics": "0.18.4"
77
+ "wrangler": "^4.92.0",
78
+ "@growth-labs/analytics": "0.20.4"
61
79
  },
62
80
  "scripts": {
63
81
  "build": "tsc",
64
82
  "check": "biome check src/",
83
+ "spike:hono-worker": "wrangler deploy --dry-run --config __tests__/fixtures/hono-worker-subpaths/wrangler.jsonc --outdir .wrangler/hono-worker-subpaths --metafile",
65
84
  "test": "vitest run",
66
85
  "test:watch": "vitest"
67
86
  }
@@ -1,11 +1,11 @@
1
1
  import { getConfig } from '../config-store.js'
2
2
  import '../runtime-bridge.js'
3
- import type { User } from '../types.js'
3
+ import type { AuthSessionState, User } from '../types.js'
4
4
  import { resolveClientSecret } from '../utils/client-secret.js'
5
5
  import { getCookie, setTokenCookies } from '../utils/cookies.js'
6
6
  import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
7
7
  import { resolveInternalIssuer } from '../utils/issuer.js'
8
- import { refreshTokens, verifyAccessToken } from '../utils/tokens.js'
8
+ import { type RefreshFailure, refreshTokens, verifyAccessToken } from '../utils/tokens.js'
9
9
 
10
10
  const PRIVATE_NO_STORE_HEADERS = [
11
11
  'Cache-Control',
@@ -65,6 +65,7 @@ export async function onRequest(
65
65
  }
66
66
  }
67
67
 
68
+ let refreshFailure = null as RefreshFailure | null
68
69
  if (!user && refreshToken) {
69
70
  const clientId = options.clientId || context.url.hostname
70
71
  refreshed = await refreshTokens(
@@ -73,6 +74,9 @@ export async function onRequest(
73
74
  clientId,
74
75
  resolvedClientSecret,
75
76
  options.resource,
77
+ (failure) => {
78
+ refreshFailure = failure
79
+ },
76
80
  )
77
81
  if (refreshed) {
78
82
  user = {
@@ -84,13 +88,55 @@ export async function onRequest(
84
88
  }
85
89
  }
86
90
 
87
- // 3. Populate context.locals.user
91
+ // 3. Populate context.locals.user and say HOW the session resolved. A site
92
+ // cannot act on `user === null` alone: an anonymous visitor, a member whose
93
+ // refresh token the issuer has definitively rejected, and a member caught by
94
+ // a passing issuer fault all look the same, and the second one was being
95
+ // rendered the locked page with dead cookies he could only fix by logging
96
+ // out and back in.
88
97
  context.locals.user = user
98
+ const session = resolveSessionState({
99
+ user,
100
+ refreshed: refreshed !== null,
101
+ hadAccessToken: accessToken !== null,
102
+ hadRefreshToken: refreshToken !== null,
103
+ refreshFailure,
104
+ })
105
+ context.locals.authSession = session
106
+
107
+ // The cookies are deliberately left alone, even when the state is `expired`.
108
+ // The issuer keeps refresh tokens in an eventually consistent store, so a
109
+ // token only seconds old can read as missing from another location and be
110
+ // answered invalid_grant; today that costs one anonymous page and heals on
111
+ // the next navigation, and clearing would turn it into a forced sign-in.
112
+ // Recovery never needed the clear: signing in again overwrites them.
113
+ const documentNavigation = isDocumentNavigation(request)
114
+
115
+ if (!user && refreshToken) {
116
+ const failure: RefreshFailure = refreshFailure ?? { reason: 'unreported' }
117
+ try {
118
+ console.warn('[auth] session refresh failed', {
119
+ ...failure,
120
+ pathname: context.url.pathname,
121
+ documentNavigation,
122
+ session,
123
+ })
124
+ } catch {
125
+ /* a patched console must not turn a guest page into a 500 */
126
+ }
127
+ }
89
128
 
90
129
  // 4. Gate protected paths
91
130
  if (!user && isGatedPath(context.url.pathname, options.gatedPaths)) {
131
+ // Only the issuer ending the session earns the error code, which is the
132
+ // one the login renderer already knows how to explain. A rejected access
133
+ // token with no refresh token beside it (wrong audience, say) is not a
134
+ // session that ended, and its redirect stays exactly as it always was.
135
+ const sessionEnded = refreshFailure?.reason === 'invalid_grant'
92
136
  const loginUrl = new URL(
93
- `${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}`,
137
+ `${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}${
138
+ sessionEnded ? '&error=session_expired' : ''
139
+ }`,
94
140
  context.url.origin,
95
141
  )
96
142
  return Response.redirect(loginUrl.toString(), 302)
@@ -120,6 +166,30 @@ export async function onRequest(
120
166
  return response
121
167
  }
122
168
 
169
+ function resolveSessionState(input: {
170
+ user: User | null
171
+ refreshed: boolean
172
+ hadAccessToken: boolean
173
+ hadRefreshToken: boolean
174
+ refreshFailure: RefreshFailure | null
175
+ }): AuthSessionState {
176
+ if (input.user) return input.refreshed ? 'refreshed' : 'verified'
177
+ if (!input.hadAccessToken && !input.hadRefreshToken) return 'anonymous'
178
+ // A rejected access token with no refresh token beside it cannot be revived.
179
+ if (!input.hadRefreshToken) return 'expired'
180
+ return input.refreshFailure?.reason === 'invalid_grant' ? 'expired' : 'refresh_failed'
181
+ }
182
+
183
+ /** Sec-Fetch-* is authoritative where the browser sends it; Accept is the fallback. */
184
+ function isDocumentNavigation(request: Request): boolean {
185
+ if (request.method !== 'GET') return false
186
+ const dest = request.headers.get('sec-fetch-dest')
187
+ if (dest) return dest === 'document'
188
+ const mode = request.headers.get('sec-fetch-mode')
189
+ if (mode) return mode === 'navigate'
190
+ return (request.headers.get('accept') ?? '').includes('text/html')
191
+ }
192
+
123
193
  function isGatedPath(pathname: string, gatedPaths: string[]): boolean {
124
194
  return gatedPaths.some((pattern) => {
125
195
  if (pattern.endsWith('/*')) {
package/src/types.ts CHANGED
@@ -149,11 +149,24 @@ export interface TokenExchangeResult {
149
149
  }
150
150
  }
151
151
 
152
+ /**
153
+ * How the request's session resolved. `user === null` alone cannot tell a site
154
+ * what to do: `anonymous` never signed in; `expired` holds credentials that
155
+ * could not be revived on this request — the issuer answered the refresh token
156
+ * `invalid_grant`, or a rejected access token arrived with no refresh token
157
+ * beside it — so offer a sign-in-again path; `refresh_failed` hit a fault that
158
+ * may pass. NEITHER is proof the session is dead (the issuer's token store is
159
+ * eventually consistent), so never clear cookies on them: signing in again
160
+ * overwrites the cookies, and the middleware itself clears nothing.
161
+ */
162
+ export type AuthSessionState = 'anonymous' | 'verified' | 'refreshed' | 'expired' | 'refresh_failed'
163
+
152
164
  // Augment Astro's locals
153
165
  declare global {
154
166
  namespace App {
155
167
  interface Locals {
156
168
  user: User | null
169
+ authSession?: AuthSessionState
157
170
  synthetic?: boolean
158
171
  }
159
172
  }
@@ -0,0 +1,8 @@
1
+ export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
2
+ return `Basic ${btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`)}`
3
+ }
4
+
5
+ function formEncode(value: string): string {
6
+ const params = new URLSearchParams({ value })
7
+ return params.toString().slice('value='.length)
8
+ }
@@ -5,6 +5,7 @@ import {
5
5
  literalClientSecret,
6
6
  } from './client-secret-literal.js'
7
7
 
8
+ export { createClientSecretBasicAuthHeader } from './client-secret-header.js'
8
9
  export type { ClientSecretLiteral }
9
10
  export { isClientSecretLiteral, literalClientSecret }
10
11
 
@@ -140,12 +141,3 @@ export async function resolveClientSecret(
140
141
  // No Request context: resolve without caching (test or internal call).
141
142
  return doResolve(clientSecret)
142
143
  }
143
-
144
- export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
145
- return `Basic ${btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`)}`
146
- }
147
-
148
- function formEncode(value: string): string {
149
- const params = new URLSearchParams({ value })
150
- return params.toString().slice('value='.length)
151
- }
@@ -16,7 +16,12 @@ export function getCookie(request: Request, name: string): string | null {
16
16
  const header = request.headers.get('cookie')
17
17
  if (!header) return null
18
18
  const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
19
- return match ? decodeURIComponent(match[1]) : null
19
+ if (!match) return null
20
+ try {
21
+ return decodeURIComponent(match[1])
22
+ } catch {
23
+ return null
24
+ }
20
25
  }
21
26
 
22
27
  export function setCookie(
@@ -49,4 +49,4 @@ export {
49
49
  } from './openauth.js'
50
50
  export { createPKCEChallenge, generatePKCE } from './pkce.js'
51
51
  export { generateState, verifyState } from './state.js'
52
- export type { VerifiedClaims } from './tokens.js'
52
+ export type { RefreshFailure, RefreshFailureReason, VerifiedClaims } from './tokens.js'
@@ -1,7 +1,8 @@
1
1
  import { createLocalJWKSet, jwtVerify } from 'jose'
2
2
  import { JWTExpired } from 'jose/errors'
3
3
  import type { TokenRefreshResult } from '../types.js'
4
- import { createClientSecretBasicAuthHeader } from './client-secret.js'
4
+ import { createClientSecretBasicAuthHeader } from './client-secret-header.js'
5
+ import { extractIdentityId } from './identity.js'
5
6
 
6
7
  type JWKSFunction = ReturnType<typeof createLocalJWKSet>
7
8
 
@@ -69,6 +70,9 @@ export interface VerifiedClaims {
69
70
  image?: string
70
71
  synthetic?: boolean
71
72
  purpose?: string
73
+ userId?: string
74
+ roles?: string[]
75
+ audiences?: string[]
72
76
  }
73
77
 
74
78
  export interface TokenPayloadClaims extends VerifiedClaims {
@@ -103,6 +107,12 @@ export interface VerifyAccessTokenOptions {
103
107
  audience?: string | string[]
104
108
  /** Signature algorithms to accept. Defaults to `['ES256']` (identity-platform's signing alg). */
105
109
  algorithms?: string[]
110
+ /** Fetch signing keys from this backchannel base while still enforcing `issuer`. */
111
+ jwksIssuer?: string
112
+ /** Enforce the identity-platform user access-token claim contract. */
113
+ requireUserToken?: boolean
114
+ /** Permitted JWT clock skew in seconds for strict user-token validation. */
115
+ clockSkew?: number
106
116
  }
107
117
 
108
118
  async function verifyAccessTokenPayload(
@@ -113,15 +123,38 @@ async function verifyAccessTokenPayload(
113
123
  try {
114
124
  const header = decodeJwtHeader(token)
115
125
  const kid = typeof header.kid === 'string' ? header.kid : undefined
116
- const jwks = await getJWKS(issuer, kid)
126
+ const jwks = await getJWKS(options.jwksIssuer ?? issuer, kid)
117
127
  const { payload } = await jwtVerify(token, jwks, {
118
128
  issuer,
119
129
  audience: options.audience,
120
130
  algorithms: options.algorithms ?? ['ES256'],
131
+ clockTolerance: options.clockSkew,
121
132
  })
122
133
  if (payload.mode !== 'access') {
123
134
  throw new Error(`unexpected token mode: ${String(payload.mode)}`)
124
135
  }
136
+ if (options.requireUserToken) {
137
+ const properties = payload.properties
138
+ if (
139
+ payload.type !== 'user' ||
140
+ typeof payload.iat !== 'number' ||
141
+ typeof payload.exp !== 'number' ||
142
+ typeof payload.sub !== 'string' ||
143
+ typeof properties !== 'object' ||
144
+ properties === null
145
+ ) {
146
+ throw new Error('required user access-token claims are missing')
147
+ }
148
+ const userId = extractIdentityId(payload.sub)
149
+ if (
150
+ payload.iat > Math.floor(Date.now() / 1000) + (options.clockSkew ?? 0) ||
151
+ (properties as Record<string, unknown>).userId !== userId ||
152
+ typeof (properties as Record<string, unknown>).email !== 'string' ||
153
+ (properties as Record<string, unknown>).email === ''
154
+ ) {
155
+ throw new Error('access-token identity claims are inconsistent')
156
+ }
157
+ }
125
158
  return payload as Record<string, unknown>
126
159
  } catch (err) {
127
160
  if (!isExpiredJwtError(err)) {
@@ -139,6 +172,12 @@ export async function verifyAccessToken(
139
172
  const payload = await verifyAccessTokenPayload(issuer, token, options)
140
173
  if (!payload) return null
141
174
  const claims = extractTokenClaims(payload)
175
+ const properties = (payload.properties ?? {}) as Record<string, unknown>
176
+ const audience = Array.isArray(payload.aud)
177
+ ? payload.aud.filter((value): value is string => typeof value === 'string')
178
+ : typeof payload.aud === 'string'
179
+ ? [payload.aud]
180
+ : []
142
181
  return {
143
182
  sub: claims.sub,
144
183
  email: claims.email,
@@ -146,6 +185,11 @@ export async function verifyAccessToken(
146
185
  image: claims.image,
147
186
  synthetic: payload.synthetic === true ? true : undefined,
148
187
  purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
188
+ userId: typeof properties.userId === 'string' ? properties.userId : undefined,
189
+ roles: Array.isArray(payload.roles)
190
+ ? payload.roles.filter((value): value is string => typeof value === 'string')
191
+ : undefined,
192
+ audiences: audience,
149
193
  }
150
194
  }
151
195
 
@@ -187,14 +231,67 @@ function tokenResultFromClaims(
187
231
  }
188
232
  }
189
233
 
234
+ /**
235
+ * Why a refresh produced no tokens. `invalid_grant` is the issuer saying it does
236
+ * not recognise this refresh token — the strongest signal there is, but NOT
237
+ * proof: the issuer's token store is eventually consistent, so a token only
238
+ * seconds old can read as missing from another location. Every other reason may
239
+ * simply pass on a later request. A caller must never discard the session on
240
+ * any of them; signing in again overwrites the cookies without help.
241
+ */
242
+ export type RefreshFailureReason =
243
+ | 'invalid_grant'
244
+ | 'issuer_rejected'
245
+ | 'issuer_error'
246
+ | 'malformed_response'
247
+ | 'claims_rejected'
248
+ | 'network'
249
+ | 'not_configured'
250
+ /** The refresher returned nothing and did not say why. */
251
+ | 'unreported'
252
+
253
+ export interface RefreshFailure {
254
+ reason: RefreshFailureReason
255
+ /** HTTP status of the issuer's answer, when there was one. */
256
+ status?: number
257
+ /** The OAuth `error` code the issuer returned, when it was readable. */
258
+ error?: string
259
+ }
260
+
261
+ async function oauthErrorCode(response: Response): Promise<string | undefined> {
262
+ try {
263
+ const body: unknown = await response.json()
264
+ const code = (body as { error?: unknown } | null)?.error
265
+ return typeof code === 'string' ? code : undefined
266
+ } catch {
267
+ return undefined
268
+ }
269
+ }
270
+
190
271
  export async function refreshTokens(
191
272
  issuer: string,
192
273
  refreshToken: string,
193
274
  clientId?: string,
194
275
  clientSecret?: string,
195
276
  resource?: string,
277
+ onFailure?: (failure: RefreshFailure) => void,
196
278
  ): Promise<TokenRefreshResult | null> {
197
- if (!resource) return null
279
+ // Reporting is advisory: a callback that throws must not change the outcome,
280
+ // and the catch below must not report a second time on its behalf.
281
+ let reported = false
282
+ const report = (failure: RefreshFailure): void => {
283
+ if (reported) return
284
+ reported = true
285
+ try {
286
+ onFailure?.(failure)
287
+ } catch {
288
+ /* the caller's reporter is not this function's failure */
289
+ }
290
+ }
291
+ if (!resource) {
292
+ report({ reason: 'not_configured' })
293
+ return null
294
+ }
198
295
  try {
199
296
  const params = new URLSearchParams({
200
297
  grant_type: 'refresh_token',
@@ -216,16 +313,33 @@ export async function refreshTokens(
216
313
  body: params.toString(),
217
314
  })
218
315
 
219
- if (!response.ok) return null
316
+ if (!response.ok) {
317
+ const error = await oauthErrorCode(response)
318
+ const reason: RefreshFailureReason =
319
+ error === 'invalid_grant'
320
+ ? 'invalid_grant'
321
+ : response.status >= 500
322
+ ? 'issuer_error'
323
+ : 'issuer_rejected'
324
+ report({ reason, status: response.status, ...(error ? { error } : {}) })
325
+ return null
326
+ }
220
327
 
221
- const data = await response.json()
222
- if (!tokenResponseHasTokens(data)) return null
328
+ const data: unknown = await response.json().catch(() => null)
329
+ if (!tokenResponseHasTokens(data)) {
330
+ report({ reason: 'malformed_response', status: response.status })
331
+ return null
332
+ }
223
333
 
224
334
  const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, resource)
225
- if (!claims) return null
335
+ if (!claims) {
336
+ report({ reason: 'claims_rejected', status: response.status })
337
+ return null
338
+ }
226
339
 
227
340
  return tokenResultFromClaims(data.access_token, data.refresh_token, claims)
228
341
  } catch {
342
+ report({ reason: 'network' })
229
343
  return null
230
344
  }
231
345
  }