@better-auth/core 1.6.11 → 1.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +4 -2
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +2 -2
- package/dist/oauth2/utils.d.mts +9 -1
- package/dist/oauth2/utils.mjs +12 -1
- package/dist/oauth2/verify.mjs +15 -5
- package/dist/social-providers/apple.mjs +11 -1
- package/dist/types/context.d.mts +14 -6
- package/dist/types/init-options.d.mts +6 -2
- package/dist/utils/redirect-uri.d.mts +19 -0
- package/dist/utils/redirect-uri.mjs +41 -0
- package/dist/utils/string.d.mts +5 -1
- package/dist/utils/string.mjs +20 -1
- package/dist/utils/url.d.mts +18 -1
- package/dist/utils/url.mjs +25 -1
- package/package.json +5 -5
- package/src/db/adapter/factory.ts +6 -0
- package/src/oauth2/index.ts +1 -0
- package/src/oauth2/utils.ts +19 -0
- package/src/oauth2/verify.ts +20 -4
- package/src/social-providers/apple.ts +19 -1
- package/src/types/context.ts +15 -7
- package/src/types/init-options.ts +6 -2
- package/src/utils/redirect-uri.ts +45 -0
- package/src/utils/string.ts +37 -0
- package/src/utils/url.ts +25 -0
package/dist/context/global.mjs
CHANGED
|
@@ -711,7 +711,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
711
711
|
limit: 1
|
|
712
712
|
}))[0];
|
|
713
713
|
if (!target) return null;
|
|
714
|
-
|
|
714
|
+
const deleted = await trx.deleteMany({
|
|
715
715
|
model: unsafeModel,
|
|
716
716
|
where: [...unsafeWhere, {
|
|
717
717
|
field: "id",
|
|
@@ -720,7 +720,9 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
720
720
|
connector: "AND",
|
|
721
721
|
mode: "sensitive"
|
|
722
722
|
}]
|
|
723
|
-
})
|
|
723
|
+
});
|
|
724
|
+
if (typeof deleted !== "number") throw new BetterAuthError(`Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`);
|
|
725
|
+
return deleted > 0 ? target : null;
|
|
724
726
|
}));
|
|
725
727
|
resultNeedsOutputTransform = false;
|
|
726
728
|
}
|
|
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
|
|
|
2
2
|
import { getOpenTelemetryAPI } from "./api.mjs";
|
|
3
3
|
//#region src/instrumentation/tracer.ts
|
|
4
4
|
const INSTRUMENTATION_SCOPE = "better-auth";
|
|
5
|
-
const INSTRUMENTATION_VERSION = "1.6.
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.6.13";
|
|
6
6
|
/**
|
|
7
7
|
* Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
|
|
8
8
|
* callbacks). These are APIErrors with 3xx status codes and should not be
|
package/dist/oauth2/index.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { OAuth2Tokens, OAuth2UserInfo, OAuthProvider, ProviderOptions } from "./
|
|
|
2
2
|
import { clientCredentialsToken, clientCredentialsTokenRequest, createClientCredentialsTokenRequest } from "./client-credentials-token.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "./create-authorization-url.mjs";
|
|
4
4
|
import { createRefreshAccessTokenRequest, refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
|
|
5
|
-
import { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
5
|
+
import { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
6
6
|
import { authorizationCodeRequest, createAuthorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
|
|
7
7
|
import { getJwks, verifyAccessToken, verifyJwsAccessToken } from "./verify.mjs";
|
|
8
|
-
export { type OAuth2Tokens, type OAuth2UserInfo, type OAuthProvider, type ProviderOptions, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
|
8
|
+
export { type OAuth2Tokens, type OAuth2UserInfo, type OAuthProvider, type ProviderOptions, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
package/dist/oauth2/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { clientCredentialsToken, clientCredentialsTokenRequest, createClientCredentialsTokenRequest } from "./client-credentials-token.mjs";
|
|
2
|
-
import { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
2
|
+
import { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "./create-authorization-url.mjs";
|
|
4
4
|
import { createRefreshAccessTokenRequest, refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
|
|
5
5
|
import { authorizationCodeRequest, createAuthorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
|
|
6
6
|
import { getJwks, verifyAccessToken, verifyJwsAccessToken } from "./verify.mjs";
|
|
7
|
-
export { authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
|
7
|
+
export { applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
package/dist/oauth2/utils.d.mts
CHANGED
|
@@ -2,6 +2,14 @@ import { OAuth2Tokens } from "./oauth-provider.mjs";
|
|
|
2
2
|
|
|
3
3
|
//#region src/oauth2/utils.d.ts
|
|
4
4
|
declare function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens;
|
|
5
|
+
/**
|
|
6
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
7
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
8
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
9
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
10
|
+
* fallback is configured.
|
|
11
|
+
*/
|
|
12
|
+
declare function applyDefaultAccessTokenExpiry(tokens: OAuth2Tokens, accessTokenExpiresIn: number | undefined): OAuth2Tokens;
|
|
5
13
|
/**
|
|
6
14
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
7
15
|
* array index 0 for the cross-platform form used by ID token audience
|
|
@@ -13,4 +21,4 @@ declare function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens;
|
|
|
13
21
|
declare function getPrimaryClientId(clientId: unknown): string | undefined;
|
|
14
22
|
declare function generateCodeChallenge(codeVerifier: string): Promise<string>;
|
|
15
23
|
//#endregion
|
|
16
|
-
export { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
|
24
|
+
export { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
package/dist/oauth2/utils.mjs
CHANGED
|
@@ -17,6 +17,17 @@ function getOAuth2Tokens(data) {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
21
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
22
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
23
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
24
|
+
* fallback is configured.
|
|
25
|
+
*/
|
|
26
|
+
function applyDefaultAccessTokenExpiry(tokens, accessTokenExpiresIn) {
|
|
27
|
+
if (!tokens.accessTokenExpiresAt && accessTokenExpiresIn) tokens.accessTokenExpiresAt = new Date(Date.now() + accessTokenExpiresIn * 1e3);
|
|
28
|
+
return tokens;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
20
31
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
21
32
|
* array index 0 for the cross-platform form used by ID token audience
|
|
22
33
|
* verification. Index 0 is the designated primary and pairs with
|
|
@@ -34,4 +45,4 @@ async function generateCodeChallenge(codeVerifier) {
|
|
|
34
45
|
return base64Url.encode(new Uint8Array(hash), { padding: false });
|
|
35
46
|
}
|
|
36
47
|
//#endregion
|
|
37
|
-
export { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
|
48
|
+
export { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { logger } from "../env/logger.mjs";
|
|
2
2
|
import { APIError } from "better-call";
|
|
3
3
|
import { betterFetch } from "@better-fetch/fetch";
|
|
4
|
-
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
|
|
4
|
+
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
5
|
//#region src/oauth2/verify.ts
|
|
6
|
+
const joseInfrastructureErrorCodes = new Set([
|
|
7
|
+
errors.JWKSTimeout.code,
|
|
8
|
+
errors.JWKSInvalid.code,
|
|
9
|
+
errors.JWKSMultipleMatchingKeys.code
|
|
10
|
+
]);
|
|
11
|
+
function isJoseInfrastructureError(error) {
|
|
12
|
+
return joseInfrastructureErrorCodes.has(error.code);
|
|
13
|
+
}
|
|
6
14
|
/** Last fetched jwks used locally in getJwks @internal */
|
|
7
15
|
let jwks;
|
|
8
16
|
/**
|
|
@@ -28,7 +36,7 @@ async function getJwks(token, opts) {
|
|
|
28
36
|
if (error instanceof Error) throw error;
|
|
29
37
|
throw new Error(error);
|
|
30
38
|
}
|
|
31
|
-
if (!jwtHeaders.kid) throw new
|
|
39
|
+
if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
32
40
|
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
|
|
33
41
|
jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
34
42
|
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
@@ -51,9 +59,11 @@ async function verifyAccessToken(token, opts) {
|
|
|
51
59
|
verifyOptions: opts.verifyOptions
|
|
52
60
|
});
|
|
53
61
|
} catch (error) {
|
|
54
|
-
if (error instanceof Error) if (error.name === "TypeError" || error.name === "JWSInvalid") {} else if (error
|
|
55
|
-
else if (error
|
|
56
|
-
|
|
62
|
+
if (error instanceof Error) if (error.name === "TypeError" || error.name === "JWSInvalid") {} else if (error instanceof errors.JWTExpired) throw new APIError("UNAUTHORIZED", { message: "token expired" });
|
|
63
|
+
else if (error instanceof errors.JOSEError) {
|
|
64
|
+
if (isJoseInfrastructureError(error)) throw error;
|
|
65
|
+
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
66
|
+
} else throw error;
|
|
57
67
|
else throw new Error(error);
|
|
58
68
|
}
|
|
59
69
|
if (opts?.remoteVerify) {
|
|
@@ -7,6 +7,16 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
|
|
|
7
7
|
import { betterFetch } from "@better-fetch/fetch";
|
|
8
8
|
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
9
9
|
//#region src/social-providers/apple.ts
|
|
10
|
+
async function sha256Hex(value) {
|
|
11
|
+
const data = new TextEncoder().encode(value);
|
|
12
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
13
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
async function nonceMatches(jwtNonce, nonce) {
|
|
16
|
+
if (typeof jwtNonce !== "string") return false;
|
|
17
|
+
if (jwtNonce === nonce) return true;
|
|
18
|
+
return jwtNonce === await sha256Hex(nonce);
|
|
19
|
+
}
|
|
10
20
|
const apple = (options) => {
|
|
11
21
|
const tokenEndpoint = "https://appleid.apple.com/auth/token";
|
|
12
22
|
return {
|
|
@@ -55,7 +65,7 @@ const apple = (options) => {
|
|
|
55
65
|
["email_verified", "is_private_email"].forEach((field) => {
|
|
56
66
|
if (jwtClaims[field] !== void 0) jwtClaims[field] = Boolean(jwtClaims[field]);
|
|
57
67
|
});
|
|
58
|
-
if (nonce && jwtClaims.nonce
|
|
68
|
+
if (nonce && !await nonceMatches(jwtClaims.nonce, nonce)) return false;
|
|
59
69
|
return !!jwtClaims;
|
|
60
70
|
} catch {
|
|
61
71
|
return false;
|
package/dist/types/context.d.mts
CHANGED
|
@@ -89,7 +89,14 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
89
89
|
* @param id - The account row's primary key (the `id` column, not the `accountId` column).
|
|
90
90
|
*/
|
|
91
91
|
deleteAccount(id: string): Promise<void>;
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Delete every session belonging to a user.
|
|
94
|
+
*/
|
|
95
|
+
deleteUserSessions(userId: string): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Delete sessions by their session tokens.
|
|
98
|
+
*/
|
|
99
|
+
deleteSessions(sessionTokens: string[]): Promise<void>;
|
|
93
100
|
findOAuthUser(email: string, accountId: string, providerId: string): Promise<{
|
|
94
101
|
user: User;
|
|
95
102
|
linkedAccount: Account | null;
|
|
@@ -107,7 +114,6 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
107
114
|
updateUserByEmail<T extends Record<string, any>>(email: string, data: Partial<User & Record<string, any>>): Promise<User & T>;
|
|
108
115
|
updatePassword(userId: string, password: string): Promise<void>;
|
|
109
116
|
findAccounts(userId: string): Promise<Account[]>;
|
|
110
|
-
findAccount(accountId: string): Promise<Account | null>;
|
|
111
117
|
findAccountByProviderId(accountId: string, providerId: string): Promise<Account | null>;
|
|
112
118
|
findAccountByUserId(userId: string): Promise<Account[]>;
|
|
113
119
|
updateAccount(id: string, data: Partial<Account>): Promise<Account>;
|
|
@@ -118,9 +124,11 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
118
124
|
* Atomically consume a single-use verification row by `identifier` and
|
|
119
125
|
* return it. Only the first concurrent caller receives the latest row;
|
|
120
126
|
* subsequent callers receive `null`. Consuming one row invalidates the
|
|
121
|
-
* whole identifier so stale rows cannot be replayed.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
127
|
+
* whole identifier so stale rows cannot be replayed. Rows past their
|
|
128
|
+
* `expiresAt` are treated as already invalid: the row is deleted but
|
|
129
|
+
* `null` is returned, so callers do not need to gate on `expiresAt`
|
|
130
|
+
* themselves. Callers MUST gate any state change (issue session, mint
|
|
131
|
+
* token, change password) on a non-null result.
|
|
124
132
|
*
|
|
125
133
|
* Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
|
|
126
134
|
* pair at single-use credential consumption sites.
|
|
@@ -183,7 +191,7 @@ type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> = Plugin
|
|
|
183
191
|
* - "cookie": Store state in an encrypted cookie (stateless)
|
|
184
192
|
* - "database": Store state in the database
|
|
185
193
|
*
|
|
186
|
-
* @default "cookie"
|
|
194
|
+
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
|
|
187
195
|
*/
|
|
188
196
|
storeStateStrategy: "database" | "cookie";
|
|
189
197
|
};
|
|
@@ -963,7 +963,11 @@ type BetterAuthOptions = {
|
|
|
963
963
|
*/
|
|
964
964
|
allowUnlinkingAll?: boolean;
|
|
965
965
|
/**
|
|
966
|
-
*
|
|
966
|
+
* When enabled, linking an account copies the provider's profile onto
|
|
967
|
+
* the local user, matching the fields persisted on sign-up (`name`,
|
|
968
|
+
* `image`, and any `mapProfileToUser` fields). The local `email` and
|
|
969
|
+
* `emailVerified` are never changed, so a link cannot rebind the
|
|
970
|
+
* account's identity.
|
|
967
971
|
*
|
|
968
972
|
* @default false
|
|
969
973
|
*/
|
|
@@ -996,7 +1000,7 @@ type BetterAuthOptions = {
|
|
|
996
1000
|
* - "cookie": Store state in an encrypted cookie (stateless)
|
|
997
1001
|
* - "database": Store state in the database
|
|
998
1002
|
*
|
|
999
|
-
* @default "cookie"
|
|
1003
|
+
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
|
|
1000
1004
|
*/
|
|
1001
1005
|
storeStateStrategy?: "database" | "cookie";
|
|
1002
1006
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/redirect-uri.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
|
|
6
|
+
* server stores and later hands back to a browser.
|
|
7
|
+
*
|
|
8
|
+
* - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
|
|
9
|
+
* - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
|
|
10
|
+
* `*.localhost` per RFC 6761), where HTTP is allowed for local development.
|
|
11
|
+
* - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
|
|
12
|
+
*
|
|
13
|
+
* This is the single source of truth for redirect-URI validation across the
|
|
14
|
+
* OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
|
|
15
|
+
* rather than re-implementing the scheme policy per plugin.
|
|
16
|
+
*/
|
|
17
|
+
declare const SafeUrlSchema: z.ZodURL;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { SafeUrlSchema };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isLoopbackHost } from "./host.mjs";
|
|
2
|
+
import { DANGEROUS_URL_SCHEMES } from "./url.mjs";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
//#region src/utils/redirect-uri.ts
|
|
5
|
+
/**
|
|
6
|
+
* Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
|
|
7
|
+
* server stores and later hands back to a browser.
|
|
8
|
+
*
|
|
9
|
+
* - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
|
|
10
|
+
* - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
|
|
11
|
+
* `*.localhost` per RFC 6761), where HTTP is allowed for local development.
|
|
12
|
+
* - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
|
|
13
|
+
*
|
|
14
|
+
* This is the single source of truth for redirect-URI validation across the
|
|
15
|
+
* OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
|
|
16
|
+
* rather than re-implementing the scheme policy per plugin.
|
|
17
|
+
*/
|
|
18
|
+
const SafeUrlSchema = z.url().superRefine((val, ctx) => {
|
|
19
|
+
if (!URL.canParse(val)) {
|
|
20
|
+
ctx.addIssue({
|
|
21
|
+
code: "custom",
|
|
22
|
+
message: "URL must be parseable",
|
|
23
|
+
fatal: true
|
|
24
|
+
});
|
|
25
|
+
return z.NEVER;
|
|
26
|
+
}
|
|
27
|
+
const u = new URL(val);
|
|
28
|
+
if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
|
|
29
|
+
ctx.addIssue({
|
|
30
|
+
code: "custom",
|
|
31
|
+
message: "URL cannot use javascript:, data:, or vbscript: scheme"
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
|
|
36
|
+
code: "custom",
|
|
37
|
+
message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
//#endregion
|
|
41
|
+
export { SafeUrlSchema };
|
package/dist/utils/string.d.mts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
//#region src/utils/string.d.ts
|
|
2
2
|
declare function capitalizeFirstLetter(str: string): string;
|
|
3
|
+
declare function toSnakeCase(input: string): string;
|
|
4
|
+
declare function toKebabCase(input: string): string;
|
|
5
|
+
declare function toCamelCase(input: string): string;
|
|
6
|
+
declare function toPascalCase(input: string): string;
|
|
3
7
|
//#endregion
|
|
4
|
-
export { capitalizeFirstLetter };
|
|
8
|
+
export { capitalizeFirstLetter, toCamelCase, toKebabCase, toPascalCase, toSnakeCase };
|
package/dist/utils/string.mjs
CHANGED
|
@@ -2,5 +2,24 @@
|
|
|
2
2
|
function capitalizeFirstLetter(str) {
|
|
3
3
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
4
4
|
}
|
|
5
|
+
const WORD_PATTERN = /[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
|
|
6
|
+
const APOSTROPHE_PATTERN = /['\u2019]/g;
|
|
7
|
+
function splitWords(input) {
|
|
8
|
+
return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
|
|
9
|
+
}
|
|
10
|
+
function toSnakeCase(input) {
|
|
11
|
+
return splitWords(input).map((word) => word.toLowerCase()).join("_");
|
|
12
|
+
}
|
|
13
|
+
function toKebabCase(input) {
|
|
14
|
+
return splitWords(input).map((word) => word.toLowerCase()).join("-");
|
|
15
|
+
}
|
|
16
|
+
function toCamelCase(input) {
|
|
17
|
+
return splitWords(input).reduce((acc, word, i) => {
|
|
18
|
+
return acc + (i === 0 ? word.toLowerCase() : `${word[0].toUpperCase()}${word.slice(1)}`);
|
|
19
|
+
}, "");
|
|
20
|
+
}
|
|
21
|
+
function toPascalCase(input) {
|
|
22
|
+
return splitWords(input).map((word) => `${word[0].toUpperCase()}${word.slice(1).toLowerCase()}`).join("");
|
|
23
|
+
}
|
|
5
24
|
//#endregion
|
|
6
|
-
export { capitalizeFirstLetter };
|
|
25
|
+
export { capitalizeFirstLetter, toCamelCase, toKebabCase, toPascalCase, toSnakeCase };
|
package/dist/utils/url.d.mts
CHANGED
|
@@ -16,5 +16,22 @@
|
|
|
16
16
|
* // Returns: "/sso/saml2/callback/provider1"
|
|
17
17
|
*/
|
|
18
18
|
declare function normalizePathname(requestUrl: string, basePath: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Schemes that execute or embed code when navigated to or accepted as a
|
|
21
|
+
* redirect target. These are never safe as an OAuth `redirect_uri` or as a
|
|
22
|
+
* client-side navigation target (`window.location.href`, `location.assign`, ...).
|
|
23
|
+
*/
|
|
24
|
+
declare const DANGEROUS_URL_SCHEMES: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Returns `false` only when `value` is an absolute URL using a dangerous scheme
|
|
27
|
+
* (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
|
|
28
|
+
* safe absolute schemes (`http`, `https`, custom app schemes such as
|
|
29
|
+
* `myapp://`) return `true`.
|
|
30
|
+
*
|
|
31
|
+
* Use this to guard browser navigation sinks and any redirect target that may
|
|
32
|
+
* originate from untrusted input. It is intentionally narrow: it blocks code
|
|
33
|
+
* execution schemes without rejecting relative paths or mobile deep links.
|
|
34
|
+
*/
|
|
35
|
+
declare function isSafeUrlScheme(value: string): boolean;
|
|
19
36
|
//#endregion
|
|
20
|
-
export { normalizePathname };
|
|
37
|
+
export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
|
package/dist/utils/url.mjs
CHANGED
|
@@ -27,5 +27,29 @@ function normalizePathname(requestUrl, basePath) {
|
|
|
27
27
|
if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
|
|
28
28
|
return pathname;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Schemes that execute or embed code when navigated to or accepted as a
|
|
32
|
+
* redirect target. These are never safe as an OAuth `redirect_uri` or as a
|
|
33
|
+
* client-side navigation target (`window.location.href`, `location.assign`, ...).
|
|
34
|
+
*/
|
|
35
|
+
const DANGEROUS_URL_SCHEMES = [
|
|
36
|
+
"javascript:",
|
|
37
|
+
"data:",
|
|
38
|
+
"vbscript:"
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Returns `false` only when `value` is an absolute URL using a dangerous scheme
|
|
42
|
+
* (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
|
|
43
|
+
* safe absolute schemes (`http`, `https`, custom app schemes such as
|
|
44
|
+
* `myapp://`) return `true`.
|
|
45
|
+
*
|
|
46
|
+
* Use this to guard browser navigation sinks and any redirect target that may
|
|
47
|
+
* originate from untrusted input. It is intentionally narrow: it blocks code
|
|
48
|
+
* execution schemes without rejecting relative paths or mobile deep links.
|
|
49
|
+
*/
|
|
50
|
+
function isSafeUrlScheme(value) {
|
|
51
|
+
if (!URL.canParse(value)) return true;
|
|
52
|
+
return !DANGEROUS_URL_SCHEMES.includes(new URL(value).protocol);
|
|
53
|
+
}
|
|
30
54
|
//#endregion
|
|
31
|
-
export { normalizePathname };
|
|
55
|
+
export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.13",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
"zod": "^4.3.6"
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
|
-
"@better-auth/utils": "0.4.
|
|
155
|
+
"@better-auth/utils": "0.4.1",
|
|
156
156
|
"@better-fetch/fetch": "1.1.21",
|
|
157
157
|
"@opentelemetry/api": "^1.9.0",
|
|
158
158
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
@@ -160,18 +160,18 @@
|
|
|
160
160
|
"better-call": "1.3.5",
|
|
161
161
|
"@cloudflare/workers-types": "^4.20250121.0",
|
|
162
162
|
"jose": "^6.1.3",
|
|
163
|
-
"kysely": "^0.28.17",
|
|
163
|
+
"kysely": "^0.28.17 || ^0.29.0",
|
|
164
164
|
"nanostores": "^1.1.1",
|
|
165
165
|
"tsdown": "0.21.1"
|
|
166
166
|
},
|
|
167
167
|
"peerDependencies": {
|
|
168
|
-
"@better-auth/utils": "0.4.
|
|
168
|
+
"@better-auth/utils": "0.4.1",
|
|
169
169
|
"@better-fetch/fetch": "1.1.21",
|
|
170
170
|
"@opentelemetry/api": "^1.9.0",
|
|
171
171
|
"better-call": "1.3.5",
|
|
172
172
|
"@cloudflare/workers-types": ">=4",
|
|
173
173
|
"jose": "^6.1.0",
|
|
174
|
-
"kysely": "^0.28.5",
|
|
174
|
+
"kysely": "^0.28.5 || ^0.29.0",
|
|
175
175
|
"nanostores": "^1.0.1"
|
|
176
176
|
},
|
|
177
177
|
"peerDependenciesMeta": {
|
|
@@ -1391,6 +1391,12 @@ export const createAdapterFactory =
|
|
|
1391
1391
|
},
|
|
1392
1392
|
],
|
|
1393
1393
|
});
|
|
1394
|
+
// A non-numeric count coerces to a false miss, so fail loud.
|
|
1395
|
+
if (typeof deleted !== "number") {
|
|
1396
|
+
throw new BetterAuthError(
|
|
1397
|
+
`Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1394
1400
|
return deleted > 0 ? (target as T) : null;
|
|
1395
1401
|
}),
|
|
1396
1402
|
);
|
package/src/oauth2/index.ts
CHANGED
package/src/oauth2/utils.ts
CHANGED
|
@@ -28,6 +28,25 @@ export function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
33
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
34
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
35
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
36
|
+
* fallback is configured.
|
|
37
|
+
*/
|
|
38
|
+
export function applyDefaultAccessTokenExpiry(
|
|
39
|
+
tokens: OAuth2Tokens,
|
|
40
|
+
accessTokenExpiresIn: number | undefined,
|
|
41
|
+
): OAuth2Tokens {
|
|
42
|
+
if (!tokens.accessTokenExpiresAt && accessTokenExpiresIn) {
|
|
43
|
+
tokens.accessTokenExpiresAt = new Date(
|
|
44
|
+
Date.now() + accessTokenExpiresIn * 1000,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
33
52
|
* array index 0 for the cross-platform form used by ID token audience
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -9,11 +9,22 @@ import type {
|
|
|
9
9
|
import {
|
|
10
10
|
createLocalJWKSet,
|
|
11
11
|
decodeProtectedHeader,
|
|
12
|
+
errors as joseErrors,
|
|
12
13
|
jwtVerify,
|
|
13
14
|
UnsecuredJWT,
|
|
14
15
|
} from "jose";
|
|
15
16
|
import { logger } from "../env";
|
|
16
17
|
|
|
18
|
+
const joseInfrastructureErrorCodes = new Set([
|
|
19
|
+
joseErrors.JWKSTimeout.code,
|
|
20
|
+
joseErrors.JWKSInvalid.code,
|
|
21
|
+
joseErrors.JWKSMultipleMatchingKeys.code,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
25
|
+
return joseInfrastructureErrorCodes.has(error.code);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
/** Last fetched jwks used locally in getJwks @internal */
|
|
18
29
|
let jwks: JSONWebKeySet | undefined;
|
|
19
30
|
|
|
@@ -82,7 +93,9 @@ export async function getJwks(
|
|
|
82
93
|
throw new Error(error as unknown as string);
|
|
83
94
|
}
|
|
84
95
|
|
|
85
|
-
if (!jwtHeaders.kid)
|
|
96
|
+
if (!jwtHeaders.kid) {
|
|
97
|
+
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
98
|
+
}
|
|
86
99
|
|
|
87
100
|
// Fetch jwks if not set or has a different kid than the one stored
|
|
88
101
|
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
|
|
@@ -137,13 +150,16 @@ export async function verifyAccessToken(
|
|
|
137
150
|
if (error instanceof Error) {
|
|
138
151
|
if (error.name === "TypeError" || error.name === "JWSInvalid") {
|
|
139
152
|
// likely an opaque token (continue)
|
|
140
|
-
} else if (error
|
|
153
|
+
} else if (error instanceof joseErrors.JWTExpired) {
|
|
141
154
|
throw new APIError("UNAUTHORIZED", {
|
|
142
155
|
message: "token expired",
|
|
143
156
|
});
|
|
144
|
-
} else if (error
|
|
157
|
+
} else if (error instanceof joseErrors.JOSEError) {
|
|
158
|
+
if (isJoseInfrastructureError(error)) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
145
161
|
throw new APIError("UNAUTHORIZED", {
|
|
146
|
-
message: "token
|
|
162
|
+
message: "invalid access token",
|
|
147
163
|
});
|
|
148
164
|
} else {
|
|
149
165
|
throw error;
|
|
@@ -77,6 +77,24 @@ export interface AppleOptions extends ProviderOptions<AppleProfile> {
|
|
|
77
77
|
audience?: (string | string[]) | undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
async function sha256Hex(value: string) {
|
|
81
|
+
const data = new TextEncoder().encode(value);
|
|
82
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
83
|
+
return Array.from(new Uint8Array(digest))
|
|
84
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
85
|
+
.join("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function nonceMatches(jwtNonce: unknown, nonce: string) {
|
|
89
|
+
if (typeof jwtNonce !== "string") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (jwtNonce === nonce) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return jwtNonce === (await sha256Hex(nonce));
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
export const apple = (options: AppleOptions) => {
|
|
81
99
|
const tokenEndpoint = "https://appleid.apple.com/auth/token";
|
|
82
100
|
return {
|
|
@@ -141,7 +159,7 @@ export const apple = (options: AppleOptions) => {
|
|
|
141
159
|
jwtClaims[field] = Boolean(jwtClaims[field]);
|
|
142
160
|
}
|
|
143
161
|
});
|
|
144
|
-
if (nonce && jwtClaims.nonce
|
|
162
|
+
if (nonce && !(await nonceMatches(jwtClaims.nonce, nonce))) {
|
|
145
163
|
return false;
|
|
146
164
|
}
|
|
147
165
|
return !!jwtClaims;
|
package/src/types/context.ts
CHANGED
|
@@ -158,7 +158,15 @@ export interface InternalAdapter<
|
|
|
158
158
|
*/
|
|
159
159
|
deleteAccount(id: string): Promise<void>;
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Delete every session belonging to a user.
|
|
163
|
+
*/
|
|
164
|
+
deleteUserSessions(userId: string): Promise<void>;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Delete sessions by their session tokens.
|
|
168
|
+
*/
|
|
169
|
+
deleteSessions(sessionTokens: string[]): Promise<void>;
|
|
162
170
|
|
|
163
171
|
findOAuthUser(
|
|
164
172
|
email: string,
|
|
@@ -196,8 +204,6 @@ export interface InternalAdapter<
|
|
|
196
204
|
|
|
197
205
|
findAccounts(userId: string): Promise<Account[]>;
|
|
198
206
|
|
|
199
|
-
findAccount(accountId: string): Promise<Account | null>;
|
|
200
|
-
|
|
201
207
|
findAccountByProviderId(
|
|
202
208
|
accountId: string,
|
|
203
209
|
providerId: string,
|
|
@@ -220,9 +226,11 @@ export interface InternalAdapter<
|
|
|
220
226
|
* Atomically consume a single-use verification row by `identifier` and
|
|
221
227
|
* return it. Only the first concurrent caller receives the latest row;
|
|
222
228
|
* subsequent callers receive `null`. Consuming one row invalidates the
|
|
223
|
-
* whole identifier so stale rows cannot be replayed.
|
|
224
|
-
*
|
|
225
|
-
*
|
|
229
|
+
* whole identifier so stale rows cannot be replayed. Rows past their
|
|
230
|
+
* `expiresAt` are treated as already invalid: the row is deleted but
|
|
231
|
+
* `null` is returned, so callers do not need to gate on `expiresAt`
|
|
232
|
+
* themselves. Callers MUST gate any state change (issue session, mint
|
|
233
|
+
* token, change password) on a non-null result.
|
|
226
234
|
*
|
|
227
235
|
* Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
|
|
228
236
|
* pair at single-use credential consumption sites.
|
|
@@ -319,7 +327,7 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
|
|
|
319
327
|
* - "cookie": Store state in an encrypted cookie (stateless)
|
|
320
328
|
* - "database": Store state in the database
|
|
321
329
|
*
|
|
322
|
-
* @default "cookie"
|
|
330
|
+
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
|
|
323
331
|
*/
|
|
324
332
|
storeStateStrategy: "database" | "cookie";
|
|
325
333
|
};
|
|
@@ -1093,7 +1093,11 @@ export type BetterAuthOptions = {
|
|
|
1093
1093
|
*/
|
|
1094
1094
|
allowUnlinkingAll?: boolean;
|
|
1095
1095
|
/**
|
|
1096
|
-
*
|
|
1096
|
+
* When enabled, linking an account copies the provider's profile onto
|
|
1097
|
+
* the local user, matching the fields persisted on sign-up (`name`,
|
|
1098
|
+
* `image`, and any `mapProfileToUser` fields). The local `email` and
|
|
1099
|
+
* `emailVerified` are never changed, so a link cannot rebind the
|
|
1100
|
+
* account's identity.
|
|
1097
1101
|
*
|
|
1098
1102
|
* @default false
|
|
1099
1103
|
*/
|
|
@@ -1126,7 +1130,7 @@ export type BetterAuthOptions = {
|
|
|
1126
1130
|
* - "cookie": Store state in an encrypted cookie (stateless)
|
|
1127
1131
|
* - "database": Store state in the database
|
|
1128
1132
|
*
|
|
1129
|
-
* @default "cookie"
|
|
1133
|
+
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
|
|
1130
1134
|
*/
|
|
1131
1135
|
storeStateStrategy?: "database" | "cookie";
|
|
1132
1136
|
/**
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { isLoopbackHost } from "./host";
|
|
3
|
+
import { DANGEROUS_URL_SCHEMES } from "./url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
|
|
7
|
+
* server stores and later hands back to a browser.
|
|
8
|
+
*
|
|
9
|
+
* - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
|
|
10
|
+
* - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
|
|
11
|
+
* `*.localhost` per RFC 6761), where HTTP is allowed for local development.
|
|
12
|
+
* - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
|
|
13
|
+
*
|
|
14
|
+
* This is the single source of truth for redirect-URI validation across the
|
|
15
|
+
* OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
|
|
16
|
+
* rather than re-implementing the scheme policy per plugin.
|
|
17
|
+
*/
|
|
18
|
+
export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
|
|
19
|
+
if (!URL.canParse(val)) {
|
|
20
|
+
ctx.addIssue({
|
|
21
|
+
code: "custom",
|
|
22
|
+
message: "URL must be parseable",
|
|
23
|
+
fatal: true,
|
|
24
|
+
});
|
|
25
|
+
return z.NEVER;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const u = new URL(val);
|
|
29
|
+
|
|
30
|
+
if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
|
|
31
|
+
ctx.addIssue({
|
|
32
|
+
code: "custom",
|
|
33
|
+
message: "URL cannot use javascript:, data:, or vbscript: scheme",
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
|
|
39
|
+
ctx.addIssue({
|
|
40
|
+
code: "custom",
|
|
41
|
+
message:
|
|
42
|
+
"Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
package/src/utils/string.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
1
|
export function capitalizeFirstLetter(str: string) {
|
|
2
2
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
3
3
|
}
|
|
4
|
+
|
|
5
|
+
const WORD_PATTERN =
|
|
6
|
+
/[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
|
|
7
|
+
const APOSTROPHE_PATTERN = /['\u2019]/g;
|
|
8
|
+
|
|
9
|
+
function splitWords(input: string): string[] {
|
|
10
|
+
return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toSnakeCase(input: string): string {
|
|
14
|
+
return splitWords(input)
|
|
15
|
+
.map((word) => word.toLowerCase())
|
|
16
|
+
.join("_");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toKebabCase(input: string): string {
|
|
20
|
+
return splitWords(input)
|
|
21
|
+
.map((word) => word.toLowerCase())
|
|
22
|
+
.join("-");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toCamelCase(input: string): string {
|
|
26
|
+
return splitWords(input).reduce((acc, word, i) => {
|
|
27
|
+
return (
|
|
28
|
+
acc +
|
|
29
|
+
(i === 0
|
|
30
|
+
? word.toLowerCase()
|
|
31
|
+
: `${word[0]!.toUpperCase()}${word.slice(1)}`)
|
|
32
|
+
);
|
|
33
|
+
}, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toPascalCase(input: string): string {
|
|
37
|
+
return splitWords(input)
|
|
38
|
+
.map((word) => `${word[0]!.toUpperCase()}${word.slice(1).toLowerCase()}`)
|
|
39
|
+
.join("");
|
|
40
|
+
}
|
package/src/utils/url.ts
CHANGED
|
@@ -41,3 +41,28 @@ export function normalizePathname(
|
|
|
41
41
|
|
|
42
42
|
return pathname;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Schemes that execute or embed code when navigated to or accepted as a
|
|
47
|
+
* redirect target. These are never safe as an OAuth `redirect_uri` or as a
|
|
48
|
+
* client-side navigation target (`window.location.href`, `location.assign`, ...).
|
|
49
|
+
*/
|
|
50
|
+
export const DANGEROUS_URL_SCHEMES = ["javascript:", "data:", "vbscript:"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns `false` only when `value` is an absolute URL using a dangerous scheme
|
|
54
|
+
* (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
|
|
55
|
+
* safe absolute schemes (`http`, `https`, custom app schemes such as
|
|
56
|
+
* `myapp://`) return `true`.
|
|
57
|
+
*
|
|
58
|
+
* Use this to guard browser navigation sinks and any redirect target that may
|
|
59
|
+
* originate from untrusted input. It is intentionally narrow: it blocks code
|
|
60
|
+
* execution schemes without rejecting relative paths or mobile deep links.
|
|
61
|
+
*/
|
|
62
|
+
export function isSafeUrlScheme(value: string): boolean {
|
|
63
|
+
if (!URL.canParse(value)) {
|
|
64
|
+
// Relative URLs carry no scheme to abuse.
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return !DANGEROUS_URL_SCHEMES.includes(new URL(value).protocol);
|
|
68
|
+
}
|