better-auth 1.8.0-beta.7 → 1.8.0-beta.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/index.d.mts +67 -1
- package/dist/api/index.mjs +4 -1
- package/dist/api/routes/account.mjs +2 -0
- package/dist/api/routes/id-token-nonce.d.mts +85 -0
- package/dist/api/routes/id-token-nonce.mjs +80 -0
- package/dist/api/routes/index.d.mts +2 -1
- package/dist/api/routes/sign-in.mjs +2 -0
- package/dist/db/get-migration.mjs +9 -11
- package/dist/db/internal-adapter.mjs +20 -6
- package/dist/package.mjs +1 -1
- package/dist/plugins/device-authorization/routes.mjs +15 -0
- package/dist/plugins/generic-oauth/index.d.mts +1 -4
- package/dist/plugins/generic-oauth/index.mjs +39 -23
- package/dist/plugins/generic-oauth/types.d.mts +10 -2
- package/dist/plugins/one-tap/client.d.mts +4 -0
- package/dist/plugins/one-tap/client.mjs +90 -77
- package/dist/plugins/one-tap/index.d.mts +40 -0
- package/dist/plugins/one-tap/index.mjs +112 -85
- package/dist/plugins/siwe/index.mjs +1 -1
- package/dist/state.d.mts +2 -5
- package/dist/state.mjs +1 -4
- package/dist/test-utils/http-test-instance.d.mts +8 -0
- package/dist/utils/hide-metadata.d.mts +2 -5
- package/dist/utils/hide-metadata.mjs +1 -4
- package/package.json +8 -8
package/dist/api/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OverrideMerge, Prettify as Prettify$1, UnionToIntersection } from "../types/helper.mjs";
|
|
2
2
|
import { AdditionalSessionFieldsInput, AdditionalUserFieldsInput } from "../types/models.mjs";
|
|
3
3
|
import "../types/index.mjs";
|
|
4
|
+
import { generateGenericState, parseGenericState } from "../state.mjs";
|
|
4
5
|
import "../index.mjs";
|
|
5
6
|
import { isAPIError } from "../utils/is-api-error.mjs";
|
|
6
7
|
import { DispatchContext, dispatchAuthEndpoint } from "./dispatch.mjs";
|
|
@@ -11,6 +12,7 @@ import { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refre
|
|
|
11
12
|
import { callbackOAuth } from "./routes/callback.mjs";
|
|
12
13
|
import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail } from "./routes/email-verification.mjs";
|
|
13
14
|
import { error } from "./routes/error.mjs";
|
|
15
|
+
import { consumeIssuedIdTokenNonce, consumeNonce, issueIdTokenNonce, issueNonce, issuedNonceResponses } from "./routes/id-token-nonce.mjs";
|
|
14
16
|
import { ok } from "./routes/ok.mjs";
|
|
15
17
|
import { requestPasswordReset, requestPasswordResetCallback, resetPassword, verifyPassword } from "./routes/password.mjs";
|
|
16
18
|
import { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
|
|
@@ -1664,6 +1666,38 @@ export declare function getEndpoints<Option extends BetterAuthOptions>(ctx: Awai
|
|
|
1664
1666
|
url: string;
|
|
1665
1667
|
redirect: boolean;
|
|
1666
1668
|
}>;
|
|
1669
|
+
readonly issueIdTokenNonce: import("better-call").StrictEndpoint<"/id-token/nonce", {
|
|
1670
|
+
method: "POST";
|
|
1671
|
+
body: import("zod").ZodObject<{
|
|
1672
|
+
provider: import("zod").ZodType<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown, import("zod/v4/core").$ZodTypeInternals<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown>>;
|
|
1673
|
+
}, import("zod/v4/core").$strip>;
|
|
1674
|
+
metadata: {
|
|
1675
|
+
noStore: boolean;
|
|
1676
|
+
openapi: {
|
|
1677
|
+
description: string;
|
|
1678
|
+
responses: {
|
|
1679
|
+
200: {
|
|
1680
|
+
description: string;
|
|
1681
|
+
content: {
|
|
1682
|
+
"application/json": {
|
|
1683
|
+
schema: {
|
|
1684
|
+
type: "object";
|
|
1685
|
+
properties: {
|
|
1686
|
+
nonce: {
|
|
1687
|
+
type: "string";
|
|
1688
|
+
};
|
|
1689
|
+
};
|
|
1690
|
+
required: string[];
|
|
1691
|
+
};
|
|
1692
|
+
};
|
|
1693
|
+
};
|
|
1694
|
+
};
|
|
1695
|
+
};
|
|
1696
|
+
};
|
|
1697
|
+
};
|
|
1698
|
+
}, {
|
|
1699
|
+
nonce: string;
|
|
1700
|
+
}>;
|
|
1667
1701
|
readonly listUserAccounts: import("better-call").StrictEndpoint<"/list-accounts", {
|
|
1668
1702
|
method: "GET";
|
|
1669
1703
|
use: Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
@@ -3678,6 +3712,38 @@ export declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext
|
|
|
3678
3712
|
url: string;
|
|
3679
3713
|
redirect: boolean;
|
|
3680
3714
|
}>;
|
|
3715
|
+
readonly issueIdTokenNonce: import("better-call").StrictEndpoint<"/id-token/nonce", {
|
|
3716
|
+
method: "POST";
|
|
3717
|
+
body: import("zod").ZodObject<{
|
|
3718
|
+
provider: import("zod").ZodType<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown, import("zod/v4/core").$ZodTypeInternals<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown>>;
|
|
3719
|
+
}, import("zod/v4/core").$strip>;
|
|
3720
|
+
metadata: {
|
|
3721
|
+
noStore: boolean;
|
|
3722
|
+
openapi: {
|
|
3723
|
+
description: string;
|
|
3724
|
+
responses: {
|
|
3725
|
+
200: {
|
|
3726
|
+
description: string;
|
|
3727
|
+
content: {
|
|
3728
|
+
"application/json": {
|
|
3729
|
+
schema: {
|
|
3730
|
+
type: "object";
|
|
3731
|
+
properties: {
|
|
3732
|
+
nonce: {
|
|
3733
|
+
type: "string";
|
|
3734
|
+
};
|
|
3735
|
+
};
|
|
3736
|
+
required: string[];
|
|
3737
|
+
};
|
|
3738
|
+
};
|
|
3739
|
+
};
|
|
3740
|
+
};
|
|
3741
|
+
};
|
|
3742
|
+
};
|
|
3743
|
+
};
|
|
3744
|
+
}, {
|
|
3745
|
+
nonce: string;
|
|
3746
|
+
}>;
|
|
3681
3747
|
readonly listUserAccounts: import("better-call").StrictEndpoint<"/list-accounts", {
|
|
3682
3748
|
method: "GET";
|
|
3683
3749
|
use: Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
@@ -4048,4 +4114,4 @@ export declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext
|
|
|
4048
4114
|
} extends (infer T_2) ? { [K in keyof T_2 as K extends keyof T_1 ? never : K]: T_2[K]; } : never) & T_1> : never : never : never;
|
|
4049
4115
|
};
|
|
4050
4116
|
//#endregion
|
|
4051
|
-
export { APIError, type AuthEndpoint, type AuthMiddleware, type DispatchContext, NO_STORE_HEADERS, accountInfo, addOAuthServerContext, callbackOAuth, changeEmail, changePassword, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getAuthoritativeSessionFromCtx, getIP, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
|
4117
|
+
export { APIError, type AuthEndpoint, type AuthMiddleware, type DispatchContext, NO_STORE_HEADERS, accountInfo, addOAuthServerContext, callbackOAuth, changeEmail, changePassword, consumeIssuedIdTokenNonce, consumeNonce, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, generateGenericState, getAccessToken, getAuthoritativeSessionFromCtx, getIP, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, issueIdTokenNonce, issueNonce, issuedNonceResponses, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, parseGenericState, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
package/dist/api/index.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { onRequestRateLimit } from "./rate-limiter/index.mjs";
|
|
|
5
5
|
import { getShouldSkipSessionRefresh, setShouldSkipSessionRefresh } from "./state/should-session-refresh.mjs";
|
|
6
6
|
import { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
|
|
7
7
|
import { addOAuthServerContext, getOAuthState } from "./state/oauth.mjs";
|
|
8
|
+
import { generateGenericState, parseGenericState } from "../state.mjs";
|
|
9
|
+
import { consumeIssuedIdTokenNonce, consumeNonce, issueIdTokenNonce, issueNonce, issuedNonceResponses } from "./routes/id-token-nonce.mjs";
|
|
8
10
|
import { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refreshToken, unlinkAccount } from "./routes/account.mjs";
|
|
9
11
|
import { callbackOAuth } from "./routes/callback.mjs";
|
|
10
12
|
import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail } from "./routes/email-verification.mjs";
|
|
@@ -134,6 +136,7 @@ function getEndpoints(ctx, options) {
|
|
|
134
136
|
revokeSessions,
|
|
135
137
|
revokeOtherSessions,
|
|
136
138
|
linkSocialAccount,
|
|
139
|
+
issueIdTokenNonce,
|
|
137
140
|
listUserAccounts,
|
|
138
141
|
deleteUserCallback,
|
|
139
142
|
unlinkAccount,
|
|
@@ -222,4 +225,4 @@ const router = (ctx, options) => {
|
|
|
222
225
|
});
|
|
223
226
|
};
|
|
224
227
|
//#endregion
|
|
225
|
-
export { APIError, NO_STORE_HEADERS, accountInfo, addOAuthServerContext, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, getAccessToken, getAuthoritativeSessionFromCtx, getEndpoints, getIP, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
|
228
|
+
export { APIError, NO_STORE_HEADERS, accountInfo, addOAuthServerContext, callbackOAuth, changeEmail, changePassword, checkEndpointConflicts, consumeIssuedIdTokenNonce, consumeNonce, createAuthEndpoint, createAuthMiddleware, createEmailVerificationToken, deleteUser, deleteUserCallback, dispatchAuthEndpoint, error, formCsrfMiddleware, freshSessionMiddleware, generateGenericState, getAccessToken, getAuthoritativeSessionFromCtx, getEndpoints, getIP, getOAuthState, getSession, getSessionFromCtx, getShouldSkipSessionRefresh, isAPIError, isStateful, issueIdTokenNonce, issueNonce, issuedNonceResponses, linkSocialAccount, listSessions, listUserAccounts, ok, optionsMiddleware, originCheck, originCheckMiddleware, parseGenericState, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, requireOrgRole, requireResourceOwnership, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, router, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, setShouldSkipSessionRefresh, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
|
@@ -11,6 +11,7 @@ import { toBoolean } from "../../utils/boolean.mjs";
|
|
|
11
11
|
import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils.mjs";
|
|
12
12
|
import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs";
|
|
13
13
|
import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
|
|
14
|
+
import { consumeIssuedIdTokenNonce } from "./id-token-nonce.mjs";
|
|
14
15
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
15
16
|
import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
|
|
16
17
|
import { SocialProviderListEnum } from "@better-auth/core/social-providers";
|
|
@@ -168,6 +169,7 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
|
|
|
168
169
|
throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED);
|
|
169
170
|
}
|
|
170
171
|
const { token, nonce } = c.body.idToken;
|
|
172
|
+
await consumeIssuedIdTokenNonce(c, provider, nonce);
|
|
171
173
|
if (!await verifyProviderIdToken(provider, token, nonce, c)) {
|
|
172
174
|
c.context.logger.warn("Invalid id token", { provider: c.body.provider });
|
|
173
175
|
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { GenericEndpointContext } from "@better-auth/core";
|
|
2
|
+
import { OAuthProvider } from "@better-auth/core/oauth2";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
//#region src/api/routes/id-token-nonce.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Store a new single-use nonce under `scope` and return it. It expires after
|
|
7
|
+
* five minutes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function issueNonce(ctx: GenericEndpointContext, scope: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Consume a nonce that {@link issueNonce} stored under `scope`. Throws
|
|
12
|
+
* `INVALID_ID_TOKEN_NONCE` when it is missing, malformed, unknown, expired, or
|
|
13
|
+
* already used. Call it before verifying the token, so a failed attempt burns
|
|
14
|
+
* the nonce too.
|
|
15
|
+
*/
|
|
16
|
+
export declare function consumeNonce(ctx: GenericEndpointContext, scope: string, nonce: string | undefined): Promise<void>;
|
|
17
|
+
/** OpenAPI responses for an endpoint that returns `{ nonce }` from {@link issueNonce}. */
|
|
18
|
+
export declare const issuedNonceResponses: {
|
|
19
|
+
200: {
|
|
20
|
+
description: string;
|
|
21
|
+
content: {
|
|
22
|
+
"application/json": {
|
|
23
|
+
schema: {
|
|
24
|
+
type: "object";
|
|
25
|
+
properties: {
|
|
26
|
+
nonce: {
|
|
27
|
+
type: "string";
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
required: string[];
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
type ProviderWithOptions = Pick<OAuthProvider, "id" | "options">;
|
|
37
|
+
/**
|
|
38
|
+
* Consume the issued nonce presented with a client-submitted ID token, from
|
|
39
|
+
* `/sign-in/social` or `/link-social`. A no-op for providers without
|
|
40
|
+
* `requireIssuedIdTokenNonce`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function consumeIssuedIdTokenNonce(ctx: GenericEndpointContext, provider: ProviderWithOptions, nonce: string | undefined): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* ### Endpoint
|
|
45
|
+
*
|
|
46
|
+
* POST `/id-token/nonce`
|
|
47
|
+
*
|
|
48
|
+
* Issues a single-use nonce for a client to pass to the provider's SDK before
|
|
49
|
+
* submitting the resulting ID token to `/sign-in/social` or `/link-social`.
|
|
50
|
+
* Only available for providers that set `requireIssuedIdTokenNonce`.
|
|
51
|
+
*/
|
|
52
|
+
export declare const issueIdTokenNonce: import("better-call").StrictEndpoint<"/id-token/nonce", {
|
|
53
|
+
method: "POST";
|
|
54
|
+
body: z.ZodObject<{
|
|
55
|
+
provider: z.ZodType<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown, z.core.$ZodTypeInternals<(string & {}) | "github" | "apple" | "atlassian" | "cloudflare" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "huggingface" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linear" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat", unknown>>;
|
|
56
|
+
}, z.core.$strip>;
|
|
57
|
+
metadata: {
|
|
58
|
+
noStore: boolean;
|
|
59
|
+
openapi: {
|
|
60
|
+
description: string;
|
|
61
|
+
responses: {
|
|
62
|
+
200: {
|
|
63
|
+
description: string;
|
|
64
|
+
content: {
|
|
65
|
+
"application/json": {
|
|
66
|
+
schema: {
|
|
67
|
+
type: "object";
|
|
68
|
+
properties: {
|
|
69
|
+
nonce: {
|
|
70
|
+
type: "string";
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
required: string[];
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}, {
|
|
82
|
+
nonce: string;
|
|
83
|
+
}>;
|
|
84
|
+
//#endregion
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { generateRandomString } from "../../crypto/random.mjs";
|
|
2
|
+
import { getAwaitableValue } from "../../context/helpers.mjs";
|
|
3
|
+
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
4
|
+
import { SocialProviderListEnum } from "@better-auth/core/social-providers";
|
|
5
|
+
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
//#region src/api/routes/id-token-nonce.ts
|
|
8
|
+
const ISSUED_NONCE_EXPIRES_IN_MS = 3e5;
|
|
9
|
+
const ISSUED_NONCE_LENGTH = 32;
|
|
10
|
+
const ISSUED_NONCE_PATTERN = new RegExp(`^[a-zA-Z0-9]{${ISSUED_NONCE_LENGTH}}$`);
|
|
11
|
+
/**
|
|
12
|
+
* Store a new single-use nonce under `scope` and return it. It expires after
|
|
13
|
+
* five minutes.
|
|
14
|
+
*/
|
|
15
|
+
async function issueNonce(ctx, scope) {
|
|
16
|
+
const nonce = generateRandomString(ISSUED_NONCE_LENGTH, "a-z", "A-Z", "0-9");
|
|
17
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
18
|
+
identifier: `${scope}:${nonce}`,
|
|
19
|
+
value: scope,
|
|
20
|
+
expiresAt: new Date(Date.now() + ISSUED_NONCE_EXPIRES_IN_MS)
|
|
21
|
+
});
|
|
22
|
+
return nonce;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Consume a nonce that {@link issueNonce} stored under `scope`. Throws
|
|
26
|
+
* `INVALID_ID_TOKEN_NONCE` when it is missing, malformed, unknown, expired, or
|
|
27
|
+
* already used. Call it before verifying the token, so a failed attempt burns
|
|
28
|
+
* the nonce too.
|
|
29
|
+
*/
|
|
30
|
+
async function consumeNonce(ctx, scope, nonce) {
|
|
31
|
+
if (!(nonce && ISSUED_NONCE_PATTERN.test(nonce) ? await ctx.context.internalAdapter.consumeVerificationValue(`${scope}:${nonce}`) : null)) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_ID_TOKEN_NONCE);
|
|
32
|
+
}
|
|
33
|
+
/** OpenAPI responses for an endpoint that returns `{ nonce }` from {@link issueNonce}. */
|
|
34
|
+
const issuedNonceResponses = { 200: {
|
|
35
|
+
description: "Success",
|
|
36
|
+
content: { "application/json": { schema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: { nonce: { type: "string" } },
|
|
39
|
+
required: ["nonce"]
|
|
40
|
+
} } }
|
|
41
|
+
} };
|
|
42
|
+
const idTokenNonceScope = (providerId) => `id-token-nonce:${providerId}`;
|
|
43
|
+
const requiresIssuedNonce = (provider) => provider.options?.requireIssuedIdTokenNonce === true;
|
|
44
|
+
/**
|
|
45
|
+
* Consume the issued nonce presented with a client-submitted ID token, from
|
|
46
|
+
* `/sign-in/social` or `/link-social`. A no-op for providers without
|
|
47
|
+
* `requireIssuedIdTokenNonce`.
|
|
48
|
+
*/
|
|
49
|
+
async function consumeIssuedIdTokenNonce(ctx, provider, nonce) {
|
|
50
|
+
if (!requiresIssuedNonce(provider)) return;
|
|
51
|
+
await consumeNonce(ctx, idTokenNonceScope(provider.id), nonce);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* ### Endpoint
|
|
55
|
+
*
|
|
56
|
+
* POST `/id-token/nonce`
|
|
57
|
+
*
|
|
58
|
+
* Issues a single-use nonce for a client to pass to the provider's SDK before
|
|
59
|
+
* submitting the resulting ID token to `/sign-in/social` or `/link-social`.
|
|
60
|
+
* Only available for providers that set `requireIssuedIdTokenNonce`.
|
|
61
|
+
*/
|
|
62
|
+
const issueIdTokenNonce = createAuthEndpoint("/id-token/nonce", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: z.object({ provider: SocialProviderListEnum }),
|
|
65
|
+
metadata: {
|
|
66
|
+
noStore: true,
|
|
67
|
+
openapi: {
|
|
68
|
+
description: "Issue a single-use nonce to bind a provider ID token to one sign-in or account-linking request",
|
|
69
|
+
responses: issuedNonceResponses
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, async (ctx) => {
|
|
73
|
+
const provider = await getAwaitableValue(ctx.context.socialProviders, { value: ctx.body.provider });
|
|
74
|
+
if (!provider) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.PROVIDER_NOT_FOUND);
|
|
75
|
+
if (!requiresIssuedNonce(provider)) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ID_TOKEN_NONCE_NOT_ENABLED);
|
|
76
|
+
const nonce = await issueNonce(ctx, idTokenNonceScope(provider.id));
|
|
77
|
+
return ctx.json({ nonce });
|
|
78
|
+
});
|
|
79
|
+
//#endregion
|
|
80
|
+
export { consumeIssuedIdTokenNonce, consumeNonce, issueIdTokenNonce, issueNonce, issuedNonceResponses };
|
|
@@ -2,6 +2,7 @@ import { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refre
|
|
|
2
2
|
import { callbackOAuth } from "./callback.mjs";
|
|
3
3
|
import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail } from "./email-verification.mjs";
|
|
4
4
|
import { error } from "./error.mjs";
|
|
5
|
+
import { consumeIssuedIdTokenNonce, consumeNonce, issueIdTokenNonce, issueNonce, issuedNonceResponses } from "./id-token-nonce.mjs";
|
|
5
6
|
import { ok } from "./ok.mjs";
|
|
6
7
|
import { requestPasswordReset, requestPasswordResetCallback, resetPassword, verifyPassword } from "./password.mjs";
|
|
7
8
|
import { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
|
|
@@ -10,4 +11,4 @@ import { signOut } from "./sign-out.mjs";
|
|
|
10
11
|
import { signUpEmail } from "./sign-up.mjs";
|
|
11
12
|
import { updateSession } from "./update-session.mjs";
|
|
12
13
|
import { changeEmail, changePassword, deleteUser, deleteUserCallback, setPassword, updateUser } from "./update-user.mjs";
|
|
13
|
-
export { accountInfo, callbackOAuth, changeEmail, changePassword, createEmailVerificationToken, deleteUser, deleteUserCallback, error, freshSessionMiddleware, getAccessToken, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, linkSocialAccount, listSessions, listUserAccounts, ok, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
|
14
|
+
export { accountInfo, callbackOAuth, changeEmail, changePassword, consumeIssuedIdTokenNonce, consumeNonce, createEmailVerificationToken, deleteUser, deleteUserCallback, error, freshSessionMiddleware, getAccessToken, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, issueIdTokenNonce, issueNonce, issuedNonceResponses, linkSocialAccount, listSessions, listUserAccounts, ok, refreshToken, requestOnlySessionMiddleware, requestPasswordReset, requestPasswordResetCallback, resetPassword, revokeOtherSessions, revokeSession, revokeSessions, sendVerificationEmail, sendVerificationEmailFn, sensitiveSessionMiddleware, sessionMiddleware, setPassword, signInEmail, signInSocial, signOut, signUpEmail, unlinkAccount, updateSession, updateUser, verifyEmail, verifyPassword };
|
|
@@ -7,6 +7,7 @@ import { OAUTH_CALLBACK_ERROR_CODES, missingEmailLogMessage } from "../../oauth2
|
|
|
7
7
|
import { getOAuthCallbackPath } from "../../oauth2/utils.mjs";
|
|
8
8
|
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
9
9
|
import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
|
|
10
|
+
import { consumeIssuedIdTokenNonce } from "./id-token-nonce.mjs";
|
|
10
11
|
import { safeCloneRequest } from "../../utils/request.mjs";
|
|
11
12
|
import { createEmailVerificationToken } from "./email-verification.mjs";
|
|
12
13
|
import { assertPasswordNotTooLong } from "../../utils/password.mjs";
|
|
@@ -157,6 +158,7 @@ const signInSocial = () => createAuthEndpoint("/sign-in/social", {
|
|
|
157
158
|
throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED);
|
|
158
159
|
}
|
|
159
160
|
const { token, nonce } = c.body.idToken;
|
|
161
|
+
await consumeIssuedIdTokenNonce(c, provider, nonce);
|
|
160
162
|
if (!await verifyProviderIdToken(provider, token, nonce, c)) {
|
|
161
163
|
c.context.logger.warn("Invalid id token", { provider: c.body.provider });
|
|
162
164
|
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildSchema } from "./get-schema.mjs";
|
|
2
2
|
import { createLogger } from "@better-auth/core/env";
|
|
3
3
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
4
|
-
import { createKyselyAdapter, getMssqlSchema, getPostgresSchema, toIntrospectedTables, toPhysicalSchema } from "@better-auth/kysely-adapter";
|
|
4
|
+
import { createKyselyAdapter, getMssqlSchema, getPostgresSchema, introspectPostgresTables, toIntrospectedTables, toPhysicalSchema } from "@better-auth/kysely-adapter";
|
|
5
5
|
import { initGetFieldName, initGetModelName } from "@better-auth/core/db/adapter";
|
|
6
6
|
import { diffSchema, formatSchemaFinding, getDatabaseFieldIndexName, getDatabaseIndexStringLength, getPortableDatabaseIdentifierKey, invalidateSchemaChecks } from "@better-auth/core/db/internal";
|
|
7
7
|
import { sql } from "kysely";
|
|
@@ -354,9 +354,8 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
354
354
|
logger.error("Only kysely adapter is supported for migrations. You can use `generate` command to generate the schema, if you're using a different adapter.");
|
|
355
355
|
process.exit(1);
|
|
356
356
|
}
|
|
357
|
-
const allTableMetadata = await db.introspection.getTables();
|
|
358
357
|
let target;
|
|
359
|
-
let tableMetadata
|
|
358
|
+
let tableMetadata;
|
|
360
359
|
switch (dbType) {
|
|
361
360
|
case "postgres": {
|
|
362
361
|
const schema = schemaName ?? await getPostgresSchema(db);
|
|
@@ -373,11 +372,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
373
372
|
} catch (error) {
|
|
374
373
|
logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`);
|
|
375
374
|
}
|
|
376
|
-
|
|
377
|
-
* Kysely 0.28 does not expose `isForeign`, while 0.29 adds foreign table metadata.
|
|
378
|
-
* @see https://github.com/kysely-org/kysely/pull/1494
|
|
379
|
-
*/
|
|
380
|
-
tableMetadata = allTableMetadata.filter((table) => table.schema === schema && !table.isView && !("isForeign" in table && table.isForeign));
|
|
375
|
+
tableMetadata = (await introspectPostgresTables(db, [schema])).filter((table) => !table.isView && !table.isForeign);
|
|
381
376
|
logger.debug(`Found ${tableMetadata.length} table(s) in schema '${schema}': ${tableMetadata.map((table) => table.name).join(", ") || "(none)"}`);
|
|
382
377
|
break;
|
|
383
378
|
}
|
|
@@ -388,15 +383,18 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
388
383
|
schema
|
|
389
384
|
};
|
|
390
385
|
logger.debug(`SQL Server migration: Using schema '${schema}' (from the current user's default schema)`);
|
|
391
|
-
tableMetadata =
|
|
386
|
+
tableMetadata = (await db.introspection.getTables()).filter((table) => table.schema === schema);
|
|
392
387
|
break;
|
|
393
388
|
}
|
|
394
389
|
case "mysql":
|
|
395
390
|
target = { type: "mysql" };
|
|
391
|
+
tableMetadata = await db.introspection.getTables();
|
|
396
392
|
break;
|
|
397
|
-
case "sqlite":
|
|
393
|
+
case "sqlite":
|
|
394
|
+
target = { type: "sqlite" };
|
|
395
|
+
tableMetadata = await db.introspection.getTables();
|
|
398
396
|
}
|
|
399
|
-
const databaseIndexMap = await getDatabaseIndexMap(db, target,
|
|
397
|
+
const databaseIndexMap = await getDatabaseIndexMap(db, target, tableMetadata.map((table) => table.name), introspectIndexes);
|
|
400
398
|
const databaseColumnBounds = await getDatabaseColumnBounds(db, target);
|
|
401
399
|
const schemaProblems = diffSchema(toPhysicalSchema(db, betterAuthSchema), toIntrospectedTables(tableMetadata)).filter((finding) => finding.kind === "unexpected-required-column").map((finding) => formatSchemaFinding(finding, "database"));
|
|
402
400
|
const toBeCreated = [];
|
|
@@ -131,6 +131,20 @@ const createInternalAdapter = (adapter, ctx) => {
|
|
|
131
131
|
if (verificationConsumeLocks.get(key) === next) verificationConsumeLocks.delete(key);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
async function deleteExpiredVerifications() {
|
|
135
|
+
if (options.verification?.disableCleanup) return;
|
|
136
|
+
await queueAfterTransactionHook(async () => {
|
|
137
|
+
try {
|
|
138
|
+
await deleteManyWithHooks([{
|
|
139
|
+
field: "expiresAt",
|
|
140
|
+
value: /* @__PURE__ */ new Date(),
|
|
141
|
+
operator: "lt"
|
|
142
|
+
}], "verification", void 0);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.error("Failed to delete expired verification values", error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
134
148
|
return {
|
|
135
149
|
createOAuthUser: async (user, account) => {
|
|
136
150
|
return runWithTransaction(adapter, async () => {
|
|
@@ -795,11 +809,7 @@ const createInternalAdapter = (adapter, ctx) => {
|
|
|
795
809
|
}
|
|
796
810
|
let verification = await findByIdentifier(storedIdentifier);
|
|
797
811
|
if (!verification.length && storageOption && storageOption !== "plain") verification = await findByIdentifier(identifier);
|
|
798
|
-
|
|
799
|
-
field: "expiresAt",
|
|
800
|
-
value: /* @__PURE__ */ new Date(),
|
|
801
|
-
operator: "lt"
|
|
802
|
-
}], "verification", void 0);
|
|
812
|
+
await deleteExpiredVerifications();
|
|
803
813
|
return verification[0] || null;
|
|
804
814
|
},
|
|
805
815
|
deleteVerificationByIdentifier: async (identifier) => {
|
|
@@ -898,6 +908,7 @@ const createInternalAdapter = (adapter, ctx) => {
|
|
|
898
908
|
if (consumed) break;
|
|
899
909
|
}
|
|
900
910
|
if (consumed && secondaryStorage) await Promise.all(identifiersToTry.map((stored) => secondaryStorage.delete(`verification:${stored}`)));
|
|
911
|
+
if (consumed) await deleteExpiredVerifications();
|
|
901
912
|
}
|
|
902
913
|
if (!consumed || consumed.expiresAt < /* @__PURE__ */ new Date()) return null;
|
|
903
914
|
return consumed;
|
|
@@ -930,7 +941,10 @@ const createInternalAdapter = (adapter, ctx) => {
|
|
|
930
941
|
* is best-effort there (it is intended for development and tests).
|
|
931
942
|
*/
|
|
932
943
|
reserveVerificationValue: async (data) => {
|
|
933
|
-
const reservationId = encodeDeterministicId(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), options
|
|
944
|
+
const reservationId = encodeDeterministicId(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), options, {
|
|
945
|
+
adapter,
|
|
946
|
+
model: "verification"
|
|
947
|
+
});
|
|
934
948
|
const storageOption = getStorageOption(data.identifier, options.verification?.storeIdentifier);
|
|
935
949
|
const storedIdentifier = await processIdentifier(data.identifier, storageOption);
|
|
936
950
|
if (secondaryStorage && !options.verification?.storeInDatabase) throw new BetterAuthError("reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values.");
|
package/dist/package.mjs
CHANGED
|
@@ -634,6 +634,21 @@ const deviceVerify = (grant) => createAuthEndpoint("/device", {
|
|
|
634
634
|
increment: {},
|
|
635
635
|
set: { userId: session.user.id }
|
|
636
636
|
})) deviceCodeRecord.userId = session.user.id;
|
|
637
|
+
else {
|
|
638
|
+
const current = await ctx.context.adapter.findOne({
|
|
639
|
+
model: "deviceCode",
|
|
640
|
+
where: [{
|
|
641
|
+
field: "id",
|
|
642
|
+
value: deviceCodeRecord.id
|
|
643
|
+
}]
|
|
644
|
+
});
|
|
645
|
+
if (!current) throw new APIError("BAD_REQUEST", {
|
|
646
|
+
error: "invalid_request",
|
|
647
|
+
error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message
|
|
648
|
+
});
|
|
649
|
+
deviceCodeRecord.userId = current.userId;
|
|
650
|
+
deviceCodeRecord.status = current.status;
|
|
651
|
+
}
|
|
637
652
|
}
|
|
638
653
|
const canReviewRequest = session?.user.id !== void 0 && deviceCodeRecord.userId === session.user.id;
|
|
639
654
|
return ctx.json({
|
|
@@ -25,10 +25,7 @@ declare module "@better-auth/core" {
|
|
|
25
25
|
* Extracts common fields from GenericOAuthConfig for provider helpers.
|
|
26
26
|
*/
|
|
27
27
|
export type BaseOAuthProviderOptions = Pick<GenericOAuthConfig, "clientId" | "clientSecret" | "tokenEndpointAuth" | "scopes" | "redirectURI" | "endSessionEndpoint" | "postLogoutRedirectURI" | "disableProviderLogout" | "pkce" | "disableImplicitSignUp" | "disableSignUp" | "overrideUserInfo">;
|
|
28
|
-
type PendingDiscovery = Map<string,
|
|
29
|
-
config: GenericOAuthConfig;
|
|
30
|
-
attempt?: Promise<void> | undefined;
|
|
31
|
-
}>;
|
|
28
|
+
type PendingDiscovery = Map<string, GenericOAuthConfig>;
|
|
32
29
|
/**
|
|
33
30
|
* A generic OAuth plugin that registers any OAuth/OIDC provider
|
|
34
31
|
* as a first-class social provider.
|
|
@@ -25,6 +25,7 @@ function isClientSecretTokenEndpointAuth(tokenEndpointAuth) {
|
|
|
25
25
|
}
|
|
26
26
|
/** A request waits on a discovery retry, so bound it (the SSO plugin uses 10s too). */
|
|
27
27
|
const DISCOVERY_RETRY_TIMEOUT_MS = 1e4;
|
|
28
|
+
const MAX_DISCOVERY_REDIRECTS = 5;
|
|
28
29
|
/**
|
|
29
30
|
* Providers skipped because startup discovery failed. Init stores them on each auth
|
|
30
31
|
* instance's context, so one plugin object can serve several instances. It is a context
|
|
@@ -44,18 +45,35 @@ const readIdTokenClaims = (idToken) => {
|
|
|
44
45
|
}
|
|
45
46
|
};
|
|
46
47
|
async function fetchDiscovery(url, headers, timeout) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
const signal = timeout ? AbortSignal.timeout(timeout) : void 0;
|
|
49
|
+
let target = url;
|
|
50
|
+
for (let hop = 0;; hop++) {
|
|
51
|
+
const redirect = {};
|
|
52
|
+
const result = await betterFetch(target, {
|
|
53
|
+
method: "GET",
|
|
54
|
+
headers,
|
|
55
|
+
redirect: headers ? "manual" : void 0,
|
|
56
|
+
signal,
|
|
57
|
+
onError: ({ response }) => {
|
|
58
|
+
if (response.status >= 300 && response.status < 400) redirect.location = response.headers.get("location");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (headers && redirect.location) {
|
|
62
|
+
const next = new URL(redirect.location, target);
|
|
63
|
+
if (next.origin !== new URL(url).origin) throw new Error(`discovery redirected to ${next.origin}; with discoveryHeaders set, only same-origin redirects are followed. Set discoveryUrl to the final URL.`);
|
|
64
|
+
if (hop >= MAX_DISCOVERY_REDIRECTS) throw new Error(`discovery redirected more than ${MAX_DISCOVERY_REDIRECTS} times. Set discoveryUrl to the final URL.`);
|
|
65
|
+
target = next.href;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (headers && result.error?.status === 0) throw new Error("discovery redirected; with discoveryHeaders set, set discoveryUrl to the final URL.");
|
|
69
|
+
if (result.error || !result.data) return null;
|
|
70
|
+
if (result.data.issuer) try {
|
|
71
|
+
new URL(result.data.issuer);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return result.data;
|
|
57
76
|
}
|
|
58
|
-
return result.data;
|
|
59
77
|
}
|
|
60
78
|
async function fetchUserInfo(tokens, userInfoUrl, idTokenClaims) {
|
|
61
79
|
if (tokens.idToken) {
|
|
@@ -100,6 +118,7 @@ const genericOAuth = (options) => {
|
|
|
100
118
|
const buildProviders = async (configs, ctx, discoveryTimeout) => {
|
|
101
119
|
const genericProviders = [];
|
|
102
120
|
for (const c of configs) {
|
|
121
|
+
if (Object.keys(c.authorizationHeaders ?? {}).some((name) => name.toLowerCase() === "authorization")) throw new Error(`Provider "${c.providerId}": authorizationHeaders cannot set "Authorization". Use tokenEndpointAuth: { method: "custom", customizeRequest } instead.`);
|
|
103
122
|
let authorizationUrl = c.authorizationUrl;
|
|
104
123
|
let tokenUrl = c.tokenUrl;
|
|
105
124
|
let userInfoUrl = c.userInfoUrl;
|
|
@@ -128,8 +147,9 @@ const genericOAuth = (options) => {
|
|
|
128
147
|
ctx.logger.error(`Provider "${c.providerId}": invalid jwks_uri "${discovered.jwks_uri}" in discovery document. Provider skipped.`);
|
|
129
148
|
continue;
|
|
130
149
|
}
|
|
150
|
+
const jwksHeaders = jwksUrl.origin === new URL(c.discoveryUrl).origin ? c.discoveryHeaders : void 0;
|
|
131
151
|
idTokenConfig = {
|
|
132
|
-
jwks: createRemoteJWKSet(jwksUrl),
|
|
152
|
+
jwks: createRemoteJWKSet(jwksUrl, jwksHeaders && { headers: jwksHeaders }),
|
|
133
153
|
issuer: discovered.issuer,
|
|
134
154
|
audience: c.clientId,
|
|
135
155
|
algorithms: isOidc ? signingAlgs : void 0
|
|
@@ -287,7 +307,8 @@ const genericOAuth = (options) => {
|
|
|
287
307
|
authentication: c.authentication,
|
|
288
308
|
tokenEndpointAuth,
|
|
289
309
|
tokenEndpoint: tokenUrl,
|
|
290
|
-
extraParams: resolvedRefreshParams
|
|
310
|
+
extraParams: resolvedRefreshParams,
|
|
311
|
+
headers: c.authorizationHeaders
|
|
291
312
|
});
|
|
292
313
|
return applyDefaultAccessTokenExpiry(tokens, c.accessTokenExpiresIn);
|
|
293
314
|
},
|
|
@@ -303,18 +324,13 @@ const genericOAuth = (options) => {
|
|
|
303
324
|
}
|
|
304
325
|
return genericProviders;
|
|
305
326
|
};
|
|
306
|
-
const retryDiscovery = (ctx, providerId) => {
|
|
327
|
+
const retryDiscovery = async (ctx, providerId) => {
|
|
307
328
|
const pendingDiscovery = getPendingDiscovery(ctx);
|
|
308
329
|
const pending = pendingDiscovery?.get(providerId);
|
|
309
330
|
if (!pending) return;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
ctx.socialProviders.unshift(provider);
|
|
314
|
-
}).finally(() => {
|
|
315
|
-
pending.attempt = void 0;
|
|
316
|
-
});
|
|
317
|
-
return pending.attempt;
|
|
331
|
+
const [provider] = await buildProviders([pending], ctx, DISCOVERY_RETRY_TIMEOUT_MS);
|
|
332
|
+
if (!provider || !pendingDiscovery?.delete(providerId)) return;
|
|
333
|
+
ctx.socialProviders.unshift(provider);
|
|
318
334
|
};
|
|
319
335
|
const requestedProviderId = (context) => {
|
|
320
336
|
const id = context.body?.provider ?? context.body?.providerId ?? context.query?.provider ?? context.params?.id;
|
|
@@ -326,7 +342,7 @@ const genericOAuth = (options) => {
|
|
|
326
342
|
init: async (ctx) => {
|
|
327
343
|
const genericProviders = await buildProviders(options.config, ctx);
|
|
328
344
|
const built = new Set(genericProviders.map((p) => p.id));
|
|
329
|
-
const genericOAuthPendingDiscovery = new Map(options.config.filter((c) => !built.has(c.providerId)).map((c) => [c.providerId,
|
|
345
|
+
const genericOAuthPendingDiscovery = new Map(options.config.filter((c) => !built.has(c.providerId)).map((c) => [c.providerId, c]));
|
|
330
346
|
const existingIds = new Set(ctx.socialProviders.map((p) => p.id));
|
|
331
347
|
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`);
|
|
332
348
|
return { context: {
|
|
@@ -211,13 +211,21 @@ export interface GenericOAuthConfig<ID extends string = string> {
|
|
|
211
211
|
*/
|
|
212
212
|
authentication?: ("basic" | "post") | undefined;
|
|
213
213
|
/**
|
|
214
|
-
* Custom headers to include in the discovery request
|
|
214
|
+
* Custom headers to include in the discovery request, and in the JWKS request when
|
|
215
|
+
* `jwks_uri` has the same origin as `discoveryUrl`. When set, discovery follows
|
|
216
|
+
* redirects only within that origin and fails on a redirect to another origin.
|
|
215
217
|
* Useful for providers like Epic that require specific headers (e.g., Epic-Client-ID).
|
|
216
218
|
*/
|
|
217
219
|
discoveryHeaders?: Record<string, string> | undefined;
|
|
218
220
|
/**
|
|
219
|
-
* Custom headers to include in the authorization
|
|
221
|
+
* Custom headers to include in token endpoint requests: the authorization
|
|
222
|
+
* code exchange and token refresh. A custom `getToken` replaces the default
|
|
223
|
+
* code exchange, so it must add these headers itself.
|
|
220
224
|
* Useful for providers like Qonto that require specific headers (e.g., X-Qonto-Staging-Token for local development).
|
|
225
|
+
*
|
|
226
|
+
* `Authorization` is not allowed in any letter case, and initialization fails
|
|
227
|
+
* if it is present. Set it from `tokenEndpointAuth` with `method: "custom"`
|
|
228
|
+
* and `customizeRequest` instead.
|
|
221
229
|
*/
|
|
222
230
|
authorizationHeaders?: Record<string, string> | undefined;
|
|
223
231
|
/**
|
|
@@ -138,6 +138,10 @@ export interface GoogleOneTapActionOptions extends Omit<GoogleOneTapOptions, "cl
|
|
|
138
138
|
* This lets you render an alternative UI (e.g. a Google Sign-In button) to restart the process.
|
|
139
139
|
*/
|
|
140
140
|
onPromptNotification?: ((notification?: any | undefined) => void) | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* @deprecated Ignored. The client fetches a single-use nonce from
|
|
143
|
+
* `/one-tap/nonce` for every prompt and button render.
|
|
144
|
+
*/
|
|
141
145
|
nonce?: string | undefined;
|
|
142
146
|
/**
|
|
143
147
|
* Button mode configuration. When provided, renders a "Sign In with Google" button
|
|
@@ -2,6 +2,9 @@ import { PACKAGE_VERSION } from "../../version.mjs";
|
|
|
2
2
|
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
|
|
3
3
|
//#region src/plugins/one-tap/client.ts
|
|
4
4
|
let isRequestInProgress = false;
|
|
5
|
+
const BUTTON_NONCE_REFRESH_MS = 24e4;
|
|
6
|
+
const BUTTON_NONCE_RETRY_MS = 1e4;
|
|
7
|
+
const buttonRefreshTimers = /* @__PURE__ */ new WeakMap();
|
|
5
8
|
function isFedCMSupported() {
|
|
6
9
|
return typeof window !== "undefined" && "IdentityCredential" in window;
|
|
7
10
|
}
|
|
@@ -33,102 +36,112 @@ const oneTapClient = (options) => {
|
|
|
33
36
|
console.warn("Google One Tap is only available in browser environments");
|
|
34
37
|
return;
|
|
35
38
|
}
|
|
36
|
-
if (opts?.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!container) {
|
|
40
|
-
console.error("Google One Tap: Button container not found", opts.button.container);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
async function callback(idToken) {
|
|
44
|
-
if ((await $fetch("/one-tap/callback", {
|
|
45
|
-
method: "POST",
|
|
46
|
-
body: {
|
|
47
|
-
idToken,
|
|
48
|
-
callbackURL: opts?.callbackURL
|
|
49
|
-
},
|
|
50
|
-
...opts?.fetchOptions,
|
|
51
|
-
...fetchOptions
|
|
52
|
-
}))?.error) return;
|
|
53
|
-
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
|
|
54
|
-
const target = opts?.callbackURL ?? "/";
|
|
55
|
-
if (isSafeUrlScheme(target)) window.location.href = target;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const autoSelect = opts?.autoSelect ?? options.autoSelect;
|
|
59
|
-
const cancelOnTapOutside = opts?.cancelOnTapOutside ?? options.cancelOnTapOutside;
|
|
60
|
-
const contextValue = opts?.context ?? options.context ?? "signin";
|
|
61
|
-
window.google?.accounts.id.initialize({
|
|
62
|
-
client_id: options.clientId,
|
|
63
|
-
callback: async (response) => {
|
|
64
|
-
try {
|
|
65
|
-
await callback(response.credential);
|
|
66
|
-
} catch (error) {
|
|
67
|
-
console.error("Error during button callback:", error);
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
auto_select: autoSelect,
|
|
71
|
-
cancel_on_tap_outside: cancelOnTapOutside,
|
|
72
|
-
context: contextValue,
|
|
73
|
-
ux_mode: opts?.uxMode || "popup",
|
|
74
|
-
nonce: opts?.nonce,
|
|
75
|
-
itp_support: true,
|
|
76
|
-
...opts.button.fedCM && {
|
|
77
|
-
use_fedcm_for_button: true,
|
|
78
|
-
button_auto_select: autoSelect
|
|
79
|
-
},
|
|
80
|
-
...options.additionalOptions
|
|
81
|
-
});
|
|
82
|
-
window.google?.accounts.id.renderButton(container, opts.button.config ?? { type: "icon" });
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
async function callback(idToken) {
|
|
39
|
+
if (opts?.nonce !== void 0) console.warn("Google One Tap: the `nonce` option is ignored; the nonce is issued by the server.");
|
|
40
|
+
/** Returns whether it navigated away. */
|
|
41
|
+
async function callback(idToken, nonce) {
|
|
86
42
|
if ((await $fetch("/one-tap/callback", {
|
|
87
43
|
method: "POST",
|
|
88
44
|
body: {
|
|
89
45
|
idToken,
|
|
46
|
+
nonce,
|
|
90
47
|
callbackURL: opts?.callbackURL
|
|
91
48
|
},
|
|
92
49
|
...opts?.fetchOptions,
|
|
93
50
|
...fetchOptions
|
|
94
|
-
}))?.error) return;
|
|
51
|
+
}))?.error) return false;
|
|
95
52
|
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
|
|
96
53
|
const target = opts?.callbackURL ?? "/";
|
|
97
|
-
if (isSafeUrlScheme(target))
|
|
54
|
+
if (isSafeUrlScheme(target)) {
|
|
55
|
+
window.location.href = target;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
98
58
|
}
|
|
59
|
+
return false;
|
|
99
60
|
}
|
|
61
|
+
const { body: _body, query: _query, params: _params, output: _output, errorSchema: _errorSchema, onResponse: _onResponse, onSuccess: _onSuccess, onError: _onError, onRetry: _onRetry, ...nonceFetchOptions } = {
|
|
62
|
+
...opts?.fetchOptions,
|
|
63
|
+
...fetchOptions
|
|
64
|
+
};
|
|
65
|
+
const fetchNonce = async () => {
|
|
66
|
+
const { data, error } = await $fetch("/one-tap/nonce", {
|
|
67
|
+
...nonceFetchOptions,
|
|
68
|
+
method: "POST",
|
|
69
|
+
throw: false
|
|
70
|
+
});
|
|
71
|
+
if (!data?.nonce) throw new Error("Google One Tap: failed to fetch a nonce", { cause: error });
|
|
72
|
+
return data.nonce;
|
|
73
|
+
};
|
|
100
74
|
const autoSelect = opts?.autoSelect ?? options.autoSelect;
|
|
101
|
-
const
|
|
102
|
-
|
|
75
|
+
const initialize = (nonce, onCredential) => window.google?.accounts.id.initialize({
|
|
76
|
+
client_id: options.clientId,
|
|
77
|
+
callback: onCredential,
|
|
78
|
+
auto_select: autoSelect,
|
|
79
|
+
cancel_on_tap_outside: opts?.cancelOnTapOutside ?? options.cancelOnTapOutside,
|
|
80
|
+
context: opts?.context ?? options.context ?? "signin",
|
|
81
|
+
ux_mode: opts?.uxMode || "popup",
|
|
82
|
+
/**
|
|
83
|
+
* @see {@link https://developers.google.com/identity/gsi/web/guides/overview}
|
|
84
|
+
*/
|
|
85
|
+
itp_support: true,
|
|
86
|
+
...opts?.button?.fedCM && {
|
|
87
|
+
use_fedcm_for_button: true,
|
|
88
|
+
button_auto_select: autoSelect
|
|
89
|
+
},
|
|
90
|
+
...options.additionalOptions,
|
|
91
|
+
nonce
|
|
92
|
+
});
|
|
93
|
+
if (opts?.button) {
|
|
94
|
+
await loadGoogleScript();
|
|
95
|
+
const container = typeof opts.button.container === "string" ? document.querySelector(opts.button.container) : opts.button.container;
|
|
96
|
+
if (!container) {
|
|
97
|
+
console.error("Google One Tap: Button container not found", opts.button.container);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const buttonConfig = opts.button.config ?? { type: "icon" };
|
|
101
|
+
const renderButton = async () => {
|
|
102
|
+
clearTimeout(buttonRefreshTimers.get(container));
|
|
103
|
+
const nonce = await fetchNonce();
|
|
104
|
+
initialize(nonce, async (response) => {
|
|
105
|
+
let navigated = false;
|
|
106
|
+
try {
|
|
107
|
+
navigated = await callback(response.credential, nonce);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error("Error during button callback:", error);
|
|
110
|
+
}
|
|
111
|
+
if (!navigated) await refreshButton();
|
|
112
|
+
});
|
|
113
|
+
container.replaceChildren();
|
|
114
|
+
buttonRefreshTimers.set(container, setTimeout(refreshButton, BUTTON_NONCE_REFRESH_MS));
|
|
115
|
+
window.google?.accounts.id.renderButton(container, buttonConfig);
|
|
116
|
+
};
|
|
117
|
+
const refreshButton = async () => {
|
|
118
|
+
if (!container.isConnected) return;
|
|
119
|
+
try {
|
|
120
|
+
await renderButton();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error("Google One Tap: failed to refresh the button", error);
|
|
123
|
+
buttonRefreshTimers.set(container, setTimeout(refreshButton, BUTTON_NONCE_RETRY_MS));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
await renderButton();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
103
129
|
isRequestInProgress = true;
|
|
104
130
|
try {
|
|
105
|
-
await loadGoogleScript();
|
|
131
|
+
const [, nonce] = await Promise.all([loadGoogleScript(), fetchNonce()]);
|
|
106
132
|
await new Promise((resolve, reject) => {
|
|
107
133
|
let isResolved = false;
|
|
108
134
|
const baseDelay = options.promptOptions?.baseDelay ?? 1e3;
|
|
109
135
|
const maxAttempts = options.promptOptions?.maxAttempts ?? 5;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
reject(error);
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
auto_select: autoSelect,
|
|
123
|
-
cancel_on_tap_outside: cancelOnTapOutside,
|
|
124
|
-
context: contextValue,
|
|
125
|
-
ux_mode: opts?.uxMode || "popup",
|
|
126
|
-
nonce: opts?.nonce,
|
|
127
|
-
/**
|
|
128
|
-
* @see {@link https://developers.google.com/identity/gsi/web/guides/overview}
|
|
129
|
-
*/
|
|
130
|
-
itp_support: true,
|
|
131
|
-
...options.additionalOptions
|
|
136
|
+
initialize(nonce, async (response) => {
|
|
137
|
+
isResolved = true;
|
|
138
|
+
try {
|
|
139
|
+
await callback(response.credential, nonce);
|
|
140
|
+
resolve();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error("Error during One Tap callback:", error);
|
|
143
|
+
reject(error);
|
|
144
|
+
}
|
|
132
145
|
});
|
|
133
146
|
const handlePrompt = (attempt) => {
|
|
134
147
|
if (isResolved) return;
|
|
@@ -26,10 +26,50 @@ export declare const oneTap: (options?: OneTapOptions | undefined) => {
|
|
|
26
26
|
id: "one-tap";
|
|
27
27
|
version: string;
|
|
28
28
|
endpoints: {
|
|
29
|
+
/**
|
|
30
|
+
* ### Endpoint
|
|
31
|
+
*
|
|
32
|
+
* POST `/one-tap/nonce`
|
|
33
|
+
*
|
|
34
|
+
* Issues a single-use nonce to pass to Google before requesting the
|
|
35
|
+
* One Tap ID token. `/one-tap/callback` consumes it and requires the
|
|
36
|
+
* token's `nonce` claim to match.
|
|
37
|
+
*/
|
|
38
|
+
oneTapNonce: import("better-call").StrictEndpoint<"/one-tap/nonce", {
|
|
39
|
+
method: "POST";
|
|
40
|
+
metadata: {
|
|
41
|
+
noStore: boolean;
|
|
42
|
+
openapi: {
|
|
43
|
+
summary: string;
|
|
44
|
+
description: string;
|
|
45
|
+
responses: {
|
|
46
|
+
200: {
|
|
47
|
+
description: string;
|
|
48
|
+
content: {
|
|
49
|
+
"application/json": {
|
|
50
|
+
schema: {
|
|
51
|
+
type: "object";
|
|
52
|
+
properties: {
|
|
53
|
+
nonce: {
|
|
54
|
+
type: "string";
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
required: string[];
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}, {
|
|
66
|
+
nonce: string;
|
|
67
|
+
}>;
|
|
29
68
|
oneTapCallback: import("better-call").StrictEndpoint<"/one-tap/callback", {
|
|
30
69
|
method: "POST";
|
|
31
70
|
body: z.ZodObject<{
|
|
32
71
|
idToken: z.ZodString;
|
|
72
|
+
nonce: z.ZodString;
|
|
33
73
|
callbackURL: z.ZodOptional<z.ZodString>;
|
|
34
74
|
}, z.core.$strip>;
|
|
35
75
|
metadata: {
|
|
@@ -5,6 +5,7 @@ import { toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
|
|
|
5
5
|
import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/errors.mjs";
|
|
6
6
|
import { toBoolean } from "../../utils/boolean.mjs";
|
|
7
7
|
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
8
|
+
import { consumeNonce, issueNonce, issuedNonceResponses } from "../../api/routes/id-token-nonce.mjs";
|
|
8
9
|
import { APIError as APIError$1 } from "../../api/index.mjs";
|
|
9
10
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
10
11
|
import { BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
@@ -14,6 +15,7 @@ import * as z from "zod";
|
|
|
14
15
|
//#region src/plugins/one-tap/index.ts
|
|
15
16
|
const oneTapCallbackBodySchema = z.object({
|
|
16
17
|
idToken: z.string().meta({ description: "Google ID token, which the client obtains from the One Tap API" }),
|
|
18
|
+
nonce: z.string().meta({ description: "Nonce issued by /one-tap/nonce and passed to Google when the ID token was requested" }),
|
|
17
19
|
/**
|
|
18
20
|
* Sent so the global origin-check middleware validates the post-login
|
|
19
21
|
* redirect target against `trustedOrigins`. Without it the client performs
|
|
@@ -21,98 +23,123 @@ const oneTapCallbackBodySchema = z.object({
|
|
|
21
23
|
*/
|
|
22
24
|
callbackURL: z.string().meta({ description: "URL to redirect to after a successful sign-in" }).optional()
|
|
23
25
|
});
|
|
26
|
+
const ONE_TAP_NONCE_SCOPE = "one-tap-nonce";
|
|
24
27
|
const oneTap = (options) => ({
|
|
25
28
|
id: "one-tap",
|
|
26
29
|
version: PACKAGE_VERSION,
|
|
27
|
-
endpoints: {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
endpoints: {
|
|
31
|
+
/**
|
|
32
|
+
* ### Endpoint
|
|
33
|
+
*
|
|
34
|
+
* POST `/one-tap/nonce`
|
|
35
|
+
*
|
|
36
|
+
* Issues a single-use nonce to pass to Google before requesting the
|
|
37
|
+
* One Tap ID token. `/one-tap/callback` consumes it and requires the
|
|
38
|
+
* token's `nonce` claim to match.
|
|
39
|
+
*/
|
|
40
|
+
oneTapNonce: createAuthEndpoint("/one-tap/nonce", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
metadata: {
|
|
43
|
+
noStore: true,
|
|
44
|
+
openapi: {
|
|
45
|
+
summary: "Issue a One Tap nonce",
|
|
46
|
+
description: "Issue a single-use nonce to bind a Google One Tap ID token to one sign-in",
|
|
47
|
+
responses: issuedNonceResponses
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}, async (ctx) => ctx.json({ nonce: await issueNonce(ctx, ONE_TAP_NONCE_SCOPE) })),
|
|
51
|
+
oneTapCallback: createAuthEndpoint("/one-tap/callback", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
body: oneTapCallbackBodySchema,
|
|
54
|
+
metadata: { openapi: {
|
|
55
|
+
summary: "One tap callback",
|
|
56
|
+
description: "Use this endpoint to authenticate with Google One Tap",
|
|
57
|
+
responses: {
|
|
58
|
+
200: {
|
|
59
|
+
description: "Successful response",
|
|
60
|
+
content: { "application/json": { schema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
session: { $ref: "#/components/schemas/Session" },
|
|
64
|
+
user: { $ref: "#/components/schemas/User" }
|
|
65
|
+
}
|
|
66
|
+
} } }
|
|
67
|
+
},
|
|
68
|
+
400: { description: "Invalid token" }
|
|
69
|
+
}
|
|
70
|
+
} }
|
|
71
|
+
}, async (ctx) => {
|
|
72
|
+
const { idToken, nonce } = ctx.body;
|
|
73
|
+
await consumeNonce(ctx, ONE_TAP_NONCE_SCOPE, nonce);
|
|
74
|
+
const googleOAuthProvider = await getAwaitableValue(ctx.context.socialProviders, { value: "google" });
|
|
75
|
+
const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() : ctx.context.options.socialProviders?.google;
|
|
76
|
+
const audience = options?.clientId || googleProvider?.clientId;
|
|
77
|
+
if (!audience || Array.isArray(audience) && audience.length === 0) throw new APIError$1("BAD_REQUEST", { message: "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google." });
|
|
78
|
+
const payload = await verifyGoogleIdToken({
|
|
79
|
+
token: idToken,
|
|
80
|
+
audience,
|
|
81
|
+
nonce
|
|
82
|
+
});
|
|
83
|
+
if (!payload) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
84
|
+
if (!payload.sub) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
85
|
+
const configuredHostedDomain = googleProvider?.hd;
|
|
86
|
+
if (!isGoogleHostedDomainAllowed(configuredHostedDomain, payload.hd)) {
|
|
87
|
+
ctx.context.logger.error(`Google One Tap sign-in rejected: id token hosted domain (hd) "${payload.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${configuredHostedDomain}".`);
|
|
88
|
+
throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
45
89
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!providerResult?.user) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
74
|
-
const providerUser = providerResult.user;
|
|
75
|
-
const { sub } = payload;
|
|
76
|
-
const { email: rawEmail, name, image } = providerUser;
|
|
77
|
-
if (typeof rawEmail !== "string" || !rawEmail) throw new APIError$1("BAD_REQUEST", { message: "Email not available in token" });
|
|
78
|
-
if (typeof sub !== "string" || !sub) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
79
|
-
const email = rawEmail.toLowerCase();
|
|
80
|
-
const emailVerified = toBoolean(providerUser.emailVerified);
|
|
81
|
-
const result = await handleOAuthUserInfo(ctx, {
|
|
82
|
-
userInfo: {
|
|
83
|
-
...providerUser,
|
|
84
|
-
id: sub,
|
|
85
|
-
email,
|
|
86
|
-
emailVerified,
|
|
87
|
-
name: typeof name === "string" ? name : "",
|
|
88
|
-
image: typeof image === "string" ? image : void 0
|
|
89
|
-
},
|
|
90
|
-
account: {
|
|
91
|
-
providerId: "google",
|
|
92
|
-
accountId: sub,
|
|
93
|
-
idToken,
|
|
94
|
-
scope: "openid,profile,email"
|
|
95
|
-
},
|
|
96
|
-
disableSignUp: options?.disableSignup || googleProvider?.disableSignUp,
|
|
97
|
-
overrideUserInfo: googleProvider?.overrideUserInfoOnSignIn,
|
|
98
|
-
source: {
|
|
99
|
-
method: "oauth",
|
|
100
|
-
oauth: {
|
|
90
|
+
const providerResult = googleOAuthProvider ? await googleOAuthProvider.getUserInfo({ idToken }) : {
|
|
91
|
+
user: {
|
|
92
|
+
name: payload.name,
|
|
93
|
+
email: payload.email,
|
|
94
|
+
image: payload.picture,
|
|
95
|
+
emailVerified: toBoolean(payload.email_verified)
|
|
96
|
+
},
|
|
97
|
+
data: payload
|
|
98
|
+
};
|
|
99
|
+
if (!providerResult?.user) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
100
|
+
const providerUser = providerResult.user;
|
|
101
|
+
const { sub } = payload;
|
|
102
|
+
const { email: rawEmail, name, image } = providerUser;
|
|
103
|
+
if (typeof rawEmail !== "string" || !rawEmail) throw new APIError$1("BAD_REQUEST", { message: "Email not available in token" });
|
|
104
|
+
if (typeof sub !== "string" || !sub) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
|
|
105
|
+
const email = rawEmail.toLowerCase();
|
|
106
|
+
const emailVerified = toBoolean(providerUser.emailVerified);
|
|
107
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
108
|
+
userInfo: {
|
|
109
|
+
...providerUser,
|
|
110
|
+
id: sub,
|
|
111
|
+
email,
|
|
112
|
+
emailVerified,
|
|
113
|
+
name: typeof name === "string" ? name : "",
|
|
114
|
+
image: typeof image === "string" ? image : void 0
|
|
115
|
+
},
|
|
116
|
+
account: {
|
|
101
117
|
providerId: "google",
|
|
102
|
-
|
|
118
|
+
accountId: sub,
|
|
119
|
+
idToken,
|
|
120
|
+
scope: "openid,profile,email"
|
|
121
|
+
},
|
|
122
|
+
disableSignUp: options?.disableSignup || googleProvider?.disableSignUp,
|
|
123
|
+
overrideUserInfo: googleProvider?.overrideUserInfoOnSignIn,
|
|
124
|
+
source: {
|
|
125
|
+
method: "oauth",
|
|
126
|
+
oauth: {
|
|
127
|
+
providerId: "google",
|
|
128
|
+
profile: toOAuthProfileRecord(providerResult.data)
|
|
129
|
+
}
|
|
103
130
|
}
|
|
131
|
+
});
|
|
132
|
+
if (result.error) {
|
|
133
|
+
if (result.error === OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_VERIFIED) throw APIError$1.from("FORBIDDEN", BASE_ERROR_CODES.EMAIL_NOT_VERIFIED);
|
|
134
|
+
throw new APIError$1("UNAUTHORIZED", { message: result.error });
|
|
104
135
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
token: result.data.session.token,
|
|
113
|
-
user: parseUserOutput(ctx.context.options, result.data.user)
|
|
114
|
-
});
|
|
115
|
-
}) },
|
|
136
|
+
await setSessionCookie(ctx, result.data);
|
|
137
|
+
return ctx.json({
|
|
138
|
+
token: result.data.session.token,
|
|
139
|
+
user: parseUserOutput(ctx.context.options, result.data.user)
|
|
140
|
+
});
|
|
141
|
+
})
|
|
142
|
+
},
|
|
116
143
|
options
|
|
117
144
|
});
|
|
118
145
|
//#endregion
|
|
@@ -274,9 +274,9 @@ const siwe = (options) => {
|
|
|
274
274
|
});
|
|
275
275
|
} catch (error) {
|
|
276
276
|
if (isAPIError(error)) throw error;
|
|
277
|
+
ctx.context.logger.error("Failed to verify SIWE message", error);
|
|
277
278
|
throw APIError.fromStatus("UNAUTHORIZED", {
|
|
278
279
|
message: "Something went wrong. Please try again later.",
|
|
279
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
280
280
|
status: 401
|
|
281
281
|
});
|
|
282
282
|
}
|
package/dist/state.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GenericEndpointContext } from "@better-auth/core";
|
|
2
2
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { getAuthStateVerificationIdentifier } from "@better-auth/core/utils/state";
|
|
4
5
|
//#region src/state.d.ts
|
|
5
6
|
declare const stateDataSchema: z.ZodObject<{
|
|
6
7
|
callbackURL: z.ZodString;
|
|
@@ -19,10 +20,6 @@ declare const stateDataSchema: z.ZodObject<{
|
|
|
19
20
|
serverContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
20
21
|
}, z.core.$loose>;
|
|
21
22
|
export type StateData = z.infer<typeof stateDataSchema>;
|
|
22
|
-
/**
|
|
23
|
-
* Verification key for database-backed OAuth and SAML relay state.
|
|
24
|
-
*/
|
|
25
|
-
export declare const getAuthStateVerificationIdentifier: (state: string) => string;
|
|
26
23
|
export declare function generateGenericState(c: GenericEndpointContext, stateData: StateData, settings?: {
|
|
27
24
|
cookieName: string;
|
|
28
25
|
}): Promise<{
|
|
@@ -50,4 +47,4 @@ export declare function parseGenericState(c: GenericEndpointContext, state: stri
|
|
|
50
47
|
serverContext?: Record<string, unknown> | undefined;
|
|
51
48
|
}>;
|
|
52
49
|
//#endregion
|
|
53
|
-
export {};
|
|
50
|
+
export { getAuthStateVerificationIdentifier };
|
package/dist/state.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { expireCookie } from "./cookies/index.mjs";
|
|
|
4
4
|
import { derivePurposeKey } from "./crypto/purpose.mjs";
|
|
5
5
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
6
6
|
import * as z from "zod";
|
|
7
|
+
import { getAuthStateVerificationIdentifier } from "@better-auth/core/utils/state";
|
|
7
8
|
//#region src/state.ts
|
|
8
9
|
const stateDataSchema = z.looseObject({
|
|
9
10
|
callbackURL: z.string(),
|
|
@@ -37,10 +38,6 @@ const stateDataSchema = z.looseObject({
|
|
|
37
38
|
*/
|
|
38
39
|
serverContext: z.record(z.string(), z.unknown()).optional()
|
|
39
40
|
});
|
|
40
|
-
/**
|
|
41
|
-
* Verification key for database-backed OAuth and SAML relay state.
|
|
42
|
-
*/
|
|
43
|
-
const getAuthStateVerificationIdentifier = (state) => `auth-state:${state}`;
|
|
44
41
|
const INTERNAL_STATE_KEYS = new Set(Object.keys(stateDataSchema.shape));
|
|
45
42
|
var StateError = class extends BetterAuthError {
|
|
46
43
|
code;
|
|
@@ -200,6 +200,14 @@ export declare function getHttpTestInstance<O extends Partial<BetterAuthOptions>
|
|
|
200
200
|
readonly code: "ID_TOKEN_NOT_SUPPORTED";
|
|
201
201
|
message: string;
|
|
202
202
|
};
|
|
203
|
+
INVALID_ID_TOKEN_NONCE: {
|
|
204
|
+
readonly code: "INVALID_ID_TOKEN_NONCE";
|
|
205
|
+
message: string;
|
|
206
|
+
};
|
|
207
|
+
ID_TOKEN_NONCE_NOT_ENABLED: {
|
|
208
|
+
readonly code: "ID_TOKEN_NONCE_NOT_ENABLED";
|
|
209
|
+
message: string;
|
|
210
|
+
};
|
|
203
211
|
FAILED_TO_GET_USER_INFO: {
|
|
204
212
|
readonly code: "FAILED_TO_GET_USER_INFO";
|
|
205
213
|
message: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-auth",
|
|
3
|
-
"version": "1.8.0-beta.
|
|
3
|
+
"version": "1.8.0-beta.8",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -503,13 +503,13 @@
|
|
|
503
503
|
"kysely": "^0.28.17 || ^0.29.0",
|
|
504
504
|
"nanostores": "^1.3.0",
|
|
505
505
|
"zod": "^4.5.4",
|
|
506
|
-
"@better-auth/core": "1.8.0-beta.
|
|
507
|
-
"@better-auth/drizzle-adapter": "1.8.0-beta.
|
|
508
|
-
"@better-auth/kysely-adapter": "1.8.0-beta.
|
|
509
|
-
"@better-auth/memory-adapter": "1.8.0-beta.
|
|
510
|
-
"@better-auth/mongo-adapter": "1.8.0-beta.
|
|
511
|
-
"@better-auth/prisma-adapter": "1.8.0-beta.
|
|
512
|
-
"@better-auth/telemetry": "1.8.0-beta.
|
|
506
|
+
"@better-auth/core": "1.8.0-beta.8",
|
|
507
|
+
"@better-auth/drizzle-adapter": "1.8.0-beta.8",
|
|
508
|
+
"@better-auth/kysely-adapter": "1.8.0-beta.8",
|
|
509
|
+
"@better-auth/memory-adapter": "1.8.0-beta.8",
|
|
510
|
+
"@better-auth/mongo-adapter": "1.8.0-beta.8",
|
|
511
|
+
"@better-auth/prisma-adapter": "1.8.0-beta.8",
|
|
512
|
+
"@better-auth/telemetry": "1.8.0-beta.8"
|
|
513
513
|
},
|
|
514
514
|
"devDependencies": {
|
|
515
515
|
"@lynx-js/react": "^0.121.2",
|