@growth-labs/auth 0.11.0 → 0.13.0
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/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +67 -3
- package/dist/middleware/auth.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/client-secret-header.d.ts +2 -0
- package/dist/utils/client-secret-header.d.ts.map +1 -0
- package/dist/utils/client-secret-header.js +8 -0
- package/dist/utils/client-secret-header.js.map +1 -0
- package/dist/utils/client-secret.d.ts +1 -1
- package/dist/utils/client-secret.d.ts.map +1 -1
- package/dist/utils/client-secret.js +1 -7
- package/dist/utils/client-secret.js.map +1 -1
- package/dist/utils/cookies.d.ts.map +1 -1
- package/dist/utils/cookies.js +8 -1
- package/dist/utils/cookies.js.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/tokens.d.ts +28 -1
- package/dist/utils/tokens.d.ts.map +1 -1
- package/dist/utils/tokens.js +78 -8
- package/dist/utils/tokens.js.map +1 -1
- package/dist/workers.d.ts +89 -0
- package/dist/workers.d.ts.map +1 -0
- package/dist/workers.js +516 -0
- package/dist/workers.js.map +1 -0
- package/package.json +24 -5
- package/src/middleware/auth.ts +74 -4
- package/src/types.ts +13 -0
- package/src/utils/client-secret-header.ts +8 -0
- package/src/utils/client-secret.ts +1 -9
- package/src/utils/cookies.ts +6 -1
- package/src/utils/index.ts +1 -1
- package/src/utils/tokens.ts +121 -7
- package/src/workers.ts +758 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"types": "./dist/server.d.ts",
|
|
17
17
|
"import": "./dist/server.js"
|
|
18
18
|
},
|
|
19
|
+
"./workers": {
|
|
20
|
+
"types": "./dist/workers.d.ts",
|
|
21
|
+
"import": "./dist/workers.js"
|
|
22
|
+
},
|
|
19
23
|
"./middleware/*": {
|
|
20
24
|
"types": "./dist/middleware/*.d.ts",
|
|
21
25
|
"import": "./dist/middleware/*.js",
|
|
@@ -46,22 +50,37 @@
|
|
|
46
50
|
},
|
|
47
51
|
"peerDependencies": {
|
|
48
52
|
"@growth-labs/analytics": ">=0.5.1",
|
|
49
|
-
"astro": "^6.1.10 || ^7.0.0"
|
|
53
|
+
"astro": "^6.1.10 || ^7.0.0",
|
|
54
|
+
"hono": "^4.12.25"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@growth-labs/analytics": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"astro": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"hono": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
50
66
|
},
|
|
51
67
|
"dependencies": {
|
|
52
68
|
"jose": "^6.0.0",
|
|
53
69
|
"zod": "^3.23.0"
|
|
54
70
|
},
|
|
55
71
|
"devDependencies": {
|
|
56
|
-
"astro": "
|
|
72
|
+
"astro": "7.3.1",
|
|
73
|
+
"hono": "^4.12.25",
|
|
57
74
|
"typescript": "^5.8.0",
|
|
58
|
-
"vite": "
|
|
75
|
+
"vite": "8.0.16",
|
|
59
76
|
"vitest": "^4.1.8",
|
|
60
|
-
"
|
|
77
|
+
"wrangler": "^4.92.0",
|
|
78
|
+
"@growth-labs/analytics": "0.20.4"
|
|
61
79
|
},
|
|
62
80
|
"scripts": {
|
|
63
81
|
"build": "tsc",
|
|
64
82
|
"check": "biome check src/",
|
|
83
|
+
"spike:hono-worker": "wrangler deploy --dry-run --config __tests__/fixtures/hono-worker-subpaths/wrangler.jsonc --outdir .wrangler/hono-worker-subpaths --metafile",
|
|
65
84
|
"test": "vitest run",
|
|
66
85
|
"test:watch": "vitest"
|
|
67
86
|
}
|
package/src/middleware/auth.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { getConfig } from '../config-store.js'
|
|
2
2
|
import '../runtime-bridge.js'
|
|
3
|
-
import type { User } from '../types.js'
|
|
3
|
+
import type { AuthSessionState, User } from '../types.js'
|
|
4
4
|
import { resolveClientSecret } from '../utils/client-secret.js'
|
|
5
5
|
import { getCookie, setTokenCookies } from '../utils/cookies.js'
|
|
6
6
|
import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
|
|
7
7
|
import { resolveInternalIssuer } from '../utils/issuer.js'
|
|
8
|
-
import { refreshTokens, verifyAccessToken } from '../utils/tokens.js'
|
|
8
|
+
import { type RefreshFailure, refreshTokens, verifyAccessToken } from '../utils/tokens.js'
|
|
9
9
|
|
|
10
10
|
const PRIVATE_NO_STORE_HEADERS = [
|
|
11
11
|
'Cache-Control',
|
|
@@ -65,6 +65,7 @@ export async function onRequest(
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
let refreshFailure = null as RefreshFailure | null
|
|
68
69
|
if (!user && refreshToken) {
|
|
69
70
|
const clientId = options.clientId || context.url.hostname
|
|
70
71
|
refreshed = await refreshTokens(
|
|
@@ -73,6 +74,9 @@ export async function onRequest(
|
|
|
73
74
|
clientId,
|
|
74
75
|
resolvedClientSecret,
|
|
75
76
|
options.resource,
|
|
77
|
+
(failure) => {
|
|
78
|
+
refreshFailure = failure
|
|
79
|
+
},
|
|
76
80
|
)
|
|
77
81
|
if (refreshed) {
|
|
78
82
|
user = {
|
|
@@ -84,13 +88,55 @@ export async function onRequest(
|
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
// 3. Populate context.locals.user
|
|
91
|
+
// 3. Populate context.locals.user and say HOW the session resolved. A site
|
|
92
|
+
// cannot act on `user === null` alone: an anonymous visitor, a member whose
|
|
93
|
+
// refresh token the issuer has definitively rejected, and a member caught by
|
|
94
|
+
// a passing issuer fault all look the same, and the second one was being
|
|
95
|
+
// rendered the locked page with dead cookies he could only fix by logging
|
|
96
|
+
// out and back in.
|
|
88
97
|
context.locals.user = user
|
|
98
|
+
const session = resolveSessionState({
|
|
99
|
+
user,
|
|
100
|
+
refreshed: refreshed !== null,
|
|
101
|
+
hadAccessToken: accessToken !== null,
|
|
102
|
+
hadRefreshToken: refreshToken !== null,
|
|
103
|
+
refreshFailure,
|
|
104
|
+
})
|
|
105
|
+
context.locals.authSession = session
|
|
106
|
+
|
|
107
|
+
// The cookies are deliberately left alone, even when the state is `expired`.
|
|
108
|
+
// The issuer keeps refresh tokens in an eventually consistent store, so a
|
|
109
|
+
// token only seconds old can read as missing from another location and be
|
|
110
|
+
// answered invalid_grant; today that costs one anonymous page and heals on
|
|
111
|
+
// the next navigation, and clearing would turn it into a forced sign-in.
|
|
112
|
+
// Recovery never needed the clear: signing in again overwrites them.
|
|
113
|
+
const documentNavigation = isDocumentNavigation(request)
|
|
114
|
+
|
|
115
|
+
if (!user && refreshToken) {
|
|
116
|
+
const failure: RefreshFailure = refreshFailure ?? { reason: 'unreported' }
|
|
117
|
+
try {
|
|
118
|
+
console.warn('[auth] session refresh failed', {
|
|
119
|
+
...failure,
|
|
120
|
+
pathname: context.url.pathname,
|
|
121
|
+
documentNavigation,
|
|
122
|
+
session,
|
|
123
|
+
})
|
|
124
|
+
} catch {
|
|
125
|
+
/* a patched console must not turn a guest page into a 500 */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
89
128
|
|
|
90
129
|
// 4. Gate protected paths
|
|
91
130
|
if (!user && isGatedPath(context.url.pathname, options.gatedPaths)) {
|
|
131
|
+
// Only the issuer ending the session earns the error code, which is the
|
|
132
|
+
// one the login renderer already knows how to explain. A rejected access
|
|
133
|
+
// token with no refresh token beside it (wrong audience, say) is not a
|
|
134
|
+
// session that ended, and its redirect stays exactly as it always was.
|
|
135
|
+
const sessionEnded = refreshFailure?.reason === 'invalid_grant'
|
|
92
136
|
const loginUrl = new URL(
|
|
93
|
-
`${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}
|
|
137
|
+
`${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}${
|
|
138
|
+
sessionEnded ? '&error=session_expired' : ''
|
|
139
|
+
}`,
|
|
94
140
|
context.url.origin,
|
|
95
141
|
)
|
|
96
142
|
return Response.redirect(loginUrl.toString(), 302)
|
|
@@ -120,6 +166,30 @@ export async function onRequest(
|
|
|
120
166
|
return response
|
|
121
167
|
}
|
|
122
168
|
|
|
169
|
+
function resolveSessionState(input: {
|
|
170
|
+
user: User | null
|
|
171
|
+
refreshed: boolean
|
|
172
|
+
hadAccessToken: boolean
|
|
173
|
+
hadRefreshToken: boolean
|
|
174
|
+
refreshFailure: RefreshFailure | null
|
|
175
|
+
}): AuthSessionState {
|
|
176
|
+
if (input.user) return input.refreshed ? 'refreshed' : 'verified'
|
|
177
|
+
if (!input.hadAccessToken && !input.hadRefreshToken) return 'anonymous'
|
|
178
|
+
// A rejected access token with no refresh token beside it cannot be revived.
|
|
179
|
+
if (!input.hadRefreshToken) return 'expired'
|
|
180
|
+
return input.refreshFailure?.reason === 'invalid_grant' ? 'expired' : 'refresh_failed'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Sec-Fetch-* is authoritative where the browser sends it; Accept is the fallback. */
|
|
184
|
+
function isDocumentNavigation(request: Request): boolean {
|
|
185
|
+
if (request.method !== 'GET') return false
|
|
186
|
+
const dest = request.headers.get('sec-fetch-dest')
|
|
187
|
+
if (dest) return dest === 'document'
|
|
188
|
+
const mode = request.headers.get('sec-fetch-mode')
|
|
189
|
+
if (mode) return mode === 'navigate'
|
|
190
|
+
return (request.headers.get('accept') ?? '').includes('text/html')
|
|
191
|
+
}
|
|
192
|
+
|
|
123
193
|
function isGatedPath(pathname: string, gatedPaths: string[]): boolean {
|
|
124
194
|
return gatedPaths.some((pattern) => {
|
|
125
195
|
if (pattern.endsWith('/*')) {
|
package/src/types.ts
CHANGED
|
@@ -149,11 +149,24 @@ export interface TokenExchangeResult {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* How the request's session resolved. `user === null` alone cannot tell a site
|
|
154
|
+
* what to do: `anonymous` never signed in; `expired` holds credentials that
|
|
155
|
+
* could not be revived on this request — the issuer answered the refresh token
|
|
156
|
+
* `invalid_grant`, or a rejected access token arrived with no refresh token
|
|
157
|
+
* beside it — so offer a sign-in-again path; `refresh_failed` hit a fault that
|
|
158
|
+
* may pass. NEITHER is proof the session is dead (the issuer's token store is
|
|
159
|
+
* eventually consistent), so never clear cookies on them: signing in again
|
|
160
|
+
* overwrites the cookies, and the middleware itself clears nothing.
|
|
161
|
+
*/
|
|
162
|
+
export type AuthSessionState = 'anonymous' | 'verified' | 'refreshed' | 'expired' | 'refresh_failed'
|
|
163
|
+
|
|
152
164
|
// Augment Astro's locals
|
|
153
165
|
declare global {
|
|
154
166
|
namespace App {
|
|
155
167
|
interface Locals {
|
|
156
168
|
user: User | null
|
|
169
|
+
authSession?: AuthSessionState
|
|
157
170
|
synthetic?: boolean
|
|
158
171
|
}
|
|
159
172
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
|
|
2
|
+
return `Basic ${btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`)}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function formEncode(value: string): string {
|
|
6
|
+
const params = new URLSearchParams({ value })
|
|
7
|
+
return params.toString().slice('value='.length)
|
|
8
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
literalClientSecret,
|
|
6
6
|
} from './client-secret-literal.js'
|
|
7
7
|
|
|
8
|
+
export { createClientSecretBasicAuthHeader } from './client-secret-header.js'
|
|
8
9
|
export type { ClientSecretLiteral }
|
|
9
10
|
export { isClientSecretLiteral, literalClientSecret }
|
|
10
11
|
|
|
@@ -140,12 +141,3 @@ export async function resolveClientSecret(
|
|
|
140
141
|
// No Request context: resolve without caching (test or internal call).
|
|
141
142
|
return doResolve(clientSecret)
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
-
export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
|
|
145
|
-
return `Basic ${btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`)}`
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function formEncode(value: string): string {
|
|
149
|
-
const params = new URLSearchParams({ value })
|
|
150
|
-
return params.toString().slice('value='.length)
|
|
151
|
-
}
|
package/src/utils/cookies.ts
CHANGED
|
@@ -16,7 +16,12 @@ export function getCookie(request: Request, name: string): string | null {
|
|
|
16
16
|
const header = request.headers.get('cookie')
|
|
17
17
|
if (!header) return null
|
|
18
18
|
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
19
|
-
|
|
19
|
+
if (!match) return null
|
|
20
|
+
try {
|
|
21
|
+
return decodeURIComponent(match[1])
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export function setCookie(
|
package/src/utils/index.ts
CHANGED
|
@@ -49,4 +49,4 @@ export {
|
|
|
49
49
|
} from './openauth.js'
|
|
50
50
|
export { createPKCEChallenge, generatePKCE } from './pkce.js'
|
|
51
51
|
export { generateState, verifyState } from './state.js'
|
|
52
|
-
export type { VerifiedClaims } from './tokens.js'
|
|
52
|
+
export type { RefreshFailure, RefreshFailureReason, VerifiedClaims } from './tokens.js'
|
package/src/utils/tokens.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createLocalJWKSet, jwtVerify } from 'jose'
|
|
2
2
|
import { JWTExpired } from 'jose/errors'
|
|
3
3
|
import type { TokenRefreshResult } from '../types.js'
|
|
4
|
-
import { createClientSecretBasicAuthHeader } from './client-secret.js'
|
|
4
|
+
import { createClientSecretBasicAuthHeader } from './client-secret-header.js'
|
|
5
|
+
import { extractIdentityId } from './identity.js'
|
|
5
6
|
|
|
6
7
|
type JWKSFunction = ReturnType<typeof createLocalJWKSet>
|
|
7
8
|
|
|
@@ -69,6 +70,9 @@ export interface VerifiedClaims {
|
|
|
69
70
|
image?: string
|
|
70
71
|
synthetic?: boolean
|
|
71
72
|
purpose?: string
|
|
73
|
+
userId?: string
|
|
74
|
+
roles?: string[]
|
|
75
|
+
audiences?: string[]
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export interface TokenPayloadClaims extends VerifiedClaims {
|
|
@@ -103,6 +107,12 @@ export interface VerifyAccessTokenOptions {
|
|
|
103
107
|
audience?: string | string[]
|
|
104
108
|
/** Signature algorithms to accept. Defaults to `['ES256']` (identity-platform's signing alg). */
|
|
105
109
|
algorithms?: string[]
|
|
110
|
+
/** Fetch signing keys from this backchannel base while still enforcing `issuer`. */
|
|
111
|
+
jwksIssuer?: string
|
|
112
|
+
/** Enforce the identity-platform user access-token claim contract. */
|
|
113
|
+
requireUserToken?: boolean
|
|
114
|
+
/** Permitted JWT clock skew in seconds for strict user-token validation. */
|
|
115
|
+
clockSkew?: number
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
async function verifyAccessTokenPayload(
|
|
@@ -113,15 +123,38 @@ async function verifyAccessTokenPayload(
|
|
|
113
123
|
try {
|
|
114
124
|
const header = decodeJwtHeader(token)
|
|
115
125
|
const kid = typeof header.kid === 'string' ? header.kid : undefined
|
|
116
|
-
const jwks = await getJWKS(issuer, kid)
|
|
126
|
+
const jwks = await getJWKS(options.jwksIssuer ?? issuer, kid)
|
|
117
127
|
const { payload } = await jwtVerify(token, jwks, {
|
|
118
128
|
issuer,
|
|
119
129
|
audience: options.audience,
|
|
120
130
|
algorithms: options.algorithms ?? ['ES256'],
|
|
131
|
+
clockTolerance: options.clockSkew,
|
|
121
132
|
})
|
|
122
133
|
if (payload.mode !== 'access') {
|
|
123
134
|
throw new Error(`unexpected token mode: ${String(payload.mode)}`)
|
|
124
135
|
}
|
|
136
|
+
if (options.requireUserToken) {
|
|
137
|
+
const properties = payload.properties
|
|
138
|
+
if (
|
|
139
|
+
payload.type !== 'user' ||
|
|
140
|
+
typeof payload.iat !== 'number' ||
|
|
141
|
+
typeof payload.exp !== 'number' ||
|
|
142
|
+
typeof payload.sub !== 'string' ||
|
|
143
|
+
typeof properties !== 'object' ||
|
|
144
|
+
properties === null
|
|
145
|
+
) {
|
|
146
|
+
throw new Error('required user access-token claims are missing')
|
|
147
|
+
}
|
|
148
|
+
const userId = extractIdentityId(payload.sub)
|
|
149
|
+
if (
|
|
150
|
+
payload.iat > Math.floor(Date.now() / 1000) + (options.clockSkew ?? 0) ||
|
|
151
|
+
(properties as Record<string, unknown>).userId !== userId ||
|
|
152
|
+
typeof (properties as Record<string, unknown>).email !== 'string' ||
|
|
153
|
+
(properties as Record<string, unknown>).email === ''
|
|
154
|
+
) {
|
|
155
|
+
throw new Error('access-token identity claims are inconsistent')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
125
158
|
return payload as Record<string, unknown>
|
|
126
159
|
} catch (err) {
|
|
127
160
|
if (!isExpiredJwtError(err)) {
|
|
@@ -139,6 +172,12 @@ export async function verifyAccessToken(
|
|
|
139
172
|
const payload = await verifyAccessTokenPayload(issuer, token, options)
|
|
140
173
|
if (!payload) return null
|
|
141
174
|
const claims = extractTokenClaims(payload)
|
|
175
|
+
const properties = (payload.properties ?? {}) as Record<string, unknown>
|
|
176
|
+
const audience = Array.isArray(payload.aud)
|
|
177
|
+
? payload.aud.filter((value): value is string => typeof value === 'string')
|
|
178
|
+
: typeof payload.aud === 'string'
|
|
179
|
+
? [payload.aud]
|
|
180
|
+
: []
|
|
142
181
|
return {
|
|
143
182
|
sub: claims.sub,
|
|
144
183
|
email: claims.email,
|
|
@@ -146,6 +185,11 @@ export async function verifyAccessToken(
|
|
|
146
185
|
image: claims.image,
|
|
147
186
|
synthetic: payload.synthetic === true ? true : undefined,
|
|
148
187
|
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
|
|
188
|
+
userId: typeof properties.userId === 'string' ? properties.userId : undefined,
|
|
189
|
+
roles: Array.isArray(payload.roles)
|
|
190
|
+
? payload.roles.filter((value): value is string => typeof value === 'string')
|
|
191
|
+
: undefined,
|
|
192
|
+
audiences: audience,
|
|
149
193
|
}
|
|
150
194
|
}
|
|
151
195
|
|
|
@@ -187,14 +231,67 @@ function tokenResultFromClaims(
|
|
|
187
231
|
}
|
|
188
232
|
}
|
|
189
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Why a refresh produced no tokens. `invalid_grant` is the issuer saying it does
|
|
236
|
+
* not recognise this refresh token — the strongest signal there is, but NOT
|
|
237
|
+
* proof: the issuer's token store is eventually consistent, so a token only
|
|
238
|
+
* seconds old can read as missing from another location. Every other reason may
|
|
239
|
+
* simply pass on a later request. A caller must never discard the session on
|
|
240
|
+
* any of them; signing in again overwrites the cookies without help.
|
|
241
|
+
*/
|
|
242
|
+
export type RefreshFailureReason =
|
|
243
|
+
| 'invalid_grant'
|
|
244
|
+
| 'issuer_rejected'
|
|
245
|
+
| 'issuer_error'
|
|
246
|
+
| 'malformed_response'
|
|
247
|
+
| 'claims_rejected'
|
|
248
|
+
| 'network'
|
|
249
|
+
| 'not_configured'
|
|
250
|
+
/** The refresher returned nothing and did not say why. */
|
|
251
|
+
| 'unreported'
|
|
252
|
+
|
|
253
|
+
export interface RefreshFailure {
|
|
254
|
+
reason: RefreshFailureReason
|
|
255
|
+
/** HTTP status of the issuer's answer, when there was one. */
|
|
256
|
+
status?: number
|
|
257
|
+
/** The OAuth `error` code the issuer returned, when it was readable. */
|
|
258
|
+
error?: string
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function oauthErrorCode(response: Response): Promise<string | undefined> {
|
|
262
|
+
try {
|
|
263
|
+
const body: unknown = await response.json()
|
|
264
|
+
const code = (body as { error?: unknown } | null)?.error
|
|
265
|
+
return typeof code === 'string' ? code : undefined
|
|
266
|
+
} catch {
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
190
271
|
export async function refreshTokens(
|
|
191
272
|
issuer: string,
|
|
192
273
|
refreshToken: string,
|
|
193
274
|
clientId?: string,
|
|
194
275
|
clientSecret?: string,
|
|
195
276
|
resource?: string,
|
|
277
|
+
onFailure?: (failure: RefreshFailure) => void,
|
|
196
278
|
): Promise<TokenRefreshResult | null> {
|
|
197
|
-
|
|
279
|
+
// Reporting is advisory: a callback that throws must not change the outcome,
|
|
280
|
+
// and the catch below must not report a second time on its behalf.
|
|
281
|
+
let reported = false
|
|
282
|
+
const report = (failure: RefreshFailure): void => {
|
|
283
|
+
if (reported) return
|
|
284
|
+
reported = true
|
|
285
|
+
try {
|
|
286
|
+
onFailure?.(failure)
|
|
287
|
+
} catch {
|
|
288
|
+
/* the caller's reporter is not this function's failure */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!resource) {
|
|
292
|
+
report({ reason: 'not_configured' })
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
198
295
|
try {
|
|
199
296
|
const params = new URLSearchParams({
|
|
200
297
|
grant_type: 'refresh_token',
|
|
@@ -216,16 +313,33 @@ export async function refreshTokens(
|
|
|
216
313
|
body: params.toString(),
|
|
217
314
|
})
|
|
218
315
|
|
|
219
|
-
if (!response.ok)
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const error = await oauthErrorCode(response)
|
|
318
|
+
const reason: RefreshFailureReason =
|
|
319
|
+
error === 'invalid_grant'
|
|
320
|
+
? 'invalid_grant'
|
|
321
|
+
: response.status >= 500
|
|
322
|
+
? 'issuer_error'
|
|
323
|
+
: 'issuer_rejected'
|
|
324
|
+
report({ reason, status: response.status, ...(error ? { error } : {}) })
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
220
327
|
|
|
221
|
-
const data = await response.json()
|
|
222
|
-
if (!tokenResponseHasTokens(data))
|
|
328
|
+
const data: unknown = await response.json().catch(() => null)
|
|
329
|
+
if (!tokenResponseHasTokens(data)) {
|
|
330
|
+
report({ reason: 'malformed_response', status: response.status })
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
223
333
|
|
|
224
334
|
const claims = await verifyIssuedAccessTokenClaims(issuer, data.access_token, resource)
|
|
225
|
-
if (!claims)
|
|
335
|
+
if (!claims) {
|
|
336
|
+
report({ reason: 'claims_rejected', status: response.status })
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
226
339
|
|
|
227
340
|
return tokenResultFromClaims(data.access_token, data.refresh_token, claims)
|
|
228
341
|
} catch {
|
|
342
|
+
report({ reason: 'network' })
|
|
229
343
|
return null
|
|
230
344
|
}
|
|
231
345
|
}
|