@aventara/access 0.0.0-stage → 0.1.0-rc.0
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/CHANGELOG.md +831 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2439 -2
- package/dist/access/access-options.d.ts +66 -0
- package/dist/access/access-options.js +5 -0
- package/dist/access/access.error.d.ts +35 -0
- package/dist/access/access.error.js +11 -0
- package/dist/access/access.factory.d.ts +273 -0
- package/dist/access/access.factory.js +440 -0
- package/dist/access/better-auth.hardening.d.ts +120 -0
- package/dist/access/better-auth.hardening.js +420 -0
- package/dist/access/better-auth.loader.d.ts +67 -0
- package/dist/access/better-auth.loader.js +65 -0
- package/dist/access/better-auth.range.d.ts +9 -0
- package/dist/access/better-auth.range.js +28 -0
- package/dist/access/better-auth.require.d.ts +90 -0
- package/dist/access/better-auth.require.js +6 -0
- package/dist/access/better-auth.require.node.d.ts +7 -0
- package/dist/access/better-auth.require.node.js +52 -0
- package/dist/access/claims.mapper.d.ts +79 -0
- package/dist/access/claims.mapper.js +152 -0
- package/dist/access/key-set.cache.d.ts +10 -0
- package/dist/access/key-set.cache.js +63 -0
- package/dist/access/policies-block.checker.d.ts +64 -0
- package/dist/access/policies-block.checker.js +138 -0
- package/dist/access/principal.subject.d.ts +12 -0
- package/dist/access/principal.subject.js +60 -0
- package/dist/access/profile.policy.d.ts +74 -0
- package/dist/access/profile.policy.js +110 -0
- package/dist/access/request.state.d.ts +89 -0
- package/dist/access/request.state.js +196 -0
- package/dist/access/session.hooks.d.ts +114 -0
- package/dist/access/session.hooks.js +258 -0
- package/dist/access/token.verifier.d.ts +35 -0
- package/dist/access/token.verifier.js +69 -0
- package/dist/access-schema.d.ts +48 -0
- package/dist/access-schema.js +5 -0
- package/dist/access-warning.emitter.d.ts +2 -0
- package/dist/access-warning.emitter.js +20 -0
- package/dist/access.builder.d.ts +299 -0
- package/dist/access.builder.js +66 -0
- package/dist/access.engine.d.ts +142 -0
- package/dist/access.engine.js +470 -0
- package/dist/audit/audit-event.d.ts +135 -0
- package/dist/audit/audit-event.js +8 -0
- package/dist/audit/audit.log.d.ts +122 -0
- package/dist/audit/audit.log.js +345 -0
- package/dist/audit/audit.schema.d.ts +143 -0
- package/dist/audit/audit.schema.js +50 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +2 -0
- package/dist/aventara-where.translator.d.ts +19 -0
- package/dist/aventara-where.translator.js +87 -0
- package/dist/beacon/beacon.check.d.ts +59 -0
- package/dist/beacon/beacon.check.js +128 -0
- package/dist/beacon/beacon.schema.d.ts +41 -0
- package/dist/beacon/beacon.schema.js +48 -0
- package/dist/beacon/beacon.store.d.ts +29 -0
- package/dist/beacon/beacon.store.js +78 -0
- package/dist/better-auth/access.client-plugin.d.ts +75 -0
- package/dist/better-auth/access.client-plugin.js +133 -0
- package/dist/better-auth/access.plugin.d.ts +164 -0
- package/dist/better-auth/access.plugin.js +348 -0
- package/dist/better-auth/access.provider.d.ts +78 -0
- package/dist/better-auth/access.provider.js +100 -0
- package/dist/better-auth/index.d.ts +7 -0
- package/dist/better-auth/index.js +4 -0
- package/dist/better-auth/organization.bridge.d.ts +66 -0
- package/dist/better-auth/organization.bridge.js +144 -0
- package/dist/canonical-json.writer.d.ts +12 -0
- package/dist/canonical-json.writer.js +30 -0
- package/dist/cli/avaccess.bin.d.ts +2 -0
- package/dist/cli/avaccess.bin.js +26 -0
- package/dist/cli/avaccess.program.d.ts +15 -0
- package/dist/cli/avaccess.program.js +166 -0
- package/dist/cli/beacon-pins.file.d.ts +14 -0
- package/dist/cli/beacon-pins.file.js +62 -0
- package/dist/cli/beacon.loader.d.ts +20 -0
- package/dist/cli/beacon.loader.js +71 -0
- package/dist/cli/cli.context.d.ts +49 -0
- package/dist/cli/cli.context.js +66 -0
- package/dist/cli/diff.command.d.ts +2 -0
- package/dist/cli/diff.command.js +66 -0
- package/dist/cli/generate.command.d.ts +2 -0
- package/dist/cli/generate.command.js +82 -0
- package/dist/cli/keys.command.d.ts +2 -0
- package/dist/cli/keys.command.js +96 -0
- package/dist/cli/lock.file.d.ts +31 -0
- package/dist/cli/lock.file.js +181 -0
- package/dist/cli/module.loader.d.ts +13 -0
- package/dist/cli/module.loader.js +63 -0
- package/dist/cli/policies.diff.d.ts +16 -0
- package/dist/cli/policies.diff.js +83 -0
- package/dist/cli/pull.command.d.ts +2 -0
- package/dist/cli/pull.command.js +234 -0
- package/dist/cli/push.command.d.ts +2 -0
- package/dist/cli/push.command.js +59 -0
- package/dist/cli/registry.client.d.ts +63 -0
- package/dist/cli/registry.client.js +287 -0
- package/dist/cli/schema.command.d.ts +2 -0
- package/dist/cli/schema.command.js +60 -0
- package/dist/cli/serve.command.d.ts +6 -0
- package/dist/cli/serve.command.js +200 -0
- package/dist/cli/status.command.d.ts +2 -0
- package/dist/cli/status.command.js +31 -0
- package/dist/cli/translator.flag.d.ts +6 -0
- package/dist/cli/translator.flag.js +24 -0
- package/dist/cli/verify.command.d.ts +2 -0
- package/dist/cli/verify.command.js +40 -0
- package/dist/client/access-client.factory.d.ts +92 -0
- package/dist/client/access-client.factory.js +269 -0
- package/dist/client/access-verbs.schema.d.ts +66 -0
- package/dist/client/access-verbs.schema.js +159 -0
- package/dist/client/verb-rate.limiter.d.ts +116 -0
- package/dist/client/verb-rate.limiter.js +358 -0
- package/dist/foreign/foreign-facts.d.ts +30 -0
- package/dist/foreign/foreign-facts.js +75 -0
- package/dist/foreign/foreign.merger.d.ts +10 -0
- package/dist/foreign/foreign.merger.js +52 -0
- package/dist/foreign-references.check.d.ts +12 -0
- package/dist/foreign-references.check.js +134 -0
- package/dist/generator/client.emitter.d.ts +28 -0
- package/dist/generator/client.emitter.js +411 -0
- package/dist/generator/copied-modules.reader.d.ts +14 -0
- package/dist/generator/copied-modules.reader.js +80 -0
- package/dist/generator/namespace.facts.d.ts +28 -0
- package/dist/generator/namespace.facts.js +32 -0
- package/dist/generator/orm.translator.d.ts +48 -0
- package/dist/generator/orm.translator.js +29 -0
- package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
- package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
- package/dist/generator/prisma7/prisma-where.translator.d.ts +72 -0
- package/dist/generator/prisma7/prisma-where.translator.js +136 -0
- package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
- package/dist/generator/prisma7/prisma7.translator.js +87 -0
- package/dist/generator/schema.emitter.d.ts +14 -0
- package/dist/generator/schema.emitter.js +23 -0
- package/dist/generator/tsconfig.reader.d.ts +44 -0
- package/dist/generator/tsconfig.reader.js +110 -0
- package/dist/generator/typescript-literal.writer.d.ts +5 -0
- package/dist/generator/typescript-literal.writer.js +42 -0
- package/dist/guardrails/guardrails-contract.d.ts +45 -0
- package/dist/guardrails/guardrails-contract.js +12 -0
- package/dist/guardrails/guardrails.builder.d.ts +48 -0
- package/dist/guardrails/guardrails.builder.js +32 -0
- package/dist/guardrails/guardrails.compiler.d.ts +10 -0
- package/dist/guardrails/guardrails.compiler.js +254 -0
- package/dist/guardrails/guardrails.composer.d.ts +7 -0
- package/dist/guardrails/guardrails.composer.js +95 -0
- package/dist/guardrails/guardrails.hash.d.ts +10 -0
- package/dist/guardrails/guardrails.hash.js +38 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
- package/dist/kiosk/kiosk-verbs.schema.js +61 -0
- package/dist/kiosk/kiosk.plugin.d.ts +217 -0
- package/dist/kiosk/kiosk.plugin.js +877 -0
- package/dist/kiosk/kiosk.schema.d.ts +176 -0
- package/dist/kiosk/kiosk.schema.js +101 -0
- package/dist/kiosk/kiosk.store.d.ts +171 -0
- package/dist/kiosk/kiosk.store.js +478 -0
- package/dist/kiosk/lan-range.policy.d.ts +31 -0
- package/dist/kiosk/lan-range.policy.js +113 -0
- package/dist/kiosk/pin.policy.d.ts +9 -0
- package/dist/kiosk/pin.policy.js +143 -0
- package/dist/kiosk/session-use.marker.d.ts +4 -0
- package/dist/kiosk/session-use.marker.js +16 -0
- package/dist/lock/lock-contract.d.ts +38 -0
- package/dist/lock/lock-contract.js +67 -0
- package/dist/lock/lock.verifier.d.ts +27 -0
- package/dist/lock/lock.verifier.js +62 -0
- package/dist/mcp/agent-scope.gate.d.ts +11 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +36 -0
- package/dist/mcp/agent.grant.js +92 -0
- package/dist/mcp/mcp-options.d.ts +60 -0
- package/dist/mcp/mcp-options.js +72 -0
- package/dist/oauth/access-token.verifier.d.ts +66 -0
- package/dist/oauth/access-token.verifier.js +201 -0
- package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
- package/dist/oauth/oauth-provider.hardening.js +261 -0
- package/dist/passwords/argon2id.hasher.d.ts +97 -0
- package/dist/passwords/argon2id.hasher.js +256 -0
- package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
- package/dist/passwords/node-rs-argon2.adapter.js +42 -0
- package/dist/passwords/passwords.options.d.ts +79 -0
- package/dist/passwords/passwords.options.js +212 -0
- package/dist/policies-contract.d.ts +192 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +36 -0
- package/dist/policies.compiler.js +1358 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +382 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +53 -0
- package/dist/predicate.evaluator.d.ts +62 -0
- package/dist/predicate.evaluator.js +378 -0
- package/dist/random-secret.generator.d.ts +2 -0
- package/dist/random-secret.generator.js +15 -0
- package/dist/reference-reads.check.d.ts +12 -0
- package/dist/reference-reads.check.js +148 -0
- package/dist/registry/access.registry.d.ts +173 -0
- package/dist/registry/access.registry.js +865 -0
- package/dist/registry/beacon.ledger.d.ts +30 -0
- package/dist/registry/beacon.ledger.js +93 -0
- package/dist/registry/ownership.check.d.ts +54 -0
- package/dist/registry/ownership.check.js +215 -0
- package/dist/registry/registry.endpoints.d.ts +50 -0
- package/dist/registry/registry.endpoints.js +193 -0
- package/dist/registry/registry.key.d.ts +7 -0
- package/dist/registry/registry.key.js +13 -0
- package/dist/registry/registry.options.d.ts +13 -0
- package/dist/registry/registry.options.js +28 -0
- package/dist/registry/registry.refusal.d.ts +6 -0
- package/dist/registry/registry.refusal.js +11 -0
- package/dist/registry/registry.schema.d.ts +238 -0
- package/dist/registry/registry.schema.js +127 -0
- package/dist/resources/auth-tables.catalog.d.ts +91 -0
- package/dist/resources/auth-tables.catalog.js +102 -0
- package/dist/resources/auth-tables.guard.d.ts +95 -0
- package/dist/resources/auth-tables.guard.js +405 -0
- package/dist/scope.resolver.d.ts +138 -0
- package/dist/scope.resolver.js +512 -0
- package/dist/sha256.digest.d.ts +2 -0
- package/dist/sha256.digest.js +81 -0
- package/dist/standalone/access.server.d.ts +41 -0
- package/dist/standalone/access.server.js +301 -0
- package/dist/standalone/index.d.ts +1 -0
- package/dist/standalone/index.js +1 -0
- package/package.json +101 -3
- package/src/aventara-where.translator.ts +116 -0
- package/src/beacon/beacon.check.ts +204 -0
- package/src/canonical-json.writer.ts +51 -0
- package/src/generator/prisma7/prisma-where.translator.ts +241 -0
- package/src/policies-contract.ts +184 -0
- package/src/policies.decider.ts +650 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +791 -0
- package/src/sha256.digest.ts +85 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The `mcp` block: Access as the authorization layer of one MCP endpoint
|
|
2
|
+
// (`createAccess({ mcp: { resource } })`, `aventaraAccess({ mcp: { resource } })`).
|
|
3
|
+
// Better Auth authenticates: its own OAuth provider (`@better-auth/oauth-provider`,
|
|
4
|
+
// which the app installs in Better Auth's plugins as it would for plain Better Auth,
|
|
5
|
+
// its options passed through untouched) is the authorization server and issues the
|
|
6
|
+
// tokens. Access issues none: it names the MCP endpoint they are for, refuses a
|
|
7
|
+
// provider configured short of what an agent's authorization needs
|
|
8
|
+
// (`oauth/oauth-provider.hardening.ts`), verifies the tokens, maps their scopes to the
|
|
9
|
+
// rules under the `agent` origin, and gives the framework `mcp.authorization`:
|
|
10
|
+
// `access.mcp`, `{ servers, verify }`.
|
|
11
|
+
//
|
|
12
|
+
// The authorization server's issuer is Access's fixed issuer, the origin of Better
|
|
13
|
+
// Auth's base URL, which `jwt()` signs every token with:
|
|
14
|
+
// one string, which the provider's metadata, its `iss` in each authorization
|
|
15
|
+
// response (RFC 9207) and the tokens' `iss` all carry, and which the endpoint's
|
|
16
|
+
// protected-resource metadata advertises. It is https, or, for a server on this
|
|
17
|
+
// machine, `http://127.0.0.1` or `http://[::1]` (the framework's rule for MCP authorization servers): a name
|
|
18
|
+
// such as `localhost` is resolved, and may resolve elsewhere.
|
|
19
|
+
import { AccessError } from "../access/access.error.js";
|
|
20
|
+
import { checkResourceUrl } from "../oauth/oauth-provider.hardening.js";
|
|
21
|
+
import { isRecord } from "../policies.compiler.js";
|
|
22
|
+
const refuse = (message) => {
|
|
23
|
+
throw new AccessError("ACCESS_OPTIONS_INVALID", message);
|
|
24
|
+
};
|
|
25
|
+
/** The hosts an http authorization server may name, as the WHATWG URL parser writes them (the framework's rule). */
|
|
26
|
+
const LOOPBACK_SERVER_HOSTS = new Set([
|
|
27
|
+
"127.0.0.1",
|
|
28
|
+
"[::1]",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* The `mcp` block, checked: `{ resource, openRegistration? }`, the MCP endpoint's URL
|
|
32
|
+
* as its protected-resource metadata writes it (`checkResourceUrl`), and whether open
|
|
33
|
+
* registration is taken. `undefined` when the block is absent.
|
|
34
|
+
*/
|
|
35
|
+
export const checkMcpOptions = (value, at, issuer) => {
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(value) ||
|
|
40
|
+
Object.keys(value).some((key) => key !== "resource" && key !== "openRegistration") ||
|
|
41
|
+
typeof value.resource !== "string" ||
|
|
42
|
+
(value.openRegistration !== undefined &&
|
|
43
|
+
typeof value.openRegistration !== "boolean")) {
|
|
44
|
+
return refuse(`\`${at}\` is \`{ resource, openRegistration? }\`: the MCP endpoint's URL, as its protected-resource metadata names it (\`"https://app.example.com/mcp"\`), and \`true\` to take the OAuth provider's open client registration.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
resource: checkResourceUrl(value.resource, `${at}.resource`, issuer),
|
|
48
|
+
openRegistration: value.openRegistration === true,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The authorization server `access.mcp.servers` names: the issuer, when it is https,
|
|
53
|
+
* or http on `127.0.0.1` or `[::1]` (the framework's rule). `localhost` is
|
|
54
|
+
* refused with the address to write instead.
|
|
55
|
+
*/
|
|
56
|
+
export const authorizationServerOf = (issuer, baseURLAt) => {
|
|
57
|
+
const url = new URL(issuer);
|
|
58
|
+
if (url.port === "0") {
|
|
59
|
+
return refuse(`\`mcp\` advertises Access's issuer (${issuer}, from \`${baseURLAt}\`) as the MCP endpoint's OAuth authorization server, and it names port 0, which no server listens on.`);
|
|
60
|
+
}
|
|
61
|
+
if (url.protocol === "https:") {
|
|
62
|
+
return issuer;
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol === "http:" && LOOPBACK_SERVER_HOSTS.has(url.hostname)) {
|
|
65
|
+
return issuer;
|
|
66
|
+
}
|
|
67
|
+
if (url.protocol === "http:" && url.hostname === "localhost") {
|
|
68
|
+
url.hostname = "127.0.0.1";
|
|
69
|
+
return refuse(`\`mcp\` advertises Access's issuer (${issuer}, from \`${baseURLAt}\`) as the MCP endpoint's OAuth authorization server, and an http one must be written as an address of this machine, not a name that is resolved (and may resolve elsewhere): set \`${baseURLAt}\` to ${url.origin}.`);
|
|
70
|
+
}
|
|
71
|
+
return refuse(`\`mcp\` advertises Access's issuer (${issuer}, from \`${baseURLAt}\`) as the MCP endpoint's OAuth authorization server, which must be https (or, on this machine, \`http://127.0.0.1\` or \`http://[::1]\`): set \`${baseURLAt}\` to its https URL.`);
|
|
72
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type JSONWebKeySet } from "jose";
|
|
2
|
+
import type { AccessClaimsSource } from "../access/claims.mapper.js";
|
|
3
|
+
type ClaimsMap = {
|
|
4
|
+
readonly [claim: string]: string | number | boolean;
|
|
5
|
+
};
|
|
6
|
+
type AccessTokenVerifierSource = {
|
|
7
|
+
/** The published key set: `{ keys: [...] }`. */
|
|
8
|
+
readonly keys: () => Promise<JSONWebKeySet>;
|
|
9
|
+
/** The tokens' `iss`: the instance's fixed issuer. */
|
|
10
|
+
readonly issuer: string;
|
|
11
|
+
/** The resource's URL: the audience every token must name. */
|
|
12
|
+
readonly resource: string;
|
|
13
|
+
/** The provider's UserInfo URL, which a token asked with `openid` names beside the resource; `undefined` when Better Auth's base URL follows the request. */
|
|
14
|
+
readonly userInfo: string | undefined;
|
|
15
|
+
/** Better Auth's context, once Better Auth is built. */
|
|
16
|
+
readonly context: () => Promise<unknown>;
|
|
17
|
+
/** A user's claims, as a token `GET /token` issued now would carry them. */
|
|
18
|
+
readonly claimsFor: (source: AccessClaimsSource) => Promise<ClaimsMap>;
|
|
19
|
+
/** Milliseconds since the epoch; a test passes its own clock. */
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
/** What an access token grants: the client it was issued to, and its scopes. */
|
|
23
|
+
export type TokenGrant = {
|
|
24
|
+
readonly clientId: string;
|
|
25
|
+
readonly scopes: ReadonlySet<string>;
|
|
26
|
+
};
|
|
27
|
+
/** Who an access token acts for, and what it grants. */
|
|
28
|
+
export type TokenCaller = {
|
|
29
|
+
readonly identity: {
|
|
30
|
+
readonly sub: string;
|
|
31
|
+
readonly claims: ClaimsMap;
|
|
32
|
+
};
|
|
33
|
+
readonly grant: TokenGrant;
|
|
34
|
+
};
|
|
35
|
+
export type AccessTokenVerifier = {
|
|
36
|
+
/** The caller an access token for the resource proves, or `null` for any other token. */
|
|
37
|
+
verify(token: string): Promise<TokenCaller | null>;
|
|
38
|
+
};
|
|
39
|
+
export declare const createAccessTokenVerifier: (source: AccessTokenVerifierSource) => AccessTokenVerifier;
|
|
40
|
+
/**
|
|
41
|
+
* The provider's UserInfo URL, which a token asked with `openid` names as its second
|
|
42
|
+
* audience: Better Auth's base URL (with its base path) and `/oauth2/userinfo`.
|
|
43
|
+
* `undefined` when the base URL follows the request (a dynamic `baseURL`): such a
|
|
44
|
+
* token then names an audience Access cannot know, and is refused.
|
|
45
|
+
*/
|
|
46
|
+
export declare const userInfoAudience: (block: {
|
|
47
|
+
readonly baseURL?: unknown;
|
|
48
|
+
readonly basePath?: unknown;
|
|
49
|
+
}, modules: {
|
|
50
|
+
readonly getBaseURL: (baseURL: string | undefined, basePath: string | undefined) => string | undefined;
|
|
51
|
+
readonly isDynamicBaseURLConfig: (baseURL: unknown) => boolean;
|
|
52
|
+
}) => string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* `access.mcp` over the MCP endpoint's verifier: the framework's `mcp.authorization`, frozen,
|
|
55
|
+
* with exactly the two members the framework takes.
|
|
56
|
+
*/
|
|
57
|
+
export declare const mcpAuthorizationOf: (agents: AccessTokenVerifier, server: string) => {
|
|
58
|
+
readonly servers: readonly [string];
|
|
59
|
+
verify(token: string): Promise<{
|
|
60
|
+
readonly sub: string;
|
|
61
|
+
readonly claims: ClaimsMap;
|
|
62
|
+
readonly clientId: string;
|
|
63
|
+
readonly scopes: readonly string[];
|
|
64
|
+
} | null>;
|
|
65
|
+
};
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Verifies the access tokens Better Auth's OAuth provider (`@better-auth/oauth-provider`)
|
|
2
|
+
// issues for one resource (RFC 8707), and finds who each acts for. Access issues no
|
|
3
|
+
// token itself: it reads the provider's. Two resources use it: the MCP endpoint
|
|
4
|
+
// (`mcp`, whose callers are agents) and the service itself when it opts in to its
|
|
5
|
+
// own audience (`audience`).
|
|
6
|
+
//
|
|
7
|
+
// A token passes when it is a JWT access token (`typ: "at+jwt"`, RFC 9068) signed
|
|
8
|
+
// with the keys `jwt()` publishes, by an algorithm `jwt()` signs with
|
|
9
|
+
// (`JWT_ALGORITHMS`), with the instance's issuer, current, issued for the one
|
|
10
|
+
// resource alone (`aud` the resource; with `openid` beside it, the provider's
|
|
11
|
+
// UserInfo endpoint too, and nothing else), to a client (`azp`, `client_id`) for a
|
|
12
|
+
// user (`sub`, never the client itself: a client-credentials token acts for no one),
|
|
13
|
+
// in the session the user consented in (`sid`), with its scopes, and not bound to a
|
|
14
|
+
// DPoP key (`cnf`): this endpoint reads a bearer token and no proof, and a bound
|
|
15
|
+
// token presented without one is refused (RFC 9449 section 7.1).
|
|
16
|
+
//
|
|
17
|
+
// Then, on every request, what the provider's own introspection checks, and more: the
|
|
18
|
+
// client exists and is not disabled; the resource is still one of the provider's (an
|
|
19
|
+
// `oauthResource` row: a deleted one takes no token, as the provider's introspection
|
|
20
|
+
// refuses it); the session the token was issued in has not ended, and is not bound to
|
|
21
|
+
// a kiosk device (`kioskDeviceId`: such a session is issued no token, whatever route
|
|
22
|
+
// minted one, since a token would act for the till's user off the device and past its
|
|
23
|
+
// lock and idle timeout); the user exists;
|
|
24
|
+
// and, unless the client is a trusted one that skips consent, the user's consent to
|
|
25
|
+
// that client still covers the token's scopes and its resource, so revoking consent
|
|
26
|
+
// stops the agent at once. A consent stored under a reference (the provider's
|
|
27
|
+
// `postLogin.consentReferenceId`, which Access refuses) never counts: a JWT access
|
|
28
|
+
// token does not say which reference it was consented under. The claims are the
|
|
29
|
+
// user's now, as a token `GET /token` issued now would carry them: a role taken away
|
|
30
|
+
// or a ban takes effect on the agent's next request, not when its token expires.
|
|
31
|
+
//
|
|
32
|
+
// A token that fails any of this is `null`. The token is read and never sent on:
|
|
33
|
+
// nothing here calls another service with it.
|
|
34
|
+
import { errors, jwtVerify } from "jose";
|
|
35
|
+
import { createKeySetCache } from "../access/key-set.cache.js";
|
|
36
|
+
import { CLOCK_TOLERANCE_S, JWT_ALGORITHMS } from "../access/token.verifier.js";
|
|
37
|
+
const isNonEmpty = (value) => typeof value === "string" && value !== "";
|
|
38
|
+
/** Whether `aud` names the resource alone (with `openid`, the UserInfo endpoint beside it). */
|
|
39
|
+
const forResource = (aud, resource, userInfo) => {
|
|
40
|
+
if (aud === resource) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return (Array.isArray(aud) &&
|
|
44
|
+
aud.length === 2 &&
|
|
45
|
+
userInfo !== undefined &&
|
|
46
|
+
userInfo !== resource &&
|
|
47
|
+
aud.includes(resource) &&
|
|
48
|
+
aud.includes(userInfo));
|
|
49
|
+
};
|
|
50
|
+
const scopesOf = (scope) => typeof scope === "string"
|
|
51
|
+
? scope.split(" ").filter((value) => value !== "")
|
|
52
|
+
: undefined;
|
|
53
|
+
const timeOf = (value) => value instanceof Date
|
|
54
|
+
? value.getTime()
|
|
55
|
+
: typeof value === "string" || typeof value === "number"
|
|
56
|
+
? new Date(value).getTime()
|
|
57
|
+
: Number.NaN;
|
|
58
|
+
export const createAccessTokenVerifier = (source) => {
|
|
59
|
+
const now = source.now ?? (() => Date.now());
|
|
60
|
+
const keySet = createKeySetCache(source.keys, now);
|
|
61
|
+
/** Who the token's claims name, when the store still says they may act: the user, and the session it was issued in. */
|
|
62
|
+
const live = async (claims) => {
|
|
63
|
+
const { adapter, internalAdapter } = (await source.context());
|
|
64
|
+
const [client, resource, user, session, consents] = await Promise.all([
|
|
65
|
+
adapter.findOne({
|
|
66
|
+
model: "oauthClient",
|
|
67
|
+
where: [{ field: "clientId", value: claims.clientId }],
|
|
68
|
+
}),
|
|
69
|
+
adapter.findOne({
|
|
70
|
+
model: "oauthResource",
|
|
71
|
+
where: [{ field: "identifier", value: source.resource }],
|
|
72
|
+
}),
|
|
73
|
+
internalAdapter.findUserById(claims.sub),
|
|
74
|
+
adapter.findOne({
|
|
75
|
+
model: "session",
|
|
76
|
+
where: [{ field: "id", value: claims.sid }],
|
|
77
|
+
}),
|
|
78
|
+
adapter.findMany({
|
|
79
|
+
model: "oauthConsent",
|
|
80
|
+
where: [
|
|
81
|
+
{ field: "clientId", value: claims.clientId },
|
|
82
|
+
{ field: "userId", value: claims.sub },
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
if (client === null ||
|
|
87
|
+
client.disabled === true ||
|
|
88
|
+
resource === null ||
|
|
89
|
+
user === null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (session === null ||
|
|
93
|
+
!(timeOf(session.expiresAt) > now()) ||
|
|
94
|
+
(typeof session.kioskDeviceId === "string" &&
|
|
95
|
+
session.kioskDeviceId !== "")) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (client.skipConsent !== true &&
|
|
99
|
+
!consents.some((consent) => (consent.referenceId === undefined ||
|
|
100
|
+
consent.referenceId === null ||
|
|
101
|
+
consent.referenceId === "") &&
|
|
102
|
+
Array.isArray(consent.resources) &&
|
|
103
|
+
consent.resources.includes(source.resource) &&
|
|
104
|
+
Array.isArray(consent.scopes) &&
|
|
105
|
+
claims.scopes.every((scope) => consent.scopes.includes(scope)))) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return { user, session };
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
async verify(token) {
|
|
112
|
+
let payload;
|
|
113
|
+
try {
|
|
114
|
+
({ payload } = await keySet.withKeys((keys) => jwtVerify(token, keys, {
|
|
115
|
+
typ: "at+jwt",
|
|
116
|
+
issuer: source.issuer,
|
|
117
|
+
algorithms: [...JWT_ALGORITHMS],
|
|
118
|
+
requiredClaims: [
|
|
119
|
+
"sub",
|
|
120
|
+
"aud",
|
|
121
|
+
"exp",
|
|
122
|
+
"iat",
|
|
123
|
+
"azp",
|
|
124
|
+
"client_id",
|
|
125
|
+
"scope",
|
|
126
|
+
"sid",
|
|
127
|
+
],
|
|
128
|
+
clockTolerance: CLOCK_TOLERANCE_S,
|
|
129
|
+
currentDate: new Date(now()),
|
|
130
|
+
})));
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof errors.JOSEError) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const { sub, aud, azp, client_id: clientId, sid, cnf } = payload;
|
|
139
|
+
const scopes = scopesOf(payload.scope);
|
|
140
|
+
if (!isNonEmpty(sub) ||
|
|
141
|
+
!isNonEmpty(azp) ||
|
|
142
|
+
clientId !== azp ||
|
|
143
|
+
sub === azp ||
|
|
144
|
+
cnf !== undefined ||
|
|
145
|
+
scopes === undefined ||
|
|
146
|
+
!isNonEmpty(sid) ||
|
|
147
|
+
!forResource(aud, source.resource, source.userInfo)) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const found = await live({
|
|
151
|
+
sub,
|
|
152
|
+
clientId: azp,
|
|
153
|
+
sid,
|
|
154
|
+
scopes,
|
|
155
|
+
});
|
|
156
|
+
if (found === null) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
identity: { sub, claims: await source.claimsFor(found) },
|
|
161
|
+
grant: { clientId: azp, scopes: new Set(scopes) },
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* The provider's UserInfo URL, which a token asked with `openid` names as its second
|
|
168
|
+
* audience: Better Auth's base URL (with its base path) and `/oauth2/userinfo`.
|
|
169
|
+
* `undefined` when the base URL follows the request (a dynamic `baseURL`): such a
|
|
170
|
+
* token then names an audience Access cannot know, and is refused.
|
|
171
|
+
*/
|
|
172
|
+
export const userInfoAudience = (block, modules) => {
|
|
173
|
+
if (modules.isDynamicBaseURLConfig(block.baseURL)) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const base = modules.getBaseURL(typeof block.baseURL === "string" ? block.baseURL : undefined, typeof block.basePath === "string" ? block.basePath : undefined);
|
|
177
|
+
return base === undefined
|
|
178
|
+
? undefined
|
|
179
|
+
: `${base.replace(/\/+$/, "")}/oauth2/userinfo`;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* `access.mcp` over the MCP endpoint's verifier: the framework's `mcp.authorization`, frozen,
|
|
183
|
+
* with exactly the two members the framework takes.
|
|
184
|
+
*/
|
|
185
|
+
export const mcpAuthorizationOf = (agents, server) => Object.freeze({
|
|
186
|
+
servers: Object.freeze([server]),
|
|
187
|
+
async verify(token) {
|
|
188
|
+
if (typeof token !== "string") {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const caller = await agents.verify(token);
|
|
192
|
+
return caller === null
|
|
193
|
+
? null
|
|
194
|
+
: Object.freeze({
|
|
195
|
+
sub: caller.identity.sub,
|
|
196
|
+
claims: caller.identity.claims,
|
|
197
|
+
clientId: caller.grant.clientId,
|
|
198
|
+
scopes: Object.freeze([...caller.grant.scopes]),
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** How a refusal names where the plugins are. */
|
|
2
|
+
type ProviderPlace = {
|
|
3
|
+
/** Where the app lists Better Auth's plugins: `betterAuth.plugins`, `plugins`. */
|
|
4
|
+
readonly at: string;
|
|
5
|
+
/** The Access plugin itself, in a list it is part of (`aventaraAccess()`): it must come before the provider. */
|
|
6
|
+
readonly own?: unknown;
|
|
7
|
+
};
|
|
8
|
+
/** What Access reads the provider's tokens for. */
|
|
9
|
+
type ProviderUse = {
|
|
10
|
+
/** The resources whose tokens Access reads: the MCP endpoint, the service's own audience. */
|
|
11
|
+
readonly resources: readonly string[];
|
|
12
|
+
/** What reads them, as a refusal names it: `mcp`, `audience`, or both. */
|
|
13
|
+
readonly by: string;
|
|
14
|
+
/** With `mcp`: the MCP endpoint, whose agents read and write with Access's scopes. */
|
|
15
|
+
readonly mcp: string | undefined;
|
|
16
|
+
/** With `audience`: the service's own audience. */
|
|
17
|
+
readonly audience: string | undefined;
|
|
18
|
+
/** `mcp.openRegistration`: the provider's open client registration is taken. */
|
|
19
|
+
readonly openRegistration: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The OAuth provider among `plugins`, checked: present once, a version in range,
|
|
23
|
+
* and the options the tokens Access reads need.
|
|
24
|
+
*/
|
|
25
|
+
export declare const checkOAuthProvider: (plugins: readonly unknown[], use: ProviderUse, place: ProviderPlace) => void;
|
|
26
|
+
/**
|
|
27
|
+
* A `before` hook (Better Auth's plugin hook shape) that refuses an authorization
|
|
28
|
+
* request without PKCE: a `code_challenge` made with the SHA-256 method (`code_challenge_method`).
|
|
29
|
+
* It reads the request's parameters as the provider does (a `POST`'s body, else the
|
|
30
|
+
* query; a sign-in resuming the request carries the signed query it began with). A
|
|
31
|
+
* request by reference (`request_uri`) is refused whatever else it carries: the
|
|
32
|
+
* provider would read its parameters from the reference, not from the request.
|
|
33
|
+
*/
|
|
34
|
+
export declare const pkceGuard: (modules: {
|
|
35
|
+
readonly createAuthMiddleware: (handler: (context: never) => Promise<unknown>) => unknown;
|
|
36
|
+
readonly APIError: new (status: string, body?: {
|
|
37
|
+
readonly [key: string]: unknown;
|
|
38
|
+
}) => Error;
|
|
39
|
+
}) => {
|
|
40
|
+
matcher: (context: {
|
|
41
|
+
readonly path?: unknown;
|
|
42
|
+
} | undefined) => boolean;
|
|
43
|
+
handler: unknown;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A resource Access reads the provider's tokens for, checked: an absolute https URL
|
|
47
|
+
* (or http on this machine) with no credentials, query or fragment, written as a
|
|
48
|
+
* protected resource's metadata writes it (the origin, then the path with no trailing
|
|
49
|
+
* slash), and not the issuer itself, the audience of the tokens `GET /token` issues.
|
|
50
|
+
*/
|
|
51
|
+
export declare const checkResourceUrl: (resource: unknown, at: string, issuer: string) => string;
|
|
52
|
+
/**
|
|
53
|
+
* What Access reads Better Auth's OAuth provider's tokens for, when it reads any.
|
|
54
|
+
* `userInfo` is the provider's UserInfo URL, when Access knows it: no resource Access
|
|
55
|
+
* reads tokens for may be it, since a token asked with `openid` names it beside its
|
|
56
|
+
* resource.
|
|
57
|
+
*/
|
|
58
|
+
export declare const oauthUseOf: (mcp: {
|
|
59
|
+
readonly resource: string;
|
|
60
|
+
readonly openRegistration: boolean;
|
|
61
|
+
} | undefined, audience: string | undefined, userInfo: string | undefined) => ProviderUse | undefined;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// What Access holds Better Auth's OAuth provider (`@better-auth/oauth-provider`) to
|
|
2
|
+
// when it reads the provider's tokens (`mcp`, `audience`), and the one hook it adds
|
|
3
|
+
// to it. The provider is the app's, in Better Auth's plugins with its own options,
|
|
4
|
+
// passed through untouched, as plain Better Auth takes it: Access issues no token and
|
|
5
|
+
// serves no OAuth route. It refuses to start on options that would let a token's
|
|
6
|
+
// authorization fall short of what the MCP authorization specification and OAuth 2.1
|
|
7
|
+
// ask, rather than change them quietly (fail closed, as the GHSA-fmh4 guard does):
|
|
8
|
+
//
|
|
9
|
+
// - the exact provider version Access is built and tested against;
|
|
10
|
+
// - JWT access tokens (`disableJwtPlugin` off): `access.mcp.verify` reads the
|
|
11
|
+
// audience, client and scopes from the token, signed with the keys `jwt()` publishes;
|
|
12
|
+
// - each resource Access reads tokens for (the MCP endpoint, the service's own
|
|
13
|
+
// `audience`) among the provider's `resources`, so a token can name it as its
|
|
14
|
+
// audience (RFC 8707);
|
|
15
|
+
// - `aventara:read` among its `scopes`, the scope a token reads with;
|
|
16
|
+
// - refresh tokens rotated strictly (`refreshTokenReuseInterval: 0`, the provider's
|
|
17
|
+
// own default): a spent refresh token is never answered again, however soon;
|
|
18
|
+
// - exact redirect URI matching (no `validateRedirectUri`), which the provider does
|
|
19
|
+
// by default (RFC 9700, section 2.1; a native client's loopback redirect may use another
|
|
20
|
+
// port, RFC 8252, section 7.3);
|
|
21
|
+
// - no authorization request by reference (`requestUriResolver`): its parameters
|
|
22
|
+
// come from the resolver, never the request the PKCE hook reads;
|
|
23
|
+
// - no consent under a reference (`postLogin.consentReferenceId`): a JWT access token
|
|
24
|
+
// does not carry the reference, so Access could not tell which consent it was
|
|
25
|
+
// given under;
|
|
26
|
+
// - with `audience`, each client linked to the resources it may ask for
|
|
27
|
+
// (`enforcePerClientResources`, the provider's default): a client registered for
|
|
28
|
+
// one resource gets no token for another;
|
|
29
|
+
// - with `mcp` and `audience`, the service's audience never linked to a client as it
|
|
30
|
+
// registers (`clientRegistrationDefaultResources`,
|
|
31
|
+
// `clientRegistrationAllowedResources`): an agent's client that registers itself is
|
|
32
|
+
// for the MCP endpoint alone, and a token it refreshed to the service would pass
|
|
33
|
+
// for the service's own;
|
|
34
|
+
// - no open dynamic client registration (`allowUnauthenticatedClientRegistration`),
|
|
35
|
+
// unless `mcp.openRegistration` takes it: such a client is whoever it says it is.
|
|
36
|
+
// Client ID Metadata Documents (`@better-auth/cimd`) register a client as the domain
|
|
37
|
+
// its document is served from; with the plugin there, a version in Access's range.
|
|
38
|
+
//
|
|
39
|
+
// And, on every authorization request, PKCE with its SHA-256 method (`pkceGuard`): the provider
|
|
40
|
+
// asks it of public clients and most confidential ones, and lets a confidential
|
|
41
|
+
// OpenID request with a `nonce` (or a client created with `require_pkce: false`) go
|
|
42
|
+
// without; while Access reads its tokens, no request does, and none by reference
|
|
43
|
+
// (`request_uri`). The hook refuses a request before the provider runs, as the
|
|
44
|
+
// `service` profile's hook refuses a session-starting route; it issues nothing.
|
|
45
|
+
import { AccessError } from "../access/access.error.js";
|
|
46
|
+
import { normalisedId } from "../access/better-auth.hardening.js";
|
|
47
|
+
import { BETTER_AUTH_RANGE, BETTER_AUTH_RANGE_SAID, inBetterAuthRange, } from "../access/better-auth.range.js";
|
|
48
|
+
import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "../mcp/agent.grant.js";
|
|
49
|
+
/** The OAuth provider's plugin id. */
|
|
50
|
+
const OAUTH_PROVIDER_ID = "oauth-provider";
|
|
51
|
+
/** The Client ID Metadata Document plugin's id. */
|
|
52
|
+
const CIMD_ID = "cimd";
|
|
53
|
+
const refuse = (message) => {
|
|
54
|
+
throw new AccessError("ACCESS_OPTIONS_INVALID", message);
|
|
55
|
+
};
|
|
56
|
+
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
57
|
+
/**
|
|
58
|
+
* The OAuth provider among `plugins`, checked: present once, a version in range,
|
|
59
|
+
* and the options the tokens Access reads need.
|
|
60
|
+
*/
|
|
61
|
+
export const checkOAuthProvider = (plugins, use, place) => {
|
|
62
|
+
const { at } = place;
|
|
63
|
+
const listed = use.resources.map((resource) => `"${resource}"`).join(", ");
|
|
64
|
+
const found = plugins.filter((plugin) => normalisedId(plugin?.id) ===
|
|
65
|
+
OAUTH_PROVIDER_ID);
|
|
66
|
+
// An agent's client registers itself for the MCP endpoint alone; a client the
|
|
67
|
+
// service's audience takes is linked to it one by one.
|
|
68
|
+
const install = `\`oauthProvider({ loginPage, consentPage, scopes: ["${MCP_READ_SCOPE}", "${MCP_WRITE_SCOPE}", "offline_access"], resources: [${listed}]${use.mcp === undefined ? "" : `, clientRegistrationDefaultResources: ["${use.mcp}"]`} })\` from @better-auth/oauth-provider${use.audience === undefined ? "" : `, and link each client the service takes to "${use.audience}" (\`auth.api.adminLinkClientResource\`)`}`;
|
|
69
|
+
if (found.length === 0) {
|
|
70
|
+
refuse(`\`${use.by}\` reads the access tokens Better Auth's OAuth provider issues, and ${at} has none: add ${install}.`);
|
|
71
|
+
}
|
|
72
|
+
if (found.length > 1) {
|
|
73
|
+
refuse(`${at} holds ${found.length} OAuth providers (plugin id "${OAUTH_PROVIDER_ID}"); Better Auth runs one. Keep one.`);
|
|
74
|
+
}
|
|
75
|
+
const provider = found[0];
|
|
76
|
+
if (!inBetterAuthRange(provider.version)) {
|
|
77
|
+
throw new AccessError("ACCESS_BETTER_AUTH_VERSION", `\`${use.by}\` runs on @better-auth/oauth-provider ${BETTER_AUTH_RANGE_SAID}, and ${at} holds ${typeof provider.version === "string" ? provider.version : "a version Access cannot read"}: npm install @better-auth/oauth-provider@${BETTER_AUTH_RANGE}`);
|
|
78
|
+
}
|
|
79
|
+
if (place.own !== undefined) {
|
|
80
|
+
const own = plugins.indexOf(place.own);
|
|
81
|
+
if (own > plugins.indexOf(provider)) {
|
|
82
|
+
refuse(`${at} lists the OAuth provider before aventaraAccess(): Better Auth starts its plugins in order, and the provider reads, as it starts, the jwt() the Access plugin carries. Put aventaraAccess() first.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const options = provider.options ?? {};
|
|
86
|
+
if (options.disableJwtPlugin === true) {
|
|
87
|
+
refuse(`\`${use.by}\` reads a token's audience, client and scopes from the token, signed with the keys jwt() publishes, and the OAuth provider's \`disableJwtPlugin: true\` issues opaque tokens: remove it.`);
|
|
88
|
+
}
|
|
89
|
+
const resources = Array.isArray(options.resources) ? options.resources : [];
|
|
90
|
+
for (const resource of use.resources) {
|
|
91
|
+
if (!resources.some((entry) => entry === resource ||
|
|
92
|
+
entry?.identifier ===
|
|
93
|
+
resource)) {
|
|
94
|
+
refuse(`the OAuth provider issues a token for ${resource} only when its \`resources\` list it (RFC 8707): add "${resource}" to them.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const scopes = listOf(options.scopes);
|
|
98
|
+
if (!scopes.includes(MCP_READ_SCOPE)) {
|
|
99
|
+
refuse(use.mcp !== undefined
|
|
100
|
+
? `an agent reads through the MCP endpoint with the scope "${MCP_READ_SCOPE}" (and writes with "${MCP_WRITE_SCOPE}"), and the OAuth provider's \`scopes\` have neither: add them.`
|
|
101
|
+
: `a token addressed to the service reads with the scope "${MCP_READ_SCOPE}" (and writes with "${MCP_WRITE_SCOPE}"), and the OAuth provider's \`scopes\` have neither: add them.`);
|
|
102
|
+
}
|
|
103
|
+
if (options.refreshTokenReuseInterval !== 0) {
|
|
104
|
+
refuse(`the OAuth provider answers a spent refresh token again for ${String(options.refreshTokenReuseInterval)} seconds (\`refreshTokenReuseInterval\`): refresh tokens rotate strictly while Access reads the provider's tokens, so set it to 0.`);
|
|
105
|
+
}
|
|
106
|
+
if (options.validateRedirectUri !== undefined) {
|
|
107
|
+
refuse("the OAuth provider's `validateRedirectUri` accepts redirect URIs the client did not register: while Access reads the provider's tokens, an authorization code goes only to a redirect URI that matches a registered one exactly. Remove it.");
|
|
108
|
+
}
|
|
109
|
+
if (options.requestUriResolver !== undefined) {
|
|
110
|
+
refuse("the OAuth provider's `requestUriResolver` takes an authorization request by reference (`request_uri`), whose parameters Access cannot hold to PKCE: while Access reads the provider's tokens, every authorization request carries its own. Remove it.");
|
|
111
|
+
}
|
|
112
|
+
if (options.postLogin !== undefined &&
|
|
113
|
+
options.postLogin !== null &&
|
|
114
|
+
options.postLogin.consentReferenceId !== undefined) {
|
|
115
|
+
refuse("the OAuth provider's `postLogin.consentReferenceId` stores each consent under a reference, and a JWT access token does not say which: Access could not tell whether the consent a token was issued under still stands. Remove it.");
|
|
116
|
+
}
|
|
117
|
+
if (use.audience !== undefined) {
|
|
118
|
+
if (options.enforcePerClientResources === false) {
|
|
119
|
+
refuse(`the OAuth provider's \`enforcePerClientResources: false\` lets any client ask for a token addressed to the service (${use.audience}): with \`audience\`, a client gets one only when it is linked to it. Remove it.`);
|
|
120
|
+
}
|
|
121
|
+
if (use.mcp !== undefined) {
|
|
122
|
+
for (const option of [
|
|
123
|
+
"clientRegistrationDefaultResources",
|
|
124
|
+
"clientRegistrationAllowedResources",
|
|
125
|
+
]) {
|
|
126
|
+
if (listOf(options[option]).includes(use.audience)) {
|
|
127
|
+
refuse(`the OAuth provider's \`${option}\` links the service's audience (${use.audience}) to a client as it registers: an agent's client that registers itself would be issued tokens that pass for the service's own. List only the MCP endpoint there ("${use.mcp}"), and link each client the service takes to its audience yourself (\`auth.api.adminLinkClientResource\`).`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (options.allowUnauthenticatedClientRegistration === true &&
|
|
133
|
+
!use.openRegistration) {
|
|
134
|
+
refuse(use.mcp !== undefined
|
|
135
|
+
? "the OAuth provider's `allowUnauthenticatedClientRegistration: true` registers any client that asks, as whoever it says it is: its name, logo and redirect URI. Prefer Client ID Metadata Documents (`cimd()` from @better-auth/cimd), whose client is the domain its document is served from; to take open registration anyway, set `mcp.openRegistration: true`."
|
|
136
|
+
: "the OAuth provider's `allowUnauthenticatedClientRegistration: true` registers any client that asks, as whoever it says it is, and `audience` takes no such client: register clients signed in, with an initial access token, or by Client ID Metadata Documents (`cimd()` from @better-auth/cimd). Remove it.");
|
|
137
|
+
}
|
|
138
|
+
const cimd = plugins.find((plugin) => normalisedId(plugin?.id) ===
|
|
139
|
+
CIMD_ID);
|
|
140
|
+
if (cimd !== undefined && !inBetterAuthRange(cimd.version)) {
|
|
141
|
+
throw new AccessError("ACCESS_BETTER_AUTH_VERSION", `\`${use.by}\` runs on @better-auth/cimd ${BETTER_AUTH_RANGE_SAID}, and ${at} holds ${typeof cimd.version === "string" ? cimd.version : "a version Access cannot read"}: npm install @better-auth/cimd@${BETTER_AUTH_RANGE}`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/** The OAuth provider's authorization endpoint, as Better Auth's hooks see its path. */
|
|
145
|
+
const AUTHORIZE_PATH = "/oauth2/authorize";
|
|
146
|
+
/**
|
|
147
|
+
* A `before` hook (Better Auth's plugin hook shape) that refuses an authorization
|
|
148
|
+
* request without PKCE: a `code_challenge` made with the SHA-256 method (`code_challenge_method`).
|
|
149
|
+
* It reads the request's parameters as the provider does (a `POST`'s body, else the
|
|
150
|
+
* query; a sign-in resuming the request carries the signed query it began with). A
|
|
151
|
+
* request by reference (`request_uri`) is refused whatever else it carries: the
|
|
152
|
+
* provider would read its parameters from the reference, not from the request.
|
|
153
|
+
*/
|
|
154
|
+
export const pkceGuard = (modules) => ({
|
|
155
|
+
matcher: (context) => context?.path === AUTHORIZE_PATH,
|
|
156
|
+
handler: modules.createAuthMiddleware(async (given) => {
|
|
157
|
+
const context = given;
|
|
158
|
+
const settings = context.authorizeSettings ?? { isAuthorize: true };
|
|
159
|
+
const source = context.method === "POST" && settings.isAuthorize === true
|
|
160
|
+
? context.body
|
|
161
|
+
: context.query;
|
|
162
|
+
const params = (typeof source === "object" && source !== null ? source : {});
|
|
163
|
+
const query = (typeof context.query === "object" && context.query !== null
|
|
164
|
+
? context.query
|
|
165
|
+
: {});
|
|
166
|
+
if (params.request_uri !== undefined || query.request_uri !== undefined) {
|
|
167
|
+
throw new modules.APIError("BAD_REQUEST", {
|
|
168
|
+
error: "invalid_request",
|
|
169
|
+
error_description: "This authorization server takes no request by reference: send the authorization request's parameters, with code_challenge and code_challenge_method=S256.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const { code_challenge: challenge, code_challenge_method: method } = params;
|
|
173
|
+
if (typeof challenge !== "string" ||
|
|
174
|
+
challenge === "" ||
|
|
175
|
+
method !== "S256") {
|
|
176
|
+
throw new modules.APIError("BAD_REQUEST", {
|
|
177
|
+
error: "invalid_request",
|
|
178
|
+
error_description: "This authorization server requires PKCE: send code_challenge with code_challenge_method=S256.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
/** The hosts a resource served over http may name: this machine. */
|
|
184
|
+
const LOOPBACK_RESOURCE_HOSTS = new Set([
|
|
185
|
+
"localhost",
|
|
186
|
+
"127.0.0.1",
|
|
187
|
+
"[::1]",
|
|
188
|
+
]);
|
|
189
|
+
/**
|
|
190
|
+
* A resource Access reads the provider's tokens for, checked: an absolute https URL
|
|
191
|
+
* (or http on this machine) with no credentials, query or fragment, written as a
|
|
192
|
+
* protected resource's metadata writes it (the origin, then the path with no trailing
|
|
193
|
+
* slash), and not the issuer itself, the audience of the tokens `GET /token` issues.
|
|
194
|
+
*/
|
|
195
|
+
export const checkResourceUrl = (resource, at, issuer) => {
|
|
196
|
+
if (typeof resource !== "string") {
|
|
197
|
+
return refuse(`\`${at}\` is an absolute URL (\`"https://app.example.com/mcp"\`); it was ${JSON.stringify(resource)}.`);
|
|
198
|
+
}
|
|
199
|
+
let url;
|
|
200
|
+
try {
|
|
201
|
+
url = new URL(resource);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return refuse(`\`${at}\` is an absolute URL (\`"https://app.example.com/mcp"\`); it was ${JSON.stringify(resource)}.`);
|
|
205
|
+
}
|
|
206
|
+
if (!(url.protocol === "https:" ||
|
|
207
|
+
(url.protocol === "http:" && LOOPBACK_RESOURCE_HOSTS.has(url.hostname)))) {
|
|
208
|
+
return refuse(`\`${at}\` must be an https URL (or http on this machine: \`localhost\`, \`127.0.0.1\`, \`[::1]\`); it was ${JSON.stringify(resource)}.`);
|
|
209
|
+
}
|
|
210
|
+
if (url.username !== "" ||
|
|
211
|
+
url.password !== "" ||
|
|
212
|
+
resource.includes("?") ||
|
|
213
|
+
resource.includes("#") ||
|
|
214
|
+
url.pathname.includes("//")) {
|
|
215
|
+
return refuse(`\`${at}\` names one resource alone: no credentials, query, fragment or empty path segment; it was ${JSON.stringify(resource)}.`);
|
|
216
|
+
}
|
|
217
|
+
const canonical = `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
218
|
+
if (resource !== canonical) {
|
|
219
|
+
return refuse(`\`${at}\` must be written as the resource's metadata writes it, which a client sends back verbatim as its resource indicator: ${canonical}`);
|
|
220
|
+
}
|
|
221
|
+
if (canonical === issuer) {
|
|
222
|
+
return refuse(`\`${at}\` is Access's issuer (${issuer}), the audience of the tokens \`GET /token\` issues: a token for one must never pass for the other. Name the resource by its own path (\`${issuer}/mcp\`, \`${issuer}/api\`).`);
|
|
223
|
+
}
|
|
224
|
+
return canonical;
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* What Access reads Better Auth's OAuth provider's tokens for, when it reads any.
|
|
228
|
+
* `userInfo` is the provider's UserInfo URL, when Access knows it: no resource Access
|
|
229
|
+
* reads tokens for may be it, since a token asked with `openid` names it beside its
|
|
230
|
+
* resource.
|
|
231
|
+
*/
|
|
232
|
+
export const oauthUseOf = (mcp, audience, userInfo) => {
|
|
233
|
+
if (mcp === undefined && audience === undefined) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (mcp !== undefined && mcp.resource === audience) {
|
|
237
|
+
throw new AccessError("ACCESS_OPTIONS_INVALID", "`audience` is the MCP endpoint (`mcp.resource`): an agent's token would pass for the service's own. Give the service its own URL.");
|
|
238
|
+
}
|
|
239
|
+
for (const [at, resource] of [
|
|
240
|
+
["mcp.resource", mcp?.resource],
|
|
241
|
+
["audience", audience],
|
|
242
|
+
]) {
|
|
243
|
+
if (resource !== undefined && resource === userInfo) {
|
|
244
|
+
throw new AccessError("ACCESS_OPTIONS_INVALID", `\`${at}\` is the OAuth provider's UserInfo endpoint (${userInfo}), which every token asked with \`openid\` names beside its own resource: such a token would pass for it. Name the resource by its own path.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
resources: [
|
|
249
|
+
...(mcp === undefined ? [] : [mcp.resource]),
|
|
250
|
+
...(audience === undefined ? [] : [audience]),
|
|
251
|
+
],
|
|
252
|
+
by: mcp !== undefined && audience !== undefined
|
|
253
|
+
? "mcp` and `audience"
|
|
254
|
+
: mcp !== undefined
|
|
255
|
+
? "mcp"
|
|
256
|
+
: "audience",
|
|
257
|
+
mcp: mcp?.resource,
|
|
258
|
+
audience,
|
|
259
|
+
openRegistration: mcp?.openRegistration === true,
|
|
260
|
+
};
|
|
261
|
+
};
|