better-auth 1.7.6 → 1.7.8
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/rate-limiter/index.mjs +4 -1
- package/dist/api/routes/password.mjs +5 -11
- package/dist/api/routes/sign-in.mjs +1 -1
- package/dist/api/routes/sign-up.mjs +1 -1
- package/dist/api/routes/update-user.mjs +10 -5
- package/dist/client/config.mjs +3 -4
- package/dist/cookies/index.d.mts +5 -5
- package/dist/crypto/purpose.mjs +19 -0
- package/dist/db/get-migration.mjs +13 -11
- package/dist/db/get-schema.mjs +27 -11
- package/dist/db/internal-adapter.mjs +73 -56
- package/dist/db/to-zod.d.mts +8 -6
- package/dist/db/to-zod.mjs +12 -2
- package/dist/package.mjs +1 -1
- package/dist/plugins/custom-session/index.mjs +6 -13
- package/dist/plugins/email-otp/routes.mjs +19 -10
- package/dist/plugins/generic-oauth/index.d.mts +13 -0
- package/dist/plugins/generic-oauth/index.mjs +232 -189
- package/dist/plugins/last-login-method/index.mjs +3 -3
- package/dist/plugins/magic-link/index.mjs +29 -16
- package/dist/plugins/oauth-proxy/index.d.mts +5 -6
- package/dist/plugins/oauth-proxy/index.mjs +11 -10
- package/dist/plugins/organization/client.mjs +2 -1
- package/dist/plugins/organization/routes/crud-members.d.mts +1 -42
- package/dist/plugins/organization/routes/crud-members.mjs +1 -1
- package/dist/plugins/phone-number/routes.mjs +33 -15
- package/dist/state.mjs +9 -6
- package/dist/utils/hashing.mjs +1 -1
- package/dist/utils/middleware-response.mjs +4 -1
- package/dist/utils/password.mjs +33 -1
- package/package.json +13 -13
|
@@ -6,7 +6,7 @@ import { symmetricDecrypt } from "../../crypto/index.mjs";
|
|
|
6
6
|
import { setCookieCache, setSessionCookie } from "../../cookies/index.mjs";
|
|
7
7
|
import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
|
|
8
8
|
import { getSessionFromCtx, sensitiveSessionMiddleware } from "../../api/routes/session.mjs";
|
|
9
|
-
import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
|
|
9
|
+
import { assertPasswordNotTooLong, assertPasswordNotTooShort, resetCredentialPassword } from "../../utils/password.mjs";
|
|
10
10
|
import { APIError as APIError$1 } from "../../api/index.mjs";
|
|
11
11
|
import { EMAIL_OTP_ERROR_CODES } from "./error-codes.mjs";
|
|
12
12
|
import { splitAtLastColon, toOTPIdentifier } from "./utils.mjs";
|
|
@@ -584,20 +584,18 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
|
|
|
584
584
|
const email = ctx.body.email.toLowerCase();
|
|
585
585
|
assertPasswordNotTooShort(ctx, ctx.body.password);
|
|
586
586
|
assertPasswordNotTooLong(ctx, ctx.body.password);
|
|
587
|
-
|
|
587
|
+
const identifier = toOTPIdentifier("forget-password", email);
|
|
588
|
+
let otpConsumed = false;
|
|
589
|
+
if (!await matchesUsableStoredOTP(ctx, opts, identifier, ctx.body.otp)) {
|
|
590
|
+
await atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp);
|
|
591
|
+
otpConsumed = true;
|
|
592
|
+
}
|
|
588
593
|
const user = await ctx.context.internalAdapter.findUserByEmail(email);
|
|
589
594
|
if (!user) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
|
|
590
595
|
const passwordHash = await ctx.context.password.hash(ctx.body.password);
|
|
591
|
-
|
|
592
|
-
userId: user.user.id,
|
|
593
|
-
providerId: "credential",
|
|
594
|
-
accountId: user.user.id,
|
|
595
|
-
password: passwordHash
|
|
596
|
-
});
|
|
597
|
-
else await ctx.context.internalAdapter.updatePassword(user.user.id, passwordHash);
|
|
596
|
+
await resetCredentialPassword(ctx, user.user.id, passwordHash, otpConsumed ? void 0 : () => atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp));
|
|
598
597
|
if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
|
|
599
598
|
if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
|
|
600
|
-
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.user.id);
|
|
601
599
|
return ctx.json({ success: true });
|
|
602
600
|
});
|
|
603
601
|
const requestEmailChangeEmailOTPBodySchema = z.object({
|
|
@@ -748,6 +746,17 @@ const changeEmailEmailOTP = (opts) => createAuthEndpoint("/email-otp/change-emai
|
|
|
748
746
|
});
|
|
749
747
|
const defaultOTPGenerator = (options) => generateRandomString(options.otpLength ?? 6, "0-9");
|
|
750
748
|
/**
|
|
749
|
+
* Reports whether the provided OTP matches a stored, unexpired record with
|
|
750
|
+
* attempts left, without consuming it or counting an attempt.
|
|
751
|
+
*/
|
|
752
|
+
async function matchesUsableStoredOTP(ctx, opts, identifier, providedOTP) {
|
|
753
|
+
const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
754
|
+
if (!existing || existing.expiresAt < /* @__PURE__ */ new Date()) return false;
|
|
755
|
+
const [otpValue, attempts] = splitAtLastColon(existing.value);
|
|
756
|
+
if (parseInt(attempts || "0") >= (opts?.allowedAttempts || 3)) return false;
|
|
757
|
+
return verifyStoredOTP(ctx, opts, otpValue, providedOTP);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
751
760
|
* Verifies a single-use OTP with race-condition protection.
|
|
752
761
|
*
|
|
753
762
|
* The atomic consume is the single gate: only the first concurrent caller
|
|
@@ -9,10 +9,12 @@ import { OktaOptions, okta } from "./providers/okta.mjs";
|
|
|
9
9
|
import { PatreonOptions, patreon } from "./providers/patreon.mjs";
|
|
10
10
|
import { SlackOptions, slack } from "./providers/slack.mjs";
|
|
11
11
|
import { YandexOptions, yandex } from "./providers/yandex.mjs";
|
|
12
|
+
import * as _$_better_auth_core0 from "@better-auth/core";
|
|
12
13
|
import { AuthContext } from "@better-auth/core";
|
|
13
14
|
import * as _$_better_auth_core_oauth20 from "@better-auth/core/oauth2";
|
|
14
15
|
import { OAuthProvider } from "@better-auth/core/oauth2";
|
|
15
16
|
import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
|
|
17
|
+
import * as _$better_call0 from "better-call";
|
|
16
18
|
|
|
17
19
|
//#region src/plugins/generic-oauth/index.d.ts
|
|
18
20
|
declare module "@better-auth/core" {
|
|
@@ -27,6 +29,10 @@ declare module "@better-auth/core" {
|
|
|
27
29
|
* Extracts common fields from GenericOAuthConfig for provider helpers.
|
|
28
30
|
*/
|
|
29
31
|
type BaseOAuthProviderOptions = Pick<GenericOAuthConfig, "clientId" | "clientSecret" | "tokenEndpointAuth" | "scopes" | "redirectURI" | "endSessionEndpoint" | "postLogoutRedirectURI" | "disableProviderLogout" | "pkce" | "disableImplicitSignUp" | "disableSignUp" | "overrideUserInfo">;
|
|
32
|
+
type PendingDiscovery = Map<string, {
|
|
33
|
+
config: GenericOAuthConfig;
|
|
34
|
+
attempt?: Promise<void> | undefined;
|
|
35
|
+
}>;
|
|
30
36
|
/**
|
|
31
37
|
* A generic OAuth plugin that registers any OAuth/OIDC provider
|
|
32
38
|
* as a first-class social provider.
|
|
@@ -40,8 +46,15 @@ declare const genericOAuth: <const ID extends string>(options: GenericOAuthOptio
|
|
|
40
46
|
init: (ctx: AuthContext) => Promise<{
|
|
41
47
|
context: {
|
|
42
48
|
socialProviders: OAuthProvider<object, Partial<_$_better_auth_core_oauth20.ProviderOptions<object>>>[];
|
|
49
|
+
genericOAuthPendingDiscovery: PendingDiscovery;
|
|
43
50
|
};
|
|
44
51
|
}>;
|
|
52
|
+
hooks: {
|
|
53
|
+
before: {
|
|
54
|
+
matcher: (context: _$_better_auth_core0.HookEndpointContext) => boolean;
|
|
55
|
+
handler: _$better_call0.Middleware<_$better_call0.MiddlewareOptions, (inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<void>>;
|
|
56
|
+
}[];
|
|
57
|
+
};
|
|
45
58
|
options: GenericOAuthOptions<ID>;
|
|
46
59
|
$ERROR_CODES: {
|
|
47
60
|
INVALID_OAUTH_CONFIGURATION: _$_better_auth_core_utils_error_codes0.RawError<"INVALID_OAUTH_CONFIGURATION">;
|
|
@@ -12,6 +12,7 @@ import { slack } from "./providers/slack.mjs";
|
|
|
12
12
|
import { yandex } from "./providers/yandex.mjs";
|
|
13
13
|
import { APIError } from "@better-auth/core/error";
|
|
14
14
|
import { applyDefaultAccessTokenExpiry, createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, verifyProviderIdToken } from "@better-auth/core/oauth2";
|
|
15
|
+
import { createAuthMiddleware } from "@better-auth/core/api";
|
|
15
16
|
import { createRemoteJWKSet, decodeJwt } from "jose";
|
|
16
17
|
import { betterFetch } from "@better-fetch/fetch";
|
|
17
18
|
//#region src/plugins/generic-oauth/index.ts
|
|
@@ -21,10 +22,20 @@ function isSecretlessTokenEndpointAuth(tokenEndpointAuth) {
|
|
|
21
22
|
function isClientSecretTokenEndpointAuth(tokenEndpointAuth) {
|
|
22
23
|
return tokenEndpointAuth?.method === "client_secret_basic" || tokenEndpointAuth?.method === "client_secret_post";
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
/** A request waits on a discovery retry, so bound it (the SSO plugin uses 10s too). */
|
|
26
|
+
const DISCOVERY_RETRY_TIMEOUT_MS = 1e4;
|
|
27
|
+
/**
|
|
28
|
+
* Providers skipped because startup discovery failed. Init stores them on each auth
|
|
29
|
+
* instance's context, so one plugin object can serve several instances. It is a context
|
|
30
|
+
* field rather than a WeakMap because requests see copies of the context, and a later
|
|
31
|
+
* plugin may replace `socialProviders`.
|
|
32
|
+
*/
|
|
33
|
+
const getPendingDiscovery = (ctx) => ctx.genericOAuthPendingDiscovery;
|
|
34
|
+
async function fetchDiscovery(url, headers, timeout) {
|
|
25
35
|
const result = await betterFetch(url, {
|
|
26
36
|
method: "GET",
|
|
27
|
-
headers
|
|
37
|
+
headers,
|
|
38
|
+
signal: timeout ? AbortSignal.timeout(timeout) : void 0
|
|
28
39
|
});
|
|
29
40
|
if (result.error || !result.data) return null;
|
|
30
41
|
if (result.data.issuer) try {
|
|
@@ -74,203 +85,235 @@ const genericOAuth = (options) => {
|
|
|
74
85
|
seenIds.add(id);
|
|
75
86
|
}
|
|
76
87
|
if (nonUniqueIds.size > 0) console.warn(`Duplicate provider IDs found: ${Array.from(nonUniqueIds).join(", ")}`);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} catch {
|
|
108
|
-
ctx.logger.error(`Provider "${c.providerId}": invalid jwks_uri "${discovered.jwks_uri}" in discovery document. Provider skipped.`);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
idTokenConfig = {
|
|
112
|
-
jwks: createRemoteJWKSet(jwksUrl),
|
|
113
|
-
issuer: discovered.issuer,
|
|
114
|
-
audience: c.clientId,
|
|
115
|
-
algorithms: isOidc ? signingAlgs : void 0
|
|
116
|
-
};
|
|
88
|
+
const buildProviders = async (configs, ctx, discoveryTimeout) => {
|
|
89
|
+
const genericProviders = [];
|
|
90
|
+
for (const c of configs) {
|
|
91
|
+
let authorizationUrl = c.authorizationUrl;
|
|
92
|
+
let tokenUrl = c.tokenUrl;
|
|
93
|
+
let userInfoUrl = c.userInfoUrl;
|
|
94
|
+
let endSessionEndpoint = c.endSessionEndpoint;
|
|
95
|
+
let issuer;
|
|
96
|
+
let isOidc = false;
|
|
97
|
+
let idTokenConfig;
|
|
98
|
+
if (c.discoveryUrl) {
|
|
99
|
+
const discovered = await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders, discoveryTimeout).catch((err) => {
|
|
100
|
+
ctx.logger.error(`Discovery fetch failed for "${c.providerId}": ${err}`);
|
|
101
|
+
return null;
|
|
102
|
+
});
|
|
103
|
+
if (discovered) {
|
|
104
|
+
authorizationUrl ??= discovered.authorization_endpoint;
|
|
105
|
+
tokenUrl ??= discovered.token_endpoint;
|
|
106
|
+
userInfoUrl ??= discovered.userinfo_endpoint;
|
|
107
|
+
endSessionEndpoint ??= discovered.end_session_endpoint;
|
|
108
|
+
issuer = discovered.issuer;
|
|
109
|
+
const signingAlgs = discovered.id_token_signing_alg_values_supported;
|
|
110
|
+
isOidc = Array.isArray(signingAlgs) && signingAlgs.length > 0;
|
|
111
|
+
if (discovered.jwks_uri && discovered.issuer) {
|
|
112
|
+
let jwksUrl;
|
|
113
|
+
try {
|
|
114
|
+
jwksUrl = new URL(discovered.jwks_uri, c.discoveryUrl);
|
|
115
|
+
} catch {
|
|
116
|
+
ctx.logger.error(`Provider "${c.providerId}": invalid jwks_uri "${discovered.jwks_uri}" in discovery document. Provider skipped.`);
|
|
117
|
+
continue;
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
idTokenConfig = {
|
|
120
|
+
jwks: createRemoteJWKSet(jwksUrl),
|
|
121
|
+
issuer: discovered.issuer,
|
|
122
|
+
audience: c.clientId,
|
|
123
|
+
algorithms: isOidc ? signingAlgs : void 0
|
|
124
|
+
};
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
throw new Error(`Provider "${c.providerId}": requires verified ID tokens, but discovery did not provide a usable issuer and jwks_uri.`);
|
|
127
|
+
if (!authorizationUrl || !tokenUrl && !c.getToken) {
|
|
128
|
+
ctx.logger.error(`Provider "${c.providerId}": discovery left no usable authorization endpoint or token exchange. Provider skipped.`);
|
|
129
|
+
continue;
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
131
|
+
}
|
|
132
|
+
if (c.requireIdTokenVerification && !idTokenConfig) {
|
|
133
|
+
if (c.discoveryUrl) {
|
|
134
|
+
ctx.logger.error(`Provider "${c.providerId}": requires verified ID tokens, but discovery did not provide a usable issuer and jwks_uri. Provider skipped.`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Provider "${c.providerId}": requires verified ID tokens, but discovery did not provide a usable issuer and jwks_uri.`);
|
|
138
|
+
}
|
|
139
|
+
const tokenEndpointAuth = c.tokenEndpointAuth;
|
|
140
|
+
if (c.clientSecret && isSecretlessTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" cannot be combined with clientSecret`);
|
|
141
|
+
if (!c.clientSecret && isClientSecretTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" requires clientSecret`);
|
|
142
|
+
if (!c.clientSecret && !tokenEndpointAuth && c.authentication === "basic") throw new Error(`Provider "${c.providerId}": authentication "basic" requires clientSecret`);
|
|
143
|
+
const accountSubject = c.accountSubject;
|
|
144
|
+
const provider = {
|
|
145
|
+
id: c.providerId,
|
|
146
|
+
name: c.name ?? c.providerId,
|
|
147
|
+
issuer,
|
|
148
|
+
accountSubject: ({ tokens, profile }) => {
|
|
149
|
+
const genericProfile = profile;
|
|
150
|
+
if (accountSubject) return accountSubject({
|
|
151
|
+
tokens,
|
|
152
|
+
profile: genericProfile
|
|
153
|
+
});
|
|
154
|
+
return isOidc ? genericProfile.sub ?? "" : genericProfile.id ?? "";
|
|
155
|
+
},
|
|
156
|
+
idToken: idTokenConfig,
|
|
157
|
+
requiresIdTokenNonce: idTokenConfig !== void 0 && c.disableIdTokenNonceBinding !== true,
|
|
158
|
+
allowIdpInitiated: c.allowIdpInitiated,
|
|
159
|
+
async createEndSessionURL(data) {
|
|
160
|
+
if (c.disableProviderLogout) return null;
|
|
161
|
+
if (!endSessionEndpoint) return null;
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = new URL(endSessionEndpoint);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (data.idToken) url.searchParams.set("id_token_hint", data.idToken);
|
|
169
|
+
const configuredRedirectURI = data.postLogoutRedirectURI ?? c.postLogoutRedirectURI;
|
|
170
|
+
const postLogoutRedirectURI = configuredRedirectURI ? new URL(configuredRedirectURI, ctx.baseURL).toString() : void 0;
|
|
171
|
+
if (postLogoutRedirectURI) {
|
|
172
|
+
url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectURI);
|
|
173
|
+
url.searchParams.set("client_id", c.clientId);
|
|
174
|
+
if (data.state) url.searchParams.set("state", data.state);
|
|
175
|
+
} else if (!data.idToken) url.searchParams.set("client_id", c.clientId);
|
|
176
|
+
return url;
|
|
177
|
+
},
|
|
178
|
+
createAuthorizationURL(data) {
|
|
179
|
+
if (!authorizationUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION);
|
|
180
|
+
return createAuthorizationURL({
|
|
181
|
+
id: c.providerId,
|
|
182
|
+
options: {
|
|
183
|
+
clientId: c.clientId,
|
|
184
|
+
clientSecret: c.clientSecret,
|
|
185
|
+
redirectURI: c.redirectURI
|
|
186
|
+
},
|
|
187
|
+
authorizationEndpoint: authorizationUrl,
|
|
188
|
+
state: data.state,
|
|
189
|
+
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
|
|
190
|
+
scopes: (() => {
|
|
191
|
+
const merged = [...data.scopes ?? [], ...c.scopes ?? []];
|
|
192
|
+
if (isOidc && !merged.includes("openid")) merged.unshift("openid");
|
|
193
|
+
return merged;
|
|
194
|
+
})(),
|
|
195
|
+
redirectURI: data.redirectURI,
|
|
196
|
+
prompt: c.prompt,
|
|
197
|
+
accessType: c.accessType,
|
|
198
|
+
responseType: c.responseType,
|
|
199
|
+
responseMode: c.responseMode,
|
|
200
|
+
nonce: data.idTokenNonce,
|
|
201
|
+
additionalParams: {
|
|
202
|
+
...c.authorizationUrlParams ?? {},
|
|
203
|
+
...data.additionalParams ?? {}
|
|
204
|
+
},
|
|
205
|
+
loginHint: data.loginHint
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
async validateAuthorizationCode(data) {
|
|
209
|
+
if (c.getToken) return applyDefaultAccessTokenExpiry(await c.getToken(data), c.accessTokenExpiresIn);
|
|
210
|
+
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
|
|
211
|
+
return applyDefaultAccessTokenExpiry(await validateAuthorizationCode({
|
|
212
|
+
headers: c.authorizationHeaders,
|
|
213
|
+
code: data.code,
|
|
214
|
+
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
|
|
215
|
+
redirectURI: data.redirectURI,
|
|
216
|
+
options: {
|
|
217
|
+
clientId: c.clientId,
|
|
218
|
+
clientSecret: c.clientSecret,
|
|
219
|
+
redirectURI: c.redirectURI
|
|
220
|
+
},
|
|
221
|
+
tokenEndpoint: tokenUrl,
|
|
222
|
+
authentication: c.authentication,
|
|
223
|
+
tokenEndpointAuth,
|
|
224
|
+
additionalParams: c.tokenUrlParams
|
|
225
|
+
}), c.accessTokenExpiresIn);
|
|
226
|
+
},
|
|
227
|
+
async getUserInfo(tokens) {
|
|
228
|
+
const { expectedIdTokenNonce, ...oauthTokens } = tokens;
|
|
229
|
+
if (oauthTokens.idToken && provider.idToken) {
|
|
230
|
+
if (!await verifyProviderIdToken(provider, oauthTokens.idToken, expectedIdTokenNonce)) {
|
|
231
|
+
ctx.logger.error(`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`);
|
|
158
232
|
return null;
|
|
159
233
|
}
|
|
160
|
-
if (data.idToken) url.searchParams.set("id_token_hint", data.idToken);
|
|
161
|
-
const configuredRedirectURI = data.postLogoutRedirectURI ?? c.postLogoutRedirectURI;
|
|
162
|
-
const postLogoutRedirectURI = configuredRedirectURI ? new URL(configuredRedirectURI, ctx.baseURL).toString() : void 0;
|
|
163
|
-
if (postLogoutRedirectURI) {
|
|
164
|
-
url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectURI);
|
|
165
|
-
url.searchParams.set("client_id", c.clientId);
|
|
166
|
-
if (data.state) url.searchParams.set("state", data.state);
|
|
167
|
-
} else if (!data.idToken) url.searchParams.set("client_id", c.clientId);
|
|
168
|
-
return url;
|
|
169
|
-
},
|
|
170
|
-
createAuthorizationURL(data) {
|
|
171
|
-
if (!authorizationUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION);
|
|
172
|
-
return createAuthorizationURL({
|
|
173
|
-
id: c.providerId,
|
|
174
|
-
options: {
|
|
175
|
-
clientId: c.clientId,
|
|
176
|
-
clientSecret: c.clientSecret,
|
|
177
|
-
redirectURI: c.redirectURI
|
|
178
|
-
},
|
|
179
|
-
authorizationEndpoint: authorizationUrl,
|
|
180
|
-
state: data.state,
|
|
181
|
-
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
|
|
182
|
-
scopes: (() => {
|
|
183
|
-
const merged = [...data.scopes ?? [], ...c.scopes ?? []];
|
|
184
|
-
if (isOidc && !merged.includes("openid")) merged.unshift("openid");
|
|
185
|
-
return merged;
|
|
186
|
-
})(),
|
|
187
|
-
redirectURI: data.redirectURI,
|
|
188
|
-
prompt: c.prompt,
|
|
189
|
-
accessType: c.accessType,
|
|
190
|
-
responseType: c.responseType,
|
|
191
|
-
responseMode: c.responseMode,
|
|
192
|
-
nonce: data.idTokenNonce,
|
|
193
|
-
additionalParams: {
|
|
194
|
-
...c.authorizationUrlParams ?? {},
|
|
195
|
-
...data.additionalParams ?? {}
|
|
196
|
-
},
|
|
197
|
-
loginHint: data.loginHint
|
|
198
|
-
});
|
|
199
|
-
},
|
|
200
|
-
async validateAuthorizationCode(data) {
|
|
201
|
-
if (c.getToken) return applyDefaultAccessTokenExpiry(await c.getToken(data), c.accessTokenExpiresIn);
|
|
202
|
-
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
|
|
203
|
-
return applyDefaultAccessTokenExpiry(await validateAuthorizationCode({
|
|
204
|
-
headers: c.authorizationHeaders,
|
|
205
|
-
code: data.code,
|
|
206
|
-
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
|
|
207
|
-
redirectURI: data.redirectURI,
|
|
208
|
-
options: {
|
|
209
|
-
clientId: c.clientId,
|
|
210
|
-
clientSecret: c.clientSecret,
|
|
211
|
-
redirectURI: c.redirectURI
|
|
212
|
-
},
|
|
213
|
-
tokenEndpoint: tokenUrl,
|
|
214
|
-
authentication: c.authentication,
|
|
215
|
-
tokenEndpointAuth,
|
|
216
|
-
additionalParams: c.tokenUrlParams
|
|
217
|
-
}), c.accessTokenExpiresIn);
|
|
218
|
-
},
|
|
219
|
-
async getUserInfo(tokens) {
|
|
220
|
-
const { expectedIdTokenNonce, ...oauthTokens } = tokens;
|
|
221
|
-
if (oauthTokens.idToken && provider.idToken) {
|
|
222
|
-
if (!await verifyProviderIdToken(provider, oauthTokens.idToken, expectedIdTokenNonce)) {
|
|
223
|
-
ctx.logger.error(`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`);
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const raw = c.getUserInfo ? await c.getUserInfo(oauthTokens) : await fetchUserInfo(oauthTokens, userInfoUrl);
|
|
228
|
-
if (!raw) return null;
|
|
229
|
-
const mapped = c.mapProfileToUser ? await c.mapProfileToUser(raw) : {};
|
|
230
|
-
const user = {
|
|
231
|
-
email: raw.email,
|
|
232
|
-
emailVerified: raw.emailVerified,
|
|
233
|
-
image: raw.image,
|
|
234
|
-
name: raw.name,
|
|
235
|
-
...mapped
|
|
236
|
-
};
|
|
237
|
-
return {
|
|
238
|
-
user: {
|
|
239
|
-
...user,
|
|
240
|
-
image: user.image ?? void 0
|
|
241
|
-
},
|
|
242
|
-
data: raw
|
|
243
|
-
};
|
|
244
|
-
},
|
|
245
|
-
async refreshAccessToken(refreshToken, refreshCtx) {
|
|
246
|
-
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
|
|
247
|
-
const resolvedRefreshParams = typeof c.refreshTokenParams === "function" ? await c.refreshTokenParams(refreshCtx) : c.refreshTokenParams;
|
|
248
|
-
return applyDefaultAccessTokenExpiry(await refreshAccessToken({
|
|
249
|
-
refreshToken,
|
|
250
|
-
options: {
|
|
251
|
-
clientId: c.clientId,
|
|
252
|
-
clientSecret: c.clientSecret
|
|
253
|
-
},
|
|
254
|
-
authentication: c.authentication,
|
|
255
|
-
tokenEndpointAuth,
|
|
256
|
-
tokenEndpoint: tokenUrl,
|
|
257
|
-
extraParams: resolvedRefreshParams
|
|
258
|
-
}), c.accessTokenExpiresIn);
|
|
259
|
-
},
|
|
260
|
-
disableImplicitSignUp: c.disableImplicitSignUp,
|
|
261
|
-
disableSignUp: c.disableSignUp,
|
|
262
|
-
options: {
|
|
263
|
-
disableSignUp: c.disableSignUp,
|
|
264
|
-
overrideUserInfoOnSignIn: c.overrideUserInfo,
|
|
265
|
-
requireEmailVerification: c.requireEmailVerification
|
|
266
234
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
235
|
+
const raw = c.getUserInfo ? await c.getUserInfo(oauthTokens) : await fetchUserInfo(oauthTokens, userInfoUrl);
|
|
236
|
+
if (!raw) return null;
|
|
237
|
+
const mapped = c.mapProfileToUser ? await c.mapProfileToUser(raw) : {};
|
|
238
|
+
const user = {
|
|
239
|
+
email: raw.email,
|
|
240
|
+
emailVerified: raw.emailVerified,
|
|
241
|
+
image: raw.image,
|
|
242
|
+
name: raw.name,
|
|
243
|
+
...mapped
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
user: {
|
|
247
|
+
...user,
|
|
248
|
+
image: user.image ?? void 0
|
|
249
|
+
},
|
|
250
|
+
data: raw
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
async refreshAccessToken(refreshToken, refreshCtx) {
|
|
254
|
+
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
|
|
255
|
+
const resolvedRefreshParams = typeof c.refreshTokenParams === "function" ? await c.refreshTokenParams(refreshCtx) : c.refreshTokenParams;
|
|
256
|
+
return applyDefaultAccessTokenExpiry(await refreshAccessToken({
|
|
257
|
+
refreshToken,
|
|
258
|
+
options: {
|
|
259
|
+
clientId: c.clientId,
|
|
260
|
+
clientSecret: c.clientSecret
|
|
261
|
+
},
|
|
262
|
+
authentication: c.authentication,
|
|
263
|
+
tokenEndpointAuth,
|
|
264
|
+
tokenEndpoint: tokenUrl,
|
|
265
|
+
extraParams: resolvedRefreshParams
|
|
266
|
+
}), c.accessTokenExpiresIn);
|
|
267
|
+
},
|
|
268
|
+
disableImplicitSignUp: c.disableImplicitSignUp,
|
|
269
|
+
disableSignUp: c.disableSignUp,
|
|
270
|
+
options: {
|
|
271
|
+
disableSignUp: c.disableSignUp,
|
|
272
|
+
overrideUserInfoOnSignIn: c.overrideUserInfo,
|
|
273
|
+
requireEmailVerification: c.requireEmailVerification
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
genericProviders.push(provider);
|
|
277
|
+
}
|
|
278
|
+
return genericProviders;
|
|
279
|
+
};
|
|
280
|
+
const retryDiscovery = (ctx, providerId) => {
|
|
281
|
+
const pendingDiscovery = getPendingDiscovery(ctx);
|
|
282
|
+
const pending = pendingDiscovery?.get(providerId);
|
|
283
|
+
if (!pending) return;
|
|
284
|
+
pending.attempt ??= buildProviders([pending.config], ctx, DISCOVERY_RETRY_TIMEOUT_MS).then(([provider]) => {
|
|
285
|
+
if (!provider) return;
|
|
286
|
+
pendingDiscovery?.delete(providerId);
|
|
287
|
+
ctx.socialProviders.unshift(provider);
|
|
288
|
+
}).finally(() => {
|
|
289
|
+
pending.attempt = void 0;
|
|
290
|
+
});
|
|
291
|
+
return pending.attempt;
|
|
292
|
+
};
|
|
293
|
+
const requestedProviderId = (context) => {
|
|
294
|
+
const id = context.body?.provider ?? context.body?.providerId ?? context.query?.provider ?? context.params?.id;
|
|
295
|
+
return typeof id === "string" ? id : void 0;
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
id: "generic-oauth",
|
|
299
|
+
version: PACKAGE_VERSION,
|
|
300
|
+
init: async (ctx) => {
|
|
301
|
+
const genericProviders = await buildProviders(options.config, ctx);
|
|
302
|
+
const built = new Set(genericProviders.map((p) => p.id));
|
|
303
|
+
const genericOAuthPendingDiscovery = new Map(options.config.filter((c) => !built.has(c.providerId)).map((c) => [c.providerId, { config: c }]));
|
|
270
304
|
const existingIds = new Set(ctx.socialProviders.map((p) => p.id));
|
|
271
305
|
for (const gp of genericProviders) if (existingIds.has(gp.id)) ctx.logger.warn(`Generic OAuth provider "${gp.id}" shadows a built-in social provider with the same ID`);
|
|
272
|
-
return { context: {
|
|
306
|
+
return { context: {
|
|
307
|
+
socialProviders: genericProviders.concat(ctx.socialProviders),
|
|
308
|
+
genericOAuthPendingDiscovery
|
|
309
|
+
} };
|
|
273
310
|
},
|
|
311
|
+
hooks: { before: [{
|
|
312
|
+
matcher: (context) => getPendingDiscovery(context.context)?.has(requestedProviderId(context) ?? "") === true,
|
|
313
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
314
|
+
await retryDiscovery(ctx.context, requestedProviderId(ctx) ?? "");
|
|
315
|
+
})
|
|
316
|
+
}] },
|
|
274
317
|
options,
|
|
275
318
|
$ERROR_CODES: GENERIC_OAUTH_ERROR_CODES
|
|
276
319
|
};
|
|
@@ -27,9 +27,9 @@ const lastLoginMethod = (userConfig) => {
|
|
|
27
27
|
return config.customResolveMethod?.(resolveContext) ?? defaultResolveMethod(resolveContext);
|
|
28
28
|
};
|
|
29
29
|
const config = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
...userConfig,
|
|
31
|
+
cookieName: userConfig?.cookieName ?? "better-auth.last_used_login_method",
|
|
32
|
+
maxAge: userConfig?.maxAge ?? 3600 * 24 * 30
|
|
33
33
|
};
|
|
34
34
|
return {
|
|
35
35
|
id: "last-login-method",
|
|
@@ -23,6 +23,20 @@ const magicLinkVerifyQuerySchema = z.object({
|
|
|
23
23
|
errorCallbackURL: z.string().meta({ description: "URL to redirect after error." }).optional(),
|
|
24
24
|
newUserCallbackURL: z.string().meta({ description: "URL to redirect after new user signup. Only used if the user is registering for the first time." }).optional()
|
|
25
25
|
});
|
|
26
|
+
const magicLinkRecordSchema = z.object({
|
|
27
|
+
type: z.literal("magic-link"),
|
|
28
|
+
email: z.email(),
|
|
29
|
+
name: z.string().optional()
|
|
30
|
+
}).strict();
|
|
31
|
+
function parseMagicLinkRecord(value) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(value);
|
|
34
|
+
return magicLinkRecordSchema.safeParse(parsed);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const magicLinkIdentifier = (storedToken) => `magic-link:${storedToken}`;
|
|
26
40
|
const magicLink = (options) => {
|
|
27
41
|
const opts = {
|
|
28
42
|
storeToken: "plain",
|
|
@@ -75,8 +89,9 @@ const magicLink = (options) => {
|
|
|
75
89
|
const verificationToken = opts?.generateToken ? await opts.generateToken(email) : generateRandomString(32, "a-z", "A-Z");
|
|
76
90
|
const storedToken = await storeToken(ctx, verificationToken);
|
|
77
91
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
78
|
-
identifier: storedToken,
|
|
92
|
+
identifier: magicLinkIdentifier(storedToken),
|
|
79
93
|
value: JSON.stringify({
|
|
94
|
+
type: "magic-link",
|
|
80
95
|
email,
|
|
81
96
|
name: ctx.body.name
|
|
82
97
|
}),
|
|
@@ -117,15 +132,9 @@ const magicLink = (options) => {
|
|
|
117
132
|
method: "GET",
|
|
118
133
|
query: magicLinkVerifyQuerySchema,
|
|
119
134
|
use: [
|
|
120
|
-
originCheck((ctx) =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
originCheck((ctx) => {
|
|
124
|
-
return ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : "/";
|
|
125
|
-
}),
|
|
126
|
-
originCheck((ctx) => {
|
|
127
|
-
return ctx.query.errorCallbackURL ? decodeURIComponent(ctx.query.errorCallbackURL) : "/";
|
|
128
|
-
})
|
|
135
|
+
originCheck((ctx) => ctx.query.callbackURL || "/"),
|
|
136
|
+
originCheck((ctx) => ctx.query.newUserCallbackURL || "/"),
|
|
137
|
+
originCheck((ctx) => ctx.query.errorCallbackURL || "/")
|
|
129
138
|
],
|
|
130
139
|
requireHeaders: true,
|
|
131
140
|
metadata: { openapi: {
|
|
@@ -144,18 +153,22 @@ const magicLink = (options) => {
|
|
|
144
153
|
} }
|
|
145
154
|
}, async (ctx) => {
|
|
146
155
|
const token = ctx.query.token;
|
|
147
|
-
const callbackURL = new URL(ctx.query.callbackURL
|
|
148
|
-
const errorCallbackURL = new URL(ctx.query.errorCallbackURL
|
|
156
|
+
const callbackURL = new URL(ctx.query.callbackURL || "/", ctx.context.baseURL).toString();
|
|
157
|
+
const errorCallbackURL = new URL(ctx.query.errorCallbackURL || callbackURL, ctx.context.baseURL);
|
|
149
158
|
function redirectWithError(error, description) {
|
|
150
159
|
errorCallbackURL.searchParams.set("error", error);
|
|
151
160
|
if (description) errorCallbackURL.searchParams.set("error_description", description);
|
|
152
161
|
throw ctx.redirect(errorCallbackURL.toString());
|
|
153
162
|
}
|
|
154
|
-
const newUserCallbackURL = new URL(ctx.query.newUserCallbackURL
|
|
155
|
-
const
|
|
156
|
-
const
|
|
163
|
+
const newUserCallbackURL = new URL(ctx.query.newUserCallbackURL || callbackURL, ctx.context.baseURL).toString();
|
|
164
|
+
const identifier = magicLinkIdentifier(await storeToken(ctx, token));
|
|
165
|
+
const pendingValue = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
166
|
+
if (!pendingValue || !parseMagicLinkRecord(pendingValue.value)?.success) redirectWithError("INVALID_TOKEN");
|
|
167
|
+
const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(identifier);
|
|
157
168
|
if (!tokenValue) redirectWithError("INVALID_TOKEN");
|
|
158
|
-
const
|
|
169
|
+
const record = parseMagicLinkRecord(tokenValue.value);
|
|
170
|
+
if (!record?.success) redirectWithError("INVALID_TOKEN");
|
|
171
|
+
const { email, name } = record.data;
|
|
159
172
|
let isNewUser = false;
|
|
160
173
|
let user = await ctx.context.internalAdapter.findUserByEmail(email).then((res) => res?.user);
|
|
161
174
|
if (!user) if (!opts.disableSignUp) {
|