@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.
- package/README.md +22 -9
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +17 -2
- 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 +24 -4
- package/dist/middleware/auth.js.map +1 -1
- package/dist/options.d.ts +7 -6
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +16 -2
- 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 +16 -5
- 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 +29 -12
- package/dist/utils/openauth.js.map +1 -1
- package/dist/utils/tokens.d.ts +1 -0
- package/dist/utils/tokens.d.ts.map +1 -1
- package/dist/utils/tokens.js +52 -24
- package/dist/utils/tokens.js.map +1 -1
- package/package.json +2 -2
- package/src/config-store.ts +18 -2
- package/src/index.ts +11 -1
- package/src/middleware/auth.ts +31 -4
- package/src/options.ts +17 -2
- 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 +23 -6
- 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 +46 -13
- package/src/utils/tokens.ts +67 -28
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authClient,
|
|
3
|
+
failClosedOnSecretResolutionFailure,
|
|
3
4
|
getConfig,
|
|
4
5
|
type RouteContext,
|
|
5
6
|
readForm,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from './shared.js'
|
|
10
11
|
|
|
11
12
|
export async function POST(context: RouteContext): Promise<Response> {
|
|
13
|
+
// Trust boundary: Origin rejection must precede config, secret, form, and issuer I/O.
|
|
12
14
|
const crossSitePost = rejectCrossSitePost(context)
|
|
13
15
|
if (crossSitePost) return crossSitePost
|
|
14
16
|
|
|
@@ -17,6 +19,9 @@ export async function POST(context: RouteContext): Promise<Response> {
|
|
|
17
19
|
return new Response('Not found', { status: 404 })
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const secretError = await failClosedOnSecretResolutionFailure(context)
|
|
23
|
+
if (secretError) return secretError
|
|
24
|
+
|
|
20
25
|
const form = await readForm(context)
|
|
21
26
|
const token = await readFormString(form, 'token')
|
|
22
27
|
const password = await readFormString(form, 'password', { trim: false })
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authClient,
|
|
3
|
+
failClosedOnSecretResolutionFailure,
|
|
3
4
|
getConfig,
|
|
4
5
|
type RouteContext,
|
|
5
6
|
readForm,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from './shared.js'
|
|
10
11
|
|
|
11
12
|
export async function POST(context: RouteContext): Promise<Response> {
|
|
13
|
+
// Trust boundary: Origin rejection must precede config, secret, form, and issuer I/O.
|
|
12
14
|
const crossSitePost = rejectCrossSitePost(context)
|
|
13
15
|
if (crossSitePost) return crossSitePost
|
|
14
16
|
|
|
@@ -17,6 +19,9 @@ export async function POST(context: RouteContext): Promise<Response> {
|
|
|
17
19
|
return new Response('Not found', { status: 404 })
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const secretError = await failClosedOnSecretResolutionFailure(context)
|
|
23
|
+
if (secretError) return secretError
|
|
24
|
+
|
|
20
25
|
const form = await readForm(context)
|
|
21
26
|
const email = await readFormString(form, 'email')
|
|
22
27
|
const result = await authClient(context).password.reset.request({ email })
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authClient,
|
|
3
3
|
callbackRedirect,
|
|
4
|
+
failClosedOnSecretResolutionFailure,
|
|
4
5
|
getConfig,
|
|
5
6
|
type RouteContext,
|
|
6
7
|
readForm,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from './shared.js'
|
|
10
11
|
|
|
11
12
|
export async function POST(context: RouteContext): Promise<Response> {
|
|
13
|
+
// Trust boundary: Origin rejection must precede config, secret, form, and issuer I/O.
|
|
12
14
|
const crossSitePost = rejectCrossSitePost(context)
|
|
13
15
|
if (crossSitePost) return crossSitePost
|
|
14
16
|
|
|
@@ -17,6 +19,9 @@ export async function POST(context: RouteContext): Promise<Response> {
|
|
|
17
19
|
return new Response('Not found', { status: 404 })
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const secretError = await failClosedOnSecretResolutionFailure(context)
|
|
23
|
+
if (secretError) return secretError
|
|
24
|
+
|
|
20
25
|
const form = await readForm(context)
|
|
21
26
|
const email = await readFormString(form, 'email')
|
|
22
27
|
const password = await readFormString(form, 'password', { trim: false })
|
package/src/routes/api/shared.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
emitAuthFailureEvent,
|
|
8
8
|
emitAuthSuccessEvent,
|
|
9
9
|
} from '../../utils/analytics.js'
|
|
10
|
+
import { resolveClientSecret } from '../../utils/client-secret.js'
|
|
10
11
|
import {
|
|
11
12
|
clearAuthFlowCookies,
|
|
12
13
|
getCookie,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
setIdentityCookie,
|
|
15
16
|
setTokenCookies,
|
|
16
17
|
} from '../../utils/cookies.js'
|
|
18
|
+
import { secretResolutionFailureResponse } from '../../utils/fail-closed.js'
|
|
17
19
|
import { canonicalIdentitySubject } from '../../utils/identity.js'
|
|
18
20
|
import { createIdentityCookiePayload, signIdentityCookie } from '../../utils/identity-cookie.js'
|
|
19
21
|
import { exchangeCode } from '../../utils/openauth.js'
|
|
@@ -133,6 +135,22 @@ function routeResult(
|
|
|
133
135
|
})
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
// Returns a 503 fail-closed response when the configured clientSecret binding
|
|
139
|
+
// cannot be resolved; null when resolution succeeds or no secret is configured.
|
|
140
|
+
// Call this before any authClient() call to guarantee zero issuer fetch on failure.
|
|
141
|
+
export async function failClosedOnSecretResolutionFailure(
|
|
142
|
+
context: RouteContext,
|
|
143
|
+
): Promise<Response | null> {
|
|
144
|
+
const options = getConfig()
|
|
145
|
+
if (options.clientSecret === undefined) return null
|
|
146
|
+
const secretResult = await resolveClientSecret(options.clientSecret, context.request)
|
|
147
|
+
if (secretResult.ok) return null
|
|
148
|
+
return secretResolutionFailureResponse(context.request, {
|
|
149
|
+
surface: 'route_preflight',
|
|
150
|
+
code: secretResult.code,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
export type { RouteContext }
|
|
137
155
|
export { authClient, getConfig, readForm, redirectTo }
|
|
138
156
|
|
|
@@ -163,11 +181,24 @@ async function completeJsonAuth(
|
|
|
163
181
|
)
|
|
164
182
|
}
|
|
165
183
|
|
|
166
|
-
const
|
|
167
|
-
|
|
184
|
+
const exchangeResult = await exchangeCode(
|
|
185
|
+
context.url,
|
|
186
|
+
code,
|
|
187
|
+
codeVerifier,
|
|
188
|
+
provider,
|
|
189
|
+
context.request,
|
|
190
|
+
)
|
|
191
|
+
if (exchangeResult !== null && 'secretFailed' in exchangeResult) {
|
|
192
|
+
return secretResolutionFailureResponse(context.request, {
|
|
193
|
+
surface: 'route_exchange',
|
|
194
|
+
code: exchangeResult.code,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
if (!exchangeResult) {
|
|
168
198
|
queueAuthFailure(context, provider, 'token_exchange_failed')
|
|
169
199
|
return routeResult(context, errorPath, { ...errorParams, error: 'auth_failed' }, 'auth_failed')
|
|
170
200
|
}
|
|
201
|
+
const tokenResponse = exchangeResult
|
|
171
202
|
|
|
172
203
|
const redirectPath = getCookie(context.request, `${options.cookiePrefix}_redirect`) || '/'
|
|
173
204
|
const redirect = new URL(redirectPath, context.url.origin).toString()
|
package/src/routes/callback.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
setIdentityCookie,
|
|
12
12
|
setTokenCookies,
|
|
13
13
|
} from '../utils/cookies.js'
|
|
14
|
+
import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
|
|
14
15
|
import { canonicalIdentitySubject } from '../utils/identity.js'
|
|
15
16
|
import { createIdentityCookiePayload, signIdentityCookie } from '../utils/identity-cookie.js'
|
|
16
17
|
import { exchangeCode } from '../utils/openauth.js'
|
|
@@ -106,18 +107,27 @@ export async function GET(context: CallbackContext): Promise<Response> {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// Exchange code for tokens
|
|
109
|
-
const
|
|
110
|
+
const exchangeResult = await exchangeCode(
|
|
110
111
|
url,
|
|
111
112
|
code,
|
|
112
113
|
codeVerifier,
|
|
113
114
|
provider === 'google' || provider === 'password' || provider === 'email-code'
|
|
114
115
|
? provider
|
|
115
116
|
: undefined,
|
|
117
|
+
request,
|
|
116
118
|
)
|
|
117
|
-
|
|
119
|
+
// Typed distinction: secret binding failure → fail closed; other failure → auth_failed.
|
|
120
|
+
if (exchangeResult !== null && 'secretFailed' in exchangeResult) {
|
|
121
|
+
return secretResolutionFailureResponse(request, {
|
|
122
|
+
surface: 'callback_exchange',
|
|
123
|
+
code: exchangeResult.code,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
if (!exchangeResult) {
|
|
118
127
|
queueAuthFailure(context, provider, 'token_exchange_failed')
|
|
119
128
|
return loginErrorResponse(request, url.origin, options.loginPath)
|
|
120
129
|
}
|
|
130
|
+
const tokenResponse = exchangeResult
|
|
121
131
|
|
|
122
132
|
// Build redirect response
|
|
123
133
|
const redirectPath = getCookie(request, `${options.cookiePrefix}_redirect`) || '/'
|
package/src/routes/logout.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getConfig } from '../config-store.js'
|
|
2
2
|
import { analyticsAvailable, emitAuthEvent } from '../utils/analytics.js'
|
|
3
|
+
import type { ClientSecretResolutionErrorCode } from '../utils/client-secret.js'
|
|
3
4
|
import { resolveClientSecret } from '../utils/client-secret.js'
|
|
4
5
|
import {
|
|
5
6
|
clearAuthFlowCookies,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
clearTokenCookies,
|
|
8
9
|
getCookie,
|
|
9
10
|
} from '../utils/cookies.js'
|
|
11
|
+
import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
|
|
10
12
|
import { revokeToken } from '../utils/openauth.js'
|
|
11
13
|
import { resolveWaitUntil } from '../utils/wait-until.js'
|
|
12
14
|
|
|
@@ -26,13 +28,27 @@ export async function GET(context: LogoutContext): Promise<Response> {
|
|
|
26
28
|
const clientId = options.clientId || context.url.hostname
|
|
27
29
|
const waitUntil = resolveWaitUntil(context)
|
|
28
30
|
|
|
29
|
-
// Revoke at issuer (non-blocking)
|
|
31
|
+
// Revoke at issuer (non-blocking).
|
|
32
|
+
// Public clients (no clientSecret configured) always attempt revocation.
|
|
33
|
+
// Confidential clients only attempt revocation when the secret resolves successfully;
|
|
34
|
+
// a binding failure skips revocation rather than sending unauthenticated requests
|
|
35
|
+
// to a confidential-client issuer endpoint.
|
|
36
|
+
let secretFailureCode: ClientSecretResolutionErrorCode | null = null
|
|
30
37
|
if (refreshToken) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
if (options.clientSecret === undefined) {
|
|
39
|
+
waitUntil(revokeToken(refreshToken, clientId))
|
|
40
|
+
} else {
|
|
41
|
+
const secretResult = await resolveClientSecret(options.clientSecret, context.request)
|
|
42
|
+
if (secretResult.ok) {
|
|
43
|
+
const secret = secretResult.secret ?? undefined
|
|
44
|
+
const promise = secret
|
|
45
|
+
? revokeToken(refreshToken, clientId, secret)
|
|
46
|
+
: revokeToken(refreshToken, clientId)
|
|
47
|
+
waitUntil(promise)
|
|
48
|
+
} else {
|
|
49
|
+
secretFailureCode = secretResult.code
|
|
50
|
+
}
|
|
51
|
+
}
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
// Emit auth_logout (non-blocking) when the consumer has @growth-labs/analytics
|
|
@@ -45,6 +61,19 @@ export async function GET(context: LogoutContext): Promise<Response> {
|
|
|
45
61
|
waitUntil(promise)
|
|
46
62
|
}
|
|
47
63
|
|
|
64
|
+
if (secretFailureCode) {
|
|
65
|
+
// Fail closed: clear all local cookies but return a fixed 503 instead of
|
|
66
|
+
// an ordinary redirect. Revocation was already skipped above.
|
|
67
|
+
const failResponse = secretResolutionFailureResponse(context.request, {
|
|
68
|
+
surface: 'logout_revocation',
|
|
69
|
+
code: secretFailureCode,
|
|
70
|
+
})
|
|
71
|
+
clearTokenCookies(failResponse, options)
|
|
72
|
+
clearIdentityCookie(failResponse)
|
|
73
|
+
clearAuthFlowCookies(failResponse, options)
|
|
74
|
+
return failResponse
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
// Build redirect response
|
|
49
78
|
const redirectUrl = new URL(options.logoutRedirect, context.url.origin).toString()
|
|
50
79
|
const response = Response.redirect(redirectUrl, 302)
|
package/src/server.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { getConfig } from './config-store.js'
|
|
|
2
2
|
import type { ResolvedAuthOptions } from './options.js'
|
|
3
3
|
import { createClientSecretBasicAuthHeader, resolveClientSecret } from './utils/client-secret.js'
|
|
4
4
|
import { getCookie } from './utils/cookies.js'
|
|
5
|
+
import {
|
|
6
|
+
ClientSecretResolutionError,
|
|
7
|
+
reportClientSecretResolutionFailure,
|
|
8
|
+
} from './utils/fail-closed.js'
|
|
5
9
|
import { resolveInternalIssuer } from './utils/issuer.js'
|
|
6
10
|
import { createPKCEChallenge } from './utils/pkce.js'
|
|
7
11
|
|
|
12
|
+
export { ClientSecretResolutionError }
|
|
13
|
+
|
|
8
14
|
export type AuthClientCodeResult = { code: string } | { error: string }
|
|
9
15
|
export type AuthClientOkResult = { ok: true } | { error: string }
|
|
10
16
|
|
|
@@ -33,7 +39,7 @@ interface AuthFlowPayload {
|
|
|
33
39
|
code_challenge: string
|
|
34
40
|
code_challenge_method: 'S256'
|
|
35
41
|
state: string
|
|
36
|
-
resource
|
|
42
|
+
resource: string
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function clientId(options: ResolvedAuthOptions, context: AuthClientContext): string {
|
|
@@ -60,7 +66,7 @@ async function authFlowPayload(
|
|
|
60
66
|
code_challenge: await createPKCEChallenge(verifier),
|
|
61
67
|
code_challenge_method: 'S256',
|
|
62
68
|
state,
|
|
63
|
-
|
|
69
|
+
resource: options.resource,
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -69,9 +75,13 @@ async function headersFor(
|
|
|
69
75
|
context: AuthClientContext,
|
|
70
76
|
): Promise<Record<string, string>> {
|
|
71
77
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
78
|
+
const secretResult = await resolveClientSecret(options.clientSecret, context.request)
|
|
79
|
+
if (!secretResult.ok) throw new ClientSecretResolutionError(secretResult.code)
|
|
80
|
+
if (secretResult.secret) {
|
|
81
|
+
headers.Authorization = createClientSecretBasicAuthHeader(
|
|
82
|
+
clientId(options, context),
|
|
83
|
+
secretResult.secret,
|
|
84
|
+
)
|
|
75
85
|
}
|
|
76
86
|
return headers
|
|
77
87
|
}
|
|
@@ -116,7 +126,14 @@ async function postJson<T extends AuthClientCodeResult | AuthClientOkResult>(
|
|
|
116
126
|
return { ok: true } as T
|
|
117
127
|
}
|
|
118
128
|
return { error: 'invalid_response' } as T
|
|
119
|
-
} catch {
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err instanceof ClientSecretResolutionError) {
|
|
131
|
+
reportClientSecretResolutionFailure({
|
|
132
|
+
surface: 'server_auth_client',
|
|
133
|
+
code: err.code,
|
|
134
|
+
})
|
|
135
|
+
return { error: 'auth_service_unavailable' } as T
|
|
136
|
+
}
|
|
120
137
|
return { error: 'request_failed' } as T
|
|
121
138
|
}
|
|
122
139
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Explicit local-dev literal type and predicate. Intentionally no runtime
|
|
2
|
+
// imports — this file is reachable from options.ts (the package main entry
|
|
3
|
+
// static-import chain) so it must not pull in cloudflare:workers.
|
|
4
|
+
// Resolution logic that requires the runtime lives in client-secret.ts.
|
|
5
|
+
export interface ClientSecretLiteral {
|
|
6
|
+
readonly __clientSecretLiteral: true
|
|
7
|
+
readonly value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SerializedClientSecretLiteral {
|
|
11
|
+
readonly __growthLabsAuthClientSecretLiteral: 1
|
|
12
|
+
readonly value: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const issuedClientSecretLiterals = new WeakSet<ClientSecretLiteral>()
|
|
16
|
+
|
|
17
|
+
export function literalClientSecret(value: string): ClientSecretLiteral {
|
|
18
|
+
const literal = Object.freeze({ __clientSecretLiteral: true as const, value })
|
|
19
|
+
issuedClientSecretLiterals.add(literal)
|
|
20
|
+
return literal
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isClientSecretLiteral(v: unknown): v is ClientSecretLiteral {
|
|
24
|
+
return (
|
|
25
|
+
typeof v === 'object' &&
|
|
26
|
+
v !== null &&
|
|
27
|
+
issuedClientSecretLiterals.has(v as ClientSecretLiteral) &&
|
|
28
|
+
(v as Record<string, unknown>).__clientSecretLiteral === true &&
|
|
29
|
+
typeof (v as Record<string, unknown>).value === 'string'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Only the integration calls this after authOptionsSchema has accepted a
|
|
34
|
+
// WeakSet-issued literal. The distinct wire marker is never accepted directly
|
|
35
|
+
// by resolveClientSecret; config-store must rehydrate it into a newly issued
|
|
36
|
+
// literal after Vite's JSON serialization boundary.
|
|
37
|
+
export function serializeClientSecretLiteral(
|
|
38
|
+
literal: ClientSecretLiteral,
|
|
39
|
+
): SerializedClientSecretLiteral {
|
|
40
|
+
if (!isClientSecretLiteral(literal)) {
|
|
41
|
+
throw new TypeError('client secret literal was not issued by literalClientSecret()')
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
__growthLabsAuthClientSecretLiteral: 1 as const,
|
|
45
|
+
value: literal.value,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function rehydrateClientSecretLiteral(value: unknown): unknown {
|
|
50
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
|
|
51
|
+
const prototype = Object.getPrototypeOf(value)
|
|
52
|
+
if (prototype !== Object.prototype && prototype !== null) return value
|
|
53
|
+
const descriptors = Object.getOwnPropertyDescriptors(value)
|
|
54
|
+
const keys = Object.keys(descriptors)
|
|
55
|
+
const marker = descriptors.__growthLabsAuthClientSecretLiteral
|
|
56
|
+
const literalValue = descriptors.value
|
|
57
|
+
if (
|
|
58
|
+
keys.length !== 2 ||
|
|
59
|
+
!keys.includes('__growthLabsAuthClientSecretLiteral') ||
|
|
60
|
+
!keys.includes('value') ||
|
|
61
|
+
!marker ||
|
|
62
|
+
!('value' in marker) ||
|
|
63
|
+
marker.value !== 1 ||
|
|
64
|
+
!literalValue ||
|
|
65
|
+
!('value' in literalValue) ||
|
|
66
|
+
typeof literalValue.value !== 'string'
|
|
67
|
+
) {
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
return literalClientSecret(literalValue.value)
|
|
71
|
+
}
|
|
@@ -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'
|