@growth-labs/auth 0.9.1 → 0.10.2

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 (87) hide show
  1. package/README.md +22 -9
  2. package/dist/config-store.d.ts.map +1 -1
  3. package/dist/config-store.js +17 -2
  4. package/dist/config-store.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +8 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/middleware/auth.d.ts.map +1 -1
  9. package/dist/middleware/auth.js +24 -4
  10. package/dist/middleware/auth.js.map +1 -1
  11. package/dist/options.d.ts +7 -6
  12. package/dist/options.d.ts.map +1 -1
  13. package/dist/options.js +16 -2
  14. package/dist/options.js.map +1 -1
  15. package/dist/routes/api/code-request.d.ts.map +1 -1
  16. package/dist/routes/api/code-request.js +5 -1
  17. package/dist/routes/api/code-request.js.map +1 -1
  18. package/dist/routes/api/code-verify.d.ts.map +1 -1
  19. package/dist/routes/api/code-verify.js +5 -1
  20. package/dist/routes/api/code-verify.js.map +1 -1
  21. package/dist/routes/api/password-reset-confirm.d.ts.map +1 -1
  22. package/dist/routes/api/password-reset-confirm.js +5 -1
  23. package/dist/routes/api/password-reset-confirm.js.map +1 -1
  24. package/dist/routes/api/password-reset-request.d.ts.map +1 -1
  25. package/dist/routes/api/password-reset-request.js +5 -1
  26. package/dist/routes/api/password-reset-request.js.map +1 -1
  27. package/dist/routes/api/password-verify.d.ts.map +1 -1
  28. package/dist/routes/api/password-verify.js +5 -1
  29. package/dist/routes/api/password-verify.js.map +1 -1
  30. package/dist/routes/api/shared.d.ts +1 -0
  31. package/dist/routes/api/shared.d.ts.map +1 -1
  32. package/dist/routes/api/shared.js +26 -2
  33. package/dist/routes/api/shared.js.map +1 -1
  34. package/dist/routes/callback.d.ts.map +1 -1
  35. package/dist/routes/callback.js +12 -3
  36. package/dist/routes/callback.js.map +1 -1
  37. package/dist/routes/logout.d.ts.map +1 -1
  38. package/dist/routes/logout.js +35 -6
  39. package/dist/routes/logout.js.map +1 -1
  40. package/dist/server.d.ts +2 -0
  41. package/dist/server.d.ts.map +1 -1
  42. package/dist/server.js +16 -5
  43. package/dist/server.js.map +1 -1
  44. package/dist/utils/client-secret-literal.d.ts +13 -0
  45. package/dist/utils/client-secret-literal.d.ts.map +1 -0
  46. package/dist/utils/client-secret-literal.js +50 -0
  47. package/dist/utils/client-secret-literal.js.map +1 -0
  48. package/dist/utils/client-secret.d.ts +12 -1
  49. package/dist/utils/client-secret.d.ts.map +1 -1
  50. package/dist/utils/client-secret.js +108 -14
  51. package/dist/utils/client-secret.js.map +1 -1
  52. package/dist/utils/fail-closed.d.ts +14 -0
  53. package/dist/utils/fail-closed.d.ts.map +1 -0
  54. package/dist/utils/fail-closed.js +53 -0
  55. package/dist/utils/fail-closed.js.map +1 -0
  56. package/dist/utils/index.d.ts +4 -1
  57. package/dist/utils/index.d.ts.map +1 -1
  58. package/dist/utils/index.js +3 -1
  59. package/dist/utils/index.js.map +1 -1
  60. package/dist/utils/openauth.d.ts +7 -1
  61. package/dist/utils/openauth.d.ts.map +1 -1
  62. package/dist/utils/openauth.js +29 -12
  63. package/dist/utils/openauth.js.map +1 -1
  64. package/dist/utils/tokens.d.ts +1 -0
  65. package/dist/utils/tokens.d.ts.map +1 -1
  66. package/dist/utils/tokens.js +52 -24
  67. package/dist/utils/tokens.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/config-store.ts +18 -2
  70. package/src/index.ts +11 -1
  71. package/src/middleware/auth.ts +31 -4
  72. package/src/options.ts +17 -2
  73. package/src/routes/api/code-request.ts +5 -0
  74. package/src/routes/api/code-verify.ts +5 -0
  75. package/src/routes/api/password-reset-confirm.ts +5 -0
  76. package/src/routes/api/password-reset-request.ts +5 -0
  77. package/src/routes/api/password-verify.ts +5 -0
  78. package/src/routes/api/shared.ts +33 -2
  79. package/src/routes/callback.ts +12 -2
  80. package/src/routes/logout.ts +35 -6
  81. package/src/server.ts +23 -6
  82. package/src/utils/client-secret-literal.ts +71 -0
  83. package/src/utils/client-secret.ts +132 -18
  84. package/src/utils/fail-closed.ts +74 -0
  85. package/src/utils/index.ts +14 -1
  86. package/src/utils/openauth.ts +46 -13
  87. package/src/utils/tokens.ts +67 -28
@@ -1,10 +1,14 @@
1
1
  import { getConfig } from '../config-store.js'
2
2
  import type { TokenExchangeResult } from '../types.js'
3
- import { createClientSecretBasicAuthHeader, resolveClientSecret } from './client-secret.js'
3
+ import {
4
+ type ClientSecretResolutionErrorCode,
5
+ createClientSecretBasicAuthHeader,
6
+ resolveClientSecret,
7
+ } from './client-secret.js'
4
8
  import { resolveInternalIssuer } from './issuer.js'
5
9
  import { generatePKCE } from './pkce.js'
6
10
  import { generateState } from './state.js'
7
- import { decodeJwtPayload, extractTokenClaims } from './tokens.js'
11
+ import { verifyIssuedAccessTokenClaims } from './tokens.js'
8
12
 
9
13
  export interface AuthFlow {
10
14
  verifier: string
@@ -46,8 +50,8 @@ export async function getAuthorizationUrl(
46
50
  code_challenge: flow.challenge,
47
51
  code_challenge_method: 'S256',
48
52
  provider,
53
+ resource: options.resource,
49
54
  })
50
- if (options.resource) params.set('resource', options.resource)
51
55
 
52
56
  return {
53
57
  url: `${options.issuer}/authorize?${params}`,
@@ -76,22 +80,52 @@ export function getResetPasswordUrl(): string {
76
80
  return `${options.issuer}/reset-password`
77
81
  }
78
82
 
83
+ function tokenResponseHasTokens(
84
+ data: unknown,
85
+ ): data is { access_token: string; refresh_token: string } {
86
+ if (typeof data !== 'object' || data === null) return false
87
+ return (
88
+ typeof (data as { access_token?: unknown }).access_token === 'string' &&
89
+ typeof (data as { refresh_token?: unknown }).refresh_token === 'string'
90
+ )
91
+ }
92
+
93
+ // Bounded sentinel returned when exchangeCode cannot resolve the client secret.
94
+ // Callers check `'secretFailed' in result` to distinguish this from an
95
+ // ordinary token-exchange failure (null) and route to a fail-closed 503
96
+ // rather than a generic auth_failed redirect.
97
+ export interface ExchangeSecretFailure {
98
+ readonly secretFailed: true
99
+ readonly code: ClientSecretResolutionErrorCode
100
+ }
101
+
102
+ // Backward-compatible exported sentinel for callers that only need a bounded
103
+ // representative failure. Runtime exchangeCode results carry the actual code.
104
+ export const EXCHANGE_SECRET_FAILURE: ExchangeSecretFailure = Object.freeze({
105
+ secretFailed: true,
106
+ code: 'BINDING_REJECTED',
107
+ })
108
+
79
109
  export async function exchangeCode(
80
110
  callbackUrl: URL,
81
111
  code: string,
82
112
  codeVerifier: string,
83
113
  providerFromCookie?: TokenExchangeResult['provider'],
84
- ): Promise<TokenExchangeResult | null> {
114
+ request?: Request,
115
+ ): Promise<TokenExchangeResult | ExchangeSecretFailure | null> {
85
116
  const options = getConfig()
86
117
  const issuer = resolveInternalIssuer(options)
87
118
  const clientId = options.clientId || callbackUrl.hostname
88
119
  const redirectUri = new URL(options.callbackPath, callbackUrl.origin).toString()
89
- const clientSecret = await resolveClientSecret(options.clientSecret)
120
+ const secretResult = await resolveClientSecret(options.clientSecret, request)
121
+ if (!secretResult.ok) {
122
+ return Object.freeze({ secretFailed: true as const, code: secretResult.code })
123
+ }
90
124
  const headers: Record<string, string> = {
91
125
  'Content-Type': 'application/x-www-form-urlencoded',
92
126
  }
93
- if (clientSecret) {
94
- headers.Authorization = createClientSecretBasicAuthHeader(clientId, clientSecret)
127
+ if (secretResult.secret) {
128
+ headers.Authorization = createClientSecretBasicAuthHeader(clientId, secretResult.secret)
95
129
  }
96
130
  const body = new URLSearchParams({
97
131
  grant_type: 'authorization_code',
@@ -99,8 +133,8 @@ export async function exchangeCode(
99
133
  code_verifier: codeVerifier,
100
134
  redirect_uri: redirectUri,
101
135
  client_id: clientId,
136
+ resource: options.resource,
102
137
  })
103
- if (options.resource) body.set('resource', options.resource)
104
138
 
105
139
  try {
106
140
  const response = await fetch(`${issuer}/token`, {
@@ -111,12 +145,11 @@ export async function exchangeCode(
111
145
 
112
146
  if (!response.ok) return null
113
147
 
114
- const data = (await response.json()) as {
115
- access_token: string
116
- refresh_token: string
117
- }
148
+ const data = await response.json()
149
+ if (!tokenResponseHasTokens(data)) return null
118
150
 
119
- const claims = extractTokenClaims(decodeJwtPayload(data.access_token))
151
+ const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, options.resource)
152
+ if (!claims) return null
120
153
 
121
154
  return {
122
155
  accessToken: data.access_token,
@@ -105,11 +105,11 @@ export interface VerifyAccessTokenOptions {
105
105
  algorithms?: string[]
106
106
  }
107
107
 
108
- export async function verifyAccessToken(
108
+ async function verifyAccessTokenPayload(
109
109
  issuer: string,
110
110
  token: string,
111
111
  options: VerifyAccessTokenOptions = {},
112
- ): Promise<VerifiedClaims | null> {
112
+ ): Promise<Record<string, unknown> | null> {
113
113
  try {
114
114
  const header = decodeJwtHeader(token)
115
115
  const kid = typeof header.kid === 'string' ? header.kid : undefined
@@ -122,15 +122,7 @@ export async function verifyAccessToken(
122
122
  if (payload.mode !== 'access') {
123
123
  throw new Error(`unexpected token mode: ${String(payload.mode)}`)
124
124
  }
125
- const claims = extractTokenClaims(payload as Record<string, unknown>)
126
- return {
127
- sub: claims.sub,
128
- email: claims.email,
129
- name: claims.name,
130
- image: claims.image,
131
- synthetic: payload.synthetic === true ? true : undefined,
132
- purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
133
- }
125
+ return payload as Record<string, unknown>
134
126
  } catch (err) {
135
127
  if (!isExpiredJwtError(err)) {
136
128
  console.error('verifyAccessToken error:', err)
@@ -139,6 +131,62 @@ export async function verifyAccessToken(
139
131
  }
140
132
  }
141
133
 
134
+ export async function verifyAccessToken(
135
+ issuer: string,
136
+ token: string,
137
+ options: VerifyAccessTokenOptions = {},
138
+ ): Promise<VerifiedClaims | null> {
139
+ const payload = await verifyAccessTokenPayload(issuer, token, options)
140
+ if (!payload) return null
141
+ const claims = extractTokenClaims(payload)
142
+ return {
143
+ sub: claims.sub,
144
+ email: claims.email,
145
+ name: claims.name,
146
+ image: claims.image,
147
+ synthetic: payload.synthetic === true ? true : undefined,
148
+ purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
149
+ }
150
+ }
151
+
152
+ export async function verifyIssuedAccessTokenClaims(
153
+ issuer: string,
154
+ token: string,
155
+ audience: string | undefined,
156
+ ): Promise<TokenPayloadClaims | null> {
157
+ if (!audience) return null
158
+ const payload = await verifyAccessTokenPayload(issuer, token, { audience })
159
+ if (!payload) return null
160
+ return extractTokenClaims(payload)
161
+ }
162
+
163
+ function tokenResponseHasTokens(
164
+ data: unknown,
165
+ ): data is { access_token: string; refresh_token: string } {
166
+ if (typeof data !== 'object' || data === null) return false
167
+ return (
168
+ typeof (data as { access_token?: unknown }).access_token === 'string' &&
169
+ typeof (data as { refresh_token?: unknown }).refresh_token === 'string'
170
+ )
171
+ }
172
+
173
+ function tokenResultFromClaims(
174
+ accessToken: string,
175
+ refreshToken: string,
176
+ claims: TokenPayloadClaims,
177
+ ): TokenRefreshResult {
178
+ return {
179
+ accessToken,
180
+ refreshToken,
181
+ claims: {
182
+ sub: claims.sub,
183
+ email: claims.email,
184
+ name: claims.name,
185
+ image: claims.image,
186
+ },
187
+ }
188
+ }
189
+
142
190
  export async function refreshTokens(
143
191
  issuer: string,
144
192
  refreshToken: string,
@@ -146,13 +194,14 @@ export async function refreshTokens(
146
194
  clientSecret?: string,
147
195
  resource?: string,
148
196
  ): Promise<TokenRefreshResult | null> {
197
+ if (!resource) return null
149
198
  try {
150
199
  const params = new URLSearchParams({
151
200
  grant_type: 'refresh_token',
152
201
  refresh_token: refreshToken,
153
202
  })
154
203
  if (clientId) params.set('client_id', clientId)
155
- if (resource) params.set('resource', resource)
204
+ params.set('resource', resource)
156
205
 
157
206
  const headers: Record<string, string> = {
158
207
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -169,23 +218,13 @@ export async function refreshTokens(
169
218
 
170
219
  if (!response.ok) return null
171
220
 
172
- const data = (await response.json()) as {
173
- access_token: string
174
- refresh_token: string
175
- }
221
+ const data = await response.json()
222
+ if (!tokenResponseHasTokens(data)) return null
176
223
 
177
- const claims = extractTokenClaims(decodeJwtPayload(data.access_token))
178
-
179
- return {
180
- accessToken: data.access_token,
181
- refreshToken: data.refresh_token,
182
- claims: {
183
- sub: claims.sub,
184
- email: claims.email,
185
- name: claims.name,
186
- image: claims.image,
187
- },
188
- }
224
+ const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, resource)
225
+ if (!claims) return null
226
+
227
+ return tokenResultFromClaims(data.access_token, data.refresh_token, claims)
189
228
  } catch {
190
229
  return null
191
230
  }