@better-auth/core 1.7.0-beta.5 → 1.7.0-beta.7
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/api/index.d.mts +44 -1
- package/dist/api/index.mjs +40 -1
- package/dist/context/global.mjs +1 -1
- package/dist/context/transaction.d.mts +7 -4
- package/dist/context/transaction.mjs +6 -3
- package/dist/db/adapter/factory.mjs +57 -31
- package/dist/db/adapter/index.d.mts +54 -10
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +12 -7
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/create-authorization-url.d.mts +3 -1
- package/dist/oauth2/create-authorization-url.mjs +3 -1
- package/dist/oauth2/dpop.d.mts +142 -0
- package/dist/oauth2/dpop.mjs +246 -0
- package/dist/oauth2/index.d.mts +4 -3
- package/dist/oauth2/index.mjs +3 -2
- package/dist/oauth2/oauth-provider.d.mts +37 -3
- package/dist/oauth2/refresh-access-token.mjs +15 -1
- package/dist/oauth2/verify.d.mts +74 -15
- package/dist/oauth2/verify.mjs +172 -20
- package/dist/social-providers/apple.d.mts +2 -0
- package/dist/social-providers/atlassian.d.mts +2 -0
- package/dist/social-providers/cognito.d.mts +2 -0
- package/dist/social-providers/discord.d.mts +2 -0
- package/dist/social-providers/dropbox.d.mts +2 -0
- package/dist/social-providers/facebook.d.mts +2 -0
- package/dist/social-providers/figma.d.mts +2 -0
- package/dist/social-providers/github.d.mts +2 -0
- package/dist/social-providers/gitlab.d.mts +2 -0
- package/dist/social-providers/google.d.mts +2 -0
- package/dist/social-providers/huggingface.d.mts +2 -0
- package/dist/social-providers/index.d.mts +71 -0
- package/dist/social-providers/kakao.d.mts +2 -0
- package/dist/social-providers/kick.d.mts +2 -0
- package/dist/social-providers/line.d.mts +2 -0
- package/dist/social-providers/linear.d.mts +2 -0
- package/dist/social-providers/linkedin.d.mts +2 -0
- package/dist/social-providers/microsoft-entra-id.d.mts +12 -0
- package/dist/social-providers/microsoft-entra-id.mjs +17 -2
- package/dist/social-providers/naver.d.mts +2 -0
- package/dist/social-providers/notion.d.mts +2 -0
- package/dist/social-providers/paybin.d.mts +2 -0
- package/dist/social-providers/paypal.d.mts +2 -0
- package/dist/social-providers/polar.d.mts +2 -0
- package/dist/social-providers/railway.d.mts +2 -0
- package/dist/social-providers/reddit.d.mts +2 -0
- package/dist/social-providers/reddit.mjs +1 -1
- package/dist/social-providers/roblox.d.mts +2 -0
- package/dist/social-providers/salesforce.d.mts +2 -0
- package/dist/social-providers/slack.d.mts +2 -0
- package/dist/social-providers/spotify.d.mts +2 -0
- package/dist/social-providers/tiktok.d.mts +2 -0
- package/dist/social-providers/twitch.d.mts +2 -0
- package/dist/social-providers/twitter.d.mts +2 -0
- package/dist/social-providers/vercel.d.mts +2 -0
- package/dist/social-providers/vk.d.mts +2 -0
- package/dist/social-providers/wechat.d.mts +2 -0
- package/dist/social-providers/wechat.mjs +1 -1
- package/dist/social-providers/zoom.d.mts +2 -0
- package/dist/types/context.d.mts +17 -0
- package/dist/types/init-options.d.mts +45 -5
- package/dist/types/plugin-client.d.mts +12 -2
- package/dist/utils/host.d.mts +1 -1
- package/dist/utils/host.mjs +7 -0
- package/dist/utils/url.mjs +4 -3
- package/package.json +5 -5
- package/src/api/index.ts +82 -0
- package/src/context/transaction.ts +45 -12
- package/src/db/adapter/factory.ts +127 -72
- package/src/db/adapter/index.ts +54 -9
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +12 -7
- package/src/oauth2/create-authorization-url.ts +4 -0
- package/src/oauth2/dpop.ts +568 -0
- package/src/oauth2/index.ts +45 -1
- package/src/oauth2/oauth-provider.ts +40 -2
- package/src/oauth2/refresh-access-token.ts +27 -3
- package/src/oauth2/verify-id-token.ts +2 -0
- package/src/oauth2/verify.ts +329 -66
- package/src/social-providers/microsoft-entra-id.ts +44 -1
- package/src/social-providers/reddit.ts +5 -1
- package/src/social-providers/wechat.ts +8 -1
- package/src/types/context.ts +18 -0
- package/src/types/init-options.ts +40 -8
- package/src/types/plugin-client.ts +16 -2
- package/src/utils/host.ts +25 -1
- package/src/utils/url.ts +10 -4
|
@@ -29,6 +29,21 @@ interface RefreshAccessTokenInput extends RefreshAccessTokenRequestInput {
|
|
|
29
29
|
tokenEndpoint: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Body keys owned by the refresh-token flow or unsafe to copy from caller input.
|
|
34
|
+
*/
|
|
35
|
+
const BLOCKED_REFRESH_TOKEN_PARAMS = [
|
|
36
|
+
"grant_type",
|
|
37
|
+
"refresh_token",
|
|
38
|
+
"__proto__",
|
|
39
|
+
"constructor",
|
|
40
|
+
"prototype",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
const BLOCKED_REFRESH_TOKEN_PARAMS_SET: ReadonlySet<string> = new Set(
|
|
44
|
+
BLOCKED_REFRESH_TOKEN_PARAMS,
|
|
45
|
+
);
|
|
46
|
+
|
|
32
47
|
export async function refreshAccessTokenRequest({
|
|
33
48
|
refreshToken,
|
|
34
49
|
options,
|
|
@@ -59,6 +74,17 @@ export async function refreshAccessTokenRequest({
|
|
|
59
74
|
return request;
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
function applyRefreshExtraParams(
|
|
78
|
+
body: URLSearchParams,
|
|
79
|
+
extraParams: Record<string, string> | undefined,
|
|
80
|
+
) {
|
|
81
|
+
if (!extraParams) return;
|
|
82
|
+
for (const [key, value] of Object.entries(extraParams)) {
|
|
83
|
+
if (BLOCKED_REFRESH_TOKEN_PARAMS_SET.has(key)) continue;
|
|
84
|
+
body.set(key, value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
function buildRefreshAccessTokenRequest({
|
|
63
89
|
refreshToken,
|
|
64
90
|
options,
|
|
@@ -83,9 +109,7 @@ function buildRefreshAccessTokenRequest({
|
|
|
83
109
|
}
|
|
84
110
|
}
|
|
85
111
|
if (extraParams) {
|
|
86
|
-
|
|
87
|
-
body.set(key, value);
|
|
88
|
-
}
|
|
112
|
+
applyRefreshExtraParams(body, extraParams);
|
|
89
113
|
}
|
|
90
114
|
|
|
91
115
|
return {
|
|
@@ -79,6 +79,8 @@ export async function verifyProviderIdToken(
|
|
|
79
79
|
// Opaque (non-JWS) tokens carry no signature to check. They are accepted only when the
|
|
80
80
|
// provider opts in, in which case getUserInfo resolves identity from the access token via
|
|
81
81
|
// the provider's userinfo endpoint, which validates it (e.g. Facebook Graph access tokens).
|
|
82
|
+
// An expected `nonce` is not enforced here: an opaque token carries no `nonce` claim, and the
|
|
83
|
+
// access-token-backed userinfo exchange (not the token itself) is the identity source.
|
|
82
84
|
if (token.split(".").length !== 3) {
|
|
83
85
|
return config.allowOpaqueToken === true;
|
|
84
86
|
}
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
JSONWebKeySet,
|
|
5
5
|
JWTPayload,
|
|
6
6
|
JWTVerifyOptions,
|
|
7
|
+
JWTVerifyResult,
|
|
7
8
|
ProtectedHeaderParameters,
|
|
8
9
|
} from "jose";
|
|
9
10
|
import {
|
|
@@ -14,6 +15,14 @@ import {
|
|
|
14
15
|
UnsecuredJWT,
|
|
15
16
|
} from "jose";
|
|
16
17
|
import { logger } from "../env";
|
|
18
|
+
import type { DpopReplayStore } from "./dpop";
|
|
19
|
+
import {
|
|
20
|
+
createInMemoryDpopReplayStore,
|
|
21
|
+
enforceDpopBinding,
|
|
22
|
+
getDpopJktFromPayload,
|
|
23
|
+
isDpopBindingError,
|
|
24
|
+
parseAccessTokenAuthorization,
|
|
25
|
+
} from "./dpop";
|
|
17
26
|
|
|
18
27
|
const joseInfrastructureErrorCodes = new Set([
|
|
19
28
|
joseErrors.JWKSTimeout.code,
|
|
@@ -28,12 +37,37 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
|
28
37
|
interface JwksCacheEntry {
|
|
29
38
|
jwks: JSONWebKeySet;
|
|
30
39
|
fetchedAt: number;
|
|
40
|
+
noKidRefetchedAt?: number | undefined;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
type JwksFetchOptions = {
|
|
44
|
+
/** Jwks url or promise of a Jwks */
|
|
45
|
+
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
46
|
+
/**
|
|
47
|
+
* Stable object to cache the result of a function `jwksFetch` under,
|
|
48
|
+
* with the same TTL and kid-miss refetch rules as string sources.
|
|
49
|
+
* Without it, a function source is fetched on every verification.
|
|
50
|
+
*/
|
|
51
|
+
jwksCacheKey?: object;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ResolvedJwks = {
|
|
55
|
+
jwks: JSONWebKeySet;
|
|
56
|
+
fromCache: boolean;
|
|
57
|
+
kid: string | undefined;
|
|
58
|
+
noKidRefetchedAt?: number | undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export const jwksCache = new Map<string, JwksCacheEntry>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cache for function jwks sources, keyed by a caller-provided stable object.
|
|
68
|
+
* Entries are released with their key, so per-request keys cannot accumulate.
|
|
69
|
+
*/
|
|
70
|
+
const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
|
|
37
71
|
|
|
38
72
|
/**
|
|
39
73
|
* How long a cached JWKS is trusted before it is refetched
|
|
@@ -41,6 +75,61 @@ const jwksCache = new Map<
|
|
|
41
75
|
* @internal
|
|
42
76
|
*/
|
|
43
77
|
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
78
|
+
const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns the cached key set when it is within the TTL. When the token carries
|
|
82
|
+
* `kid`, the cached set must contain that key id; without `kid`, key selection
|
|
83
|
+
* is deferred to JOSE because RFC 7515 makes the header parameter optional.
|
|
84
|
+
*/
|
|
85
|
+
function getFreshJwksWithKid(
|
|
86
|
+
cached: JwksCacheEntry | undefined,
|
|
87
|
+
kid: string | undefined,
|
|
88
|
+
): JSONWebKeySet | undefined {
|
|
89
|
+
if (!cached) return undefined;
|
|
90
|
+
if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
|
|
91
|
+
if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return cached.jwks;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shouldRefetchCachedJwksWithoutKid(
|
|
98
|
+
error: unknown,
|
|
99
|
+
resolved: ResolvedJwks,
|
|
100
|
+
) {
|
|
101
|
+
const isRetryableNoKidFailure =
|
|
102
|
+
resolved.fromCache &&
|
|
103
|
+
!resolved.kid &&
|
|
104
|
+
(error instanceof joseErrors.JWKSNoMatchingKey ||
|
|
105
|
+
error instanceof joseErrors.JWSSignatureVerificationFailed);
|
|
106
|
+
if (!isRetryableNoKidFailure) return false;
|
|
107
|
+
if (!resolved.noKidRefetchedAt) return true;
|
|
108
|
+
return (
|
|
109
|
+
Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchJwks(
|
|
114
|
+
jwksFetch: JwksFetchOptions["jwksFetch"],
|
|
115
|
+
): Promise<JSONWebKeySet> {
|
|
116
|
+
const jwks =
|
|
117
|
+
typeof jwksFetch === "string"
|
|
118
|
+
? await betterFetch<JSONWebKeySet>(jwksFetch, {
|
|
119
|
+
headers: {
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
},
|
|
122
|
+
}).then(async (res) => {
|
|
123
|
+
if (res.error)
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Jwks failed: ${res.error.message ?? res.error.statusText}`,
|
|
126
|
+
);
|
|
127
|
+
return res.data;
|
|
128
|
+
})
|
|
129
|
+
: await jwksFetch();
|
|
130
|
+
if (!jwks) throw new Error("No jwks found");
|
|
131
|
+
return jwks;
|
|
132
|
+
}
|
|
44
133
|
|
|
45
134
|
export interface VerifyAccessTokenRemote {
|
|
46
135
|
/** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
|
|
@@ -71,6 +160,65 @@ export interface VerifyAccessTokenRemote {
|
|
|
71
160
|
allowMissingAudience?: boolean;
|
|
72
161
|
}
|
|
73
162
|
|
|
163
|
+
export interface VerifyAccessTokenOptions {
|
|
164
|
+
/** Verify options */
|
|
165
|
+
verifyOptions: JWTVerifyOptions &
|
|
166
|
+
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
167
|
+
/** Scopes to additionally verify. Token must include all but not exact. */
|
|
168
|
+
scopes?: string[];
|
|
169
|
+
/** Required to verify access token locally */
|
|
170
|
+
jwksUrl?: string;
|
|
171
|
+
/** If provided, can verify a token remotely */
|
|
172
|
+
remoteVerify?: VerifyAccessTokenRemote;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface VerifyAccessTokenRequestOptions
|
|
176
|
+
extends VerifyAccessTokenOptions {
|
|
177
|
+
dpop?: {
|
|
178
|
+
proofMaxAgeSeconds?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Store used to reject replayed DPoP proof `jti` values.
|
|
181
|
+
*
|
|
182
|
+
* Defaults to a process-local in-memory store, which is only safe for a
|
|
183
|
+
* single-instance deployment: it shares no state across instances and
|
|
184
|
+
* resets on cold start, so a captured proof can be replayed against
|
|
185
|
+
* another instance within the proof's lifetime. Supply a shared,
|
|
186
|
+
* persistent store (for example one backed by your database) for any
|
|
187
|
+
* multi-instance or serverless resource server.
|
|
188
|
+
*/
|
|
189
|
+
replayStore?: DpopReplayStore;
|
|
190
|
+
signingAlgorithms?: readonly string[];
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ResourceRequestInput {
|
|
195
|
+
authorizationHeader: string | null | undefined;
|
|
196
|
+
dpopProofJwt?: string | null | undefined;
|
|
197
|
+
method: string;
|
|
198
|
+
url: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
|
|
203
|
+
* `Authorization` and `DPoP` headers and the request method and URL. Resource
|
|
204
|
+
* servers share this so every entry point maps the wire request the same way.
|
|
205
|
+
*/
|
|
206
|
+
export function requestToResourceInput(request: Request): ResourceRequestInput {
|
|
207
|
+
return {
|
|
208
|
+
authorizationHeader: request.headers.get("authorization"),
|
|
209
|
+
dpopProofJwt: request.headers.get("dpop"),
|
|
210
|
+
method: request.method,
|
|
211
|
+
url: request.url,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Process-local, single-instance replay store. See the warning on
|
|
217
|
+
* {@link VerifyAccessTokenRequestOptions.dpop.replayStore}; multi-instance
|
|
218
|
+
* resource servers must pass their own shared store.
|
|
219
|
+
*/
|
|
220
|
+
const defaultDpopReplayStore = createInMemoryDpopReplayStore();
|
|
221
|
+
|
|
74
222
|
/**
|
|
75
223
|
* Performs local verification of an access token for your APIs.
|
|
76
224
|
*
|
|
@@ -78,21 +226,36 @@ export interface VerifyAccessTokenRemote {
|
|
|
78
226
|
*/
|
|
79
227
|
export async function verifyJwsAccessToken(
|
|
80
228
|
token: string,
|
|
81
|
-
opts: {
|
|
82
|
-
/** Jwks url or promise of a Jwks */
|
|
83
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
229
|
+
opts: JwksFetchOptions & {
|
|
84
230
|
/** Verify options */
|
|
85
231
|
verifyOptions: JWTVerifyOptions &
|
|
86
232
|
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
87
233
|
},
|
|
88
234
|
) {
|
|
89
235
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
236
|
+
const resolved = await getJwksForVerification(token, opts);
|
|
237
|
+
let jwt: JWTVerifyResult<JWTPayload>;
|
|
238
|
+
try {
|
|
239
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
240
|
+
token,
|
|
241
|
+
createLocalJWKSet(resolved.jwks),
|
|
242
|
+
opts.verifyOptions,
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
|
|
246
|
+
const refreshed = await getJwksForVerification(token, {
|
|
247
|
+
...opts,
|
|
248
|
+
forceRefresh: true,
|
|
249
|
+
});
|
|
250
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
251
|
+
token,
|
|
252
|
+
createLocalJWKSet(refreshed.jwks),
|
|
253
|
+
opts.verifyOptions,
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
96
259
|
// Return the JWT payload in introspection format
|
|
97
260
|
// https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
|
|
98
261
|
if (jwt.payload.azp) {
|
|
@@ -105,12 +268,13 @@ export async function verifyJwsAccessToken(
|
|
|
105
268
|
}
|
|
106
269
|
}
|
|
107
270
|
|
|
108
|
-
export async function getJwks(
|
|
271
|
+
export async function getJwks(token: string, opts: JwksFetchOptions) {
|
|
272
|
+
return (await getJwksForVerification(token, opts)).jwks;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function getJwksForVerification(
|
|
109
276
|
token: string,
|
|
110
|
-
opts: {
|
|
111
|
-
/** Jwks url or promise of a Jwks */
|
|
112
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
113
|
-
},
|
|
277
|
+
opts: JwksFetchOptions & { forceRefresh?: boolean },
|
|
114
278
|
) {
|
|
115
279
|
// Attempt to decode the token and find a matching kid in jwks
|
|
116
280
|
let jwtHeaders: ProtectedHeaderParameters | undefined;
|
|
@@ -121,63 +285,70 @@ export async function getJwks(
|
|
|
121
285
|
throw new Error(error as unknown as string);
|
|
122
286
|
}
|
|
123
287
|
|
|
124
|
-
if (!jwtHeaders.kid) {
|
|
125
|
-
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
126
|
-
}
|
|
127
288
|
const kid = jwtHeaders.kid;
|
|
128
289
|
|
|
290
|
+
// Function sources have no usable identity of their own (callers pass
|
|
291
|
+
// fresh closures per request), so they are cached only under a stable
|
|
292
|
+
// caller-provided key object.
|
|
293
|
+
if (typeof opts.jwksFetch !== "string") {
|
|
294
|
+
const cacheKey = opts.jwksCacheKey;
|
|
295
|
+
if (!cacheKey) {
|
|
296
|
+
const jwks = await opts.jwksFetch();
|
|
297
|
+
if (!jwks) throw new Error("No jwks found");
|
|
298
|
+
return { jwks, fromCache: false, kid };
|
|
299
|
+
}
|
|
300
|
+
const cached = functionJwksCache.get(cacheKey);
|
|
301
|
+
const cachedJwks = opts.forceRefresh
|
|
302
|
+
? undefined
|
|
303
|
+
: getFreshJwksWithKid(cached, kid);
|
|
304
|
+
if (cachedJwks) {
|
|
305
|
+
return {
|
|
306
|
+
jwks: cachedJwks,
|
|
307
|
+
fromCache: true,
|
|
308
|
+
kid,
|
|
309
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const jwks = await opts.jwksFetch();
|
|
313
|
+
if (!jwks) throw new Error("No jwks found");
|
|
314
|
+
const fetchedAt = Date.now();
|
|
315
|
+
functionJwksCache.set(cacheKey, {
|
|
316
|
+
jwks,
|
|
317
|
+
fetchedAt,
|
|
318
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
319
|
+
});
|
|
320
|
+
return { jwks, fromCache: false, kid };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The cache is scoped to `cacheKey`, so a token is only ever matched
|
|
324
|
+
// against the key set published by its own source.
|
|
129
325
|
const cacheKey = opts.jwksFetch;
|
|
130
326
|
const cached = jwksCache.get(cacheKey);
|
|
131
|
-
const
|
|
132
|
-
?
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
|
|
144
|
-
headers: {
|
|
145
|
-
Accept: "application/json",
|
|
146
|
-
},
|
|
147
|
-
}).then(async (res) => {
|
|
148
|
-
if (res.error)
|
|
149
|
-
throw new Error(
|
|
150
|
-
`Jwks failed: ${res.error.message ?? res.error.statusText}`,
|
|
151
|
-
);
|
|
152
|
-
return res.data;
|
|
153
|
-
})
|
|
154
|
-
: await opts.jwksFetch();
|
|
155
|
-
if (!jwks) throw new Error("No jwks found");
|
|
156
|
-
jwksCache.set(cacheKey, { jwks, fetchedAt: Date.now() });
|
|
157
|
-
return jwks;
|
|
327
|
+
const cachedJwks = opts.forceRefresh
|
|
328
|
+
? undefined
|
|
329
|
+
: getFreshJwksWithKid(cached, kid);
|
|
330
|
+
if (!cachedJwks) {
|
|
331
|
+
const jwks = await fetchJwks(opts.jwksFetch);
|
|
332
|
+
const fetchedAt = Date.now();
|
|
333
|
+
jwksCache.set(cacheKey, {
|
|
334
|
+
jwks,
|
|
335
|
+
fetchedAt,
|
|
336
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
337
|
+
});
|
|
338
|
+
return { jwks, fromCache: false, kid };
|
|
158
339
|
}
|
|
159
340
|
|
|
160
|
-
return
|
|
341
|
+
return {
|
|
342
|
+
jwks: cachedJwks,
|
|
343
|
+
fromCache: true,
|
|
344
|
+
kid,
|
|
345
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
346
|
+
};
|
|
161
347
|
}
|
|
162
348
|
|
|
163
|
-
|
|
164
|
-
* Performs local verification of an access token for your API.
|
|
165
|
-
*
|
|
166
|
-
* Can also be configured for remote verification.
|
|
167
|
-
*/
|
|
168
|
-
export async function verifyAccessToken(
|
|
349
|
+
async function verifyAccessTokenPayload(
|
|
169
350
|
token: string,
|
|
170
|
-
opts:
|
|
171
|
-
/** Verify options */
|
|
172
|
-
verifyOptions: JWTVerifyOptions &
|
|
173
|
-
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
174
|
-
/** Scopes to additionally verify. Token must include all but not exact. */
|
|
175
|
-
scopes?: string[];
|
|
176
|
-
/** Required to verify access token locally */
|
|
177
|
-
jwksUrl?: string;
|
|
178
|
-
/** If provided, can verify a token remotely */
|
|
179
|
-
remoteVerify?: VerifyAccessTokenRemote;
|
|
180
|
-
},
|
|
351
|
+
opts: VerifyAccessTokenOptions,
|
|
181
352
|
) {
|
|
182
353
|
let payload: JWTPayload | undefined;
|
|
183
354
|
// Locally verify
|
|
@@ -286,3 +457,95 @@ export async function verifyAccessToken(
|
|
|
286
457
|
|
|
287
458
|
return payload;
|
|
288
459
|
}
|
|
460
|
+
|
|
461
|
+
function throwDpopUnauthorized(
|
|
462
|
+
message: string,
|
|
463
|
+
error?: "invalid_dpop_proof" | "invalid_token",
|
|
464
|
+
): never {
|
|
465
|
+
throw new APIError(
|
|
466
|
+
"UNAUTHORIZED",
|
|
467
|
+
error
|
|
468
|
+
? {
|
|
469
|
+
message,
|
|
470
|
+
error,
|
|
471
|
+
error_description: message,
|
|
472
|
+
}
|
|
473
|
+
: { message },
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Performs local verification of a bearer access token for your API.
|
|
479
|
+
*
|
|
480
|
+
* Can also be configured for remote verification. DPoP-bound access tokens
|
|
481
|
+
* require {@link verifyAccessTokenRequest}, because sender-constraining cannot
|
|
482
|
+
* be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
|
|
483
|
+
* and access-token hash. This function rejects DPoP-bound tokens; reach for it
|
|
484
|
+
* only when you hold a raw token string and intentionally accept bearer tokens
|
|
485
|
+
* alone.
|
|
486
|
+
*/
|
|
487
|
+
export async function verifyBearerToken(
|
|
488
|
+
token: string,
|
|
489
|
+
opts: VerifyAccessTokenOptions,
|
|
490
|
+
) {
|
|
491
|
+
const payload = await verifyAccessTokenPayload(token, opts);
|
|
492
|
+
if (getDpopJktFromPayload(payload)) {
|
|
493
|
+
throwDpopUnauthorized(
|
|
494
|
+
"DPoP-bound access token requires verifyAccessTokenRequest",
|
|
495
|
+
"invalid_token",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return payload;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Verifies an HTTP resource request carrying an OAuth access token. This is the
|
|
503
|
+
* recommended resource-server entry point: it handles both bearer and
|
|
504
|
+
* DPoP-bound tokens, the bearer case being the request with no DPoP proof.
|
|
505
|
+
*
|
|
506
|
+
* It performs the same token validation as {@link verifyBearerToken}, then adds
|
|
507
|
+
* the RFC 9449 sender-constraint checks that need request context: authorization
|
|
508
|
+
* scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
|
|
509
|
+
*/
|
|
510
|
+
export async function verifyAccessTokenRequest(
|
|
511
|
+
request: ResourceRequestInput,
|
|
512
|
+
opts: VerifyAccessTokenRequestOptions,
|
|
513
|
+
) {
|
|
514
|
+
const authorization = parseAccessTokenAuthorization(
|
|
515
|
+
request.authorizationHeader,
|
|
516
|
+
);
|
|
517
|
+
if (!authorization?.token) {
|
|
518
|
+
throwDpopUnauthorized("missing authorization header");
|
|
519
|
+
}
|
|
520
|
+
// RFC 6750 §2.1 / RFC 9449 §7.1: an access token is presented with the
|
|
521
|
+
// `Bearer` or `DPoP` scheme. Reject a scheme-less or unknown-scheme value
|
|
522
|
+
// rather than accept a bare token.
|
|
523
|
+
if (authorization.scheme === "Unknown") {
|
|
524
|
+
throwDpopUnauthorized(
|
|
525
|
+
"authorization scheme must be Bearer or DPoP",
|
|
526
|
+
"invalid_token",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const payload = await verifyAccessTokenPayload(authorization.token, opts);
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await enforceDpopBinding({
|
|
534
|
+
payload,
|
|
535
|
+
authorization,
|
|
536
|
+
proofJwt: request.dpopProofJwt,
|
|
537
|
+
method: request.method,
|
|
538
|
+
url: request.url,
|
|
539
|
+
replayStore: opts.dpop?.replayStore ?? defaultDpopReplayStore,
|
|
540
|
+
proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
|
|
541
|
+
signingAlgorithms: opts.dpop?.signingAlgorithms,
|
|
542
|
+
});
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (isDpopBindingError(error)) {
|
|
545
|
+
throwDpopUnauthorized(error.message, error.code);
|
|
546
|
+
}
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return payload;
|
|
551
|
+
}
|
|
@@ -17,6 +17,14 @@ import {
|
|
|
17
17
|
validateAuthorizationCode,
|
|
18
18
|
} from "../oauth2";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
|
|
22
|
+
* personal-account token carries it as the `tid` claim, so it distinguishes the
|
|
23
|
+
* consumer account class from work/school tenants.
|
|
24
|
+
* @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
|
|
25
|
+
*/
|
|
26
|
+
const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
29
|
* @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
|
|
22
30
|
*/
|
|
@@ -163,7 +171,14 @@ const MICROSOFT_ENTRA_ID_DEFAULT_SCOPES = [
|
|
|
163
171
|
|
|
164
172
|
export const microsoft = (options: MicrosoftOptions) => {
|
|
165
173
|
const tenant = options.tenantId || "common";
|
|
166
|
-
|
|
174
|
+
// Trim any trailing slash so endpoint URLs and the issuer comparison below
|
|
175
|
+
// never produce a double slash (e.g. a configured `https://host/` would make
|
|
176
|
+
// the expected issuer `https://host//<tid>/v2.0` and reject every token). A
|
|
177
|
+
// loop avoids a trailing-slash regex, which is a polynomial-ReDoS shape.
|
|
178
|
+
let authority = options.authority || "https://login.microsoftonline.com";
|
|
179
|
+
while (authority.endsWith("/")) {
|
|
180
|
+
authority = authority.slice(0, -1);
|
|
181
|
+
}
|
|
167
182
|
const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
|
|
168
183
|
const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
|
|
169
184
|
if (options.clientSecret && options.clientAssertion) {
|
|
@@ -235,6 +250,34 @@ export const microsoft = (options: MicrosoftOptions) => {
|
|
|
235
250
|
tenant !== "consumers"
|
|
236
251
|
? `${authority}/${tenant}/v2.0`
|
|
237
252
|
: undefined,
|
|
253
|
+
/**
|
|
254
|
+
* The multi-tenant endpoints (common/organizations/consumers) skip the
|
|
255
|
+
* issuer check above because the issuer varies per tenant, and the
|
|
256
|
+
* organizations and consumers JWKS sets overlap. Enforce the tenant
|
|
257
|
+
* binding explicitly so a token from a disallowed account class cannot
|
|
258
|
+
* pass: the issuer must name the token's own tenant, and the account
|
|
259
|
+
* class must match the configured restriction.
|
|
260
|
+
* @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
|
|
261
|
+
*/
|
|
262
|
+
verifyClaims: (claims) => {
|
|
263
|
+
const tid = claims.tid;
|
|
264
|
+
if (
|
|
265
|
+
typeof tid !== "string" ||
|
|
266
|
+
claims.iss !== `${authority}/${tid}/v2.0`
|
|
267
|
+
) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
tenant === "organizations" &&
|
|
272
|
+
tid === MICROSOFT_CONSUMER_TENANT_ID
|
|
273
|
+
) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
238
281
|
},
|
|
239
282
|
async getUserInfo(token) {
|
|
240
283
|
if (options.getUserInfo) {
|
|
@@ -116,7 +116,11 @@ export const reddit = (options: RedditOptions) => {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
119
|
-
|
|
119
|
+
// Reddit's identity scope does not return an email. Synthesize a stable,
|
|
120
|
+
// non-routable placeholder (RFC 2606 `.invalid`) keyed to the user's
|
|
121
|
+
// Reddit id rather than the routable `reddit.com`, which could collide
|
|
122
|
+
// with a real address. Left unverified; `mapProfileToUser` can override.
|
|
123
|
+
const email = userMap?.email || `${profile.id}@reddit.invalid`;
|
|
120
124
|
return {
|
|
121
125
|
user: {
|
|
122
126
|
id: profile.id,
|
|
@@ -220,7 +220,14 @@ export const wechat = (options: WeChatOptions) => {
|
|
|
220
220
|
user: {
|
|
221
221
|
id: profile.unionid || profile.openid || openid,
|
|
222
222
|
name: profile.nickname,
|
|
223
|
-
|
|
223
|
+
// WeChat does not return an email, and the OAuth callback rejects a
|
|
224
|
+
// missing one, so the default sign-in would always fail. Synthesize a
|
|
225
|
+
// stable, non-routable placeholder (RFC 2606 `.invalid`) keyed to the
|
|
226
|
+
// user's WeChat id, left unverified. Applications that collect a real
|
|
227
|
+
// email override it via `mapProfileToUser`.
|
|
228
|
+
email:
|
|
229
|
+
profile.email ||
|
|
230
|
+
`${profile.unionid || profile.openid || openid}@wechat.invalid`,
|
|
224
231
|
image: profile.headimgurl,
|
|
225
232
|
emailVerified: false,
|
|
226
233
|
...userMap,
|
package/src/types/context.ts
CHANGED
|
@@ -237,6 +237,24 @@ export interface InternalAdapter<
|
|
|
237
237
|
*/
|
|
238
238
|
consumeVerificationValue(identifier: string): Promise<Verification | null>;
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* First-writer-wins create keyed by a deterministic primary key derived from
|
|
242
|
+
* `identifier`. Returns `true` when this caller created the row and `false`
|
|
243
|
+
* when a row for the same identifier already existed.
|
|
244
|
+
*
|
|
245
|
+
* The dual of `consumeVerificationValue`: reserve races to create a marker
|
|
246
|
+
* exactly once, where consume races to delete one exactly once. Use it for
|
|
247
|
+
* replay tombstones (a SAML assertion id, a JWT `jti`) where the first caller
|
|
248
|
+
* wins. The database path is atomic via the primary key. Secondary-storage-only
|
|
249
|
+
* verification is not supported for reservation and runtime implementations
|
|
250
|
+
* should fail closed unless verification is backed by the database.
|
|
251
|
+
*/
|
|
252
|
+
reserveVerificationValue(data: {
|
|
253
|
+
identifier: string;
|
|
254
|
+
value: string;
|
|
255
|
+
expiresAt: Date;
|
|
256
|
+
}): Promise<boolean>;
|
|
257
|
+
|
|
240
258
|
updateVerificationByIdentifier(
|
|
241
259
|
identifier: string,
|
|
242
260
|
data: Partial<Verification>,
|