@growth-labs/auth 0.11.0 → 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.
@@ -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