@lanes-sh/link 0.1.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/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { MCP_SCOPE } from './metadata.ts';
|
|
2
|
+
import {
|
|
3
|
+
hashToken,
|
|
4
|
+
randomToken,
|
|
5
|
+
type AuthorizationCode,
|
|
6
|
+
type OAuthStore,
|
|
7
|
+
type RegisteredClient,
|
|
8
|
+
} from './store.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The authorization-code flow, as decisions rather than as HTTP.
|
|
12
|
+
*
|
|
13
|
+
* Everything here takes parsed input and returns what should happen; the server
|
|
14
|
+
* component turns that into a `Response`. That split is what makes the flow
|
|
15
|
+
* testable as a sequence of values — a wrong PKCE verifier, a replayed code, a
|
|
16
|
+
* redirect URI that does not match — instead of as a browser session.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately small. This implements one grant and one refresh, for public
|
|
19
|
+
* clients, with PKCE required. It is not a general authorization server and
|
|
20
|
+
* should not grow into one: no client credentials grant, no implicit flow, no
|
|
21
|
+
* consent scoping, no user directory. There is exactly one user here, and the
|
|
22
|
+
* proof of being them is the endpoint token they already have.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Codes live about as long as a redirect takes. */
|
|
26
|
+
const CODE_TTL_MS = 60_000;
|
|
27
|
+
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export type OAuthResult =
|
|
30
|
+
| { readonly kind: 'json'; readonly status: number; readonly body: unknown }
|
|
31
|
+
| { readonly kind: 'redirect'; readonly location: string }
|
|
32
|
+
/** Render the approval page. The parameters are carried through it. */
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: 'consent';
|
|
35
|
+
readonly request: AuthorizeRequest;
|
|
36
|
+
readonly retry: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* What the client calls itself, if it said.
|
|
39
|
+
*
|
|
40
|
+
* Self-reported and therefore not evidence — registration is open, so
|
|
41
|
+
* anything may claim any name. It is shown because a name is what makes
|
|
42
|
+
* the screen legible, and shown *beside the redirect host*, which is the
|
|
43
|
+
* part that cannot be faked: an impostor calling itself Claude still has
|
|
44
|
+
* to send the code somewhere, and that somewhere is on the screen.
|
|
45
|
+
*/
|
|
46
|
+
readonly clientName?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
| { readonly kind: 'error'; readonly status: number; readonly message: string };
|
|
49
|
+
|
|
50
|
+
export interface AuthorizeRequest {
|
|
51
|
+
readonly clientId: string;
|
|
52
|
+
readonly redirectUri: string;
|
|
53
|
+
readonly codeChallenge: string;
|
|
54
|
+
readonly state: string | undefined;
|
|
55
|
+
readonly scope: string;
|
|
56
|
+
readonly resource: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OAuthServerOptions {
|
|
60
|
+
readonly store: OAuthStore;
|
|
61
|
+
/** Proof of being the owner. The same token the endpoint already accepts. */
|
|
62
|
+
readonly verifyOwner: (presented: string) => Promise<boolean>;
|
|
63
|
+
readonly accessTokenTtlMs: number;
|
|
64
|
+
readonly now?: () => number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class OAuthServer {
|
|
68
|
+
readonly #options: OAuthServerOptions;
|
|
69
|
+
readonly #now: () => number;
|
|
70
|
+
|
|
71
|
+
constructor(options: OAuthServerOptions) {
|
|
72
|
+
this.#options = options;
|
|
73
|
+
this.#now = options.now ?? Date.now;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dynamic client registration (RFC 7591).
|
|
78
|
+
*
|
|
79
|
+
* Open, and that is the design rather than an oversight: registering yields
|
|
80
|
+
* only an identifier for a client that must still complete an approval this
|
|
81
|
+
* endpoint's owner performs by hand. The alternative — a pre-registered client
|
|
82
|
+
* id pasted into a console — is the setup step this whole mode exists to
|
|
83
|
+
* remove.
|
|
84
|
+
*/
|
|
85
|
+
async register(body: unknown): Promise<OAuthResult> {
|
|
86
|
+
const input = body as { redirect_uris?: unknown; client_name?: unknown };
|
|
87
|
+
const uris = Array.isArray(input?.redirect_uris)
|
|
88
|
+
? input.redirect_uris.filter((uri): uri is string => typeof uri === 'string')
|
|
89
|
+
: [];
|
|
90
|
+
|
|
91
|
+
if (uris.length === 0) {
|
|
92
|
+
return invalid('invalid_redirect_uri', 'redirect_uris must list at least one URI');
|
|
93
|
+
}
|
|
94
|
+
for (const uri of uris) {
|
|
95
|
+
if (!isSafeRedirect(uri)) {
|
|
96
|
+
return invalid('invalid_redirect_uri', `"${uri}" is not an https or loopback URI`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const client: RegisteredClient = {
|
|
101
|
+
clientId: randomToken('llc'),
|
|
102
|
+
redirectUris: uris,
|
|
103
|
+
...(typeof input.client_name === 'string' ? { clientName: input.client_name } : {}),
|
|
104
|
+
createdAt: this.#now(),
|
|
105
|
+
};
|
|
106
|
+
await this.#options.store.registerClient(client);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
kind: 'json',
|
|
110
|
+
status: 201,
|
|
111
|
+
body: {
|
|
112
|
+
client_id: client.clientId,
|
|
113
|
+
redirect_uris: client.redirectUris,
|
|
114
|
+
token_endpoint_auth_method: 'none',
|
|
115
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
116
|
+
response_types: ['code'],
|
|
117
|
+
...(client.clientName ? { client_name: client.clientName } : {}),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The authorization request, before anyone has approved anything.
|
|
124
|
+
*
|
|
125
|
+
* Errors here are shown rather than redirected. Redirecting an error to a URI
|
|
126
|
+
* that failed validation is how an open redirector is built, so a bad
|
|
127
|
+
* `client_id` or `redirect_uri` ends at this endpoint and goes no further.
|
|
128
|
+
*/
|
|
129
|
+
async authorize(params: URLSearchParams): Promise<OAuthResult> {
|
|
130
|
+
if (params.get('response_type') !== 'code') {
|
|
131
|
+
return { kind: 'error', status: 400, message: 'Only response_type=code is supported.' };
|
|
132
|
+
}
|
|
133
|
+
if (params.get('code_challenge_method') !== 'S256') {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'error',
|
|
136
|
+
status: 400,
|
|
137
|
+
message: 'PKCE is required, with code_challenge_method=S256.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const clientId = params.get('client_id') ?? '';
|
|
142
|
+
const client = await this.#options.store.client(clientId);
|
|
143
|
+
if (!client) {
|
|
144
|
+
return { kind: 'error', status: 400, message: 'Unknown client. Register first.' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const redirectUri = params.get('redirect_uri') ?? client.redirectUris[0] ?? '';
|
|
148
|
+
if (!matchesRegistered(redirectUri, client.redirectUris)) {
|
|
149
|
+
return { kind: 'error', status: 400, message: 'redirect_uri does not match a registered one.' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const challenge = params.get('code_challenge') ?? '';
|
|
153
|
+
if (!challenge) {
|
|
154
|
+
return { kind: 'error', status: 400, message: 'code_challenge is required.' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
kind: 'consent',
|
|
159
|
+
retry: false,
|
|
160
|
+
...(client.clientName ? { clientName: client.clientName } : {}),
|
|
161
|
+
request: {
|
|
162
|
+
clientId,
|
|
163
|
+
redirectUri,
|
|
164
|
+
codeChallenge: challenge,
|
|
165
|
+
state: params.get('state') ?? undefined,
|
|
166
|
+
scope: params.get('scope') || MCP_SCOPE,
|
|
167
|
+
resource: params.get('resource') ?? undefined,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The owner approving, by presenting the endpoint token.
|
|
174
|
+
*
|
|
175
|
+
* A wrong token re-renders the form rather than redirecting an error back to
|
|
176
|
+
* the client: the client has no business being told whether the owner typed
|
|
177
|
+
* their token correctly, and a redirect would end the flow on the first typo.
|
|
178
|
+
*/
|
|
179
|
+
async approve(request: AuthorizeRequest, presented: string): Promise<OAuthResult> {
|
|
180
|
+
const registered = await this.#options.store.client(request.clientId);
|
|
181
|
+
|
|
182
|
+
if (!presented || !(await this.#options.verifyOwner(presented))) {
|
|
183
|
+
return {
|
|
184
|
+
kind: 'consent',
|
|
185
|
+
request,
|
|
186
|
+
retry: true,
|
|
187
|
+
...(registered?.clientName ? { clientName: registered.clientName } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const client = registered;
|
|
192
|
+
if (!client || !matchesRegistered(request.redirectUri, client.redirectUris)) {
|
|
193
|
+
return { kind: 'error', status: 400, message: 'This approval no longer matches a client.' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const code = randomToken('llx');
|
|
197
|
+
const record: AuthorizationCode = {
|
|
198
|
+
clientId: request.clientId,
|
|
199
|
+
redirectUri: request.redirectUri,
|
|
200
|
+
codeChallenge: request.codeChallenge,
|
|
201
|
+
scope: request.scope,
|
|
202
|
+
...(request.resource ? { resource: request.resource } : {}),
|
|
203
|
+
expiresAt: this.#now() + CODE_TTL_MS,
|
|
204
|
+
};
|
|
205
|
+
await this.#options.store.putCode(code, record);
|
|
206
|
+
|
|
207
|
+
const location = new URL(request.redirectUri);
|
|
208
|
+
location.searchParams.set('code', code);
|
|
209
|
+
if (request.state !== undefined) location.searchParams.set('state', request.state);
|
|
210
|
+
return { kind: 'redirect', location: location.toString() };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Both grants. Form-encoded in, JSON out, RFC 6749 error codes throughout. */
|
|
214
|
+
async token(form: URLSearchParams): Promise<OAuthResult> {
|
|
215
|
+
switch (form.get('grant_type')) {
|
|
216
|
+
case 'authorization_code':
|
|
217
|
+
return this.#exchangeCode(form);
|
|
218
|
+
case 'refresh_token':
|
|
219
|
+
return this.#refresh(form);
|
|
220
|
+
default:
|
|
221
|
+
return invalid('unsupported_grant_type', 'Use authorization_code or refresh_token.');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async #exchangeCode(form: URLSearchParams): Promise<OAuthResult> {
|
|
226
|
+
const record = await this.#options.store.takeCode(form.get('code') ?? '');
|
|
227
|
+
if (!record) return invalid('invalid_grant', 'That code is unknown, used, or expired.');
|
|
228
|
+
|
|
229
|
+
if (record.clientId !== form.get('client_id')) {
|
|
230
|
+
return invalid('invalid_grant', 'That code was issued to a different client.');
|
|
231
|
+
}
|
|
232
|
+
// Checked even though the code is already bound to it: a client that sends a
|
|
233
|
+
// different redirect_uri here than it started with is not the client that
|
|
234
|
+
// started, and the spec requires the comparison.
|
|
235
|
+
if (record.redirectUri !== form.get('redirect_uri')) {
|
|
236
|
+
return invalid('invalid_grant', 'redirect_uri does not match the authorization request.');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const verifier = form.get('code_verifier') ?? '';
|
|
240
|
+
if (!verifier || pkceChallengeFor(verifier) !== record.codeChallenge) {
|
|
241
|
+
return invalid('invalid_grant', 'code_verifier does not match the code_challenge.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return this.#issue(record.clientId, record.scope, randomToken('llr'));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async #refresh(form: URLSearchParams): Promise<OAuthResult> {
|
|
248
|
+
const presented = form.get('refresh_token') ?? '';
|
|
249
|
+
const record = await this.#options.store.token(presented);
|
|
250
|
+
|
|
251
|
+
if (!record || record.kind === 'access') {
|
|
252
|
+
return invalid('invalid_grant', 'That refresh token is unknown or expired.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A spent token presented again is the one signal that it has been copied.
|
|
256
|
+
// Rotation alone does not answer it: whoever refreshes first walks away
|
|
257
|
+
// with a live pair, and rejecting only the token in hand leaves that pair
|
|
258
|
+
// working while the other party — usually the real client — is locked out.
|
|
259
|
+
// So the whole chain goes. A client retrying a response it never saw and a
|
|
260
|
+
// thief replaying are indistinguishable from here, and re-authorising is
|
|
261
|
+
// the cheaper of the two mistakes.
|
|
262
|
+
if (record.kind === 'consumed') {
|
|
263
|
+
await this.#options.store.revokeFamily(record.family);
|
|
264
|
+
return invalid('invalid_grant', 'That refresh token has already been used.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (record.clientId !== form.get('client_id')) {
|
|
268
|
+
return invalid('invalid_grant', 'That refresh token was issued to a different client.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await this.#options.store.consumeToken(presented);
|
|
272
|
+
return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async #issue(
|
|
276
|
+
clientId: string,
|
|
277
|
+
scope: string,
|
|
278
|
+
refreshToken: string,
|
|
279
|
+
family = randomToken('llf'),
|
|
280
|
+
): Promise<OAuthResult> {
|
|
281
|
+
const accessToken = randomToken('lla');
|
|
282
|
+
const expiresIn = Math.floor(this.#options.accessTokenTtlMs / 1000);
|
|
283
|
+
|
|
284
|
+
await this.#options.store.putToken(accessToken, {
|
|
285
|
+
clientId,
|
|
286
|
+
kind: 'access',
|
|
287
|
+
scope,
|
|
288
|
+
family,
|
|
289
|
+
expiresAt: this.#now() + this.#options.accessTokenTtlMs,
|
|
290
|
+
});
|
|
291
|
+
await this.#options.store.putToken(refreshToken, {
|
|
292
|
+
clientId,
|
|
293
|
+
kind: 'refresh',
|
|
294
|
+
scope,
|
|
295
|
+
family,
|
|
296
|
+
expiresAt: this.#now() + REFRESH_TTL_MS,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
kind: 'json',
|
|
301
|
+
status: 200,
|
|
302
|
+
body: {
|
|
303
|
+
access_token: accessToken,
|
|
304
|
+
token_type: 'Bearer',
|
|
305
|
+
expires_in: expiresIn,
|
|
306
|
+
refresh_token: refreshToken,
|
|
307
|
+
scope,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `base64url(sha256(verifier))`, which is what S256 means. */
|
|
314
|
+
export function pkceChallengeFor(verifier: string): string {
|
|
315
|
+
return Buffer.from(
|
|
316
|
+
new Bun.CryptoHasher('sha256').update(verifier, 'utf8').digest(),
|
|
317
|
+
).toString('base64url');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function invalid(error: string, description: string): OAuthResult {
|
|
321
|
+
// RFC 6749 codes exactly. A client refreshing on a 401 branches on
|
|
322
|
+
// `invalid_grant` specifically; anything else and it retries forever or gives
|
|
323
|
+
// up without re-authorising.
|
|
324
|
+
return { kind: 'json', status: 400, body: { error, error_description: description } };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* https, or loopback for a native client.
|
|
329
|
+
*
|
|
330
|
+
* A native client cannot receive an https redirect, so RFC 8252 has it listen
|
|
331
|
+
* on a loopback port instead. Everything else is refused: a redirect to `http://`
|
|
332
|
+
* on a routable host puts an authorization code on the wire in clear text.
|
|
333
|
+
*/
|
|
334
|
+
function isSafeRedirect(uri: string): boolean {
|
|
335
|
+
let parsed: URL;
|
|
336
|
+
try {
|
|
337
|
+
parsed = new URL(uri);
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (parsed.protocol === 'https:') return true;
|
|
343
|
+
return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
347
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Exact match, except for the port of a loopback URI.
|
|
352
|
+
*
|
|
353
|
+
* RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
|
|
354
|
+
* native client binds an ephemeral one it cannot know at registration time.
|
|
355
|
+
* Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
|
|
356
|
+
* and then listens on whatever port it got, so the same allowance has to cover
|
|
357
|
+
* `localhost` or it never connects.
|
|
358
|
+
*/
|
|
359
|
+
export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
|
|
360
|
+
if (registered.includes(candidate)) return true;
|
|
361
|
+
|
|
362
|
+
let parsed: URL;
|
|
363
|
+
try {
|
|
364
|
+
parsed = new URL(candidate);
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (!isLoopbackHost(parsed.hostname)) return false;
|
|
369
|
+
|
|
370
|
+
return registered.some((uri) => {
|
|
371
|
+
try {
|
|
372
|
+
const other = new URL(uri);
|
|
373
|
+
return (
|
|
374
|
+
isLoopbackHost(other.hostname) &&
|
|
375
|
+
other.protocol === parsed.protocol &&
|
|
376
|
+
other.hostname === parsed.hostname &&
|
|
377
|
+
other.pathname === parsed.pathname
|
|
378
|
+
);
|
|
379
|
+
} catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export { hashToken };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { KeyValueStore } from '#stores/state';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an authorization server has to remember.
|
|
5
|
+
*
|
|
6
|
+
* Three kinds of short-lived record — registered clients, authorization codes,
|
|
7
|
+
* and issued tokens — over the `KeyValueStore` the database already exposes.
|
|
8
|
+
* No new table and no migration, because there are none: the state store is a
|
|
9
|
+
* namespaced key-value store and this is namespaced key-value data.
|
|
10
|
+
*
|
|
11
|
+
* It is genuinely runtime state, which is the rule for anything living here.
|
|
12
|
+
* Deleting a profile's database logs every connector out and they authorise
|
|
13
|
+
* again; nothing about *what should exist* is lost, because a client
|
|
14
|
+
* registration is a fact about a past conversation rather than a declaration.
|
|
15
|
+
*
|
|
16
|
+
* **Tokens are stored hashed.** A bucket listing, a backup, or anyone who can
|
|
17
|
+
* read the objects must not thereby hold a working credential — the same reasoning that makes
|
|
18
|
+
* the profile token a constant-time comparison against a hash rather than a
|
|
19
|
+
* string sitting in a row. Codes are hashed for the same reason and matter less,
|
|
20
|
+
* living about a minute.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const CLIENTS = 'oauth/clients';
|
|
24
|
+
const CODES = 'oauth/codes';
|
|
25
|
+
const TOKENS = 'oauth/tokens';
|
|
26
|
+
|
|
27
|
+
/** Far above any real number of connectors, and far below a problem. */
|
|
28
|
+
const MAX_CLIENTS = 200;
|
|
29
|
+
|
|
30
|
+
export interface RegisteredClient {
|
|
31
|
+
readonly clientId: string;
|
|
32
|
+
readonly redirectUris: readonly string[];
|
|
33
|
+
readonly clientName?: string | undefined;
|
|
34
|
+
readonly createdAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthorizationCode {
|
|
38
|
+
readonly clientId: string;
|
|
39
|
+
readonly redirectUri: string;
|
|
40
|
+
/** The S256 challenge. The verifier never leaves the client. */
|
|
41
|
+
readonly codeChallenge: string;
|
|
42
|
+
readonly scope: string;
|
|
43
|
+
readonly resource?: string | undefined;
|
|
44
|
+
readonly expiresAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `consumed` is a spent refresh token kept as a tombstone.
|
|
49
|
+
*
|
|
50
|
+
* Deleting one outright is what made replay undetectable: a token that is
|
|
51
|
+
* simply absent cannot be told apart from one that never existed, so the single
|
|
52
|
+
* signal that a refresh token has been copied was being discarded at the moment
|
|
53
|
+
* it arrived. A tombstone keeps the family id and nothing else useful, and it
|
|
54
|
+
* opens no more than a deleted row does — every check that admits a credential
|
|
55
|
+
* tests for `access` by name.
|
|
56
|
+
*/
|
|
57
|
+
export type TokenKind = 'access' | 'refresh' | 'consumed';
|
|
58
|
+
|
|
59
|
+
export interface IssuedToken {
|
|
60
|
+
readonly clientId: string;
|
|
61
|
+
readonly kind: TokenKind;
|
|
62
|
+
readonly scope: string;
|
|
63
|
+
readonly expiresAt: number;
|
|
64
|
+
/**
|
|
65
|
+
* Which refresh chain this belongs to.
|
|
66
|
+
*
|
|
67
|
+
* Rotation replaces one refresh token with the next, and a client that
|
|
68
|
+
* retries a request it never saw the answer to will present the previous one.
|
|
69
|
+
* Keeping the family lets a replayed token invalidate the whole chain rather
|
|
70
|
+
* than only itself — the standard response to a stolen refresh token, since
|
|
71
|
+
* the theft and the retry look identical from here.
|
|
72
|
+
*/
|
|
73
|
+
readonly family: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hashToken(value: string): string {
|
|
77
|
+
return new Bun.CryptoHasher('sha256').update(value, 'utf8').digest('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 32 random bytes, base64url. The only place a credential is minted here. */
|
|
81
|
+
export function randomToken(prefix: string): string {
|
|
82
|
+
return `${prefix}_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class OAuthStore {
|
|
86
|
+
readonly #state: KeyValueStore;
|
|
87
|
+
readonly #now: () => number;
|
|
88
|
+
|
|
89
|
+
constructor(state: KeyValueStore, now: () => number = Date.now) {
|
|
90
|
+
this.#state = state;
|
|
91
|
+
this.#now = now;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async registerClient(client: RegisteredClient): Promise<void> {
|
|
95
|
+
await this.#state.set(CLIENTS, client.clientId, JSON.stringify(client));
|
|
96
|
+
await this.#pruneClients();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Keep the client list bounded.
|
|
101
|
+
*
|
|
102
|
+
* Registration is deliberately open — it yields an identifier and nothing
|
|
103
|
+
* else, and requiring authentication for it would mean the pasted client id
|
|
104
|
+
* this mode exists to avoid. Open does mean an unauthenticated caller can
|
|
105
|
+
* write rows, so the list is capped and the oldest go first.
|
|
106
|
+
*
|
|
107
|
+
* A client holding a live token is never evicted, whatever its age: that is a
|
|
108
|
+
* connector someone is using, and dropping it would log them out to make room
|
|
109
|
+
* for a stranger. The cap is far above any real number of connectors, so in
|
|
110
|
+
* ordinary use this never fires.
|
|
111
|
+
*/
|
|
112
|
+
async #pruneClients(): Promise<void> {
|
|
113
|
+
const keys = await this.#state.keys(CLIENTS);
|
|
114
|
+
if (keys.length <= MAX_CLIENTS) return;
|
|
115
|
+
|
|
116
|
+
const active = new Set<string>();
|
|
117
|
+
for (const key of await this.#state.keys(TOKENS)) {
|
|
118
|
+
const token = await this.#read<IssuedToken>(TOKENS, key);
|
|
119
|
+
if (token) active.add(token.clientId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const evictable: RegisteredClient[] = [];
|
|
123
|
+
for (const key of keys) {
|
|
124
|
+
const client = await this.#read<RegisteredClient>(CLIENTS, key);
|
|
125
|
+
if (client && !active.has(client.clientId)) evictable.push(client);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
evictable.sort((a, b) => a.createdAt - b.createdAt);
|
|
129
|
+
for (const client of evictable.slice(0, keys.length - MAX_CLIENTS)) {
|
|
130
|
+
await this.#state.delete(CLIENTS, client.clientId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async client(clientId: string): Promise<RegisteredClient | null> {
|
|
135
|
+
return this.#read<RegisteredClient>(CLIENTS, clientId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async putCode(code: string, record: AuthorizationCode): Promise<void> {
|
|
139
|
+
await this.#state.set(CODES, hashToken(code), JSON.stringify(record));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read a code and consume it in the same step.
|
|
144
|
+
*
|
|
145
|
+
* Single-use is not an optimisation: a code that survives its exchange can be
|
|
146
|
+
* replayed by anyone who reached the redirect — a browser history entry, a
|
|
147
|
+
* proxy log, a referrer header — and PKCE only binds it to the client that
|
|
148
|
+
* started the flow, not to one use.
|
|
149
|
+
*/
|
|
150
|
+
async takeCode(code: string): Promise<AuthorizationCode | null> {
|
|
151
|
+
const key = hashToken(code);
|
|
152
|
+
const record = await this.#read<AuthorizationCode>(CODES, key);
|
|
153
|
+
await this.#state.delete(CODES, key);
|
|
154
|
+
return record && record.expiresAt > this.#now() ? record : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async putToken(token: string, record: IssuedToken): Promise<void> {
|
|
158
|
+
await this.#state.set(TOKENS, hashToken(token), JSON.stringify(record));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async token(token: string): Promise<IssuedToken | null> {
|
|
162
|
+
const key = hashToken(token);
|
|
163
|
+
const record = await this.#read<IssuedToken>(TOKENS, key);
|
|
164
|
+
if (!record) return null;
|
|
165
|
+
|
|
166
|
+
if (record.expiresAt <= this.#now()) {
|
|
167
|
+
// Nothing here expires on a timer — there is no sweeper and a single-user
|
|
168
|
+
// endpoint does not need one. Expiry is enforced on read, and the row is
|
|
169
|
+
// dropped when it is noticed so the namespace does not grow forever.
|
|
170
|
+
await this.#state.delete(TOKENS, key);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return record;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async revokeToken(token: string): Promise<void> {
|
|
177
|
+
await this.#state.delete(TOKENS, hashToken(token));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Spend a refresh token, keeping the tombstone that makes a replay visible. */
|
|
181
|
+
async consumeToken(token: string): Promise<void> {
|
|
182
|
+
const key = hashToken(token);
|
|
183
|
+
const record = await this.#read<IssuedToken>(TOKENS, key);
|
|
184
|
+
if (!record) return;
|
|
185
|
+
await this.#state.set(TOKENS, key, JSON.stringify({ ...record, kind: 'consumed' }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Drop every token in a refresh family.
|
|
190
|
+
*
|
|
191
|
+
* Called when a rotated-away refresh token is presented again, which is
|
|
192
|
+
* either a client retrying or a thief replaying. Both are answered the same
|
|
193
|
+
* way, because from here they are indistinguishable and the safe reading is
|
|
194
|
+
* the expensive one.
|
|
195
|
+
*/
|
|
196
|
+
async revokeFamily(family: string): Promise<void> {
|
|
197
|
+
for (const key of await this.#state.keys(TOKENS)) {
|
|
198
|
+
const record = await this.#read<IssuedToken>(TOKENS, key);
|
|
199
|
+
if (record?.family === family) await this.#state.delete(TOKENS, key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async #read<T>(namespace: string, key: string): Promise<T | null> {
|
|
204
|
+
const raw = await this.#state.get(namespace, key);
|
|
205
|
+
if (raw === null) return null;
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(raw) as T;
|
|
208
|
+
} catch {
|
|
209
|
+
// A row that will not parse is a row nothing can use. Treating it as
|
|
210
|
+
// absent fails closed; throwing would take the endpoint down over one
|
|
211
|
+
// corrupt record.
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|