@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.
- package/dist/context/global.mjs +1 -1
- 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/oauth-provider.d.mts +9 -0
- package/dist/oauth2/verify.d.mts +14 -3
- package/dist/oauth2/verify.mjs +67 -4
- package/dist/social-providers/microsoft-entra-id.mjs +7 -3
- package/dist/utils/redirect-uri.d.mts +6 -1
- package/dist/utils/redirect-uri.mjs +11 -1
- package/package.json +1 -1
- package/src/db/adapter/factory.ts +10 -10
- package/src/error/codes.ts +1 -0
- package/src/oauth2/index.ts +2 -0
- package/src/oauth2/oauth-provider.ts +11 -0
- package/src/oauth2/verify.ts +121 -12
- package/src/social-providers/microsoft-entra-id.ts +34 -26
- package/src/utils/redirect-uri.ts +20 -0
package/dist/context/global.mjs
CHANGED
|
@@ -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.
|
|
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
|
package/dist/oauth2/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/oauth2/index.mjs
CHANGED
|
@@ -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
|
package/dist/oauth2/verify.d.mts
CHANGED
|
@@ -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
|
|
46
|
-
|
|
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 };
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
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 (
|
|
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
|
@@ -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;
|
package/src/error/codes.ts
CHANGED
package/src/oauth2/index.ts
CHANGED
|
@@ -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
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -164,8 +164,16 @@ export interface VerifyAccessTokenOptions {
|
|
|
164
164
|
/** Verify options */
|
|
165
165
|
verifyOptions: JWTVerifyOptions &
|
|
166
166
|
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
167
|
-
/** Scopes
|
|
168
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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.
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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.
|