@growth-labs/auth 0.10.0 → 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 (82) hide show
  1. package/README.md +17 -5
  2. package/dist/config-store.d.ts.map +1 -1
  3. package/dist/config-store.js +12 -1
  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 +23 -3
  10. package/dist/middleware/auth.js.map +1 -1
  11. package/dist/options.d.ts +4 -3
  12. package/dist/options.d.ts.map +1 -1
  13. package/dist/options.js +15 -1
  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 +15 -4
  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 +14 -5
  63. package/dist/utils/openauth.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/config-store.ts +13 -1
  66. package/src/index.ts +11 -1
  67. package/src/middleware/auth.ts +30 -3
  68. package/src/options.ts +16 -1
  69. package/src/routes/api/code-request.ts +5 -0
  70. package/src/routes/api/code-verify.ts +5 -0
  71. package/src/routes/api/password-reset-confirm.ts +5 -0
  72. package/src/routes/api/password-reset-request.ts +5 -0
  73. package/src/routes/api/password-verify.ts +5 -0
  74. package/src/routes/api/shared.ts +33 -2
  75. package/src/routes/callback.ts +12 -2
  76. package/src/routes/logout.ts +35 -6
  77. package/src/server.ts +21 -4
  78. package/src/utils/client-secret-literal.ts +71 -0
  79. package/src/utils/client-secret.ts +132 -18
  80. package/src/utils/fail-closed.ts +74 -0
  81. package/src/utils/index.ts +14 -1
  82. package/src/utils/openauth.ts +29 -5
@@ -1,22 +1,144 @@
1
1
  import { getRuntimeEnv } from '../runtime.js'
2
+ import {
3
+ type ClientSecretLiteral,
4
+ isClientSecretLiteral,
5
+ literalClientSecret,
6
+ } from './client-secret-literal.js'
2
7
 
3
- interface SecretsStoreBinding {
4
- get(): Promise<string>
8
+ export type { ClientSecretLiteral }
9
+ export { isClientSecretLiteral, literalClientSecret }
10
+
11
+ export type ClientSecretResolutionErrorCode =
12
+ | 'BINDING_REJECTED'
13
+ | 'BINDING_MALFORMED'
14
+ | 'BINDING_EMPTY'
15
+ | 'BINDING_MISSING'
16
+
17
+ export type ClientSecretResult =
18
+ | { ok: true; secret: string | null }
19
+ | { ok: false; code: ClientSecretResolutionErrorCode }
20
+
21
+ type PerRequestCache = Map<string, Promise<ClientSecretResult>>
22
+
23
+ const CLIENT_SECRET_READ_TIMEOUT_MS = 5_000
24
+ const CLIENT_SECRET_READ_TIMEOUT = Symbol('client-secret-read-timeout')
25
+
26
+ // Module-private request-lifetime cache. The WeakMap is keyed strictly by
27
+ // Request; entries are naturally collectible with the Request and are never
28
+ // attached to consumer-visible Request properties or symbols.
29
+ const requestCache = new WeakMap<Request, PerRequestCache>()
30
+
31
+ function getPerRequestCache(request: Request): PerRequestCache {
32
+ let cache = requestCache.get(request)
33
+ if (!cache) {
34
+ cache = new Map<string, Promise<ClientSecretResult>>()
35
+ requestCache.set(request, cache)
36
+ }
37
+ return cache
5
38
  }
6
39
 
7
- export async function resolveClientSecret(clientSecret?: string): Promise<string | undefined> {
8
- if (!clientSecret) return undefined
40
+ async function doResolve(clientSecretName: string): Promise<ClientSecretResult> {
41
+ const runtimeEnv = getRuntimeEnv()
42
+ let hasOwnBinding: boolean
43
+ try {
44
+ hasOwnBinding = Object.hasOwn(runtimeEnv, clientSecretName)
45
+ } catch {
46
+ return { ok: false, code: 'BINDING_MALFORMED' }
47
+ }
48
+ if (!hasOwnBinding) return { ok: false, code: 'BINDING_MISSING' }
49
+
50
+ let runtimeValue: unknown
51
+ try {
52
+ runtimeValue = runtimeEnv[clientSecretName]
53
+ } catch {
54
+ return { ok: false, code: 'BINDING_MALFORMED' }
55
+ }
9
56
 
10
- const runtimeValue = getRuntimeEnv()[clientSecret]
11
57
  if (typeof runtimeValue === 'string') {
12
- return runtimeValue.length > 0 ? runtimeValue : undefined
58
+ // Use trim() only to detect all-whitespace emptiness; return original bytes.
59
+ return runtimeValue.trim().length > 0
60
+ ? { ok: true, secret: runtimeValue }
61
+ : { ok: false, code: 'BINDING_EMPTY' }
13
62
  }
14
- if (isSecretsStoreBinding(runtimeValue)) {
15
- const resolved = await runtimeValue.get()
16
- return resolved.length > 0 ? resolved : undefined
63
+
64
+ if (typeof runtimeValue === 'object' && runtimeValue !== null) {
65
+ let get: unknown
66
+ try {
67
+ get = Reflect.get(runtimeValue, 'get')
68
+ } catch {
69
+ return { ok: false, code: 'BINDING_MALFORMED' }
70
+ }
71
+ if (typeof get !== 'function') return { ok: false, code: 'BINDING_MALFORMED' }
72
+
73
+ let resolved: unknown
74
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
75
+ try {
76
+ const timeout = new Promise<typeof CLIENT_SECRET_READ_TIMEOUT>((resolve) => {
77
+ timeoutId = setTimeout(
78
+ () => resolve(CLIENT_SECRET_READ_TIMEOUT),
79
+ CLIENT_SECRET_READ_TIMEOUT_MS,
80
+ )
81
+ })
82
+ const read = Promise.resolve(Reflect.apply(get, runtimeValue, []))
83
+ resolved = await Promise.race([read, timeout])
84
+ } catch {
85
+ return { ok: false, code: 'BINDING_REJECTED' }
86
+ } finally {
87
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
88
+ }
89
+ if (resolved === CLIENT_SECRET_READ_TIMEOUT) {
90
+ return { ok: false, code: 'BINDING_REJECTED' }
91
+ }
92
+ if (typeof resolved !== 'string') return { ok: false, code: 'BINDING_MALFORMED' }
93
+ return resolved.trim().length > 0
94
+ ? { ok: true, secret: resolved }
95
+ : { ok: false, code: 'BINDING_EMPTY' }
17
96
  }
18
97
 
19
- return clientSecret
98
+ // Own binding key exists but is neither a string nor a Secrets Store binding.
99
+ return { ok: false, code: 'BINDING_MALFORMED' }
100
+ }
101
+
102
+ export async function resolveClientSecret(
103
+ clientSecret: string | ClientSecretLiteral | undefined,
104
+ request?: Request,
105
+ ): Promise<ClientSecretResult> {
106
+ // undefined means no clientSecret is configured — public client
107
+ if (clientSecret === undefined) return { ok: true, secret: null }
108
+
109
+ // Explicit literal: use value directly, no binding lookup, no I/O, no caching.
110
+ if (isClientSecretLiteral(clientSecret)) {
111
+ const { value } = clientSecret
112
+ return value.trim().length > 0
113
+ ? { ok: true, secret: value }
114
+ : { ok: false, code: 'BINDING_EMPTY' }
115
+ }
116
+
117
+ // Runtime config is a serialization boundary. Any object that was not
118
+ // explicitly rehydrated into a module-issued literal is malformed config,
119
+ // never a value on which string methods may be invoked.
120
+ if (typeof clientSecret !== 'string') {
121
+ return { ok: false, code: 'BINDING_MALFORMED' }
122
+ }
123
+
124
+ // Empty/whitespace-only binding name is invalid configuration, not a public client.
125
+ if (clientSecret.trim().length === 0) {
126
+ return { ok: false, code: 'BINDING_EMPTY' }
127
+ }
128
+
129
+ // Non-empty string: treat as a binding name, cache per (Request, name).
130
+ if (request) {
131
+ const cache = getPerRequestCache(request)
132
+ let pending = cache.get(clientSecret)
133
+ if (!pending) {
134
+ pending = doResolve(clientSecret)
135
+ cache.set(clientSecret, pending)
136
+ }
137
+ return pending
138
+ }
139
+
140
+ // No Request context: resolve without caching (test or internal call).
141
+ return doResolve(clientSecret)
20
142
  }
21
143
 
22
144
  export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
@@ -27,11 +149,3 @@ function formEncode(value: string): string {
27
149
  const params = new URLSearchParams({ value })
28
150
  return params.toString().slice('value='.length)
29
151
  }
30
-
31
- function isSecretsStoreBinding(value: unknown): value is SecretsStoreBinding {
32
- return (
33
- typeof value === 'object' &&
34
- value !== null &&
35
- typeof (value as { get?: unknown }).get === 'function'
36
- )
37
- }
@@ -0,0 +1,74 @@
1
+ import type { ClientSecretResolutionErrorCode } from './client-secret.js'
2
+
3
+ export type ClientSecretFailureSurface =
4
+ | 'middleware_refresh'
5
+ | 'callback_exchange'
6
+ | 'logout_revocation'
7
+ | 'route_preflight'
8
+ | 'route_exchange'
9
+ | 'server_auth_client'
10
+
11
+ export interface ClientSecretFailureSignal {
12
+ readonly surface: ClientSecretFailureSurface
13
+ readonly code: ClientSecretResolutionErrorCode
14
+ }
15
+
16
+ // Shared no-store headers for all secret-resolution failure responses.
17
+ const SECURITY_HEADERS: Record<string, string> = {
18
+ 'Cache-Control': 'private, no-store',
19
+ Pragma: 'no-cache',
20
+ Expires: '0',
21
+ }
22
+
23
+ function acceptsJson(request: Request | undefined): boolean {
24
+ const accept = request?.headers.get('accept') ?? ''
25
+ return accept
26
+ .split(',')
27
+ .some((value) => value.trim().toLowerCase().split(';')[0] === 'application/json')
28
+ }
29
+
30
+ // Fixed-vocabulary 503 for secret-resolution failures on all surfaces.
31
+ // Browser callers receive bounded plain text; JSON callers receive one fixed
32
+ // JSON error. Neither class reflects a path, provider, error code, raw error,
33
+ // secret value, or stack. Callers must invoke this before issuer I/O.
34
+ export function reportClientSecretResolutionFailure(signal: ClientSecretFailureSignal): void {
35
+ try {
36
+ console.error('[growth-labs/auth] client_secret_resolution_failed', {
37
+ surface: signal.surface,
38
+ code: signal.code,
39
+ })
40
+ } catch {
41
+ // Observability must never replace the bounded fail-closed response.
42
+ }
43
+ }
44
+
45
+ export function secretResolutionFailureResponse(
46
+ request?: Request,
47
+ signal?: ClientSecretFailureSignal,
48
+ ): Response {
49
+ if (signal) reportClientSecretResolutionFailure(signal)
50
+ if (acceptsJson(request)) {
51
+ return Response.json(
52
+ { error: 'auth_service_unavailable' },
53
+ { status: 503, headers: new Headers(SECURITY_HEADERS as HeadersInit) },
54
+ )
55
+ }
56
+
57
+ return new Response('Service unavailable', {
58
+ status: 503,
59
+ headers: new Headers({
60
+ ...SECURITY_HEADERS,
61
+ 'Content-Type': 'text/plain; charset=UTF-8',
62
+ }),
63
+ })
64
+ }
65
+
66
+ // Typed error thrown by server-helper headersFor() on secret binding failure.
67
+ // Exported so consumers can distinguish secret-resolution failure from
68
+ // network/credential failure in custom server-side auth flows.
69
+ export class ClientSecretResolutionError extends Error {
70
+ override readonly name = 'ClientSecretResolutionError'
71
+ constructor(public readonly code: ClientSecretResolutionErrorCode) {
72
+ super('client_secret_binding_unavailable')
73
+ }
74
+ }
@@ -10,6 +10,13 @@ export {
10
10
  normalizeAuthProvider,
11
11
  trackAuthEvent,
12
12
  } from './analytics.js'
13
+ export type {
14
+ ClientSecretLiteral,
15
+ ClientSecretResolutionErrorCode,
16
+ ClientSecretResult,
17
+ } from './client-secret.js'
18
+ export { isClientSecretLiteral, literalClientSecret } from './client-secret.js'
19
+ export { ClientSecretResolutionError } from './fail-closed.js'
13
20
  export {
14
21
  type BridgeMigrationOptions,
15
22
  type BridgeMigrationStatement,
@@ -25,7 +32,13 @@ export {
25
32
  rewriteLegacyIdentitySubjects,
26
33
  } from './identity.js'
27
34
  export { signIdentityCookie, verifyIdentityCookie } from './identity-cookie.js'
28
- export { getAuthorizationUrl, getMagicLinkUrl, getPasswordLoginUrl } from './openauth.js'
35
+ export {
36
+ EXCHANGE_SECRET_FAILURE,
37
+ type ExchangeSecretFailure,
38
+ getAuthorizationUrl,
39
+ getMagicLinkUrl,
40
+ getPasswordLoginUrl,
41
+ } from './openauth.js'
29
42
  export { createPKCEChallenge, generatePKCE } from './pkce.js'
30
43
  export { generateState, verifyState } from './state.js'
31
44
  export type { VerifiedClaims } from './tokens.js'
@@ -1,6 +1,10 @@
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'
@@ -86,22 +90,42 @@ function tokenResponseHasTokens(
86
90
  )
87
91
  }
88
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
+
89
109
  export async function exchangeCode(
90
110
  callbackUrl: URL,
91
111
  code: string,
92
112
  codeVerifier: string,
93
113
  providerFromCookie?: TokenExchangeResult['provider'],
94
- ): Promise<TokenExchangeResult | null> {
114
+ request?: Request,
115
+ ): Promise<TokenExchangeResult | ExchangeSecretFailure | null> {
95
116
  const options = getConfig()
96
117
  const issuer = resolveInternalIssuer(options)
97
118
  const clientId = options.clientId || callbackUrl.hostname
98
119
  const redirectUri = new URL(options.callbackPath, callbackUrl.origin).toString()
99
- 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
+ }
100
124
  const headers: Record<string, string> = {
101
125
  'Content-Type': 'application/x-www-form-urlencoded',
102
126
  }
103
- if (clientSecret) {
104
- headers.Authorization = createClientSecretBasicAuthHeader(clientId, clientSecret)
127
+ if (secretResult.secret) {
128
+ headers.Authorization = createClientSecretBasicAuthHeader(clientId, secretResult.secret)
105
129
  }
106
130
  const body = new URLSearchParams({
107
131
  grant_type: 'authorization_code',