@better-auth/core 1.6.15 → 1.6.16
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/context/global.mjs +1 -1
- package/dist/env/env-impl.mjs +1 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/verify.d.mts +14 -0
- package/dist/oauth2/verify.mjs +23 -7
- 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/paypal.d.mts +2 -1
- package/dist/social-providers/paypal.mjs +38 -4
- package/dist/social-providers/reddit.mjs +4 -3
- package/package.json +5 -5
- package/src/env/env-impl.ts +1 -2
- package/src/oauth2/verify.ts +62 -11
- package/src/social-providers/facebook.ts +75 -2
- package/src/social-providers/google.ts +27 -1
- package/src/social-providers/paypal.ts +91 -4
- package/src/social-providers/reddit.ts +3 -3
package/dist/context/global.mjs
CHANGED
package/dist/env/env-impl.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const env = new Proxy(_envShim, {
|
|
|
27
27
|
function toBoolean(val) {
|
|
28
28
|
return val ? val !== "false" : false;
|
|
29
29
|
}
|
|
30
|
-
const nodeENV =
|
|
30
|
+
const nodeENV = env.NODE_ENV ?? "";
|
|
31
31
|
/** Detect if `NODE_ENV` environment variable is `production` */
|
|
32
32
|
const isProduction = nodeENV === "production";
|
|
33
33
|
/** Detect if `NODE_ENV` environment variable is `dev` or `development` */
|
|
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
|
|
|
2
2
|
import { getOpenTelemetryAPI } from "./api.mjs";
|
|
3
3
|
//#region src/instrumentation/tracer.ts
|
|
4
4
|
const INSTRUMENTATION_SCOPE = "better-auth";
|
|
5
|
-
const INSTRUMENTATION_VERSION = "1.6.
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.6.16";
|
|
6
6
|
/**
|
|
7
7
|
* Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
|
|
8
8
|
* callbacks). These are APIErrors with 3xx status codes and should not be
|
package/dist/oauth2/verify.d.mts
CHANGED
|
@@ -14,6 +14,20 @@ interface VerifyAccessTokenRemote {
|
|
|
14
14
|
* is also still active.
|
|
15
15
|
*/
|
|
16
16
|
force?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Accept introspection responses that omit the `aud` claim even when a
|
|
19
|
+
* required `audience` is configured in `verifyOptions`.
|
|
20
|
+
*
|
|
21
|
+
* By default verification fails closed: if you configure an `audience` and
|
|
22
|
+
* the introspection response has no `aud` (or a mismatching one), the token
|
|
23
|
+
* is rejected. Some authorization servers legitimately omit `aud` from
|
|
24
|
+
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
|
|
25
|
+
* this if you trust the issuer to bind the token to this resource through
|
|
26
|
+
* another mechanism, as it skips the audience check in that case.
|
|
27
|
+
*
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
allowMissingAudience?: boolean;
|
|
17
31
|
}
|
|
18
32
|
/**
|
|
19
33
|
* Performs local verification of an access token for your APIs.
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -11,8 +11,13 @@ const joseInfrastructureErrorCodes = new Set([
|
|
|
11
11
|
function isJoseInfrastructureError(error) {
|
|
12
12
|
return joseInfrastructureErrorCodes.has(error.code);
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const jwksCache = /* @__PURE__ */ new Map();
|
|
15
|
+
/**
|
|
16
|
+
* How long a cached JWKS is trusted before it is refetched
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const JWKS_CACHE_TTL_MS = 300 * 1e3;
|
|
16
21
|
/**
|
|
17
22
|
* Performs local verification of an access token for your APIs.
|
|
18
23
|
*
|
|
@@ -37,14 +42,24 @@ async function getJwks(token, opts) {
|
|
|
37
42
|
throw new Error(error);
|
|
38
43
|
}
|
|
39
44
|
if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const kid = jwtHeaders.kid;
|
|
46
|
+
const cacheKey = opts.jwksFetch;
|
|
47
|
+
const cached = jwksCache.get(cacheKey);
|
|
48
|
+
const isFresh = cached ? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS : false;
|
|
49
|
+
const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
|
|
50
|
+
if (!cached || !isFresh || !hasKid) {
|
|
51
|
+
const jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
42
52
|
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
43
53
|
return res.data;
|
|
44
54
|
}) : await opts.jwksFetch();
|
|
45
55
|
if (!jwks) throw new Error("No jwks found");
|
|
56
|
+
jwksCache.set(cacheKey, {
|
|
57
|
+
jwks,
|
|
58
|
+
fetchedAt: Date.now()
|
|
59
|
+
});
|
|
60
|
+
return jwks;
|
|
46
61
|
}
|
|
47
|
-
return jwks;
|
|
62
|
+
return cached.jwks;
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
50
65
|
* Performs local verification of an access token for your API.
|
|
@@ -85,8 +100,9 @@ async function verifyAccessToken(token, opts) {
|
|
|
85
100
|
if (!introspect.active) throw new APIError("UNAUTHORIZED", { message: "token inactive" });
|
|
86
101
|
try {
|
|
87
102
|
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
|
|
88
|
-
const { audience: _audience, ...
|
|
89
|
-
|
|
103
|
+
const { audience: _audience, ...verifyOptionsNoAudience } = opts.verifyOptions;
|
|
104
|
+
const skipAudience = !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
|
|
105
|
+
payload = UnsecuredJWT.decode(unsecuredJwt, skipAudience ? verifyOptionsNoAudience : opts.verifyOptions).payload;
|
|
90
106
|
} catch (error) {
|
|
91
107
|
throw new Error(error);
|
|
92
108
|
}
|
|
@@ -7,6 +7,34 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
|
|
|
7
7
|
import { betterFetch } from "@better-fetch/fetch";
|
|
8
8
|
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
9
9
|
//#region src/social-providers/facebook.ts
|
|
10
|
+
/**
|
|
11
|
+
* Validate an opaque Facebook access token against the configured app.
|
|
12
|
+
*
|
|
13
|
+
* Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
|
|
14
|
+
* token minted for any Facebook app returns that app's profile. Without this
|
|
15
|
+
* check, a token issued to an unrelated app could be presented to this
|
|
16
|
+
* app's direct sign-in path and accepted as proof of identity. We call the
|
|
17
|
+
* `debug_token` endpoint and require the token to be valid, bound to one of the
|
|
18
|
+
* configured client ids, and tied to a user.
|
|
19
|
+
*
|
|
20
|
+
* @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
|
|
21
|
+
*
|
|
22
|
+
* @returns the inspected token's `user_id` when the token is valid and bound to
|
|
23
|
+
* the configured app, otherwise `null`.
|
|
24
|
+
*/
|
|
25
|
+
async function verifyFacebookAccessToken(accessToken, options) {
|
|
26
|
+
const primaryClientId = getPrimaryClientId(options.clientId);
|
|
27
|
+
if (!primaryClientId || !options.clientSecret) return null;
|
|
28
|
+
const clientIds = Array.isArray(options.clientId) ? options.clientId : [options.clientId];
|
|
29
|
+
const { data, error } = await betterFetch("https://graph.facebook.com/debug_token", { query: {
|
|
30
|
+
input_token: accessToken,
|
|
31
|
+
access_token: `${primaryClientId}|${options.clientSecret}`
|
|
32
|
+
} });
|
|
33
|
+
if (error || !data?.data) return null;
|
|
34
|
+
const { is_valid, app_id, user_id } = data.data;
|
|
35
|
+
if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) return null;
|
|
36
|
+
return user_id;
|
|
37
|
+
}
|
|
10
38
|
const facebook = (options) => {
|
|
11
39
|
return {
|
|
12
40
|
id: "facebook",
|
|
@@ -52,7 +80,7 @@ const facebook = (options) => {
|
|
|
52
80
|
} catch {
|
|
53
81
|
return false;
|
|
54
82
|
}
|
|
55
|
-
return
|
|
83
|
+
return await verifyFacebookAccessToken(token, options) !== null;
|
|
56
84
|
},
|
|
57
85
|
refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => {
|
|
58
86
|
return refreshAccessToken({
|
|
@@ -93,6 +121,10 @@ const facebook = (options) => {
|
|
|
93
121
|
data: profile
|
|
94
122
|
};
|
|
95
123
|
}
|
|
124
|
+
const accessToken = token.accessToken;
|
|
125
|
+
if (!accessToken) return null;
|
|
126
|
+
const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
|
|
127
|
+
if (!tokenUserId) return null;
|
|
96
128
|
const { data: profile, error } = await betterFetch("https://graph.facebook.com/me?fields=" + [
|
|
97
129
|
"id",
|
|
98
130
|
"name",
|
|
@@ -101,9 +133,10 @@ const facebook = (options) => {
|
|
|
101
133
|
...options?.fields || []
|
|
102
134
|
].join(","), { auth: {
|
|
103
135
|
type: "Bearer",
|
|
104
|
-
token:
|
|
136
|
+
token: accessToken
|
|
105
137
|
} });
|
|
106
138
|
if (error) return null;
|
|
139
|
+
if (profile.id !== tokenUserId) return null;
|
|
107
140
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
108
141
|
return {
|
|
109
142
|
user: {
|
|
@@ -37,7 +37,12 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
|
|
|
37
37
|
*/
|
|
38
38
|
display?: ("page" | "popup" | "touch" | "wap") | undefined;
|
|
39
39
|
/**
|
|
40
|
-
* The hosted domain
|
|
40
|
+
* The hosted domain (Google Workspace) the user must belong to.
|
|
41
|
+
*
|
|
42
|
+
* This is sent to Google as the `hd` authorization hint and, when set, is
|
|
43
|
+
* also enforced against the `hd` claim of the returned id token/profile.
|
|
44
|
+
* Sign-in is rejected when the claim is missing or does not match, so this
|
|
45
|
+
* can be used to restrict sign-in to a Workspace domain.
|
|
41
46
|
*/
|
|
42
47
|
hd?: string | undefined;
|
|
43
48
|
}
|
|
@@ -73,6 +73,7 @@ const google = (options) => {
|
|
|
73
73
|
maxTokenAge: "1h"
|
|
74
74
|
});
|
|
75
75
|
if (nonce && jwtClaims.nonce !== nonce) return false;
|
|
76
|
+
if (options.hd && jwtClaims.hd !== options.hd) return false;
|
|
76
77
|
return true;
|
|
77
78
|
} catch {
|
|
78
79
|
return false;
|
|
@@ -82,6 +83,10 @@ const google = (options) => {
|
|
|
82
83
|
if (options.getUserInfo) return options.getUserInfo(token);
|
|
83
84
|
if (!token.idToken) return null;
|
|
84
85
|
const user = decodeJwt(token.idToken);
|
|
86
|
+
if (options.hd && user.hd !== options.hd) {
|
|
87
|
+
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
85
90
|
const userMap = await options.mapProfileToUser?.(user);
|
|
86
91
|
return {
|
|
87
92
|
user: {
|
|
@@ -28,7 +28,7 @@ import { KakaoOptions, KakaoProfile, kakao } from "./kakao.mjs";
|
|
|
28
28
|
import { NaverOptions, NaverProfile, naver } from "./naver.mjs";
|
|
29
29
|
import { LineIdTokenPayload, LineOptions, LineUserInfo, line } from "./line.mjs";
|
|
30
30
|
import { PaybinOptions, PaybinProfile, paybin } from "./paybin.mjs";
|
|
31
|
-
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal } from "./paypal.mjs";
|
|
31
|
+
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal } from "./paypal.mjs";
|
|
32
32
|
import { PolarOptions, PolarProfile, polar } from "./polar.mjs";
|
|
33
33
|
import { RailwayOptions, RailwayProfile, railway } from "./railway.mjs";
|
|
34
34
|
import { VercelOptions, VercelProfile, vercel } from "./vercel.mjs";
|
|
@@ -1831,4 +1831,4 @@ type SocialProviders = { [K in SocialProviderList[number]]?: AwaitableFunction<P
|
|
|
1831
1831
|
}> };
|
|
1832
1832
|
type SocialProviderList = typeof socialProviderList;
|
|
1833
1833
|
//#endregion
|
|
1834
|
-
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
1834
|
+
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
@@ -18,7 +18,7 @@ import { getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs";
|
|
|
18
18
|
import { naver } from "./naver.mjs";
|
|
19
19
|
import { notion } from "./notion.mjs";
|
|
20
20
|
import { paybin } from "./paybin.mjs";
|
|
21
|
-
import { paypal } from "./paypal.mjs";
|
|
21
|
+
import { getPayPalPublicKey, paypal } from "./paypal.mjs";
|
|
22
22
|
import { polar } from "./polar.mjs";
|
|
23
23
|
import { railway } from "./railway.mjs";
|
|
24
24
|
import { reddit } from "./reddit.mjs";
|
|
@@ -75,4 +75,4 @@ const socialProviders = {
|
|
|
75
75
|
const socialProviderList = Object.keys(socialProviders);
|
|
76
76
|
const SocialProviderListEnum = z.enum(socialProviderList).or(z.string());
|
|
77
77
|
//#endregion
|
|
78
|
-
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
78
|
+
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
@@ -125,5 +125,6 @@ declare const paypal: (options: PayPalOptions) => {
|
|
|
125
125
|
} | null>;
|
|
126
126
|
options: PayPalOptions;
|
|
127
127
|
};
|
|
128
|
+
declare const getPayPalPublicKey: (kid: string, jwksUri: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
|
|
128
129
|
//#endregion
|
|
129
|
-
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal };
|
|
130
|
+
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal };
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import { BetterAuthError } from "../error/index.mjs";
|
|
1
|
+
import { APIError, BetterAuthError } from "../error/index.mjs";
|
|
2
2
|
import { logger } from "../env/logger.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
|
|
4
4
|
import { base64 } from "@better-auth/utils/base64";
|
|
5
5
|
import { betterFetch } from "@better-fetch/fetch";
|
|
6
|
-
import {
|
|
6
|
+
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
7
7
|
//#region src/social-providers/paypal.ts
|
|
8
|
+
/**
|
|
9
|
+
* ID token signing algorithms advertised by PayPal's OpenID configuration.
|
|
10
|
+
* Anything outside this allowlist is rejected so each token is only ever
|
|
11
|
+
* verified with the algorithm it was issued for.
|
|
12
|
+
*
|
|
13
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
14
|
+
*/
|
|
15
|
+
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"];
|
|
8
16
|
const paypal = (options) => {
|
|
9
17
|
const isSandbox = (options.environment || "sandbox") === "sandbox";
|
|
10
18
|
const authorizationEndpoint = isSandbox ? "https://www.sandbox.paypal.com/signin/authorize" : "https://www.paypal.com/signin/authorize";
|
|
11
19
|
const tokenEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/oauth2/token" : "https://api-m.paypal.com/v1/oauth2/token";
|
|
12
20
|
const userInfoEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo" : "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
|
|
21
|
+
/**
|
|
22
|
+
* Issuer and JWKS endpoints used to cryptographically verify ID tokens.
|
|
23
|
+
*
|
|
24
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
25
|
+
*/
|
|
26
|
+
const issuer = isSandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com";
|
|
27
|
+
const jwksEndpoint = isSandbox ? "https://api.sandbox.paypal.com/v1/oauth2/certs" : "https://api.paypal.com/v1/oauth2/certs";
|
|
13
28
|
return {
|
|
14
29
|
id: "paypal",
|
|
15
30
|
name: "PayPal",
|
|
@@ -94,7 +109,19 @@ const paypal = (options) => {
|
|
|
94
109
|
if (options.disableIdTokenSignIn) return false;
|
|
95
110
|
if (options.verifyIdToken) return options.verifyIdToken(token, nonce);
|
|
96
111
|
try {
|
|
97
|
-
|
|
112
|
+
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
|
|
113
|
+
if (!jwtAlg) return false;
|
|
114
|
+
if (!PAYPAL_ID_TOKEN_ALGORITHMS.includes(jwtAlg)) return false;
|
|
115
|
+
const key = jwtAlg === "HS256" ? new TextEncoder().encode(options.clientSecret) : kid ? await getPayPalPublicKey(kid, jwksEndpoint) : void 0;
|
|
116
|
+
if (!key) return false;
|
|
117
|
+
const { payload: jwtClaims } = await jwtVerify(token, key, {
|
|
118
|
+
algorithms: [jwtAlg],
|
|
119
|
+
issuer,
|
|
120
|
+
audience: options.clientId,
|
|
121
|
+
maxTokenAge: "1h"
|
|
122
|
+
});
|
|
123
|
+
if (nonce && jwtClaims.nonce !== nonce) return false;
|
|
124
|
+
return true;
|
|
98
125
|
} catch (error) {
|
|
99
126
|
logger.error("Failed to verify PayPal ID token:", error);
|
|
100
127
|
return false;
|
|
@@ -136,5 +163,12 @@ const paypal = (options) => {
|
|
|
136
163
|
options
|
|
137
164
|
};
|
|
138
165
|
};
|
|
166
|
+
const getPayPalPublicKey = async (kid, jwksUri) => {
|
|
167
|
+
const { data } = await betterFetch(jwksUri);
|
|
168
|
+
if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" });
|
|
169
|
+
const jwk = data.keys.find((key) => key.kid === kid);
|
|
170
|
+
if (!jwk) throw new Error(`JWK with kid ${kid} not found`);
|
|
171
|
+
return await importJWK(jwk, jwk.alg);
|
|
172
|
+
};
|
|
139
173
|
//#endregion
|
|
140
|
-
export { paypal };
|
|
174
|
+
export { getPayPalPublicKey, paypal };
|
|
@@ -61,14 +61,15 @@ const reddit = (options) => {
|
|
|
61
61
|
} });
|
|
62
62
|
if (error) return null;
|
|
63
63
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
64
|
+
const email = userMap?.email || `${profile.id}@reddit.com`;
|
|
64
65
|
return {
|
|
65
66
|
user: {
|
|
66
67
|
id: profile.id,
|
|
67
68
|
name: profile.name,
|
|
68
|
-
email: profile.oauth_client_id,
|
|
69
|
-
emailVerified: profile.has_verified_email,
|
|
70
69
|
image: profile.icon_img?.split("?")[0],
|
|
71
|
-
...userMap
|
|
70
|
+
...userMap,
|
|
71
|
+
email,
|
|
72
|
+
emailVerified: userMap?.emailVerified ?? false
|
|
72
73
|
},
|
|
73
74
|
data: profile
|
|
74
75
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.16",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -153,11 +153,11 @@
|
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
155
|
"@better-auth/utils": "0.4.1",
|
|
156
|
-
"@better-fetch/fetch": "1.
|
|
156
|
+
"@better-fetch/fetch": "1.2.2",
|
|
157
157
|
"@opentelemetry/api": "^1.9.0",
|
|
158
158
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
159
159
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
160
|
-
"better-call": "1.3.
|
|
160
|
+
"better-call": "1.3.6",
|
|
161
161
|
"@cloudflare/workers-types": "^4.20250121.0",
|
|
162
162
|
"jose": "^6.1.3",
|
|
163
163
|
"kysely": "^0.28.17 || ^0.29.0",
|
|
@@ -166,9 +166,9 @@
|
|
|
166
166
|
},
|
|
167
167
|
"peerDependencies": {
|
|
168
168
|
"@better-auth/utils": "0.4.1",
|
|
169
|
-
"@better-fetch/fetch": "1.
|
|
169
|
+
"@better-fetch/fetch": "1.2.2",
|
|
170
170
|
"@opentelemetry/api": "^1.9.0",
|
|
171
|
-
"better-call": "1.3.
|
|
171
|
+
"better-call": "1.3.6",
|
|
172
172
|
"@cloudflare/workers-types": ">=4",
|
|
173
173
|
"jose": "^6.1.0",
|
|
174
174
|
"kysely": "^0.28.5 || ^0.29.0",
|
package/src/env/env-impl.ts
CHANGED
|
@@ -46,8 +46,7 @@ function toBoolean(val: boolean | string | undefined) {
|
|
|
46
46
|
return val ? val !== "false" : false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export const nodeENV =
|
|
50
|
-
(typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
|
|
49
|
+
export const nodeENV = env.NODE_ENV ?? "";
|
|
51
50
|
|
|
52
51
|
/** Detect if `NODE_ENV` environment variable is `production` */
|
|
53
52
|
export const isProduction = nodeENV === "production";
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -25,8 +25,22 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
|
25
25
|
return joseInfrastructureErrorCodes.has(error.code);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
interface JwksCacheEntry {
|
|
29
|
+
jwks: JSONWebKeySet;
|
|
30
|
+
fetchedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const jwksCache = new Map<
|
|
34
|
+
string | (() => Promise<JSONWebKeySet | undefined>),
|
|
35
|
+
JwksCacheEntry
|
|
36
|
+
>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long a cached JWKS is trusted before it is refetched
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
30
44
|
|
|
31
45
|
export interface VerifyAccessTokenRemote {
|
|
32
46
|
/** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
|
|
@@ -41,6 +55,20 @@ export interface VerifyAccessTokenRemote {
|
|
|
41
55
|
* is also still active.
|
|
42
56
|
*/
|
|
43
57
|
force?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Accept introspection responses that omit the `aud` claim even when a
|
|
60
|
+
* required `audience` is configured in `verifyOptions`.
|
|
61
|
+
*
|
|
62
|
+
* By default verification fails closed: if you configure an `audience` and
|
|
63
|
+
* the introspection response has no `aud` (or a mismatching one), the token
|
|
64
|
+
* is rejected. Some authorization servers legitimately omit `aud` from
|
|
65
|
+
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
|
|
66
|
+
* this if you trust the issuer to bind the token to this resource through
|
|
67
|
+
* another mechanism, as it skips the audience check in that case.
|
|
68
|
+
*
|
|
69
|
+
* @default false
|
|
70
|
+
*/
|
|
71
|
+
allowMissingAudience?: boolean;
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
/**
|
|
@@ -96,10 +124,21 @@ export async function getJwks(
|
|
|
96
124
|
if (!jwtHeaders.kid) {
|
|
97
125
|
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
98
126
|
}
|
|
127
|
+
const kid = jwtHeaders.kid;
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
129
|
+
const cacheKey = opts.jwksFetch;
|
|
130
|
+
const cached = jwksCache.get(cacheKey);
|
|
131
|
+
const isFresh = cached
|
|
132
|
+
? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS
|
|
133
|
+
: false;
|
|
134
|
+
const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
|
|
135
|
+
|
|
136
|
+
// Refetch when this source has no cached set, the cached set has expired, or
|
|
137
|
+
// it does not contain the token's kid (e.g. a newly rotated-in key). The
|
|
138
|
+
// cache is scoped to `cacheKey`, so a token is only ever matched against the
|
|
139
|
+
// key set published by its own source.
|
|
140
|
+
if (!cached || !isFresh || !hasKid) {
|
|
141
|
+
const jwks =
|
|
103
142
|
typeof opts.jwksFetch === "string"
|
|
104
143
|
? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
|
|
105
144
|
headers: {
|
|
@@ -114,9 +153,11 @@ export async function getJwks(
|
|
|
114
153
|
})
|
|
115
154
|
: await opts.jwksFetch();
|
|
116
155
|
if (!jwks) throw new Error("No jwks found");
|
|
156
|
+
jwksCache.set(cacheKey, { jwks, fetchedAt: Date.now() });
|
|
157
|
+
return jwks;
|
|
117
158
|
}
|
|
118
159
|
|
|
119
|
-
return jwks;
|
|
160
|
+
return cached.jwks;
|
|
120
161
|
}
|
|
121
162
|
|
|
122
163
|
/**
|
|
@@ -201,13 +242,23 @@ export async function verifyAccessToken(
|
|
|
201
242
|
throw new APIError("UNAUTHORIZED", {
|
|
202
243
|
message: "token inactive",
|
|
203
244
|
});
|
|
204
|
-
// Verifies payload using verify options (token valid through introspect)
|
|
245
|
+
// Verifies payload using verify options (token valid through introspect).
|
|
246
|
+
// Audience is enforced by default: when `verifyOptions.audience` is set
|
|
247
|
+
// but the introspection response omits `aud` (or it mismatches),
|
|
248
|
+
// `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
|
|
249
|
+
// token issued for a different resource/client on the same issuer would
|
|
250
|
+
// also pass. Only drop the audience check when the caller has explicitly
|
|
251
|
+
// opted in via `remoteVerify.allowMissingAudience`.
|
|
205
252
|
try {
|
|
206
253
|
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
|
|
207
|
-
const { audience: _audience, ...
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
const { audience: _audience, ...verifyOptionsNoAudience } =
|
|
255
|
+
opts.verifyOptions;
|
|
256
|
+
const skipAudience =
|
|
257
|
+
!introspect.aud && opts.remoteVerify.allowMissingAudience === true;
|
|
258
|
+
const verify = UnsecuredJWT.decode(
|
|
259
|
+
unsecuredJwt,
|
|
260
|
+
skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
|
|
261
|
+
);
|
|
211
262
|
payload = verify.payload;
|
|
212
263
|
} catch (error) {
|
|
213
264
|
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: {
|
|
@@ -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,15 @@ export const reddit = (options: RedditOptions) => {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
107
|
-
|
|
107
|
+
const email = userMap?.email || `${profile.id}@reddit.com`;
|
|
108
108
|
return {
|
|
109
109
|
user: {
|
|
110
110
|
id: profile.id,
|
|
111
111
|
name: profile.name,
|
|
112
|
-
email: profile.oauth_client_id,
|
|
113
|
-
emailVerified: profile.has_verified_email,
|
|
114
112
|
image: profile.icon_img?.split("?")[0]!,
|
|
115
113
|
...userMap,
|
|
114
|
+
email,
|
|
115
|
+
emailVerified: userMap?.emailVerified ?? false,
|
|
116
116
|
},
|
|
117
117
|
data: profile,
|
|
118
118
|
};
|