@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
@@ -4,6 +4,7 @@ import { type AuthClientCodeResult, type AuthClientOkResult, authClient } from '
4
4
  import {
5
5
  type AuthAnalyticsProvider,
6
6
  analyticsAvailable,
7
+ emitAuthEvent,
7
8
  emitAuthFailureEvent,
8
9
  emitAuthSuccessEvent,
9
10
  } from '../../utils/analytics.js'
@@ -240,6 +241,26 @@ function queueAuthFailure(
240
241
  )
241
242
  }
242
243
 
244
+ /**
245
+ * Fire-and-forget step event for the auth funnel's intermediate stages
246
+ * (code requested, reset requested). Success/failure terminals already
247
+ * emit via emitAuthSuccessEvent / queueAuthFailure; without the steps
248
+ * between them, a request→verify drop-off is invisible in analytics.
249
+ */
250
+ export function queueAuthStep(
251
+ context: RouteContext,
252
+ eventName: string,
253
+ data: Record<string, unknown>,
254
+ ): void {
255
+ if (!analyticsAvailable(context)) return
256
+ resolveWaitUntil(context)(
257
+ emitAuthEvent(context, eventName, {
258
+ ...data,
259
+ authPath: context.url.pathname,
260
+ }),
261
+ )
262
+ }
263
+
243
264
  async function setOptionalIdentityCookie(response: Response, subject: string): Promise<void> {
244
265
  const secret = getRuntimeEnv().GL_IDENTITY_SECRET
245
266
  if (typeof secret !== 'string' || !secret) return
@@ -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(
@@ -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