@better-auth/core 1.7.0-rc.2 → 1.7.0-rc.3

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.
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.7.0-rc.2";
5
+ const __betterAuthVersion = "1.7.0-rc.3";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -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.7.0-rc.2";
5
+ const INSTRUMENTATION_VERSION = "1.7.0-rc.3";
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
@@ -9,6 +9,6 @@ import { AccessTokenAuthorization, AccessTokenAuthorizationScheme, BEARER_AUTHOR
9
9
  import { refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
10
10
  import { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId, mergeScopes } from "./utils.mjs";
11
11
  import { authorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
12
- import { ResourceRequestInput, VerifyAccessTokenOptions, VerifyAccessTokenRequestOptions, getJwks, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken } from "./verify.mjs";
12
+ import { ResourceRequestInput, VerifyAccessTokenOptions, VerifyAccessTokenRequestOptions, createInsufficientScopeError, getJwks, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken } from "./verify.mjs";
13
13
  import { supportsIdTokenSignIn, verifyProviderIdToken } from "./verify-id-token.mjs";
14
- export { type AccessTokenAuthorization, type AccessTokenAuthorizationScheme, BEARER_AUTHORIZATION_SCHEME, CLIENT_ASSERTION_TYPE, type ClientAssertionContext, type ClientAssertionGetter, type ClientAssertionGrantType, DPOP_AUTHORIZATION_SCHEME, DPOP_PROOF_TYPE, DPOP_SIGNING_ALGORITHMS, type DpopBindingError, type DpopBindingErrorCode, type DpopProofError, type DpopProofErrorCode, type DpopReplayReservation, type DpopReplayReservations, type DpopReplayStore, type DpopSigningAlgorithm, type EnforceDpopBindingParams, type OAuth2Tokens, type OAuth2UserInfo, type OAuthAccountKeyContext, type OAuthAccountSubject, type OAuthIdTokenConfig, type OAuthMappedUser, type OAuthProvider, type OAuthRefreshContext, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, type PrivateKeyJwtClientAssertionGetterOptions, type PrivateKeyJwtSigningAlgorithm, type ProviderOptions, RESERVED_AUTHORIZATION_PARAMS, RESERVED_AUTHORIZATION_PARAMS_SET, type ResourceRequestInput, type TokenEndpointAuth, type TokenEndpointAuthMethod, type TokenEndpointSecretAuthentication, type VerifiedDpopProof, type VerifyAccessTokenOptions, type VerifyAccessTokenRequestOptions, type VerifyDpopProofOptions, additionalAuthorizationParamsSchema, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationURL, createDpopBindingError, createDpopProofError, createDpopReplayStore, createInMemoryDpopReplayStore, createPrivateKeyJwtClientAssertionGetter, decodeBasicCredentials, deriveDpopAth, deriveDpopJkt, encodeBasicCredentials, enforceDpopBinding, generateCodeChallenge, getConfirmationJkt, getDpopJktFromPayload, getJwks, getOAuth2Tokens, getPrimaryClientId, isDpopBindingError, isDpopProofError, mergeScopes, normalizeDpopHtu, parseAccessTokenAuthorization, refreshAccessToken, refreshAccessTokenRequest, requestToResourceInput, resolveClientAssertionParams, signPrivateKeyJwtClientAssertion, stripAccessTokenAuthorizationScheme, supportsIdTokenSignIn, validateAuthorizationCode, validateToken, verifyAccessTokenRequest, verifyBearerToken, verifyDpopProof, verifyJwsAccessToken, verifyProviderIdToken };
14
+ export { type AccessTokenAuthorization, type AccessTokenAuthorizationScheme, BEARER_AUTHORIZATION_SCHEME, CLIENT_ASSERTION_TYPE, type ClientAssertionContext, type ClientAssertionGetter, type ClientAssertionGrantType, DPOP_AUTHORIZATION_SCHEME, DPOP_PROOF_TYPE, DPOP_SIGNING_ALGORITHMS, type DpopBindingError, type DpopBindingErrorCode, type DpopProofError, type DpopProofErrorCode, type DpopReplayReservation, type DpopReplayReservations, type DpopReplayStore, type DpopSigningAlgorithm, type EnforceDpopBindingParams, type OAuth2Tokens, type OAuth2UserInfo, type OAuthAccountKeyContext, type OAuthAccountSubject, type OAuthIdTokenConfig, type OAuthMappedUser, type OAuthProvider, type OAuthRefreshContext, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, type PrivateKeyJwtClientAssertionGetterOptions, type PrivateKeyJwtSigningAlgorithm, type ProviderOptions, RESERVED_AUTHORIZATION_PARAMS, RESERVED_AUTHORIZATION_PARAMS_SET, type ResourceRequestInput, type TokenEndpointAuth, type TokenEndpointAuthMethod, type TokenEndpointSecretAuthentication, type VerifiedDpopProof, type VerifyAccessTokenOptions, type VerifyAccessTokenRequestOptions, type VerifyDpopProofOptions, additionalAuthorizationParamsSchema, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationURL, createDpopBindingError, createDpopProofError, createDpopReplayStore, createInMemoryDpopReplayStore, createInsufficientScopeError, createPrivateKeyJwtClientAssertionGetter, decodeBasicCredentials, deriveDpopAth, deriveDpopJkt, encodeBasicCredentials, enforceDpopBinding, generateCodeChallenge, getConfirmationJkt, getDpopJktFromPayload, getJwks, getOAuth2Tokens, getPrimaryClientId, isDpopBindingError, isDpopProofError, isInsufficientScopeError, mergeScopes, normalizeDpopHtu, parseAccessTokenAuthorization, refreshAccessToken, refreshAccessTokenRequest, requestToResourceInput, resolveClientAssertionParams, signPrivateKeyJwtClientAssertion, stripAccessTokenAuthorizationScheme, supportsIdTokenSignIn, validateAuthorizationCode, validateToken, verifyAccessTokenRequest, verifyBearerToken, verifyDpopProof, verifyJwsAccessToken, verifyProviderIdToken };
@@ -7,6 +7,6 @@ import { clientCredentialsToken, clientCredentialsTokenRequest } from "./client-
7
7
  import { BEARER_AUTHORIZATION_SCHEME, DPOP_AUTHORIZATION_SCHEME, DPOP_PROOF_TYPE, DPOP_SIGNING_ALGORITHMS, createDpopBindingError, createDpopProofError, createDpopReplayStore, createInMemoryDpopReplayStore, deriveDpopAth, deriveDpopJkt, enforceDpopBinding, getConfirmationJkt, getDpopJktFromPayload, isDpopBindingError, isDpopProofError, normalizeDpopHtu, parseAccessTokenAuthorization, stripAccessTokenAuthorizationScheme, verifyDpopProof } from "./dpop.mjs";
8
8
  import { refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
9
9
  import { authorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
10
- import { getJwks, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken } from "./verify.mjs";
10
+ import { createInsufficientScopeError, getJwks, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken } from "./verify.mjs";
11
11
  import { supportsIdTokenSignIn, verifyProviderIdToken } from "./verify-id-token.mjs";
12
- export { BEARER_AUTHORIZATION_SCHEME, CLIENT_ASSERTION_TYPE, DPOP_AUTHORIZATION_SCHEME, DPOP_PROOF_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, RESERVED_AUTHORIZATION_PARAMS, RESERVED_AUTHORIZATION_PARAMS_SET, additionalAuthorizationParamsSchema, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationURL, createDpopBindingError, createDpopProofError, createDpopReplayStore, createInMemoryDpopReplayStore, createPrivateKeyJwtClientAssertionGetter, decodeBasicCredentials, deriveDpopAth, deriveDpopJkt, encodeBasicCredentials, enforceDpopBinding, generateCodeChallenge, getConfirmationJkt, getDpopJktFromPayload, getJwks, getOAuth2Tokens, getPrimaryClientId, isDpopBindingError, isDpopProofError, mergeScopes, normalizeDpopHtu, parseAccessTokenAuthorization, refreshAccessToken, refreshAccessTokenRequest, requestToResourceInput, resolveClientAssertionParams, signPrivateKeyJwtClientAssertion, stripAccessTokenAuthorizationScheme, supportsIdTokenSignIn, validateAuthorizationCode, validateToken, verifyAccessTokenRequest, verifyBearerToken, verifyDpopProof, verifyJwsAccessToken, verifyProviderIdToken };
12
+ export { BEARER_AUTHORIZATION_SCHEME, CLIENT_ASSERTION_TYPE, DPOP_AUTHORIZATION_SCHEME, DPOP_PROOF_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, RESERVED_AUTHORIZATION_PARAMS, RESERVED_AUTHORIZATION_PARAMS_SET, additionalAuthorizationParamsSchema, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationURL, createDpopBindingError, createDpopProofError, createDpopReplayStore, createInMemoryDpopReplayStore, createInsufficientScopeError, createPrivateKeyJwtClientAssertionGetter, decodeBasicCredentials, deriveDpopAth, deriveDpopJkt, encodeBasicCredentials, enforceDpopBinding, generateCodeChallenge, getConfirmationJkt, getDpopJktFromPayload, getJwks, getOAuth2Tokens, getPrimaryClientId, isDpopBindingError, isDpopProofError, isInsufficientScopeError, mergeScopes, normalizeDpopHtu, parseAccessTokenAuthorization, refreshAccessToken, refreshAccessTokenRequest, requestToResourceInput, resolveClientAssertionParams, signPrivateKeyJwtClientAssertion, stripAccessTokenAuthorizationScheme, supportsIdTokenSignIn, validateAuthorizationCode, validateToken, verifyAccessTokenRequest, verifyBearerToken, verifyDpopProof, verifyJwsAccessToken, verifyProviderIdToken };
@@ -199,6 +199,15 @@ interface OAuthProvider<T extends object = object, O extends object = Partial<Pr
199
199
  */
200
200
  refreshAccessToken?: ((refreshToken: string, ctx?: OAuthRefreshContext) => Promise<OAuth2Tokens>) | undefined;
201
201
  revokeToken?: ((token: string) => Promise<void>) | undefined;
202
+ /**
203
+ * Builds an OpenID Connect RP-Initiated Logout URL for this provider.
204
+ * Returns `null` when provider logout is unavailable or disabled.
205
+ */
206
+ createEndSessionURL?: ((data: {
207
+ idToken?: string | null | undefined;
208
+ postLogoutRedirectURI?: string | undefined;
209
+ state?: string | undefined;
210
+ }) => Awaitable<URL | null>) | undefined;
202
211
  /**
203
212
  * Declarative id_token verification config consumed by the shared
204
213
  * `verifyProviderIdToken` verifier. Providers set this instead of implementing a boolean
@@ -1,4 +1,5 @@
1
1
  import { DpopReplayStore } from "./dpop.mjs";
2
+ import { APIError } from "better-call";
2
3
  import { JSONWebKeySet, JWTPayload, JWTVerifyOptions } from "jose";
3
4
  //#region src/oauth2/verify.d.ts
4
5
  type JwksFetchOptions = {
@@ -42,8 +43,13 @@ interface VerifyAccessTokenRemote {
42
43
  interface VerifyAccessTokenOptions {
43
44
  /** Verify options */
44
45
  verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
45
- /** Scopes to additionally verify. Token must include all but not exact. */
46
- scopes?: string[];
46
+ /** Scopes the token must satisfy. */
47
+ requiredScopes?: readonly string[];
48
+ /**
49
+ * Determines whether a required scope is satisfied by the granted scope
50
+ * set. Defaults to exact set membership.
51
+ */
52
+ isScopeSatisfied?: (requiredScope: string, grantedScopes: ReadonlySet<string>) => boolean;
47
53
  /** Required to verify access token locally */
48
54
  jwksUrl?: string;
49
55
  /** If provided, can verify a token remotely */
@@ -88,6 +94,11 @@ declare function verifyJwsAccessToken(token: string, opts: JwksFetchOptions & {
88
94
  verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
89
95
  }): Promise<JWTPayload>;
90
96
  declare function getJwks(token: string, opts: JwksFetchOptions): Promise<JSONWebKeySet>;
97
+ declare function createInsufficientScopeError(requiredScopes: readonly string[], description?: string): APIError;
98
+ /**
99
+ * Returns whether an error is a typed RFC 6750 insufficient-scope failure.
100
+ */
101
+ declare function isInsufficientScopeError(error: unknown): error is APIError;
91
102
  /**
92
103
  * Performs local verification of a bearer access token for your API.
93
104
  *
@@ -110,4 +121,4 @@ declare function verifyBearerToken(token: string, opts: VerifyAccessTokenOptions
110
121
  */
111
122
  declare function verifyAccessTokenRequest(request: ResourceRequestInput, opts: VerifyAccessTokenRequestOptions): Promise<JWTPayload>;
112
123
  //#endregion
113
- export { ResourceRequestInput, VerifyAccessTokenOptions, VerifyAccessTokenRemote, VerifyAccessTokenRequestOptions, getJwks, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };
124
+ export { ResourceRequestInput, VerifyAccessTokenOptions, VerifyAccessTokenRemote, VerifyAccessTokenRequestOptions, createInsufficientScopeError, getJwks, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };
@@ -207,12 +207,73 @@ async function verifyAccessTokenPayload(token, opts) {
207
207
  }
208
208
  }
209
209
  if (!payload) throw new APIError("UNAUTHORIZED", { message: `no token payload` });
210
- if (opts.scopes) {
211
- const validScopes = new Set(payload.scope?.split(" "));
212
- for (const sc of opts.scopes) if (!validScopes.has(sc)) throw new APIError("FORBIDDEN", { message: `invalid scope ${sc}` });
210
+ const grantedScopes = parseGrantedScopes(payload.scope);
211
+ if (opts.requiredScopes) {
212
+ const isScopeSatisfied = opts.isScopeSatisfied ?? ((requiredScope, scopes) => scopes.has(requiredScope));
213
+ const missingScopes = opts.requiredScopes.filter((scope) => !isScopeSatisfied(scope, grantedScopes));
214
+ if (missingScopes.length > 0) throw createInsufficientScopeError(missingScopes);
213
215
  }
214
216
  return payload;
215
217
  }
218
+ /**
219
+ * Build the RFC 6750 §3.1 insufficient-scope failure: the access token is valid
220
+ * but lacks scopes the operation needs.
221
+ *
222
+ * Resource-server challenge builders turn this into a `403` carrying a
223
+ * `WWW-Authenticate: Bearer error="insufficient_scope"` challenge that names
224
+ * `scopes`, so the client knows what to request when it re-authorizes. Throw it
225
+ * from a route handler to challenge for scopes only that operation needs; a
226
+ * plain `FORBIDDEN` stays a plain `403`, since a permission denial the client
227
+ * cannot fix by re-authorizing must not send the user through consent again.
228
+ *
229
+ * @param requiredScopes - Every scope the operation requires but the token lacks.
230
+ * @param description - RFC 6750 `error_description` text. It must use the
231
+ * printable ASCII character set allowed by the specification.
232
+ */
233
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
234
+ const OAUTH_ERROR_DESCRIPTION_PATTERN = /^[\x20-\x21\x23-\x5b\x5d-\x7e]+$/;
235
+ const insufficientScopeErrors = /* @__PURE__ */ new WeakSet();
236
+ function isOAuthScopeToken(value) {
237
+ return OAUTH_SCOPE_TOKEN_PATTERN.test(value);
238
+ }
239
+ function validateScopeTokens(scopes, label) {
240
+ for (const scope of scopes) if (!isOAuthScopeToken(scope)) throw new TypeError(`invalid ${label}: ${JSON.stringify(scope)}`);
241
+ }
242
+ function validateRequiredScopes(opts) {
243
+ if (opts.requiredScopes) validateScopeTokens(opts.requiredScopes, "required scope");
244
+ }
245
+ function parseGrantedScopes(scope) {
246
+ if (scope === void 0) return /* @__PURE__ */ new Set();
247
+ if (typeof scope !== "string" || scope.length === 0 || scope.split(" ").some((token) => !isOAuthScopeToken(token))) throw new APIError("UNAUTHORIZED", {
248
+ message: "access token scope claim is invalid",
249
+ error: "invalid_token",
250
+ error_description: "access token scope claim is invalid"
251
+ });
252
+ return new Set(scope.split(" "));
253
+ }
254
+ function createInsufficientScopeError(requiredScopes, description = `access token is missing required scope: ${requiredScopes.join(" ")}`) {
255
+ if (requiredScopes.length === 0) throw new TypeError("requiredScopes must contain at least one scope");
256
+ validateScopeTokens(requiredScopes, "required scope");
257
+ if (typeof description !== "string" || !OAUTH_ERROR_DESCRIPTION_PATTERN.test(description)) throw new TypeError("invalid error_description");
258
+ const error = new APIError("FORBIDDEN", {
259
+ message: description,
260
+ error: "insufficient_scope",
261
+ error_description: description,
262
+ scope: [...new Set(requiredScopes)].join(" ")
263
+ });
264
+ insufficientScopeErrors.add(error);
265
+ return error;
266
+ }
267
+ /**
268
+ * Returns whether an error is a typed RFC 6750 insufficient-scope failure.
269
+ */
270
+ function isInsufficientScopeError(error) {
271
+ if (!(error instanceof APIError) || !insufficientScopeErrors.has(error) || error.status !== "FORBIDDEN") return false;
272
+ const body = error.body;
273
+ if (body?.error !== "insufficient_scope" || typeof body.scope !== "string") return false;
274
+ const scopes = body.scope.split(" ");
275
+ return scopes.length > 0 && scopes.every(isOAuthScopeToken);
276
+ }
216
277
  function throwDpopUnauthorized(message, error) {
217
278
  throw new APIError("UNAUTHORIZED", error ? {
218
279
  message,
@@ -231,6 +292,7 @@ function throwDpopUnauthorized(message, error) {
231
292
  * alone.
232
293
  */
233
294
  async function verifyBearerToken(token, opts) {
295
+ validateRequiredScopes(opts);
234
296
  const payload = await verifyAccessTokenPayload(token, opts);
235
297
  if (getDpopJktFromPayload(payload)) throwDpopUnauthorized("DPoP-bound access token requires verifyAccessTokenRequest", "invalid_token");
236
298
  return payload;
@@ -245,6 +307,7 @@ async function verifyBearerToken(token, opts) {
245
307
  * scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
246
308
  */
247
309
  async function verifyAccessTokenRequest(request, opts) {
310
+ validateRequiredScopes(opts);
248
311
  const authorization = parseAccessTokenAuthorization(request.authorizationHeader);
249
312
  if (!authorization?.token) throwDpopUnauthorized("missing authorization header");
250
313
  if (authorization.scheme === "Unknown") throwDpopUnauthorized("authorization scheme must be Bearer or DPoP", "invalid_token");
@@ -267,4 +330,4 @@ async function verifyAccessTokenRequest(request, opts) {
267
330
  return payload;
268
331
  }
269
332
  //#endregion
270
- export { getJwks, jwksCache, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };
333
+ export { createInsufficientScopeError, getJwks, isInsufficientScopeError, jwksCache, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };
@@ -29,7 +29,7 @@ const microsoft = (options) => {
29
29
  return {
30
30
  id: "microsoft",
31
31
  name: "Microsoft EntraID",
32
- accountSubject: ({ profile }) => profile.sub,
32
+ accountSubject: ({ profile }) => profile.oid,
33
33
  accountIssuer: ({ profile }) => profile.iss,
34
34
  createAuthorizationURL(data) {
35
35
  if (!getPrimaryClientId(options.clientId)) {
@@ -99,11 +99,15 @@ const microsoft = (options) => {
99
99
  if (options.getUserInfo) return options.getUserInfo(token);
100
100
  if (!token.idToken) return null;
101
101
  const user = decodeJwt(token.idToken);
102
+ if (typeof user.oid !== "string" || user.oid.trim().length === 0) {
103
+ logger.error("Microsoft Entra ID token did not include a valid oid claim; unable to resolve a stable account identifier.");
104
+ return null;
105
+ }
102
106
  const profilePhotoSize = options.profilePhotoSize || 48;
103
- await betterFetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, {
107
+ if (!options.disableProfilePhoto && token.accessToken) await betterFetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, {
104
108
  headers: { Authorization: `Bearer ${token.accessToken}` },
105
109
  async onResponse(context) {
106
- if (options.disableProfilePhoto || !context.response.ok) return;
110
+ if (!context.response.ok) return;
107
111
  try {
108
112
  const pictureBuffer = await context.response.clone().arrayBuffer();
109
113
  const pictureBase64 = base64.encode(pictureBuffer);
@@ -1,5 +1,10 @@
1
1
  import * as z from "zod";
2
2
  //#region src/utils/redirect-uri.d.ts
3
+ /**
4
+ * Returns whether a parsed redirect URI uses an authority-free, reverse-domain
5
+ * private-use scheme as recommended by RFC 8252 §7.1.
6
+ */
7
+ declare function isReverseDomainPrivateUseRedirectUri(uri: URL): boolean;
3
8
  /**
4
9
  * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
5
10
  * server stores and later hands back to a browser.
@@ -16,4 +21,4 @@ import * as z from "zod";
16
21
  */
17
22
  declare const SafeUrlSchema: z.ZodURL;
18
23
  //#endregion
19
- export { SafeUrlSchema };
24
+ export { SafeUrlSchema, isReverseDomainPrivateUseRedirectUri };
@@ -2,6 +2,16 @@ import { isLoopbackHost } from "./host.mjs";
2
2
  import { DANGEROUS_URL_SCHEMES } from "./url.mjs";
3
3
  import * as z from "zod";
4
4
  //#region src/utils/redirect-uri.ts
5
+ const REVERSE_DOMAIN_PRIVATE_USE_SCHEME = /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
6
+ /**
7
+ * Returns whether a parsed redirect URI uses an authority-free, reverse-domain
8
+ * private-use scheme as recommended by RFC 8252 §7.1.
9
+ */
10
+ function isReverseDomainPrivateUseRedirectUri(uri) {
11
+ const scheme = uri.protocol.slice(0, -1);
12
+ const schemeSpecificPart = uri.href.slice(uri.protocol.length);
13
+ return uri.protocol !== "http:" && uri.protocol !== "https:" && uri.host.length === 0 && schemeSpecificPart.startsWith("/") && !schemeSpecificPart.startsWith("//") && REVERSE_DOMAIN_PRIVATE_USE_SCHEME.test(scheme);
14
+ }
5
15
  /**
6
16
  * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
7
17
  * server stores and later hands back to a browser.
@@ -45,4 +55,4 @@ const SafeUrlSchema = z.url().superRefine((val, ctx) => {
45
55
  });
46
56
  });
47
57
  //#endregion
48
- export { SafeUrlSchema };
58
+ export { SafeUrlSchema, isReverseDomainPrivateUseRedirectUri };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.7.0-rc.2",
3
+ "version": "1.7.0-rc.3",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -863,8 +863,8 @@ export const createAdapterFactory =
863
863
  }: {
864
864
  model: string;
865
865
  data: T;
866
- select?: string[];
867
- forceAllowId?: boolean;
866
+ select?: string[] | undefined;
867
+ forceAllowId?: boolean | undefined;
868
868
  }): Promise<R> => {
869
869
  transactionId++;
870
870
  const thisTransactionId = transactionId;
@@ -1098,8 +1098,8 @@ export const createAdapterFactory =
1098
1098
  }: {
1099
1099
  model: string;
1100
1100
  where: Where[];
1101
- select?: string[];
1102
- join?: JoinOption;
1101
+ select?: string[] | undefined;
1102
+ join?: JoinOption | undefined;
1103
1103
  }) => {
1104
1104
  transactionId++;
1105
1105
  const thisTransactionId = transactionId;
@@ -1181,12 +1181,12 @@ export const createAdapterFactory =
1181
1181
  join: unsafeJoin,
1182
1182
  }: {
1183
1183
  model: string;
1184
- where?: Where[];
1185
- limit?: number;
1184
+ where?: Where[] | undefined;
1185
+ limit?: number | undefined;
1186
1186
  select?: string[] | undefined;
1187
- sortBy?: { field: string; direction: "asc" | "desc" };
1188
- offset?: number;
1189
- join?: JoinOption;
1187
+ sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
1188
+ offset?: number | undefined;
1189
+ join?: JoinOption | undefined;
1190
1190
  }) => {
1191
1191
  transactionId++;
1192
1192
  const thisTransactionId = transactionId;
@@ -1509,7 +1509,7 @@ export const createAdapterFactory =
1509
1509
  where: unsafeWhere,
1510
1510
  }: {
1511
1511
  model: string;
1512
- where?: Where[];
1512
+ where?: Where[] | undefined;
1513
1513
  }) => {
1514
1514
  transactionId++;
1515
1515
  const thisTransactionId = transactionId;
@@ -1,3 +1,4 @@
1
+ import type {} from "@better-auth/core";
1
2
  import { defineErrorCodes } from "../utils/error-codes";
2
3
 
3
4
  declare module "@better-auth/core" {
@@ -100,7 +100,9 @@ export type {
100
100
  VerifyAccessTokenRequestOptions,
101
101
  } from "./verify";
102
102
  export {
103
+ createInsufficientScopeError,
103
104
  getJwks,
105
+ isInsufficientScopeError,
104
106
  requestToResourceInput,
105
107
  verifyAccessTokenRequest,
106
108
  verifyBearerToken,
@@ -228,6 +228,17 @@ export interface OAuthProvider<
228
228
  ) => Promise<OAuth2Tokens>)
229
229
  | undefined;
230
230
  revokeToken?: ((token: string) => Promise<void>) | undefined;
231
+ /**
232
+ * Builds an OpenID Connect RP-Initiated Logout URL for this provider.
233
+ * Returns `null` when provider logout is unavailable or disabled.
234
+ */
235
+ createEndSessionURL?:
236
+ | ((data: {
237
+ idToken?: string | null | undefined;
238
+ postLogoutRedirectURI?: string | undefined;
239
+ state?: string | undefined;
240
+ }) => Awaitable<URL | null>)
241
+ | undefined;
231
242
  /**
232
243
  * Declarative id_token verification config consumed by the shared
233
244
  * `verifyProviderIdToken` verifier. Providers set this instead of implementing a boolean
@@ -164,8 +164,16 @@ export interface VerifyAccessTokenOptions {
164
164
  /** Verify options */
165
165
  verifyOptions: JWTVerifyOptions &
166
166
  Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
167
- /** Scopes to additionally verify. Token must include all but not exact. */
168
- scopes?: string[];
167
+ /** Scopes the token must satisfy. */
168
+ requiredScopes?: readonly string[];
169
+ /**
170
+ * Determines whether a required scope is satisfied by the granted scope
171
+ * set. Defaults to exact set membership.
172
+ */
173
+ isScopeSatisfied?: (
174
+ requiredScope: string,
175
+ grantedScopes: ReadonlySet<string>,
176
+ ) => boolean;
169
177
  /** Required to verify access token locally */
170
178
  jwksUrl?: string;
171
179
  /** If provided, can verify a token remotely */
@@ -442,23 +450,122 @@ async function verifyAccessTokenPayload(
442
450
  message: `no token payload`,
443
451
  });
444
452
 
445
- // Check scopes if provided
446
- if (opts.scopes) {
447
- const validScopes = new Set(
448
- (payload.scope as string | undefined)?.split(" "),
453
+ const grantedScopes = parseGrantedScopes(payload.scope);
454
+
455
+ // Check scopes if provided.
456
+ if (opts.requiredScopes) {
457
+ const isScopeSatisfied =
458
+ opts.isScopeSatisfied ??
459
+ ((requiredScope: string, scopes: ReadonlySet<string>) =>
460
+ scopes.has(requiredScope));
461
+ // RFC 6750 §3.1: report every missing scope at once. Challenging with one
462
+ // scope at a time costs the user a browser round-trip per scope.
463
+ const missingScopes = opts.requiredScopes.filter(
464
+ (scope) => !isScopeSatisfied(scope, grantedScopes),
449
465
  );
450
- for (const sc of opts.scopes) {
451
- if (!validScopes.has(sc)) {
452
- throw new APIError("FORBIDDEN", {
453
- message: `invalid scope ${sc}`,
454
- });
455
- }
466
+ if (missingScopes.length > 0) {
467
+ throw createInsufficientScopeError(missingScopes);
456
468
  }
457
469
  }
458
470
 
459
471
  return payload;
460
472
  }
461
473
 
474
+ /**
475
+ * Build the RFC 6750 §3.1 insufficient-scope failure: the access token is valid
476
+ * but lacks scopes the operation needs.
477
+ *
478
+ * Resource-server challenge builders turn this into a `403` carrying a
479
+ * `WWW-Authenticate: Bearer error="insufficient_scope"` challenge that names
480
+ * `scopes`, so the client knows what to request when it re-authorizes. Throw it
481
+ * from a route handler to challenge for scopes only that operation needs; a
482
+ * plain `FORBIDDEN` stays a plain `403`, since a permission denial the client
483
+ * cannot fix by re-authorizing must not send the user through consent again.
484
+ *
485
+ * @param requiredScopes - Every scope the operation requires but the token lacks.
486
+ * @param description - RFC 6750 `error_description` text. It must use the
487
+ * printable ASCII character set allowed by the specification.
488
+ */
489
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
490
+ const OAUTH_ERROR_DESCRIPTION_PATTERN = /^[\x20-\x21\x23-\x5b\x5d-\x7e]+$/;
491
+ const insufficientScopeErrors = new WeakSet<APIError>();
492
+
493
+ function isOAuthScopeToken(value: string): boolean {
494
+ return OAUTH_SCOPE_TOKEN_PATTERN.test(value);
495
+ }
496
+
497
+ function validateScopeTokens(scopes: readonly string[], label: string): void {
498
+ for (const scope of scopes) {
499
+ if (!isOAuthScopeToken(scope)) {
500
+ throw new TypeError(`invalid ${label}: ${JSON.stringify(scope)}`);
501
+ }
502
+ }
503
+ }
504
+
505
+ function validateRequiredScopes(opts: VerifyAccessTokenOptions): void {
506
+ if (opts.requiredScopes) {
507
+ validateScopeTokens(opts.requiredScopes, "required scope");
508
+ }
509
+ }
510
+
511
+ function parseGrantedScopes(scope: unknown): ReadonlySet<string> {
512
+ if (scope === undefined) return new Set();
513
+ if (
514
+ typeof scope !== "string" ||
515
+ scope.length === 0 ||
516
+ scope.split(" ").some((token) => !isOAuthScopeToken(token))
517
+ ) {
518
+ throw new APIError("UNAUTHORIZED", {
519
+ message: "access token scope claim is invalid",
520
+ error: "invalid_token",
521
+ error_description: "access token scope claim is invalid",
522
+ });
523
+ }
524
+ return new Set(scope.split(" "));
525
+ }
526
+
527
+ export function createInsufficientScopeError(
528
+ requiredScopes: readonly string[],
529
+ description = `access token is missing required scope: ${requiredScopes.join(" ")}`,
530
+ ): APIError {
531
+ if (requiredScopes.length === 0) {
532
+ throw new TypeError("requiredScopes must contain at least one scope");
533
+ }
534
+ validateScopeTokens(requiredScopes, "required scope");
535
+ if (
536
+ typeof description !== "string" ||
537
+ !OAUTH_ERROR_DESCRIPTION_PATTERN.test(description)
538
+ ) {
539
+ throw new TypeError("invalid error_description");
540
+ }
541
+ const error = new APIError("FORBIDDEN", {
542
+ message: description,
543
+ error: "insufficient_scope",
544
+ error_description: description,
545
+ scope: [...new Set(requiredScopes)].join(" "),
546
+ });
547
+ insufficientScopeErrors.add(error);
548
+ return error;
549
+ }
550
+
551
+ /**
552
+ * Returns whether an error is a typed RFC 6750 insufficient-scope failure.
553
+ */
554
+ export function isInsufficientScopeError(error: unknown): error is APIError {
555
+ if (
556
+ !(error instanceof APIError) ||
557
+ !insufficientScopeErrors.has(error) ||
558
+ error.status !== "FORBIDDEN"
559
+ )
560
+ return false;
561
+ const body = error.body as { error?: unknown; scope?: unknown } | undefined;
562
+ if (body?.error !== "insufficient_scope" || typeof body.scope !== "string") {
563
+ return false;
564
+ }
565
+ const scopes = body.scope.split(" ");
566
+ return scopes.length > 0 && scopes.every(isOAuthScopeToken);
567
+ }
568
+
462
569
  function throwDpopUnauthorized(
463
570
  message: string,
464
571
  error?: "invalid_dpop_proof" | "invalid_token",
@@ -489,6 +596,7 @@ export async function verifyBearerToken(
489
596
  token: string,
490
597
  opts: VerifyAccessTokenOptions,
491
598
  ) {
599
+ validateRequiredScopes(opts);
492
600
  const payload = await verifyAccessTokenPayload(token, opts);
493
601
  if (getDpopJktFromPayload(payload)) {
494
602
  throwDpopUnauthorized(
@@ -512,6 +620,7 @@ export async function verifyAccessTokenRequest(
512
620
  request: ResourceRequestInput,
513
621
  opts: VerifyAccessTokenRequestOptions,
514
622
  ) {
623
+ validateRequiredScopes(opts);
515
624
  const authorization = parseAccessTokenAuthorization(
516
625
  request.authorizationHeader,
517
626
  );
@@ -187,7 +187,7 @@ export const microsoft = (options: MicrosoftOptions) => {
187
187
  return {
188
188
  id: "microsoft",
189
189
  name: "Microsoft EntraID",
190
- accountSubject: ({ profile }) => profile.sub,
190
+ accountSubject: ({ profile }) => profile.oid,
191
191
  accountIssuer: ({ profile }) => profile.iss,
192
192
  createAuthorizationURL(data) {
193
193
  // Microsoft Entra supports public clients (SPA / native apps with
@@ -279,33 +279,41 @@ export const microsoft = (options: MicrosoftOptions) => {
279
279
  return null;
280
280
  }
281
281
  const user = decodeJwt(token.idToken) as MicrosoftEntraIDProfile;
282
+ if (typeof user.oid !== "string" || user.oid.trim().length === 0) {
283
+ logger.error(
284
+ "Microsoft Entra ID token did not include a valid oid claim; unable to resolve a stable account identifier.",
285
+ );
286
+ return null;
287
+ }
282
288
  const profilePhotoSize = options.profilePhotoSize || 48;
283
- await betterFetch<ArrayBuffer>(
284
- `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
285
- {
286
- headers: {
287
- Authorization: `Bearer ${token.accessToken}`,
288
- },
289
- async onResponse(context) {
290
- if (options.disableProfilePhoto || !context.response.ok) {
291
- return;
292
- }
293
- try {
294
- const response = context.response.clone();
295
- const pictureBuffer = await response.arrayBuffer();
296
- const pictureBase64 = base64.encode(pictureBuffer);
297
- user.picture = `data:image/jpeg;base64, ${pictureBase64}`;
298
- } catch (e) {
299
- logger.error(
300
- e && typeof e === "object" && "name" in e
301
- ? (e.name as string)
302
- : "",
303
- e,
304
- );
305
- }
289
+ if (!options.disableProfilePhoto && token.accessToken) {
290
+ await betterFetch<ArrayBuffer>(
291
+ `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
292
+ {
293
+ headers: {
294
+ Authorization: `Bearer ${token.accessToken}`,
295
+ },
296
+ async onResponse(context) {
297
+ if (!context.response.ok) {
298
+ return;
299
+ }
300
+ try {
301
+ const response = context.response.clone();
302
+ const pictureBuffer = await response.arrayBuffer();
303
+ const pictureBase64 = base64.encode(pictureBuffer);
304
+ user.picture = `data:image/jpeg;base64, ${pictureBase64}`;
305
+ } catch (e) {
306
+ logger.error(
307
+ e && typeof e === "object" && "name" in e
308
+ ? (e.name as string)
309
+ : "",
310
+ e,
311
+ );
312
+ }
313
+ },
306
314
  },
307
- },
308
- );
315
+ );
316
+ }
309
317
  const userMap = await options.mapProfileToUser?.(user);
310
318
  // Microsoft Entra ID does NOT include email_verified claim by default.
311
319
  // It must be configured as an optional claim in the app registration.
@@ -2,6 +2,26 @@ import * as z from "zod";
2
2
  import { isLoopbackHost } from "./host";
3
3
  import { DANGEROUS_URL_SCHEMES } from "./url";
4
4
 
5
+ const REVERSE_DOMAIN_PRIVATE_USE_SCHEME =
6
+ /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
7
+
8
+ /**
9
+ * Returns whether a parsed redirect URI uses an authority-free, reverse-domain
10
+ * private-use scheme as recommended by RFC 8252 §7.1.
11
+ */
12
+ export function isReverseDomainPrivateUseRedirectUri(uri: URL): boolean {
13
+ const scheme = uri.protocol.slice(0, -1);
14
+ const schemeSpecificPart = uri.href.slice(uri.protocol.length);
15
+ return (
16
+ uri.protocol !== "http:" &&
17
+ uri.protocol !== "https:" &&
18
+ uri.host.length === 0 &&
19
+ schemeSpecificPart.startsWith("/") &&
20
+ !schemeSpecificPart.startsWith("//") &&
21
+ REVERSE_DOMAIN_PRIVATE_USE_SCHEME.test(scheme)
22
+ );
23
+ }
24
+
5
25
  /**
6
26
  * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
7
27
  * server stores and later hands back to a browser.