@greatwait/contracts 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authority.d.ts +217 -0
- package/dist/authority.js +261 -0
- package/dist/callback-ownership.d.ts +58 -0
- package/dist/callback-ownership.js +71 -0
- package/dist/callback-safety.d.ts +38 -0
- package/dist/callback-safety.js +403 -0
- package/dist/compatibility.d.ts +82 -0
- package/dist/compatibility.js +115 -0
- package/dist/delivery.d.ts +401 -0
- package/dist/delivery.js +311 -0
- package/dist/endpoints.d.ts +305 -0
- package/dist/endpoints.js +179 -0
- package/dist/events.d.ts +62 -0
- package/dist/events.js +38 -0
- package/dist/first-value-journey.d.ts +305 -0
- package/dist/first-value-journey.js +466 -0
- package/dist/forecast.d.ts +443 -0
- package/dist/forecast.js +729 -0
- package/dist/identifiers.d.ts +109 -0
- package/dist/identifiers.js +131 -0
- package/dist/identity.d.ts +196 -0
- package/dist/identity.js +182 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/mcp.d.ts +51 -0
- package/dist/mcp.js +56 -0
- package/dist/oauth.d.ts +796 -0
- package/dist/oauth.js +708 -0
- package/dist/operations.d.ts +3985 -0
- package/dist/operations.js +1837 -0
- package/dist/partition.d.ts +771 -0
- package/dist/partition.js +206 -0
- package/dist/policy.d.ts +93 -0
- package/dist/policy.js +180 -0
- package/dist/problems.d.ts +158 -0
- package/dist/problems.js +104 -0
- package/dist/protocol.d.ts +308 -0
- package/dist/protocol.js +515 -0
- package/dist/scheduling-records.d.ts +395 -0
- package/dist/scheduling-records.js +284 -0
- package/dist/scheduling.d.ts +1098 -0
- package/dist/scheduling.js +1116 -0
- package/dist/secrets.d.ts +383 -0
- package/dist/secrets.js +272 -0
- package/dist/signatures.d.ts +704 -0
- package/dist/signatures.js +867 -0
- package/dist/tenancy.d.ts +178 -0
- package/dist/tenancy.js +145 -0
- package/dist/test-sink.d.ts +986 -0
- package/dist/test-sink.js +755 -0
- package/dist/time.d.ts +45 -0
- package/dist/time.js +128 -0
- package/dist/usage.d.ts +121 -0
- package/dist/usage.js +200 -0
- package/dist/webhooks.d.ts +116 -0
- package/dist/webhooks.js +161 -0
- package/dist/workspace.d.ts +92 -0
- package/dist/workspace.js +59 -0
- package/generated/v1/callback-delivery-signature.json +467 -0
- package/generated/v1/callback-endpoint-verification.json +59 -0
- package/generated/v1/callback-receiver.json +241 -0
- package/generated/v1/examples/attempts.list.response.json +24 -0
- package/generated/v1/examples/attempts.read.response.json +20 -0
- package/generated/v1/examples/credentials.create.request.json +5 -0
- package/generated/v1/examples/credentials.create.response.json +10 -0
- package/generated/v1/examples/credentials.list.response.json +17 -0
- package/generated/v1/examples/credentials.revoke.response.json +11 -0
- package/generated/v1/examples/credentials.rotate.request.json +3 -0
- package/generated/v1/examples/credentials.rotate.response.json +22 -0
- package/generated/v1/examples/endpoints.create.request.json +3 -0
- package/generated/v1/examples/endpoints.create.response.json +16 -0
- package/generated/v1/examples/endpoints.disable.response.json +15 -0
- package/generated/v1/examples/endpoints.list.response.json +21 -0
- package/generated/v1/examples/endpoints.read.response.json +14 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.request.json +3 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.response.json +23 -0
- package/generated/v1/examples/endpoints.update.request.json +3 -0
- package/generated/v1/examples/endpoints.update.response.json +13 -0
- package/generated/v1/examples/endpoints.verify.response.json +15 -0
- package/generated/v1/examples/forecast.read.response.json +28 -0
- package/generated/v1/examples/forecast.windows.response.json +21 -0
- package/generated/v1/examples/grants.list.response.json +17 -0
- package/generated/v1/examples/grants.revoke.response.json +16 -0
- package/generated/v1/examples/meta.scaffold.response.json +5 -0
- package/generated/v1/examples/meta.version.response.json +5 -0
- package/generated/v1/examples/occurrences.list.response.json +17 -0
- package/generated/v1/examples/occurrences.read.response.json +15 -0
- package/generated/v1/examples/occurrences.redeliver.response.json +16 -0
- package/generated/v1/examples/projects.create.request.json +3 -0
- package/generated/v1/examples/projects.create.response.json +5 -0
- package/generated/v1/examples/projects.list.response.json +9 -0
- package/generated/v1/examples/schedules.archive.response.json +25 -0
- package/generated/v1/examples/schedules.cancel.response.json +24 -0
- package/generated/v1/examples/schedules.create.request.json +9 -0
- package/generated/v1/examples/schedules.create.response.json +25 -0
- package/generated/v1/examples/schedules.list.response.json +23 -0
- package/generated/v1/examples/schedules.pause.response.json +23 -0
- package/generated/v1/examples/schedules.read.response.json +25 -0
- package/generated/v1/examples/schedules.replace.request.json +8 -0
- package/generated/v1/examples/schedules.replace.response.json +25 -0
- package/generated/v1/examples/schedules.resume.response.json +23 -0
- package/generated/v1/examples/sinkdeliveries.list.response.json +19 -0
- package/generated/v1/examples/sinkdeliveries.read.response.json +16 -0
- package/generated/v1/examples/testsinks.configure.request.json +3 -0
- package/generated/v1/examples/testsinks.configure.response.json +13 -0
- package/generated/v1/examples/testsinks.create.request.json +8 -0
- package/generated/v1/examples/testsinks.create.response.json +13 -0
- package/generated/v1/examples/testsinks.delete.response.json +13 -0
- package/generated/v1/examples/testsinks.list.response.json +20 -0
- package/generated/v1/examples/testsinks.read.response.json +14 -0
- package/generated/v1/examples/usage.read.response.json +20 -0
- package/generated/v1/first-value-journey.json +436 -0
- package/generated/v1/manifest.json +1037 -0
- package/generated/v1/openapi.json +23665 -0
- package/generated/v1/policy-matrix.json +305 -0
- package/generated/v1/schemas/attempts.list.query.json +16 -0
- package/generated/v1/schemas/attempts.list.response.json +175 -0
- package/generated/v1/schemas/attempts.read.response.json +168 -0
- package/generated/v1/schemas/credentials.create.request.json +39 -0
- package/generated/v1/schemas/credentials.create.response.json +88 -0
- package/generated/v1/schemas/credentials.list.query.json +16 -0
- package/generated/v1/schemas/credentials.list.response.json +124 -0
- package/generated/v1/schemas/credentials.revoke.response.json +84 -0
- package/generated/v1/schemas/credentials.rotate.request.json +11 -0
- package/generated/v1/schemas/credentials.rotate.response.json +171 -0
- package/generated/v1/schemas/endpoints.create.request.json +12 -0
- package/generated/v1/schemas/endpoints.create.response.json +115 -0
- package/generated/v1/schemas/endpoints.disable.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.disable.response.json +100 -0
- package/generated/v1/schemas/endpoints.list.query.json +16 -0
- package/generated/v1/schemas/endpoints.list.response.json +146 -0
- package/generated/v1/schemas/endpoints.read.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.read.response.json +100 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +118 -0
- package/generated/v1/schemas/endpoints.update.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.update.request.json +12 -0
- package/generated/v1/schemas/endpoints.update.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.update.response.json +100 -0
- package/generated/v1/schemas/endpoints.verify.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.verify.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.verify.response.json +100 -0
- package/generated/v1/schemas/forecast.read.query.json +26 -0
- package/generated/v1/schemas/forecast.read.response.json +100 -0
- package/generated/v1/schemas/forecast.windows.query.json +26 -0
- package/generated/v1/schemas/forecast.windows.response.json +94 -0
- package/generated/v1/schemas/grants.list.response.json +98 -0
- package/generated/v1/schemas/grants.revoke.response.json +95 -0
- package/generated/v1/schemas/meta.scaffold.response.json +36 -0
- package/generated/v1/schemas/meta.version.response.json +19 -0
- package/generated/v1/schemas/occurrences.list.query.json +16 -0
- package/generated/v1/schemas/occurrences.list.response.json +83 -0
- package/generated/v1/schemas/occurrences.read.response.json +76 -0
- package/generated/v1/schemas/occurrences.redeliver.request-headers.json +13 -0
- package/generated/v1/schemas/occurrences.redeliver.response-headers.json +10 -0
- package/generated/v1/schemas/occurrences.redeliver.response.json +168 -0
- package/generated/v1/schemas/projects.create.request.json +12 -0
- package/generated/v1/schemas/projects.create.response.json +25 -0
- package/generated/v1/schemas/projects.list.response.json +35 -0
- package/generated/v1/schemas/schedules.archive.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.archive.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.archive.response.json +189 -0
- package/generated/v1/schemas/schedules.cancel.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.cancel.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.cancel.response.json +189 -0
- package/generated/v1/schemas/schedules.create.request-headers.json +12 -0
- package/generated/v1/schemas/schedules.create.request.json +99 -0
- package/generated/v1/schemas/schedules.create.response-headers.json +15 -0
- package/generated/v1/schemas/schedules.create.response.json +189 -0
- package/generated/v1/schemas/schedules.list.query.json +16 -0
- package/generated/v1/schemas/schedules.list.response.json +170 -0
- package/generated/v1/schemas/schedules.pause.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.pause.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.pause.response.json +189 -0
- package/generated/v1/schemas/schedules.read.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.read.response.json +189 -0
- package/generated/v1/schemas/schedules.replace.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.replace.request.json +99 -0
- package/generated/v1/schemas/schedules.replace.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.replace.response.json +189 -0
- package/generated/v1/schemas/schedules.resume.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.resume.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.resume.response.json +189 -0
- package/generated/v1/schemas/sinkdeliveries.list.query.json +16 -0
- package/generated/v1/schemas/sinkdeliveries.list.response.json +126 -0
- package/generated/v1/schemas/sinkdeliveries.read.response.json +98 -0
- package/generated/v1/schemas/testsinks.configure.request.json +66 -0
- package/generated/v1/schemas/testsinks.configure.response.json +115 -0
- package/generated/v1/schemas/testsinks.create.request.json +76 -0
- package/generated/v1/schemas/testsinks.create.response.json +115 -0
- package/generated/v1/schemas/testsinks.delete.response.json +115 -0
- package/generated/v1/schemas/testsinks.list.query.json +16 -0
- package/generated/v1/schemas/testsinks.list.response.json +161 -0
- package/generated/v1/schemas/testsinks.read.response.json +115 -0
- package/generated/v1/schemas/usage.read.query.json +15 -0
- package/generated/v1/schemas/usage.read.response.json +71 -0
- package/generated/v1/snippets/attempts.list.json +5 -0
- package/generated/v1/snippets/attempts.read.json +5 -0
- package/generated/v1/snippets/credentials.create.json +5 -0
- package/generated/v1/snippets/credentials.list.json +5 -0
- package/generated/v1/snippets/credentials.revoke.json +5 -0
- package/generated/v1/snippets/credentials.rotate.json +5 -0
- package/generated/v1/snippets/endpoints.create.json +5 -0
- package/generated/v1/snippets/endpoints.disable.json +5 -0
- package/generated/v1/snippets/endpoints.list.json +5 -0
- package/generated/v1/snippets/endpoints.read.json +5 -0
- package/generated/v1/snippets/endpoints.rotateSigningKey.json +5 -0
- package/generated/v1/snippets/endpoints.update.json +5 -0
- package/generated/v1/snippets/endpoints.verify.json +5 -0
- package/generated/v1/snippets/forecast.read.json +5 -0
- package/generated/v1/snippets/forecast.windows.json +5 -0
- package/generated/v1/snippets/grants.list.json +5 -0
- package/generated/v1/snippets/grants.revoke.json +5 -0
- package/generated/v1/snippets/meta.scaffold.json +5 -0
- package/generated/v1/snippets/meta.version.json +5 -0
- package/generated/v1/snippets/occurrences.list.json +5 -0
- package/generated/v1/snippets/occurrences.read.json +5 -0
- package/generated/v1/snippets/occurrences.redeliver.json +5 -0
- package/generated/v1/snippets/projects.create.json +5 -0
- package/generated/v1/snippets/projects.list.json +5 -0
- package/generated/v1/snippets/schedules.archive.json +5 -0
- package/generated/v1/snippets/schedules.cancel.json +5 -0
- package/generated/v1/snippets/schedules.create.json +5 -0
- package/generated/v1/snippets/schedules.list.json +5 -0
- package/generated/v1/snippets/schedules.pause.json +5 -0
- package/generated/v1/snippets/schedules.read.json +5 -0
- package/generated/v1/snippets/schedules.replace.json +5 -0
- package/generated/v1/snippets/schedules.resume.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.list.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.read.json +5 -0
- package/generated/v1/snippets/testsinks.configure.json +5 -0
- package/generated/v1/snippets/testsinks.create.json +5 -0
- package/generated/v1/snippets/testsinks.delete.json +5 -0
- package/generated/v1/snippets/testsinks.list.json +5 -0
- package/generated/v1/snippets/testsinks.read.json +5 -0
- package/generated/v1/snippets/usage.read.json +5 -0
- package/package.json +153 -0
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SCOPES, environmentKindSchema, scopeSchema, } from "./authority.js";
|
|
3
|
+
import { oauthClientIdSchema, oauthGrantIdSchema, projectIdSchema, } from "./identifiers.js";
|
|
4
|
+
import { timestampSchema } from "./time.js";
|
|
5
|
+
/**
|
|
6
|
+
* The delegated-authorization vocabulary: OAuth 2.1 as this product speaks it.
|
|
7
|
+
*
|
|
8
|
+
* Two things make this file worth reading before the routes that use it.
|
|
9
|
+
*
|
|
10
|
+
* **OAuth's wire shapes are not this product's wire shapes.** Everything on
|
|
11
|
+
* `/v1` answers `application/problem+json` with a `code` from `PROBLEM_CODES`;
|
|
12
|
+
* an OAuth endpoint answers `{"error": "invalid_grant"}` because RFC 6749 §5.2
|
|
13
|
+
* says so and every client in existence parses that. The two taxonomies are kept
|
|
14
|
+
* apart deliberately rather than reconciled — a client library that has to
|
|
15
|
+
* branch on which envelope it got back is a client library that will get it
|
|
16
|
+
* wrong. `OAUTH_ERRORS` below is the OAuth half, and it is closed for the same
|
|
17
|
+
* reason `PROBLEM_CODES` is.
|
|
18
|
+
*
|
|
19
|
+
* **The scope vocabulary is shared, and that is the whole point.** An OAuth
|
|
20
|
+
* grant carries `Scope` values from `authority.ts` — the same nine an API
|
|
21
|
+
* credential carries, capped by the same `capScopesToRole`. There is no
|
|
22
|
+
* OAuth-only scope and no OAuth-only operation, so a token cannot reach anything
|
|
23
|
+
* a key cannot, which is what architecture section 8 means by "API key and Clerk
|
|
24
|
+
* role mappings share one policy package".
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Where each OAuth endpoint lives.
|
|
28
|
+
*
|
|
29
|
+
* Stated once because two readers must agree: the router that serves them and
|
|
30
|
+
* the discovery documents that advertise them. A client fetches the metadata and
|
|
31
|
+
* then uses exactly the URLs it found there, so a path that drifts from its
|
|
32
|
+
* advertisement is not a 404 in one place — it is a client that cannot
|
|
33
|
+
* authenticate and cannot say why.
|
|
34
|
+
*
|
|
35
|
+
* Outside `/v1` on purpose, following the convention `CLERK_WEBHOOK_PATH` set.
|
|
36
|
+
* The `/v1` surface is the product's operation registry: every entry is
|
|
37
|
+
* projected into OpenAPI, the SDK, an MCP tool and the contract mock, and W1-07
|
|
38
|
+
* enforces parity across all of them. None of that is true of an OAuth endpoint,
|
|
39
|
+
* whose shape is fixed by a standard rather than by us, and one of which
|
|
40
|
+
* (`/oauth/token`) must never become an agent-callable tool.
|
|
41
|
+
*/
|
|
42
|
+
export const OAUTH_ROUTES = {
|
|
43
|
+
/** RFC 8414 authorization server metadata. */
|
|
44
|
+
authorizationServerMetadata: "/.well-known/oauth-authorization-server",
|
|
45
|
+
/** The browser-facing start of the flow. */
|
|
46
|
+
authorize: "/oauth/authorize",
|
|
47
|
+
/** What the consent screen reads to render itself. Session-authenticated. */
|
|
48
|
+
authorizationRequest: "/oauth/authorization-requests/:authorizationRequestId",
|
|
49
|
+
/** Where the consent screen posts approve or deny. Session-authenticated. */
|
|
50
|
+
consent: "/oauth/authorization-requests/:authorizationRequestId/consent",
|
|
51
|
+
// **No `grants` here since W4-08.** A member's list of connected
|
|
52
|
+
// applications is in no RFC and this file's own comment above called the
|
|
53
|
+
// consent endpoints "this product's own API" — so being outside `/v1` bought
|
|
54
|
+
// nothing and cost the published document, the generated client and an MCP
|
|
55
|
+
// disposition. `grants.list` and `grants.revoke` are operations now.
|
|
56
|
+
/** RFC 9728 protected resource metadata, which is how MCP clients start. */
|
|
57
|
+
protectedResourceMetadata: "/.well-known/oauth-protected-resource",
|
|
58
|
+
/** RFC 7591 dynamic client registration. */
|
|
59
|
+
register: "/oauth/register",
|
|
60
|
+
/** RFC 7009 token revocation. */
|
|
61
|
+
revoke: "/oauth/revoke",
|
|
62
|
+
/** RFC 6749 token endpoint. */
|
|
63
|
+
token: "/oauth/token",
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Every error this authorization server emits, and nothing else.
|
|
67
|
+
*
|
|
68
|
+
* Closed rather than open because an `error` value is what a client branches on,
|
|
69
|
+
* exactly like a problem `code`. RFC 6749 §4.1.2.1 and §5.2 define most of
|
|
70
|
+
* these; `invalid_target` is RFC 8707's, for a `resource` naming something this
|
|
71
|
+
* server does not protect.
|
|
72
|
+
*
|
|
73
|
+
* Note what is *not* here: there is no error meaning "your membership was
|
|
74
|
+
* downgraded" and none meaning "that grant was revoked". Both are `invalid_grant`
|
|
75
|
+
* at the token endpoint, because RFC 6749 gives one code for "the grant is no
|
|
76
|
+
* longer usable" and inventing a second would tell a client which of the two
|
|
77
|
+
* happened — which is information about the account, delivered to whoever holds
|
|
78
|
+
* a dead token.
|
|
79
|
+
*/
|
|
80
|
+
export const OAUTH_ERRORS = [
|
|
81
|
+
"access_denied",
|
|
82
|
+
"invalid_client",
|
|
83
|
+
"invalid_client_metadata",
|
|
84
|
+
"invalid_grant",
|
|
85
|
+
"invalid_redirect_uri",
|
|
86
|
+
"invalid_request",
|
|
87
|
+
"invalid_scope",
|
|
88
|
+
"invalid_target",
|
|
89
|
+
"server_error",
|
|
90
|
+
"temporarily_unavailable",
|
|
91
|
+
"unauthorized_client",
|
|
92
|
+
"unsupported_grant_type",
|
|
93
|
+
"unsupported_response_type",
|
|
94
|
+
];
|
|
95
|
+
export const oauthErrorCodeSchema = z.enum(OAUTH_ERRORS);
|
|
96
|
+
/**
|
|
97
|
+
* The error body, RFC 6749 §5.2.
|
|
98
|
+
*
|
|
99
|
+
* `error_description` is human-readable and must stay that way: it is displayed
|
|
100
|
+
* to developers and never parsed. FXO-1368 is the record of what happens when
|
|
101
|
+
* a caller does parse it — reconciliation that matched a provider's
|
|
102
|
+
* human-readable string broke when the wording changed. Anything a client needs
|
|
103
|
+
* to act on belongs in `error`.
|
|
104
|
+
*/
|
|
105
|
+
export const oauthErrorSchema = z.object({
|
|
106
|
+
error: oauthErrorCodeSchema,
|
|
107
|
+
error_description: z.string().min(1).optional(),
|
|
108
|
+
error_uri: z.url().optional(),
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* HTTP status for each error, so a route cannot pair a code with the wrong one.
|
|
112
|
+
*
|
|
113
|
+
* `invalid_client` is 401 because RFC 6749 §5.2 says so, and the difference is
|
|
114
|
+
* load-bearing: a client that cannot authenticate must not be told to fix its
|
|
115
|
+
* request. Everything else on the token endpoint is 400 — including
|
|
116
|
+
* `invalid_grant`, which is the code a revoked, replayed or expired grant
|
|
117
|
+
* produces, and which is deliberately *not* 401. A 401 invites a client to
|
|
118
|
+
* re-present the same dead credential.
|
|
119
|
+
*/
|
|
120
|
+
export const OAUTH_ERROR_STATUS = {
|
|
121
|
+
access_denied: 400,
|
|
122
|
+
invalid_client: 401,
|
|
123
|
+
// RFC 7591 §3.2.2's registration errors, which share this envelope and are
|
|
124
|
+
// 400 like every other request fault. Kept in one catalogue rather than a
|
|
125
|
+
// second so a route cannot reach for a code from the wrong standard.
|
|
126
|
+
invalid_client_metadata: 400,
|
|
127
|
+
invalid_grant: 400,
|
|
128
|
+
invalid_redirect_uri: 400,
|
|
129
|
+
invalid_request: 400,
|
|
130
|
+
invalid_scope: 400,
|
|
131
|
+
invalid_target: 400,
|
|
132
|
+
server_error: 500,
|
|
133
|
+
temporarily_unavailable: 503,
|
|
134
|
+
unauthorized_client: 400,
|
|
135
|
+
unsupported_grant_type: 400,
|
|
136
|
+
unsupported_response_type: 400,
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Build an error body. The status comes from the table above rather than from
|
|
140
|
+
* the caller, so the two cannot contradict each other — the same argument
|
|
141
|
+
* `problem()` makes for `PROBLEM_CODES`.
|
|
142
|
+
*/
|
|
143
|
+
export function oauthError(error, description) {
|
|
144
|
+
return {
|
|
145
|
+
body: oauthErrorSchema.parse({
|
|
146
|
+
error,
|
|
147
|
+
...(description === undefined ? {} : { error_description: description }),
|
|
148
|
+
}),
|
|
149
|
+
status: OAUTH_ERROR_STATUS[error],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The only grant types this server issues against.
|
|
154
|
+
*
|
|
155
|
+
* No implicit grant, no resource owner password credentials, no client
|
|
156
|
+
* credentials. The first two are removed by OAuth 2.1; the third would be a
|
|
157
|
+
* client acting with no human behind it, which is what an API credential already
|
|
158
|
+
* is and what `capScopesToRole` has no role to cap against.
|
|
159
|
+
*/
|
|
160
|
+
export const OAUTH_GRANT_TYPES = [
|
|
161
|
+
"authorization_code",
|
|
162
|
+
"refresh_token",
|
|
163
|
+
];
|
|
164
|
+
export const oauthGrantTypeSchema = z.enum(OAUTH_GRANT_TYPES);
|
|
165
|
+
/** Authorization code is the only response type. See above. */
|
|
166
|
+
export const OAUTH_RESPONSE_TYPES = ["code"];
|
|
167
|
+
/**
|
|
168
|
+
* PKCE challenge methods, and `plain` is not one.
|
|
169
|
+
*
|
|
170
|
+
* OAuth 2.1 removes it, and the reason is worth stating where the constant is:
|
|
171
|
+
* `plain` sends the verifier as the challenge, so anyone who can read the
|
|
172
|
+
* authorization request can complete the exchange — which is the entire attack
|
|
173
|
+
* PKCE exists to stop. A client asking for `plain` is refused rather than
|
|
174
|
+
* downgraded to it.
|
|
175
|
+
*/
|
|
176
|
+
export const PKCE_METHODS = ["S256"];
|
|
177
|
+
export const pkceMethodSchema = z.enum(PKCE_METHODS);
|
|
178
|
+
/**
|
|
179
|
+
* RFC 7636 §4.1: 43–128 characters from an unreserved alphabet. The floor is
|
|
180
|
+
* what makes the verifier unguessable, so it is enforced rather than assumed.
|
|
181
|
+
*/
|
|
182
|
+
export const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/u;
|
|
183
|
+
/** The base64url SHA-256 of a verifier: 43 characters, no padding. */
|
|
184
|
+
export const PKCE_CHALLENGE_PATTERN = /^[A-Za-z0-9\-_]{43}$/u;
|
|
185
|
+
/**
|
|
186
|
+
* Client authentication methods this server accepts, which is one: none.
|
|
187
|
+
*
|
|
188
|
+
* **Every client here is a public client.** An MCP client is a desktop
|
|
189
|
+
* application, a CLI, or a browser extension; it cannot keep a secret, and RFC
|
|
190
|
+
* 7591 registration hands a `client_secret` to whoever asked for one without
|
|
191
|
+
* ever authenticating them. Issuing a secret to an unauthenticated registrant
|
|
192
|
+
* and then treating it as proof of identity is a confidence trick played on
|
|
193
|
+
* ourselves.
|
|
194
|
+
*
|
|
195
|
+
* What replaces it is PKCE, which is mandatory here, plus exact redirect
|
|
196
|
+
* matching — together those bind an authorization code to the software instance
|
|
197
|
+
* that started the flow, which is the property a client secret is usually
|
|
198
|
+
* pressed into providing. A confidential-client flow can be added later without
|
|
199
|
+
* a contract break; adding it now would be surface with no consumer.
|
|
200
|
+
*/
|
|
201
|
+
export const TOKEN_ENDPOINT_AUTH_METHODS = ["none"];
|
|
202
|
+
/**
|
|
203
|
+
* How long a member has to finish consenting before the request expires.
|
|
204
|
+
*
|
|
205
|
+
* Short because a pending authorization request is a live intent to grant
|
|
206
|
+
* access, and a long-lived one is something an attacker can hope a member
|
|
207
|
+
* approves absent-mindedly later. Ten minutes is enough to sign in to Clerk from
|
|
208
|
+
* a cold browser and read the screen.
|
|
209
|
+
*/
|
|
210
|
+
export const AUTHORIZATION_REQUEST_TTL_SECONDS = 600;
|
|
211
|
+
/**
|
|
212
|
+
* How long an issued authorization code may be exchanged.
|
|
213
|
+
*
|
|
214
|
+
* RFC 6749 §4.1.2 recommends a maximum of ten minutes; this is deliberately much
|
|
215
|
+
* shorter, because the code travels through the user's browser — a redirect
|
|
216
|
+
* chain, an address bar, a browser history entry, and whatever referrer policy
|
|
217
|
+
* the client's own page carries. Sixty seconds is more than a token exchange
|
|
218
|
+
* needs and less than a leak is useful for.
|
|
219
|
+
*/
|
|
220
|
+
export const AUTHORIZATION_CODE_TTL_SECONDS = 60;
|
|
221
|
+
/**
|
|
222
|
+
* How long an access token is valid.
|
|
223
|
+
*
|
|
224
|
+
* The number that decides how stale a revocation may be *in the worst case where
|
|
225
|
+
* the token check is skipped* — which, on this server, is never: every request
|
|
226
|
+
* loads the grant and re-checks membership, so a revoked grant fails on its next
|
|
227
|
+
* use rather than at the end of this window. The lifetime is short anyway,
|
|
228
|
+
* because a token this cheap to refresh has no reason to be long-lived.
|
|
229
|
+
*/
|
|
230
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
|
231
|
+
/**
|
|
232
|
+
* How long a refresh token is valid, absent rotation.
|
|
233
|
+
*
|
|
234
|
+
* Rotation resets it, so an actively used grant survives indefinitely and an
|
|
235
|
+
* abandoned one stops working after thirty days. That is the behaviour a member
|
|
236
|
+
* expects from "apps I have connected": the ones they use keep working, and the
|
|
237
|
+
* ones they forgot about do not accumulate forever.
|
|
238
|
+
*/
|
|
239
|
+
export const REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
|
|
240
|
+
/**
|
|
241
|
+
* Token shapes.
|
|
242
|
+
*
|
|
243
|
+
* The same prefix-plus-secret construction API credentials use, for the same
|
|
244
|
+
* reason: a presented token is found by its non-secret leading segment before
|
|
245
|
+
* anything is compared, so verification never scans. The environment is in the
|
|
246
|
+
* prefix because a token that cannot be told apart from a live one at a glance
|
|
247
|
+
* is one somebody will paste into the wrong place.
|
|
248
|
+
*
|
|
249
|
+
* Three separate prefixes rather than one, because they are three different
|
|
250
|
+
* things with three different lifetimes, and a store that accepted a refresh
|
|
251
|
+
* token where an access token belonged would be one type confusion away from
|
|
252
|
+
* making the short lifetime meaningless.
|
|
253
|
+
*/
|
|
254
|
+
/** `oat_live_` and friends: four characters, the environment, two separators. */
|
|
255
|
+
export const OAUTH_TOKEN_NAMESPACE_LENGTH = 9;
|
|
256
|
+
/**
|
|
257
|
+
* Random characters after the namespace. Forty-eight of a 32-symbol alphabet is
|
|
258
|
+
* 240 bits — deliberately more than a credential's 160, because these are minted
|
|
259
|
+
* by machines at machine rates and nobody ever has to type one.
|
|
260
|
+
*/
|
|
261
|
+
export const OAUTH_TOKEN_SECRET_LENGTH = 48;
|
|
262
|
+
export const ACCESS_TOKEN_PATTERN = /^oat_(?:live|test)_[0-9a-z]{48}$/u;
|
|
263
|
+
export const REFRESH_TOKEN_PATTERN = /^ort_(?:live|test)_[0-9a-z]{48}$/u;
|
|
264
|
+
export const AUTHORIZATION_CODE_PATTERN = /^oac_(?:live|test)_[0-9a-z]{48}$/u;
|
|
265
|
+
/** Any of the three, for the one place that has to tell ours from anything else. */
|
|
266
|
+
export const OAUTH_SECRET_PATTERN = /^o(?:at|rt|ac)_(?:live|test)_[0-9a-z]{48}$/u;
|
|
267
|
+
/**
|
|
268
|
+
* A redirect URI, validated the way RFC 8252 says a public client's must be.
|
|
269
|
+
*
|
|
270
|
+
* Three shapes are accepted and everything else is refused:
|
|
271
|
+
*
|
|
272
|
+
* - `https://` anywhere. The ordinary case.
|
|
273
|
+
* - `http://` on a loopback host only — `127.0.0.1`, `[::1]`, `localhost`. A
|
|
274
|
+
* native client receives its code on a listener it started itself, and there
|
|
275
|
+
* is no TLS to have. Refusing these would refuse every desktop MCP client.
|
|
276
|
+
* - a private-use scheme containing a dot, such as `com.example.app:/callback`.
|
|
277
|
+
* RFC 8252 §7.1's reverse-domain form, which is what a mobile or editor client
|
|
278
|
+
* registers.
|
|
279
|
+
*
|
|
280
|
+
* A fragment is refused outright: RFC 6749 §3.1.2 forbids one, and the fragment
|
|
281
|
+
* is where the server appends nothing and the browser strips everything, so a
|
|
282
|
+
* client that registered one would silently never see its code.
|
|
283
|
+
*
|
|
284
|
+
* Userinfo is refused because `https://evil@good.example` is read as
|
|
285
|
+
* `good.example` by a URL parser and as `evil` by a human skimming a consent
|
|
286
|
+
* screen — and the consent screen is the one place a human is asked to make this
|
|
287
|
+
* exact judgement.
|
|
288
|
+
*/
|
|
289
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "::1", "localhost"]);
|
|
290
|
+
export const redirectUriSchema = z
|
|
291
|
+
.string()
|
|
292
|
+
.min(1)
|
|
293
|
+
.max(2048)
|
|
294
|
+
.superRefine((value, context) => {
|
|
295
|
+
let url;
|
|
296
|
+
try {
|
|
297
|
+
url = new URL(value);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
context.addIssue({
|
|
301
|
+
code: "custom",
|
|
302
|
+
message: "must be an absolute URI",
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (url.hash.length > 0) {
|
|
307
|
+
context.addIssue({
|
|
308
|
+
code: "custom",
|
|
309
|
+
message: "must not contain a fragment",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (url.username.length > 0 || url.password.length > 0) {
|
|
313
|
+
context.addIssue({
|
|
314
|
+
code: "custom",
|
|
315
|
+
message: "must not contain userinfo",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (url.protocol === "https:") {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (url.protocol === "http:") {
|
|
322
|
+
if (!LOOPBACK_HOSTS.has(url.hostname)) {
|
|
323
|
+
context.addIssue({
|
|
324
|
+
code: "custom",
|
|
325
|
+
message: "http is permitted only on a loopback host",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// A private-use scheme. The dot is RFC 8252's own test for one, and it is
|
|
331
|
+
// what keeps `myapp:/callback` — a scheme anybody could claim — out.
|
|
332
|
+
if (!url.protocol.slice(0, -1).includes(".")) {
|
|
333
|
+
context.addIssue({
|
|
334
|
+
code: "custom",
|
|
335
|
+
message: "must be https, http on a loopback host, or a private-use scheme in reverse-domain form",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
/** Whether a redirect URI names a loopback listener. Exported for the store. */
|
|
340
|
+
export function isLoopbackRedirect(value) {
|
|
341
|
+
try {
|
|
342
|
+
const url = new URL(value);
|
|
343
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** The most redirect URIs one client may register. */
|
|
350
|
+
export const MAX_REDIRECT_URIS = 8;
|
|
351
|
+
/**
|
|
352
|
+
* RFC 7591 dynamic client registration.
|
|
353
|
+
*
|
|
354
|
+
* Registration is open — no credential is required to call it — and that is a
|
|
355
|
+
* deliberate, bounded decision rather than an oversight. An MCP client
|
|
356
|
+
* discovers this server, registers itself, and only then asks a human to
|
|
357
|
+
* approve it; requiring a credential first would mean every client had to be
|
|
358
|
+
* provisioned by hand before anyone could try it, which is the friction dynamic
|
|
359
|
+
* registration exists to remove.
|
|
360
|
+
*
|
|
361
|
+
* What makes it safe is that **a registered client holds no authority at all**.
|
|
362
|
+
* It cannot read, write, or name anything until a signed-in member approves a
|
|
363
|
+
* grant, and that grant is capped by the member's own role. The worst a
|
|
364
|
+
* registration flood achieves is rows in a table, which is a rate-limiting
|
|
365
|
+
* problem — W7-03's, and named there rather than solved here with an unmeasured
|
|
366
|
+
* limit.
|
|
367
|
+
*
|
|
368
|
+
* Unknown fields are dropped rather than refused. RFC 7591 §2 lets a client send
|
|
369
|
+
* metadata a server does not implement, and says the server returns what it
|
|
370
|
+
* actually registered — so a client sending `logo_uri` gets a registration back
|
|
371
|
+
* without one, not a 400.
|
|
372
|
+
*/
|
|
373
|
+
/**
|
|
374
|
+
* A URL the consent screen may put in front of a human.
|
|
375
|
+
*
|
|
376
|
+
* `https` and nothing else. The scheme check is the whole guard: `javascript:`
|
|
377
|
+
* and `data:` are the ones that execute, and `http:` is the one an intermediary
|
|
378
|
+
* can rewrite — on the page where what the human sees *is* the decision.
|
|
379
|
+
*/
|
|
380
|
+
export const httpsUriSchema = z
|
|
381
|
+
.url()
|
|
382
|
+
.max(2048)
|
|
383
|
+
.refine((value) => value.toLowerCase().startsWith("https://"), {
|
|
384
|
+
message: "must be an https URL",
|
|
385
|
+
});
|
|
386
|
+
export const clientRegistrationRequestSchema = z.object({
|
|
387
|
+
client_name: z.string().min(1).max(120),
|
|
388
|
+
client_uri: z.url().max(2048).optional(),
|
|
389
|
+
grant_types: z.array(oauthGrantTypeSchema).min(1).optional(),
|
|
390
|
+
/**
|
|
391
|
+
* RFC 7591 §2's presentation metadata, carried because the consent screen is
|
|
392
|
+
* where a human decides whether to trust the asking application.
|
|
393
|
+
*
|
|
394
|
+
* **`https` only, and enforced rather than described.** Both review lenses
|
|
395
|
+
* found this claiming the restriction in a comment while validating with a
|
|
396
|
+
* bare `z.url()`, which accepts `http:`, `javascript:` and `data:` — on
|
|
397
|
+
* fields a stranger sets through open registration and a human then reads
|
|
398
|
+
* while deciding whether to grant access. Nothing rendered them yet, which is
|
|
399
|
+
* the only reason it was not live cross-site scripting; the next story to
|
|
400
|
+
* wire them into an `href` would have inherited it, trusting the comment.
|
|
401
|
+
*/
|
|
402
|
+
logo_uri: httpsUriSchema.optional(),
|
|
403
|
+
policy_uri: httpsUriSchema.optional(),
|
|
404
|
+
redirect_uris: z.array(redirectUriSchema).min(1).max(MAX_REDIRECT_URIS),
|
|
405
|
+
response_types: z.array(z.enum(OAUTH_RESPONSE_TYPES)).min(1).optional(),
|
|
406
|
+
scope: z.string().max(1024).optional(),
|
|
407
|
+
token_endpoint_auth_method: z.enum(TOKEN_ENDPOINT_AUTH_METHODS).optional(),
|
|
408
|
+
tos_uri: httpsUriSchema.optional(),
|
|
409
|
+
});
|
|
410
|
+
/**
|
|
411
|
+
* What registration answers with, RFC 7591 §3.2.1.
|
|
412
|
+
*
|
|
413
|
+
* No `client_secret`, and no `client_secret_expires_at` either — RFC 7591 says
|
|
414
|
+
* the latter is required *when a secret is issued*, and emitting it as `0`
|
|
415
|
+
* alongside no secret is how a client concludes it has a non-expiring one.
|
|
416
|
+
*/
|
|
417
|
+
export const clientRegistrationSchema = z.object({
|
|
418
|
+
client_id: oauthClientIdSchema,
|
|
419
|
+
client_id_issued_at: z.number().int().positive(),
|
|
420
|
+
client_name: z.string(),
|
|
421
|
+
client_uri: z.string().optional(),
|
|
422
|
+
grant_types: z.array(oauthGrantTypeSchema),
|
|
423
|
+
logo_uri: z.string().optional(),
|
|
424
|
+
policy_uri: z.string().optional(),
|
|
425
|
+
redirect_uris: z.array(z.string()),
|
|
426
|
+
response_types: z.array(z.enum(OAUTH_RESPONSE_TYPES)),
|
|
427
|
+
scope: z.string(),
|
|
428
|
+
token_endpoint_auth_method: z.enum(TOKEN_ENDPOINT_AUTH_METHODS),
|
|
429
|
+
tos_uri: z.string().optional(),
|
|
430
|
+
});
|
|
431
|
+
/**
|
|
432
|
+
* Scopes on the wire are one space-delimited string, RFC 6749 §3.3.
|
|
433
|
+
*
|
|
434
|
+
* Parsing drops anything not in `SCOPES` rather than refusing the request. The
|
|
435
|
+
* choice matters and follows `capScopesToRole`: a client asking for a scope this
|
|
436
|
+
* server does not know is asking for less than it thought, and the consent
|
|
437
|
+
* screen shows what it will actually get. Refusing outright would break every
|
|
438
|
+
* client that sends a scope some other server understood.
|
|
439
|
+
*
|
|
440
|
+
* A request whose scopes *all* fall away is a different case and is refused as
|
|
441
|
+
* `invalid_scope` by the caller — asking for nothing this server can grant is a
|
|
442
|
+
* mistake worth reporting.
|
|
443
|
+
*/
|
|
444
|
+
export function parseScopeString(value) {
|
|
445
|
+
if (value === undefined) {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
const requested = new Set(value.split(/\s+/u).filter((entry) => entry.length > 0));
|
|
449
|
+
return SCOPES.filter((scope) => requested.has(scope));
|
|
450
|
+
}
|
|
451
|
+
/** The inverse. Ordered by `SCOPES` so one grant has exactly one spelling. */
|
|
452
|
+
export function formatScopeString(scopes) {
|
|
453
|
+
const held = new Set(scopes);
|
|
454
|
+
return SCOPES.filter((scope) => held.has(scope)).join(" ");
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The authorization request, as it arrives on the query string.
|
|
458
|
+
*
|
|
459
|
+
* Everything is a string because a query string has no other type, and the
|
|
460
|
+
* coercions happen here rather than in the route. `state` is opaque and echoed
|
|
461
|
+
* back untouched — it is the client's CSRF defence and this server has no
|
|
462
|
+
* business interpreting it.
|
|
463
|
+
*
|
|
464
|
+
* `resource` is RFC 8707's, and MCP's authorization specification requires
|
|
465
|
+
* clients to send it. It is validated against this server's own resource
|
|
466
|
+
* identifier rather than merely recorded: a token minted for one audience and
|
|
467
|
+
* accepted by another is the confused-deputy problem RFC 8707 exists to close.
|
|
468
|
+
*/
|
|
469
|
+
export const authorizationParametersSchema = z.object({
|
|
470
|
+
client_id: oauthClientIdSchema,
|
|
471
|
+
code_challenge: z.string().regex(PKCE_CHALLENGE_PATTERN),
|
|
472
|
+
code_challenge_method: pkceMethodSchema,
|
|
473
|
+
redirect_uri: z.string().min(1).max(2048),
|
|
474
|
+
resource: z.string().max(2048).optional(),
|
|
475
|
+
response_type: z.enum(OAUTH_RESPONSE_TYPES),
|
|
476
|
+
scope: z.string().max(1024).optional(),
|
|
477
|
+
state: z.string().max(512).optional(),
|
|
478
|
+
});
|
|
479
|
+
/**
|
|
480
|
+
* What the consent screen is given to render.
|
|
481
|
+
*
|
|
482
|
+
* It names the client, the scopes, and the projects the member may grant
|
|
483
|
+
* within — architecture section 8 requires consent to name project, environment
|
|
484
|
+
* and scopes, and a screen that cannot show them cannot obtain that consent.
|
|
485
|
+
*
|
|
486
|
+
* `scopes` here is already capped to the member's current role, so the screen
|
|
487
|
+
* never displays a permission that would not actually be granted. A member who
|
|
488
|
+
* asked for `credentials:write` and holds `member` sees the reduced list, which
|
|
489
|
+
* is the honest thing to show and is also what will be recorded.
|
|
490
|
+
*/
|
|
491
|
+
export const consentPromptSchema = z.object({
|
|
492
|
+
client: z.object({
|
|
493
|
+
id: oauthClientIdSchema,
|
|
494
|
+
/**
|
|
495
|
+
* What the screen shows so a human can tell *who* is asking.
|
|
496
|
+
*
|
|
497
|
+
* All optional, and all attacker-controlled: a client registers itself and
|
|
498
|
+
* chooses its own name and logo. The screen must present them as claims
|
|
499
|
+
* rather than as endorsements — the value that is not attacker-controlled is
|
|
500
|
+
* `redirectUri`, which is where the access would actually go.
|
|
501
|
+
*/
|
|
502
|
+
logoUri: z.string().optional(),
|
|
503
|
+
name: z.string(),
|
|
504
|
+
policyUri: z.string().optional(),
|
|
505
|
+
termsUri: z.string().optional(),
|
|
506
|
+
uri: z.string().optional(),
|
|
507
|
+
}),
|
|
508
|
+
/**
|
|
509
|
+
* The environments a grant may name. Both, always: a project owns one of each
|
|
510
|
+
* by construction, and the choice is the member's rather than the client's.
|
|
511
|
+
*/
|
|
512
|
+
environmentKinds: z.array(environmentKindSchema).min(1),
|
|
513
|
+
expiresAt: timestampSchema,
|
|
514
|
+
/** Where the code will be delivered, so a human can check it. */
|
|
515
|
+
redirectUri: z.string(),
|
|
516
|
+
/**
|
|
517
|
+
* Active projects the member may grant within. Empty means the organization
|
|
518
|
+
* has no project yet, which the screen has to say rather than presenting an
|
|
519
|
+
* empty picker.
|
|
520
|
+
*
|
|
521
|
+
* A display aid rather than an authorization control: whatever the screen
|
|
522
|
+
* shows, the decision is re-resolved through `resolveAuthority`, so a project
|
|
523
|
+
* this list omitted — or one it should have — is decided there.
|
|
524
|
+
*/
|
|
525
|
+
projects: z
|
|
526
|
+
.array(z.object({ id: projectIdSchema, name: z.string() }))
|
|
527
|
+
.max(200),
|
|
528
|
+
/** Scopes that would be granted: requested, capped by the member's role. */
|
|
529
|
+
scopes: z.array(scopeSchema),
|
|
530
|
+
});
|
|
531
|
+
/**
|
|
532
|
+
* The member's decision.
|
|
533
|
+
*
|
|
534
|
+
* `scopes` is the member's own narrowing — a consent screen that only offered
|
|
535
|
+
* all-or-nothing would train people to click through. It is intersected with
|
|
536
|
+
* what was requested and with the role ceiling, so it can only ever reduce.
|
|
537
|
+
*
|
|
538
|
+
* `project` and `environmentKind` are the *authority* the grant will carry, and
|
|
539
|
+
* they are chosen here rather than requested by the client. A client that could
|
|
540
|
+
* name a project would be selecting a tenant, which the `/v1` conventions forbid
|
|
541
|
+
* outright and which is no less forbidden because the request arrived through a
|
|
542
|
+
* browser.
|
|
543
|
+
*/
|
|
544
|
+
export const consentDecisionSchema = z.discriminatedUnion("decision", [
|
|
545
|
+
z.object({
|
|
546
|
+
decision: z.literal("approve"),
|
|
547
|
+
environmentKind: environmentKindSchema,
|
|
548
|
+
project: projectIdSchema,
|
|
549
|
+
scopes: z.array(scopeSchema).min(1),
|
|
550
|
+
}),
|
|
551
|
+
z.object({ decision: z.literal("deny") }),
|
|
552
|
+
]);
|
|
553
|
+
/**
|
|
554
|
+
* Where to send the browser next.
|
|
555
|
+
*
|
|
556
|
+
* Returned as a body rather than served as a 302, because the caller is the
|
|
557
|
+
* consent screen's `fetch` rather than the browser itself — a redirect answered
|
|
558
|
+
* to `fetch` would be followed by the client, delivering the code to the
|
|
559
|
+
* consent screen's own JavaScript instead of to the client's redirect URI.
|
|
560
|
+
*/
|
|
561
|
+
export const consentOutcomeSchema = z.object({
|
|
562
|
+
redirectTo: z.string(),
|
|
563
|
+
});
|
|
564
|
+
/** RFC 6749 §5.1. `expires_in` is seconds, and relative, as the standard says. */
|
|
565
|
+
export const tokenResponseSchema = z.object({
|
|
566
|
+
access_token: z.string(),
|
|
567
|
+
expires_in: z.number().int().positive(),
|
|
568
|
+
refresh_token: z.string(),
|
|
569
|
+
scope: z.string(),
|
|
570
|
+
token_type: z.literal("Bearer"),
|
|
571
|
+
});
|
|
572
|
+
/**
|
|
573
|
+
* The token request, form-encoded per RFC 6749 §4.1.3 and §6.
|
|
574
|
+
*
|
|
575
|
+
* A discriminated union on `grant_type` so the two exchanges cannot borrow each
|
|
576
|
+
* other's fields: an authorization-code request without a `code_verifier` is
|
|
577
|
+
* refused by the shape rather than by a check somebody has to remember, which is
|
|
578
|
+
* what makes "PKCE is mandatory" a property of the contract.
|
|
579
|
+
*/
|
|
580
|
+
export const tokenRequestSchema = z.discriminatedUnion("grant_type", [
|
|
581
|
+
z.object({
|
|
582
|
+
client_id: oauthClientIdSchema,
|
|
583
|
+
code: z.string().min(1).max(256),
|
|
584
|
+
code_verifier: z.string().regex(PKCE_VERIFIER_PATTERN),
|
|
585
|
+
grant_type: z.literal("authorization_code"),
|
|
586
|
+
redirect_uri: z.string().min(1).max(2048),
|
|
587
|
+
resource: z.string().max(2048).optional(),
|
|
588
|
+
}),
|
|
589
|
+
z.object({
|
|
590
|
+
client_id: oauthClientIdSchema,
|
|
591
|
+
grant_type: z.literal("refresh_token"),
|
|
592
|
+
refresh_token: z.string().min(1).max(256),
|
|
593
|
+
resource: z.string().max(2048).optional(),
|
|
594
|
+
scope: z.string().max(1024).optional(),
|
|
595
|
+
}),
|
|
596
|
+
]);
|
|
597
|
+
/** RFC 7009 §2.1. The hint is advisory and this server ignores it. */
|
|
598
|
+
export const revocationRequestSchema = z.object({
|
|
599
|
+
client_id: oauthClientIdSchema,
|
|
600
|
+
token: z.string().min(1).max(256),
|
|
601
|
+
token_type_hint: z.enum(["access_token", "refresh_token"]).optional(),
|
|
602
|
+
});
|
|
603
|
+
/**
|
|
604
|
+
* What became of a grant.
|
|
605
|
+
*
|
|
606
|
+
* `revoked` is terminal and covers every way a grant dies — the member revoked
|
|
607
|
+
* it, a code or refresh token was replayed, or the client revoked its own token.
|
|
608
|
+
* There is no `expired` state: a grant whose refresh token lapsed is still a
|
|
609
|
+
* grant the member may want to see and revoke, and collapsing it into `revoked`
|
|
610
|
+
* would say somebody decided something when nobody did.
|
|
611
|
+
*/
|
|
612
|
+
export const OAUTH_GRANT_STATUSES = ["active", "revoked"];
|
|
613
|
+
export const oauthGrantStatusSchema = z.enum(OAUTH_GRANT_STATUSES);
|
|
614
|
+
/**
|
|
615
|
+
* Why a grant became terminal.
|
|
616
|
+
*
|
|
617
|
+
* Recorded because "my integration stopped working" is a support question, and
|
|
618
|
+
* the four answers are genuinely different: somebody revoked it deliberately,
|
|
619
|
+
* the client revoked its own token, or one of the two replay defences fired.
|
|
620
|
+
* The last two are security events and the first two are not, and an audit trail
|
|
621
|
+
* that cannot tell them apart cannot answer either question.
|
|
622
|
+
*/
|
|
623
|
+
export const OAUTH_REVOCATION_REASONS = [
|
|
624
|
+
"authorization-code-replayed",
|
|
625
|
+
"client-revoked",
|
|
626
|
+
"member-revoked",
|
|
627
|
+
"refresh-token-replayed",
|
|
628
|
+
];
|
|
629
|
+
export const oauthRevocationReasonSchema = z.enum(OAUTH_REVOCATION_REASONS);
|
|
630
|
+
/**
|
|
631
|
+
* A grant as the member sees it. No token material, present or past.
|
|
632
|
+
*
|
|
633
|
+
* This is the shape architecture section 8's "user-visible grant revocation"
|
|
634
|
+
* needs: enough to recognise which application this is and decide whether to
|
|
635
|
+
* keep it, and nothing that would let a reader of the list use it.
|
|
636
|
+
*/
|
|
637
|
+
export const oauthGrantSchema = z.object({
|
|
638
|
+
client: z.object({ id: oauthClientIdSchema, name: z.string() }),
|
|
639
|
+
createdAt: timestampSchema,
|
|
640
|
+
environmentKind: environmentKindSchema,
|
|
641
|
+
id: oauthGrantIdSchema,
|
|
642
|
+
lastUsedAt: timestampSchema.optional(),
|
|
643
|
+
project: projectIdSchema,
|
|
644
|
+
revocationReason: oauthRevocationReasonSchema.optional(),
|
|
645
|
+
revokedAt: timestampSchema.optional(),
|
|
646
|
+
scopes: z.array(scopeSchema).min(1),
|
|
647
|
+
status: oauthGrantStatusSchema,
|
|
648
|
+
});
|
|
649
|
+
export const oauthGrantListSchema = z.object({
|
|
650
|
+
data: z.array(oauthGrantSchema),
|
|
651
|
+
});
|
|
652
|
+
/**
|
|
653
|
+
* What disconnecting answers with.
|
|
654
|
+
*
|
|
655
|
+
* The grant rather than an empty body, for the reason `credentialRevokedSchema`
|
|
656
|
+
* gives: a member who clicked disconnect wants to see *which* connection stopped
|
|
657
|
+
* and when, and a 204 makes them ask for that separately.
|
|
658
|
+
*/
|
|
659
|
+
export const oauthGrantRevokedSchema = z.object({ grant: oauthGrantSchema });
|
|
660
|
+
/**
|
|
661
|
+
* RFC 8414 authorization server metadata.
|
|
662
|
+
*
|
|
663
|
+
* Only the fields a client uses are advertised. Two are worth noting for what
|
|
664
|
+
* they refuse rather than what they offer: `code_challenge_methods_supported` is
|
|
665
|
+
* `["S256"]`, which tells a conforming client that `plain` will be rejected
|
|
666
|
+
* before it tries; and `grant_types_supported` omits everything OAuth 2.1
|
|
667
|
+
* removed, so a client cannot conclude from silence that implicit is available.
|
|
668
|
+
*/
|
|
669
|
+
export const authorizationServerMetadataSchema = z.object({
|
|
670
|
+
authorization_endpoint: z.url(),
|
|
671
|
+
code_challenge_methods_supported: z.array(z.enum(PKCE_METHODS)).min(1),
|
|
672
|
+
grant_types_supported: z.array(oauthGrantTypeSchema).min(1),
|
|
673
|
+
issuer: z.url(),
|
|
674
|
+
registration_endpoint: z.url(),
|
|
675
|
+
response_modes_supported: z.array(z.literal("query")).min(1),
|
|
676
|
+
response_types_supported: z.array(z.enum(OAUTH_RESPONSE_TYPES)).min(1),
|
|
677
|
+
revocation_endpoint: z.url(),
|
|
678
|
+
scopes_supported: z.array(scopeSchema).min(1),
|
|
679
|
+
token_endpoint: z.url(),
|
|
680
|
+
token_endpoint_auth_methods_supported: z
|
|
681
|
+
.array(z.enum(TOKEN_ENDPOINT_AUTH_METHODS))
|
|
682
|
+
.min(1),
|
|
683
|
+
});
|
|
684
|
+
/**
|
|
685
|
+
* RFC 9728 protected resource metadata.
|
|
686
|
+
*
|
|
687
|
+
* This is where an MCP client's discovery actually begins: it calls a protected
|
|
688
|
+
* endpoint, receives 401 with `WWW-Authenticate: Bearer resource_metadata=…`,
|
|
689
|
+
* fetches this document, and learns which authorization server to talk to. A
|
|
690
|
+
* server that serves the RFC 8414 document but not this one is discoverable only
|
|
691
|
+
* by clients that already knew where to look.
|
|
692
|
+
*/
|
|
693
|
+
export const protectedResourceMetadataSchema = z.object({
|
|
694
|
+
authorization_servers: z.array(z.url()).min(1),
|
|
695
|
+
bearer_methods_supported: z.array(z.literal("header")).min(1),
|
|
696
|
+
resource: z.url(),
|
|
697
|
+
scopes_supported: z.array(scopeSchema).min(1),
|
|
698
|
+
});
|
|
699
|
+
/**
|
|
700
|
+
* The `WWW-Authenticate` value a protected endpoint returns on 401.
|
|
701
|
+
*
|
|
702
|
+
* RFC 9728 §5.1. The `resource_metadata` parameter is the whole mechanism —
|
|
703
|
+
* without it a client that receives a 401 knows only that it is unauthenticated,
|
|
704
|
+
* and has nowhere to go next.
|
|
705
|
+
*/
|
|
706
|
+
export function bearerChallenge(resourceMetadataUrl) {
|
|
707
|
+
return `Bearer resource_metadata="${resourceMetadataUrl}"`;
|
|
708
|
+
}
|