@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.
- package/README.md +17 -5
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +12 -1
- package/dist/config-store.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +23 -3
- package/dist/middleware/auth.js.map +1 -1
- package/dist/options.d.ts +4 -3
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +15 -1
- package/dist/options.js.map +1 -1
- package/dist/routes/api/code-request.d.ts.map +1 -1
- package/dist/routes/api/code-request.js +5 -1
- package/dist/routes/api/code-request.js.map +1 -1
- package/dist/routes/api/code-verify.d.ts.map +1 -1
- package/dist/routes/api/code-verify.js +5 -1
- package/dist/routes/api/code-verify.js.map +1 -1
- package/dist/routes/api/password-reset-confirm.d.ts.map +1 -1
- package/dist/routes/api/password-reset-confirm.js +5 -1
- package/dist/routes/api/password-reset-confirm.js.map +1 -1
- package/dist/routes/api/password-reset-request.d.ts.map +1 -1
- package/dist/routes/api/password-reset-request.js +5 -1
- package/dist/routes/api/password-reset-request.js.map +1 -1
- package/dist/routes/api/password-verify.d.ts.map +1 -1
- package/dist/routes/api/password-verify.js +5 -1
- package/dist/routes/api/password-verify.js.map +1 -1
- package/dist/routes/api/shared.d.ts +1 -0
- package/dist/routes/api/shared.d.ts.map +1 -1
- package/dist/routes/api/shared.js +26 -2
- package/dist/routes/api/shared.js.map +1 -1
- package/dist/routes/callback.d.ts.map +1 -1
- package/dist/routes/callback.js +12 -3
- package/dist/routes/callback.js.map +1 -1
- package/dist/routes/logout.d.ts.map +1 -1
- package/dist/routes/logout.js +35 -6
- package/dist/routes/logout.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +15 -4
- package/dist/server.js.map +1 -1
- package/dist/utils/client-secret-literal.d.ts +13 -0
- package/dist/utils/client-secret-literal.d.ts.map +1 -0
- package/dist/utils/client-secret-literal.js +50 -0
- package/dist/utils/client-secret-literal.js.map +1 -0
- package/dist/utils/client-secret.d.ts +12 -1
- package/dist/utils/client-secret.d.ts.map +1 -1
- package/dist/utils/client-secret.js +108 -14
- package/dist/utils/client-secret.js.map +1 -1
- package/dist/utils/fail-closed.d.ts +14 -0
- package/dist/utils/fail-closed.d.ts.map +1 -0
- package/dist/utils/fail-closed.js +53 -0
- package/dist/utils/fail-closed.js.map +1 -0
- package/dist/utils/index.d.ts +4 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/openauth.d.ts +7 -1
- package/dist/utils/openauth.d.ts.map +1 -1
- package/dist/utils/openauth.js +14 -5
- package/dist/utils/openauth.js.map +1 -1
- package/package.json +2 -2
- package/src/config-store.ts +13 -1
- package/src/index.ts +11 -1
- package/src/middleware/auth.ts +30 -3
- package/src/options.ts +16 -1
- package/src/routes/api/code-request.ts +5 -0
- package/src/routes/api/code-verify.ts +5 -0
- package/src/routes/api/password-reset-confirm.ts +5 -0
- package/src/routes/api/password-reset-request.ts +5 -0
- package/src/routes/api/password-verify.ts +5 -0
- package/src/routes/api/shared.ts +33 -2
- package/src/routes/callback.ts +12 -2
- package/src/routes/logout.ts +35 -6
- package/src/server.ts +21 -4
- package/src/utils/client-secret-literal.ts +71 -0
- package/src/utils/client-secret.ts +132 -18
- package/src/utils/fail-closed.ts +74 -0
- package/src/utils/index.ts +14 -1
- 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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|
|
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'
|
package/src/utils/openauth.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { getConfig } from '../config-store.js'
|
|
2
2
|
import type { TokenExchangeResult } from '../types.js'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 (
|
|
104
|
-
headers.Authorization = createClientSecretBasicAuthHeader(clientId,
|
|
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',
|