@better-auth/core 1.6.15 → 1.6.17
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 +3 -0
- package/dist/api/index.mjs +36 -0
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +82 -0
- package/dist/db/adapter/index.d.mts +51 -1
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +15 -0
- package/dist/env/env-impl.mjs +1 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/verify.d.mts +29 -6
- package/dist/oauth2/verify.mjs +112 -12
- package/dist/social-providers/facebook.mjs +35 -2
- package/dist/social-providers/google.d.mts +6 -1
- package/dist/social-providers/google.mjs +5 -0
- package/dist/social-providers/index.d.mts +2 -2
- package/dist/social-providers/index.mjs +2 -2
- package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
- package/dist/social-providers/microsoft-entra-id.mjs +13 -1
- package/dist/social-providers/paypal.d.mts +2 -1
- package/dist/social-providers/paypal.mjs +38 -4
- package/dist/social-providers/reddit.mjs +4 -3
- package/dist/social-providers/wechat.mjs +1 -1
- package/dist/types/context.d.mts +16 -0
- package/dist/types/init-options.d.mts +29 -0
- package/dist/utils/host.mjs +4 -0
- package/dist/utils/url.mjs +4 -3
- package/package.json +5 -5
- package/src/api/index.ts +45 -0
- package/src/db/adapter/factory.ts +152 -0
- package/src/db/adapter/index.ts +51 -0
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +15 -0
- package/src/env/env-impl.ts +1 -2
- package/src/oauth2/verify.ts +211 -41
- package/src/social-providers/facebook.ts +75 -2
- package/src/social-providers/google.ts +27 -1
- package/src/social-providers/microsoft-entra-id.ts +40 -1
- package/src/social-providers/paypal.ts +91 -4
- package/src/social-providers/reddit.ts +7 -3
- package/src/social-providers/wechat.ts +8 -1
- package/src/types/context.ts +17 -0
- package/src/types/init-options.ts +26 -0
- package/src/utils/host.ts +15 -0
- package/src/utils/url.ts +10 -4
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 {
|
|
@@ -25,8 +26,102 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
|
25
26
|
return joseInfrastructureErrorCodes.has(error.code);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
interface JwksCacheEntry {
|
|
30
|
+
jwks: JSONWebKeySet;
|
|
31
|
+
fetchedAt: number;
|
|
32
|
+
noKidRefetchedAt?: number | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type JwksFetchOptions = {
|
|
36
|
+
/** Jwks url or promise of a Jwks */
|
|
37
|
+
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
38
|
+
/**
|
|
39
|
+
* Stable object to cache the result of a function `jwksFetch` under,
|
|
40
|
+
* with the same TTL and kid-miss refetch rules as string sources.
|
|
41
|
+
* Without it, a function source is fetched on every verification.
|
|
42
|
+
*/
|
|
43
|
+
jwksCacheKey?: object;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ResolvedJwks = {
|
|
47
|
+
jwks: JSONWebKeySet;
|
|
48
|
+
fromCache: boolean;
|
|
49
|
+
kid: string | undefined;
|
|
50
|
+
noKidRefetchedAt?: number | undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export const jwksCache = new Map<string, JwksCacheEntry>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cache for function jwks sources, keyed by a caller-provided stable object.
|
|
60
|
+
* Entries are released with their key, so per-request keys cannot accumulate.
|
|
61
|
+
*/
|
|
62
|
+
const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How long a cached JWKS is trusted before it is refetched
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
70
|
+
const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the cached key set when it is within the TTL. When the token carries
|
|
74
|
+
* `kid`, the cached set must contain that key id; without `kid`, key selection
|
|
75
|
+
* is deferred to JOSE because RFC 7515 makes the header parameter optional.
|
|
76
|
+
*/
|
|
77
|
+
function getFreshJwksWithKid(
|
|
78
|
+
cached: JwksCacheEntry | undefined,
|
|
79
|
+
kid: string | undefined,
|
|
80
|
+
): JSONWebKeySet | undefined {
|
|
81
|
+
if (!cached) return undefined;
|
|
82
|
+
if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
|
|
83
|
+
if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return cached.jwks;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shouldRefetchCachedJwksWithoutKid(
|
|
90
|
+
error: unknown,
|
|
91
|
+
resolved: ResolvedJwks,
|
|
92
|
+
) {
|
|
93
|
+
const isRetryableNoKidFailure =
|
|
94
|
+
resolved.fromCache &&
|
|
95
|
+
!resolved.kid &&
|
|
96
|
+
(error instanceof joseErrors.JWKSNoMatchingKey ||
|
|
97
|
+
error instanceof joseErrors.JWSSignatureVerificationFailed);
|
|
98
|
+
if (!isRetryableNoKidFailure) return false;
|
|
99
|
+
if (!resolved.noKidRefetchedAt) return true;
|
|
100
|
+
return (
|
|
101
|
+
Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchJwks(
|
|
106
|
+
jwksFetch: JwksFetchOptions["jwksFetch"],
|
|
107
|
+
): Promise<JSONWebKeySet> {
|
|
108
|
+
const jwks =
|
|
109
|
+
typeof jwksFetch === "string"
|
|
110
|
+
? await betterFetch<JSONWebKeySet>(jwksFetch, {
|
|
111
|
+
headers: {
|
|
112
|
+
Accept: "application/json",
|
|
113
|
+
},
|
|
114
|
+
}).then(async (res) => {
|
|
115
|
+
if (res.error)
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Jwks failed: ${res.error.message ?? res.error.statusText}`,
|
|
118
|
+
);
|
|
119
|
+
return res.data;
|
|
120
|
+
})
|
|
121
|
+
: await jwksFetch();
|
|
122
|
+
if (!jwks) throw new Error("No jwks found");
|
|
123
|
+
return jwks;
|
|
124
|
+
}
|
|
30
125
|
|
|
31
126
|
export interface VerifyAccessTokenRemote {
|
|
32
127
|
/** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
|
|
@@ -41,6 +136,20 @@ export interface VerifyAccessTokenRemote {
|
|
|
41
136
|
* is also still active.
|
|
42
137
|
*/
|
|
43
138
|
force?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Accept introspection responses that omit the `aud` claim even when a
|
|
141
|
+
* required `audience` is configured in `verifyOptions`.
|
|
142
|
+
*
|
|
143
|
+
* By default verification fails closed: if you configure an `audience` and
|
|
144
|
+
* the introspection response has no `aud` (or a mismatching one), the token
|
|
145
|
+
* is rejected. Some authorization servers legitimately omit `aud` from
|
|
146
|
+
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
|
|
147
|
+
* this if you trust the issuer to bind the token to this resource through
|
|
148
|
+
* another mechanism, as it skips the audience check in that case.
|
|
149
|
+
*
|
|
150
|
+
* @default false
|
|
151
|
+
*/
|
|
152
|
+
allowMissingAudience?: boolean;
|
|
44
153
|
}
|
|
45
154
|
|
|
46
155
|
/**
|
|
@@ -50,21 +159,36 @@ export interface VerifyAccessTokenRemote {
|
|
|
50
159
|
*/
|
|
51
160
|
export async function verifyJwsAccessToken(
|
|
52
161
|
token: string,
|
|
53
|
-
opts: {
|
|
54
|
-
/** Jwks url or promise of a Jwks */
|
|
55
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
162
|
+
opts: JwksFetchOptions & {
|
|
56
163
|
/** Verify options */
|
|
57
164
|
verifyOptions: JWTVerifyOptions &
|
|
58
165
|
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
59
166
|
},
|
|
60
167
|
) {
|
|
61
168
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
169
|
+
const resolved = await getJwksForVerification(token, opts);
|
|
170
|
+
let jwt: JWTVerifyResult<JWTPayload>;
|
|
171
|
+
try {
|
|
172
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
173
|
+
token,
|
|
174
|
+
createLocalJWKSet(resolved.jwks),
|
|
175
|
+
opts.verifyOptions,
|
|
176
|
+
);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
|
|
179
|
+
const refreshed = await getJwksForVerification(token, {
|
|
180
|
+
...opts,
|
|
181
|
+
forceRefresh: true,
|
|
182
|
+
});
|
|
183
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
184
|
+
token,
|
|
185
|
+
createLocalJWKSet(refreshed.jwks),
|
|
186
|
+
opts.verifyOptions,
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
68
192
|
// Return the JWT payload in introspection format
|
|
69
193
|
// https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
|
|
70
194
|
if (jwt.payload.azp) {
|
|
@@ -77,12 +201,13 @@ export async function verifyJwsAccessToken(
|
|
|
77
201
|
}
|
|
78
202
|
}
|
|
79
203
|
|
|
80
|
-
export async function getJwks(
|
|
204
|
+
export async function getJwks(token: string, opts: JwksFetchOptions) {
|
|
205
|
+
return (await getJwksForVerification(token, opts)).jwks;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function getJwksForVerification(
|
|
81
209
|
token: string,
|
|
82
|
-
opts: {
|
|
83
|
-
/** Jwks url or promise of a Jwks */
|
|
84
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
85
|
-
},
|
|
210
|
+
opts: JwksFetchOptions & { forceRefresh?: boolean },
|
|
86
211
|
) {
|
|
87
212
|
// Attempt to decode the token and find a matching kid in jwks
|
|
88
213
|
let jwtHeaders: ProtectedHeaderParameters | undefined;
|
|
@@ -93,30 +218,65 @@ export async function getJwks(
|
|
|
93
218
|
throw new Error(error as unknown as string);
|
|
94
219
|
}
|
|
95
220
|
|
|
96
|
-
|
|
97
|
-
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
98
|
-
}
|
|
221
|
+
const kid = jwtHeaders.kid;
|
|
99
222
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
223
|
+
// Function sources have no usable identity of their own (callers pass
|
|
224
|
+
// fresh closures per request), so they are cached only under a stable
|
|
225
|
+
// caller-provided key object.
|
|
226
|
+
if (typeof opts.jwksFetch !== "string") {
|
|
227
|
+
const cacheKey = opts.jwksCacheKey;
|
|
228
|
+
if (!cacheKey) {
|
|
229
|
+
const jwks = await opts.jwksFetch();
|
|
230
|
+
if (!jwks) throw new Error("No jwks found");
|
|
231
|
+
return { jwks, fromCache: false, kid };
|
|
232
|
+
}
|
|
233
|
+
const cached = functionJwksCache.get(cacheKey);
|
|
234
|
+
const cachedJwks = opts.forceRefresh
|
|
235
|
+
? undefined
|
|
236
|
+
: getFreshJwksWithKid(cached, kid);
|
|
237
|
+
if (cachedJwks) {
|
|
238
|
+
return {
|
|
239
|
+
jwks: cachedJwks,
|
|
240
|
+
fromCache: true,
|
|
241
|
+
kid,
|
|
242
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const jwks = await opts.jwksFetch();
|
|
116
246
|
if (!jwks) throw new Error("No jwks found");
|
|
247
|
+
const fetchedAt = Date.now();
|
|
248
|
+
functionJwksCache.set(cacheKey, {
|
|
249
|
+
jwks,
|
|
250
|
+
fetchedAt,
|
|
251
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
252
|
+
});
|
|
253
|
+
return { jwks, fromCache: false, kid };
|
|
117
254
|
}
|
|
118
255
|
|
|
119
|
-
|
|
256
|
+
// The cache is scoped to `cacheKey`, so a token is only ever matched
|
|
257
|
+
// against the key set published by its own source.
|
|
258
|
+
const cacheKey = opts.jwksFetch;
|
|
259
|
+
const cached = jwksCache.get(cacheKey);
|
|
260
|
+
const cachedJwks = opts.forceRefresh
|
|
261
|
+
? undefined
|
|
262
|
+
: getFreshJwksWithKid(cached, kid);
|
|
263
|
+
if (!cachedJwks) {
|
|
264
|
+
const jwks = await fetchJwks(opts.jwksFetch);
|
|
265
|
+
const fetchedAt = Date.now();
|
|
266
|
+
jwksCache.set(cacheKey, {
|
|
267
|
+
jwks,
|
|
268
|
+
fetchedAt,
|
|
269
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
270
|
+
});
|
|
271
|
+
return { jwks, fromCache: false, kid };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
jwks: cachedJwks,
|
|
276
|
+
fromCache: true,
|
|
277
|
+
kid,
|
|
278
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
279
|
+
};
|
|
120
280
|
}
|
|
121
281
|
|
|
122
282
|
/**
|
|
@@ -201,13 +361,23 @@ export async function verifyAccessToken(
|
|
|
201
361
|
throw new APIError("UNAUTHORIZED", {
|
|
202
362
|
message: "token inactive",
|
|
203
363
|
});
|
|
204
|
-
// Verifies payload using verify options (token valid through introspect)
|
|
364
|
+
// Verifies payload using verify options (token valid through introspect).
|
|
365
|
+
// Audience is enforced by default: when `verifyOptions.audience` is set
|
|
366
|
+
// but the introspection response omits `aud` (or it mismatches),
|
|
367
|
+
// `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
|
|
368
|
+
// token issued for a different resource/client on the same issuer would
|
|
369
|
+
// also pass. Only drop the audience check when the caller has explicitly
|
|
370
|
+
// opted in via `remoteVerify.allowMissingAudience`.
|
|
205
371
|
try {
|
|
206
372
|
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
|
|
207
|
-
const { audience: _audience, ...
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
373
|
+
const { audience: _audience, ...verifyOptionsNoAudience } =
|
|
374
|
+
opts.verifyOptions;
|
|
375
|
+
const skipAudience =
|
|
376
|
+
!introspect.aud && opts.remoteVerify.allowMissingAudience === true;
|
|
377
|
+
const verify = UnsecuredJWT.decode(
|
|
378
|
+
unsecuredJwt,
|
|
379
|
+
skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
|
|
380
|
+
);
|
|
211
381
|
payload = verify.payload;
|
|
212
382
|
} catch (error) {
|
|
213
383
|
throw new Error(error as unknown as string);
|
|
@@ -24,6 +24,58 @@ export interface FacebookProfile {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
interface FacebookDebugTokenData {
|
|
28
|
+
app_id?: string;
|
|
29
|
+
is_valid?: boolean;
|
|
30
|
+
user_id?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate an opaque Facebook access token against the configured app.
|
|
35
|
+
*
|
|
36
|
+
* Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
|
|
37
|
+
* token minted for any Facebook app returns that app's profile. Without this
|
|
38
|
+
* check, a token issued to an unrelated app could be presented to this
|
|
39
|
+
* app's direct sign-in path and accepted as proof of identity. We call the
|
|
40
|
+
* `debug_token` endpoint and require the token to be valid, bound to one of the
|
|
41
|
+
* configured client ids, and tied to a user.
|
|
42
|
+
*
|
|
43
|
+
* @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
|
|
44
|
+
*
|
|
45
|
+
* @returns the inspected token's `user_id` when the token is valid and bound to
|
|
46
|
+
* the configured app, otherwise `null`.
|
|
47
|
+
*/
|
|
48
|
+
async function verifyFacebookAccessToken(
|
|
49
|
+
accessToken: string,
|
|
50
|
+
options: FacebookOptions,
|
|
51
|
+
): Promise<string | null> {
|
|
52
|
+
const primaryClientId = getPrimaryClientId(options.clientId);
|
|
53
|
+
if (!primaryClientId || !options.clientSecret) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const clientIds = Array.isArray(options.clientId)
|
|
57
|
+
? options.clientId
|
|
58
|
+
: [options.clientId];
|
|
59
|
+
const appAccessToken = `${primaryClientId}|${options.clientSecret}`;
|
|
60
|
+
const { data, error } = await betterFetch<{ data?: FacebookDebugTokenData }>(
|
|
61
|
+
"https://graph.facebook.com/debug_token",
|
|
62
|
+
{
|
|
63
|
+
query: {
|
|
64
|
+
input_token: accessToken,
|
|
65
|
+
access_token: appAccessToken,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
if (error || !data?.data) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const { is_valid, app_id, user_id } = data.data;
|
|
73
|
+
if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return user_id;
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
|
|
28
80
|
clientId: string | string[];
|
|
29
81
|
/**
|
|
@@ -117,7 +169,10 @@ export const facebook = (options: FacebookOptions) => {
|
|
|
117
169
|
}
|
|
118
170
|
|
|
119
171
|
/* access_token */
|
|
120
|
-
|
|
172
|
+
// An opaque access token carries no app binding of its own, so it
|
|
173
|
+
// must be validated against the configured app before it can be
|
|
174
|
+
// trusted as proof of identity.
|
|
175
|
+
return (await verifyFacebookAccessToken(token, options)) !== null;
|
|
121
176
|
},
|
|
122
177
|
refreshAccessToken: options.refreshAccessToken
|
|
123
178
|
? options.refreshAccessToken
|
|
@@ -178,6 +233,20 @@ export const facebook = (options: FacebookOptions) => {
|
|
|
178
233
|
};
|
|
179
234
|
}
|
|
180
235
|
|
|
236
|
+
// The profile is fetched with `accessToken`, which is the credential
|
|
237
|
+
// that actually proves identity here — and a separate request field
|
|
238
|
+
// from the `idToken`/token validated by `verifyIdToken`. Since an
|
|
239
|
+
// opaque token is not app-bound at `/me`, validate this exact token
|
|
240
|
+
// against the configured app before trusting the profile it returns.
|
|
241
|
+
const accessToken = token.accessToken;
|
|
242
|
+
if (!accessToken) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
|
|
246
|
+
if (!tokenUserId) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
181
250
|
const fields = [
|
|
182
251
|
"id",
|
|
183
252
|
"name",
|
|
@@ -190,13 +259,17 @@ export const facebook = (options: FacebookOptions) => {
|
|
|
190
259
|
{
|
|
191
260
|
auth: {
|
|
192
261
|
type: "Bearer",
|
|
193
|
-
token:
|
|
262
|
+
token: accessToken,
|
|
194
263
|
},
|
|
195
264
|
},
|
|
196
265
|
);
|
|
197
266
|
if (error) {
|
|
198
267
|
return null;
|
|
199
268
|
}
|
|
269
|
+
// Bind the validated token to the profile it returned.
|
|
270
|
+
if (profile.id !== tokenUserId) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
200
273
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
201
274
|
return {
|
|
202
275
|
user: {
|
|
@@ -48,7 +48,12 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
|
|
|
48
48
|
*/
|
|
49
49
|
display?: ("page" | "popup" | "touch" | "wap") | undefined;
|
|
50
50
|
/**
|
|
51
|
-
* The hosted domain
|
|
51
|
+
* The hosted domain (Google Workspace) the user must belong to.
|
|
52
|
+
*
|
|
53
|
+
* This is sent to Google as the `hd` authorization hint and, when set, is
|
|
54
|
+
* also enforced against the `hd` claim of the returned id token/profile.
|
|
55
|
+
* Sign-in is rejected when the claim is missing or does not match, so this
|
|
56
|
+
* can be used to restrict sign-in to a Workspace domain.
|
|
52
57
|
*/
|
|
53
58
|
hd?: string | undefined;
|
|
54
59
|
}
|
|
@@ -147,6 +152,15 @@ export const google = (options: GoogleOptions) => {
|
|
|
147
152
|
return false;
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
// Google's `hd` authorization parameter is only a UI hint and can
|
|
156
|
+
// be removed or changed by the user. When a hosted domain is
|
|
157
|
+
// configured, the `hd` claim in the verified id token is the
|
|
158
|
+
// authoritative value and must match, otherwise accounts outside
|
|
159
|
+
// the workspace domain would be accepted.
|
|
160
|
+
if (options.hd && jwtClaims.hd !== options.hd) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
return true;
|
|
151
165
|
} catch {
|
|
152
166
|
return false;
|
|
@@ -160,6 +174,18 @@ export const google = (options: GoogleOptions) => {
|
|
|
160
174
|
return null;
|
|
161
175
|
}
|
|
162
176
|
const user = decodeJwt(token.idToken) as GoogleProfile;
|
|
177
|
+
// Enforce the configured hosted domain on the callback profile path
|
|
178
|
+
// as well. The `hd` claim must be present and match, since the
|
|
179
|
+
// authorization-time `hd` hint does not restrict which account signs
|
|
180
|
+
// in.
|
|
181
|
+
if (options.hd && user.hd !== options.hd) {
|
|
182
|
+
logger.error(
|
|
183
|
+
`Google sign-in rejected: id token hosted domain (hd) "${
|
|
184
|
+
user.hd ?? "<missing>"
|
|
185
|
+
}" does not match the configured "hd" option "${options.hd}".`,
|
|
186
|
+
);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
163
189
|
const userMap = await options.mapProfileToUser?.(user);
|
|
164
190
|
return {
|
|
165
191
|
user: {
|
|
@@ -11,6 +11,14 @@ import {
|
|
|
11
11
|
validateAuthorizationCode,
|
|
12
12
|
} from "../oauth2";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
|
|
16
|
+
* personal-account token carries it as the `tid` claim, so it distinguishes the
|
|
17
|
+
* consumer account class from work/school tenants.
|
|
18
|
+
* @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
|
|
19
|
+
*/
|
|
20
|
+
const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
|
|
21
|
+
|
|
14
22
|
/**
|
|
15
23
|
* @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
|
|
16
24
|
*/
|
|
@@ -143,7 +151,14 @@ export interface MicrosoftOptions
|
|
|
143
151
|
|
|
144
152
|
export const microsoft = (options: MicrosoftOptions) => {
|
|
145
153
|
const tenant = options.tenantId || "common";
|
|
146
|
-
|
|
154
|
+
// Trim any trailing slash so endpoint URLs and the issuer comparison below
|
|
155
|
+
// never produce a double slash (e.g. a configured `https://host/` would make
|
|
156
|
+
// the expected issuer `https://host//<tid>/v2.0` and reject every token). A
|
|
157
|
+
// loop avoids a trailing-slash regex, which is a polynomial-ReDoS shape.
|
|
158
|
+
let authority = options.authority || "https://login.microsoftonline.com";
|
|
159
|
+
while (authority.endsWith("/")) {
|
|
160
|
+
authority = authority.slice(0, -1);
|
|
161
|
+
}
|
|
147
162
|
const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
|
|
148
163
|
const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
|
|
149
164
|
return {
|
|
@@ -229,6 +244,30 @@ export const microsoft = (options: MicrosoftOptions) => {
|
|
|
229
244
|
return false;
|
|
230
245
|
}
|
|
231
246
|
|
|
247
|
+
// The multi-tenant endpoints (common/organizations/consumers) skip
|
|
248
|
+
// jose's issuer check above because the issuer varies per tenant, and
|
|
249
|
+
// the organizations and consumers JWKS sets overlap. Enforce the tenant
|
|
250
|
+
// binding explicitly so a token from a disallowed account class cannot
|
|
251
|
+
// pass: the issuer must name the token's own tenant, and the account
|
|
252
|
+
// class must match the configured restriction.
|
|
253
|
+
// @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
|
|
254
|
+
const tid = jwtClaims.tid;
|
|
255
|
+
if (
|
|
256
|
+
typeof tid !== "string" ||
|
|
257
|
+
jwtClaims.iss !== `${authority}/${tid}/v2.0`
|
|
258
|
+
) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
tenant === "organizations" &&
|
|
263
|
+
tid === MICROSOFT_CONSUMER_TENANT_ID
|
|
264
|
+
) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
232
271
|
return true;
|
|
233
272
|
} catch (error) {
|
|
234
273
|
logger.error("Failed to verify ID token:", error);
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { base64 } from "@better-auth/utils/base64";
|
|
2
2
|
import { betterFetch } from "@better-fetch/fetch";
|
|
3
|
-
import {
|
|
3
|
+
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
4
4
|
import { logger } from "../env";
|
|
5
|
-
import { BetterAuthError } from "../error";
|
|
5
|
+
import { APIError, BetterAuthError } from "../error";
|
|
6
6
|
import type { OAuthProvider, ProviderOptions } from "../oauth2";
|
|
7
7
|
import { createAuthorizationURL } from "../oauth2";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* ID token signing algorithms advertised by PayPal's OpenID configuration.
|
|
11
|
+
* Anything outside this allowlist is rejected so each token is only ever
|
|
12
|
+
* verified with the algorithm it was issued for.
|
|
13
|
+
*
|
|
14
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
15
|
+
*/
|
|
16
|
+
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"] as const;
|
|
17
|
+
|
|
9
18
|
export interface PayPalProfile {
|
|
10
19
|
user_id: string;
|
|
11
20
|
name: string;
|
|
@@ -75,6 +84,19 @@ export const paypal = (options: PayPalOptions) => {
|
|
|
75
84
|
? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo"
|
|
76
85
|
: "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
|
|
77
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Issuer and JWKS endpoints used to cryptographically verify ID tokens.
|
|
89
|
+
*
|
|
90
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
91
|
+
*/
|
|
92
|
+
const issuer = isSandbox
|
|
93
|
+
? "https://www.sandbox.paypal.com"
|
|
94
|
+
: "https://www.paypal.com";
|
|
95
|
+
|
|
96
|
+
const jwksEndpoint = isSandbox
|
|
97
|
+
? "https://api.sandbox.paypal.com/v1/oauth2/certs"
|
|
98
|
+
: "https://api.paypal.com/v1/oauth2/certs";
|
|
99
|
+
|
|
78
100
|
return {
|
|
79
101
|
id: "paypal",
|
|
80
102
|
name: "PayPal",
|
|
@@ -201,9 +223,48 @@ export const paypal = (options: PayPalOptions) => {
|
|
|
201
223
|
if (options.verifyIdToken) {
|
|
202
224
|
return options.verifyIdToken(token, nonce);
|
|
203
225
|
}
|
|
226
|
+
|
|
227
|
+
// Cryptographically verify the ID token. Decoding alone is not enough:
|
|
228
|
+
// the signature, issuer, audience and expiration must all be checked
|
|
229
|
+
// before the token's claims can be relied on as proof of identity.
|
|
230
|
+
// See https://www.paypal.com/.well-known/openid-configuration
|
|
231
|
+
|
|
204
232
|
try {
|
|
205
|
-
const
|
|
206
|
-
return
|
|
233
|
+
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
|
|
234
|
+
if (!jwtAlg) return false;
|
|
235
|
+
if (
|
|
236
|
+
!PAYPAL_ID_TOKEN_ALGORITHMS.includes(
|
|
237
|
+
jwtAlg as (typeof PAYPAL_ID_TOKEN_ALGORITHMS)[number],
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// PayPal can sign ID tokens either asymmetrically (RS256, verified
|
|
244
|
+
// against the published JWKS) or symmetrically (HS256, verified with
|
|
245
|
+
// the client secret). Selecting the key by algorithm keeps the two
|
|
246
|
+
// paths separate so each algorithm is only verified with its
|
|
247
|
+
// corresponding key type.
|
|
248
|
+
const key =
|
|
249
|
+
jwtAlg === "HS256"
|
|
250
|
+
? new TextEncoder().encode(options.clientSecret)
|
|
251
|
+
: kid
|
|
252
|
+
? await getPayPalPublicKey(kid, jwksEndpoint)
|
|
253
|
+
: undefined;
|
|
254
|
+
if (!key) return false;
|
|
255
|
+
|
|
256
|
+
const { payload: jwtClaims } = await jwtVerify(token, key, {
|
|
257
|
+
algorithms: [jwtAlg],
|
|
258
|
+
issuer,
|
|
259
|
+
audience: options.clientId,
|
|
260
|
+
maxTokenAge: "1h",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (nonce && jwtClaims.nonce !== nonce) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return true;
|
|
207
268
|
} catch (error) {
|
|
208
269
|
logger.error("Failed to verify PayPal ID token:", error);
|
|
209
270
|
return false;
|
|
@@ -261,3 +322,29 @@ export const paypal = (options: PayPalOptions) => {
|
|
|
261
322
|
options,
|
|
262
323
|
} satisfies OAuthProvider<PayPalProfile>;
|
|
263
324
|
};
|
|
325
|
+
|
|
326
|
+
export const getPayPalPublicKey = async (kid: string, jwksUri: string) => {
|
|
327
|
+
const { data } = await betterFetch<{
|
|
328
|
+
keys: Array<{
|
|
329
|
+
kid: string;
|
|
330
|
+
alg: string;
|
|
331
|
+
kty: string;
|
|
332
|
+
use: string;
|
|
333
|
+
n: string;
|
|
334
|
+
e: string;
|
|
335
|
+
}>;
|
|
336
|
+
}>(jwksUri);
|
|
337
|
+
|
|
338
|
+
if (!data?.keys) {
|
|
339
|
+
throw new APIError("BAD_REQUEST", {
|
|
340
|
+
message: "Keys not found",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const jwk = data.keys.find((key) => key.kid === kid);
|
|
345
|
+
if (!jwk) {
|
|
346
|
+
throw new Error(`JWK with kid ${kid} not found`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return await importJWK(jwk, jwk.alg);
|
|
350
|
+
};
|
|
@@ -104,15 +104,19 @@ export const reddit = (options: RedditOptions) => {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
107
|
-
|
|
107
|
+
// Reddit's identity scope does not return an email. Synthesize a stable,
|
|
108
|
+
// non-routable placeholder (RFC 2606 `.invalid`) keyed to the user's
|
|
109
|
+
// Reddit id rather than the routable `reddit.com`, which could collide
|
|
110
|
+
// with a real address. Left unverified; `mapProfileToUser` can override.
|
|
111
|
+
const email = userMap?.email || `${profile.id}@reddit.invalid`;
|
|
108
112
|
return {
|
|
109
113
|
user: {
|
|
110
114
|
id: profile.id,
|
|
111
115
|
name: profile.name,
|
|
112
|
-
email: profile.oauth_client_id,
|
|
113
|
-
emailVerified: profile.has_verified_email,
|
|
114
116
|
image: profile.icon_img?.split("?")[0]!,
|
|
115
117
|
...userMap,
|
|
118
|
+
email,
|
|
119
|
+
emailVerified: userMap?.emailVerified ?? false,
|
|
116
120
|
},
|
|
117
121
|
data: profile,
|
|
118
122
|
};
|