@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
package/src/utils/openauth.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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'
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 (
|
|
94
|
-
headers.Authorization = createClientSecretBasicAuthHeader(clientId,
|
|
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 =
|
|
115
|
-
|
|
116
|
-
refresh_token: string
|
|
117
|
-
}
|
|
148
|
+
const data = await response.json()
|
|
149
|
+
if (!tokenResponseHasTokens(data)) return null
|
|
118
150
|
|
|
119
|
-
const claims =
|
|
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,
|
package/src/utils/tokens.ts
CHANGED
|
@@ -105,11 +105,11 @@ export interface VerifyAccessTokenOptions {
|
|
|
105
105
|
algorithms?: string[]
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
async function verifyAccessTokenPayload(
|
|
109
109
|
issuer: string,
|
|
110
110
|
token: string,
|
|
111
111
|
options: VerifyAccessTokenOptions = {},
|
|
112
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
173
|
-
|
|
174
|
-
refresh_token: string
|
|
175
|
-
}
|
|
221
|
+
const data = await response.json()
|
|
222
|
+
if (!tokenResponseHasTokens(data)) return null
|
|
176
223
|
|
|
177
|
-
const claims =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|