@synoikia/core 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 +21 -0
- package/dist/app.d.ts +79 -0
- package/dist/app.js +155 -0
- package/dist/app.js.map +1 -0
- package/dist/approvals/links.d.ts +23 -0
- package/dist/approvals/links.js +49 -0
- package/dist/approvals/links.js.map +1 -0
- package/dist/approvals/service.d.ts +110 -0
- package/dist/approvals/service.js +182 -0
- package/dist/approvals/service.js.map +1 -0
- package/dist/audit-query.d.ts +52 -0
- package/dist/audit-query.js +116 -0
- package/dist/audit-query.js.map +1 -0
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +8 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/mcp-auth.d.ts +50 -0
- package/dist/auth/mcp-auth.js +160 -0
- package/dist/auth/mcp-auth.js.map +1 -0
- package/dist/auth/mcp-tokens.d.ts +57 -0
- package/dist/auth/mcp-tokens.js +143 -0
- package/dist/auth/mcp-tokens.js.map +1 -0
- package/dist/auth/oauth.d.ts +188 -0
- package/dist/auth/oauth.js +470 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/oidc.d.ts +110 -0
- package/dist/auth/oidc.js +221 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/auth/sessions.d.ts +34 -0
- package/dist/auth/sessions.js +83 -0
- package/dist/auth/sessions.js.map +1 -0
- package/dist/auth/throttle.d.ts +31 -0
- package/dist/auth/throttle.js +52 -0
- package/dist/auth/throttle.js.map +1 -0
- package/dist/auth/tokens.d.ts +7 -0
- package/dist/auth/tokens.js +40 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp.d.ts +14 -0
- package/dist/auth/totp.js +81 -0
- package/dist/auth/totp.js.map +1 -0
- package/dist/auth/users.d.ts +70 -0
- package/dist/auth/users.js +296 -0
- package/dist/auth/users.js.map +1 -0
- package/dist/catalog/groups.d.ts +102 -0
- package/dist/catalog/groups.js +363 -0
- package/dist/catalog/groups.js.map +1 -0
- package/dist/catalog/registry.d.ts +25 -0
- package/dist/catalog/registry.js +80 -0
- package/dist/catalog/registry.js.map +1 -0
- package/dist/catalog/rules.d.ts +128 -0
- package/dist/catalog/rules.js +178 -0
- package/dist/catalog/rules.js.map +1 -0
- package/dist/catalog/sync.d.ts +29 -0
- package/dist/catalog/sync.js +222 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +83 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/env.d.ts +40 -0
- package/dist/config/env.js +71 -0
- package/dist/config/env.js.map +1 -0
- package/dist/crypto/index.d.ts +28 -0
- package/dist/crypto/index.js +88 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/rotate.d.ts +15 -0
- package/dist/crypto/rotate.js +61 -0
- package/dist/crypto/rotate.js.map +1 -0
- package/dist/db/index.d.ts +17 -0
- package/dist/db/index.js +29 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +4389 -0
- package/dist/db/schema.js +372 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/endpoints/slug.d.ts +3 -0
- package/dist/endpoints/slug.js +7 -0
- package/dist/endpoints/slug.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +43 -0
- package/dist/events.js +16 -0
- package/dist/events.js.map +1 -0
- package/dist/gate/access.d.ts +47 -0
- package/dist/gate/access.js +48 -0
- package/dist/gate/access.js.map +1 -0
- package/dist/gate/attestation.d.ts +23 -0
- package/dist/gate/attestation.js +33 -0
- package/dist/gate/attestation.js.map +1 -0
- package/dist/gate/canonical.d.ts +3 -0
- package/dist/gate/canonical.js +11 -0
- package/dist/gate/canonical.js.map +1 -0
- package/dist/gate/match.d.ts +67 -0
- package/dist/gate/match.js +133 -0
- package/dist/gate/match.js.map +1 -0
- package/dist/gate/pipeline.d.ts +61 -0
- package/dist/gate/pipeline.js +284 -0
- package/dist/gate/pipeline.js.map +1 -0
- package/dist/gate/preapproval.d.ts +24 -0
- package/dist/gate/preapproval.js +48 -0
- package/dist/gate/preapproval.js.map +1 -0
- package/dist/gate/rate-limit.d.ts +35 -0
- package/dist/gate/rate-limit.js +82 -0
- package/dist/gate/rate-limit.js.map +1 -0
- package/dist/gate/redact.d.ts +23 -0
- package/dist/gate/redact.js +87 -0
- package/dist/gate/redact.js.map +1 -0
- package/dist/http/admin/auth.d.ts +33 -0
- package/dist/http/admin/auth.js +338 -0
- package/dist/http/admin/auth.js.map +1 -0
- package/dist/http/admin/instances.d.ts +5 -0
- package/dist/http/admin/instances.js +193 -0
- package/dist/http/admin/instances.js.map +1 -0
- package/dist/http/admin/system.d.ts +5 -0
- package/dist/http/admin/system.js +225 -0
- package/dist/http/admin/system.js.map +1 -0
- package/dist/http/admin-app.d.ts +9 -0
- package/dist/http/admin-app.js +51 -0
- package/dist/http/admin-app.js.map +1 -0
- package/dist/http/common.d.ts +32 -0
- package/dist/http/common.js +80 -0
- package/dist/http/common.js.map +1 -0
- package/dist/http/mcp/approval-routes.d.ts +11 -0
- package/dist/http/mcp/approval-routes.js +117 -0
- package/dist/http/mcp/approval-routes.js.map +1 -0
- package/dist/http/mcp/endpoint.d.ts +32 -0
- package/dist/http/mcp/endpoint.js +293 -0
- package/dist/http/mcp/endpoint.js.map +1 -0
- package/dist/http/mcp/oauth-routes.d.ts +29 -0
- package/dist/http/mcp/oauth-routes.js +465 -0
- package/dist/http/mcp/oauth-routes.js.map +1 -0
- package/dist/http/mcp/pages.d.ts +63 -0
- package/dist/http/mcp/pages.js +200 -0
- package/dist/http/mcp/pages.js.map +1 -0
- package/dist/http/mcp-app.d.ts +7 -0
- package/dist/http/mcp-app.js +30 -0
- package/dist/http/mcp-app.js.map +1 -0
- package/dist/instances/connection.d.ts +37 -0
- package/dist/instances/connection.js +109 -0
- package/dist/instances/connection.js.map +1 -0
- package/dist/instances/manager.d.ts +313 -0
- package/dist/instances/manager.js +600 -0
- package/dist/instances/manager.js.map +1 -0
- package/dist/instances/settings.d.ts +42 -0
- package/dist/instances/settings.js +57 -0
- package/dist/instances/settings.js.map +1 -0
- package/dist/lenient.d.ts +16 -0
- package/dist/lenient.js +51 -0
- package/dist/lenient.js.map +1 -0
- package/dist/lock.d.ts +28 -0
- package/dist/lock.js +77 -0
- package/dist/lock.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +38 -0
- package/dist/main.js.map +1 -0
- package/dist/maintenance.d.ts +10 -0
- package/dist/maintenance.js +48 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/notify/service.d.ts +149 -0
- package/dist/notify/service.js +358 -0
- package/dist/notify/service.js.map +1 -0
- package/dist/plugins/discovery.d.ts +39 -0
- package/dist/plugins/discovery.js +157 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/minisign.d.ts +25 -0
- package/dist/plugins/minisign.js +49 -0
- package/dist/plugins/minisign.js.map +1 -0
- package/dist/plugins/process.d.ts +55 -0
- package/dist/plugins/process.js +162 -0
- package/dist/plugins/process.js.map +1 -0
- package/dist/plugins/repos.d.ts +198 -0
- package/dist/plugins/repos.js +612 -0
- package/dist/plugins/repos.js.map +1 -0
- package/dist/plugins/supervisor.d.ts +50 -0
- package/dist/plugins/supervisor.js +125 -0
- package/dist/plugins/supervisor.js.map +1 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.js +248 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +53 -0
- package/dist/sandbox/index.js +197 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +36 -0
- package/dist/server.js.map +1 -0
- package/dist/settings.d.ts +43 -0
- package/dist/settings.js +99 -0
- package/dist/settings.js.map +1 -0
- package/dist/testing/index.d.ts +73 -0
- package/dist/testing/index.js +174 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/drizzle/0000_init.sql +334 -0
- package/drizzle/0001_totp_last_step.sql +1 -0
- package/drizzle/0002_access_levels.sql +16 -0
- package/drizzle/0003_drop_exclusion_flags.sql +2 -0
- package/drizzle/0004_grant_instance_ids.sql +1 -0
- package/drizzle/0005_rule_strict_miss.sql +1 -0
- package/drizzle/0006_attestation_waived.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +2318 -0
- package/drizzle/meta/0001_snapshot.json +2325 -0
- package/drizzle/meta/0002_snapshot.json +2348 -0
- package/drizzle/meta/0003_snapshot.json +2332 -0
- package/drizzle/meta/0004_snapshot.json +2339 -0
- package/drizzle/meta/0005_snapshot.json +2346 -0
- package/drizzle/meta/0006_snapshot.json +2354 -0
- package/drizzle/meta/_journal.json +55 -0
- package/package.json +58 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { AccessCeiling } from '../gate/access.js';
|
|
3
|
+
import type { Db } from '../db/index.js';
|
|
4
|
+
import { oauthClients } from '../db/schema.js';
|
|
5
|
+
import { ServiceError } from '../errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Built-in OAuth 2.1 authorization server for MCP clients (design §6.2): dynamic client
|
|
8
|
+
* registration (RFC 7591), authorization code + PKCE S256 only, resource indicators (RFC 8707)
|
|
9
|
+
* binding tokens to endpoints, short-lived opaque access tokens and rotating refresh tokens with
|
|
10
|
+
* reuse detection. Every secret is stored as a hash.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ACCESS_PREFIX = "syno_";
|
|
13
|
+
export declare const REFRESH_PREFIX = "synr_";
|
|
14
|
+
/** OAuth protocol errors carry RFC 6749 codes (`invalid_grant`, …) and map to 400/401. */
|
|
15
|
+
export declare class OAuthError extends ServiceError {
|
|
16
|
+
readonly httpStatus: 400 | 401;
|
|
17
|
+
constructor(code: string, message: string, httpStatus?: 400 | 401);
|
|
18
|
+
}
|
|
19
|
+
/** https, loopback http (native apps, RFC 8252) and private-use schemes; never javascript:/data:/file:. */
|
|
20
|
+
export declare function isAcceptableRedirectUri(raw: string): boolean;
|
|
21
|
+
export declare const RegistrationSchema: z.ZodObject<{
|
|
22
|
+
redirect_uris: z.ZodArray<z.ZodString>;
|
|
23
|
+
client_name: z.ZodOptional<z.ZodString>;
|
|
24
|
+
token_endpoint_auth_method: z.ZodDefault<z.ZodEnum<{
|
|
25
|
+
none: "none";
|
|
26
|
+
client_secret_post: "client_secret_post";
|
|
27
|
+
client_secret_basic: "client_secret_basic";
|
|
28
|
+
}>>;
|
|
29
|
+
grant_types: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
30
|
+
response_types: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export declare function verifyPkceS256(verifier: string, challenge: string): boolean;
|
|
33
|
+
export declare const canonicalResource: (url: string) => string;
|
|
34
|
+
type ClientRow = typeof oauthClients.$inferSelect;
|
|
35
|
+
export interface TokenPair {
|
|
36
|
+
access_token: string;
|
|
37
|
+
token_type: 'Bearer';
|
|
38
|
+
expires_in: number;
|
|
39
|
+
refresh_token: string;
|
|
40
|
+
scope: string;
|
|
41
|
+
}
|
|
42
|
+
export declare class OAuthService {
|
|
43
|
+
private readonly db;
|
|
44
|
+
private readonly now;
|
|
45
|
+
constructor(db: Db, now?: () => Date);
|
|
46
|
+
register(raw: unknown, via: 'dcr' | 'admin', actor?: {
|
|
47
|
+
userId?: string;
|
|
48
|
+
}): {
|
|
49
|
+
client_id_issued_at: number;
|
|
50
|
+
client_name: string;
|
|
51
|
+
redirect_uris: string[];
|
|
52
|
+
token_endpoint_auth_method: "none" | "client_secret_post" | "client_secret_basic";
|
|
53
|
+
grant_types: string[];
|
|
54
|
+
response_types: string[];
|
|
55
|
+
client_secret?: string | undefined;
|
|
56
|
+
client_secret_expires_at?: number | undefined;
|
|
57
|
+
client_id: string;
|
|
58
|
+
};
|
|
59
|
+
client(clientId: string): ClientRow | undefined;
|
|
60
|
+
listClients(): {
|
|
61
|
+
confidential: boolean;
|
|
62
|
+
id: string;
|
|
63
|
+
clientId: string;
|
|
64
|
+
name: string;
|
|
65
|
+
redirectUris: string[];
|
|
66
|
+
registeredVia: "admin" | "dcr";
|
|
67
|
+
createdAt: Date;
|
|
68
|
+
revokedAt: Date | null;
|
|
69
|
+
}[];
|
|
70
|
+
revokeClient(id: string, actor?: {
|
|
71
|
+
userId?: string;
|
|
72
|
+
}): void;
|
|
73
|
+
/** Confidential clients must present their secret; public clients must not claim to have one. */
|
|
74
|
+
authenticateClient(clientId: string | undefined, clientSecret: string | undefined): ClientRow;
|
|
75
|
+
listGrants(): {
|
|
76
|
+
client: {
|
|
77
|
+
id: string;
|
|
78
|
+
clientId: string;
|
|
79
|
+
name: string;
|
|
80
|
+
};
|
|
81
|
+
user: {
|
|
82
|
+
id: string;
|
|
83
|
+
username: string;
|
|
84
|
+
};
|
|
85
|
+
id: string;
|
|
86
|
+
clientId: string;
|
|
87
|
+
userId: string;
|
|
88
|
+
resources: string[];
|
|
89
|
+
instanceIds: string[] | null;
|
|
90
|
+
access: "read" | "write";
|
|
91
|
+
createdAt: Date;
|
|
92
|
+
revokedAt: Date | null;
|
|
93
|
+
}[];
|
|
94
|
+
revokeGrant(id: string, actor?: {
|
|
95
|
+
userId?: string;
|
|
96
|
+
}): void;
|
|
97
|
+
/**
|
|
98
|
+
* The user's password changed or they were disabled: every live grant (and its tokens) is revoked,
|
|
99
|
+
* so a stolen refresh token dies with the old password and re-enabling a user revives nothing.
|
|
100
|
+
*/
|
|
101
|
+
revokeUserGrants(userId: string, reason: 'password_changed' | 'user_disabled', actor?: {
|
|
102
|
+
userId?: string;
|
|
103
|
+
}): number;
|
|
104
|
+
/** After consent: records the grant and returns a single-use code bound to PKCE, redirect and resources. */
|
|
105
|
+
issueCode(input: {
|
|
106
|
+
client: ClientRow;
|
|
107
|
+
userId: string;
|
|
108
|
+
resources: string[];
|
|
109
|
+
/** The instance each resource names, in the same order. */
|
|
110
|
+
instanceIds: string[];
|
|
111
|
+
/** Chosen on the consent page; carried by every token issued from this grant, refreshes included. */
|
|
112
|
+
access: AccessCeiling;
|
|
113
|
+
codeChallenge: string;
|
|
114
|
+
redirectUri: string;
|
|
115
|
+
}): string;
|
|
116
|
+
private issueTokens;
|
|
117
|
+
private revokeFamily;
|
|
118
|
+
/** `authorization_code` grant. A replayed code revokes everything issued from it (OAuth 2.1 §4.1.2). */
|
|
119
|
+
exchangeCode(input: {
|
|
120
|
+
client: ClientRow;
|
|
121
|
+
code: string;
|
|
122
|
+
redirectUri: string;
|
|
123
|
+
codeVerifier: string;
|
|
124
|
+
resource?: string[];
|
|
125
|
+
ttl: {
|
|
126
|
+
accessMs: number;
|
|
127
|
+
refreshMs: number;
|
|
128
|
+
};
|
|
129
|
+
}): TokenPair;
|
|
130
|
+
/** `refresh_token` grant with rotation; presenting an already-rotated token revokes the family. */
|
|
131
|
+
refresh(input: {
|
|
132
|
+
client: ClientRow;
|
|
133
|
+
refreshToken: string;
|
|
134
|
+
resource?: string[];
|
|
135
|
+
ttl: {
|
|
136
|
+
accessMs: number;
|
|
137
|
+
refreshMs: number;
|
|
138
|
+
};
|
|
139
|
+
}): TokenPair;
|
|
140
|
+
/** RFC 7009. Unknown tokens are not an error. Revoking a refresh token revokes its family. */
|
|
141
|
+
revokeToken(token: string, client: ClientRow): void;
|
|
142
|
+
/**
|
|
143
|
+
* Validates an access token for an MCP request to one instance. The token's resources are mapped to
|
|
144
|
+
* the instance ids recorded at consent, so a slug that was renamed or re-used matches nothing new.
|
|
145
|
+
*/
|
|
146
|
+
verifyAccess(token: string, instanceId: string): {
|
|
147
|
+
inAudience: boolean;
|
|
148
|
+
client: {
|
|
149
|
+
id: string;
|
|
150
|
+
clientId: string;
|
|
151
|
+
clientSecretHash: string | null;
|
|
152
|
+
name: string;
|
|
153
|
+
redirectUris: string[];
|
|
154
|
+
registeredVia: "admin" | "dcr";
|
|
155
|
+
createdAt: Date;
|
|
156
|
+
revokedAt: Date | null;
|
|
157
|
+
};
|
|
158
|
+
user: {
|
|
159
|
+
id: string;
|
|
160
|
+
username: string;
|
|
161
|
+
passwordHash: string | null;
|
|
162
|
+
totpSecretEnc: Buffer<ArrayBufferLike> | null;
|
|
163
|
+
totpEnabled: boolean;
|
|
164
|
+
totpLastStep: number | null;
|
|
165
|
+
recoveryCodesHash: string[] | null;
|
|
166
|
+
oidcIssuer: string | null;
|
|
167
|
+
oidcSubject: string | null;
|
|
168
|
+
createdAt: Date;
|
|
169
|
+
lastLoginAt: Date | null;
|
|
170
|
+
disabled: boolean;
|
|
171
|
+
};
|
|
172
|
+
grantId: string;
|
|
173
|
+
access: "read" | "write";
|
|
174
|
+
} | null;
|
|
175
|
+
/** An instance was deleted: grants left with no other instance are revoked with their tokens. */
|
|
176
|
+
forgetInstance(instanceId: string): void;
|
|
177
|
+
/**
|
|
178
|
+
* One-time upgrade for grants from before `instance_ids`: map each resource URL's slug to the
|
|
179
|
+
* instance that has it now (what those grants effectively meant). Unmatched resources map to nothing.
|
|
180
|
+
*/
|
|
181
|
+
backfillInstanceIds(instances: {
|
|
182
|
+
id: string;
|
|
183
|
+
slug: string;
|
|
184
|
+
}[]): number;
|
|
185
|
+
purgeExpired(): number;
|
|
186
|
+
}
|
|
187
|
+
export declare function assertResourceList(raw: string[], allowed: Set<string>): string[];
|
|
188
|
+
export {};
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, eq, inArray, isNull, lt, or } from 'drizzle-orm';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { writeAudit } from '../audit.js';
|
|
5
|
+
import { oauthClients, oauthCodes, oauthGrants, oauthTokens, users } from '../db/schema.js';
|
|
6
|
+
import { NotFoundError, ServiceError, ValidationError } from '../errors.js';
|
|
7
|
+
import { randomToken, safeEqual, sha256 } from './tokens.js';
|
|
8
|
+
/**
|
|
9
|
+
* Built-in OAuth 2.1 authorization server for MCP clients (design §6.2): dynamic client
|
|
10
|
+
* registration (RFC 7591), authorization code + PKCE S256 only, resource indicators (RFC 8707)
|
|
11
|
+
* binding tokens to endpoints, short-lived opaque access tokens and rotating refresh tokens with
|
|
12
|
+
* reuse detection. Every secret is stored as a hash.
|
|
13
|
+
*/
|
|
14
|
+
export const ACCESS_PREFIX = 'syno_';
|
|
15
|
+
export const REFRESH_PREFIX = 'synr_';
|
|
16
|
+
const CODE_TTL_MS = 60_000;
|
|
17
|
+
/** OAuth protocol errors carry RFC 6749 codes (`invalid_grant`, …) and map to 400/401. */
|
|
18
|
+
export class OAuthError extends ServiceError {
|
|
19
|
+
httpStatus;
|
|
20
|
+
constructor(code, message, httpStatus = 400) {
|
|
21
|
+
super(400, code, message);
|
|
22
|
+
this.httpStatus = httpStatus;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** https, loopback http (native apps, RFC 8252) and private-use schemes; never javascript:/data:/file:. */
|
|
26
|
+
export function isAcceptableRedirectUri(raw) {
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (url.hash)
|
|
35
|
+
return false;
|
|
36
|
+
if (url.protocol === 'https:')
|
|
37
|
+
return true;
|
|
38
|
+
if (url.protocol === 'http:')
|
|
39
|
+
return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
40
|
+
if (['javascript:', 'data:', 'file:', 'vbscript:', 'blob:', 'about:'].includes(url.protocol))
|
|
41
|
+
return false;
|
|
42
|
+
return /^[a-z][a-z0-9+.-]*\.[a-z0-9+.-]+:$/.test(url.protocol) || /^[a-z][a-z0-9+.-]{2,}:$/.test(url.protocol);
|
|
43
|
+
}
|
|
44
|
+
export const RegistrationSchema = z.object({
|
|
45
|
+
redirect_uris: z.array(z.string()).min(1).max(10),
|
|
46
|
+
client_name: z.string().trim().min(1).max(100).optional(),
|
|
47
|
+
token_endpoint_auth_method: z.enum(['none', 'client_secret_post', 'client_secret_basic']).default('none'),
|
|
48
|
+
grant_types: z.array(z.string()).optional(),
|
|
49
|
+
response_types: z.array(z.string()).optional(),
|
|
50
|
+
});
|
|
51
|
+
export function verifyPkceS256(verifier, challenge) {
|
|
52
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
|
|
53
|
+
return false;
|
|
54
|
+
return safeEqual(createHash('sha256').update(verifier).digest('base64url'), challenge);
|
|
55
|
+
}
|
|
56
|
+
export const canonicalResource = (url) => url.replace(/\/+$/, '');
|
|
57
|
+
export class OAuthService {
|
|
58
|
+
db;
|
|
59
|
+
now;
|
|
60
|
+
constructor(db, now = () => new Date()) {
|
|
61
|
+
this.db = db;
|
|
62
|
+
this.now = now;
|
|
63
|
+
}
|
|
64
|
+
// ── clients ──
|
|
65
|
+
register(raw, via, actor = {}) {
|
|
66
|
+
const input = RegistrationSchema.safeParse(raw);
|
|
67
|
+
if (!input.success)
|
|
68
|
+
throw new OAuthError('invalid_client_metadata', input.error.issues.map((i) => i.message).join('; '));
|
|
69
|
+
const bad = input.data.redirect_uris.filter((u) => !isAcceptableRedirectUri(u));
|
|
70
|
+
if (bad.length)
|
|
71
|
+
throw new OAuthError('invalid_redirect_uri', `Unacceptable redirect URI(s): ${bad.join(', ')}`);
|
|
72
|
+
if (input.data.grant_types?.some((g) => !['authorization_code', 'refresh_token'].includes(g))) {
|
|
73
|
+
throw new OAuthError('invalid_client_metadata', 'Only authorization_code and refresh_token grants are supported');
|
|
74
|
+
}
|
|
75
|
+
const confidential = input.data.token_endpoint_auth_method !== 'none';
|
|
76
|
+
const clientId = `mcp_${randomToken(16)}`;
|
|
77
|
+
const clientSecret = confidential ? randomToken(32) : undefined;
|
|
78
|
+
const row = {
|
|
79
|
+
id: randomUUID(),
|
|
80
|
+
clientId,
|
|
81
|
+
clientSecretHash: clientSecret ? sha256(clientSecret) : null,
|
|
82
|
+
name: input.data.client_name ?? 'Unnamed MCP client',
|
|
83
|
+
redirectUris: input.data.redirect_uris,
|
|
84
|
+
registeredVia: via,
|
|
85
|
+
createdAt: this.now(),
|
|
86
|
+
};
|
|
87
|
+
this.db.transaction((tx) => {
|
|
88
|
+
tx.insert(oauthClients).values(row).run();
|
|
89
|
+
writeAudit(tx, {
|
|
90
|
+
kind: 'auth',
|
|
91
|
+
decision: 'oauth_client_registered',
|
|
92
|
+
actorKind: actor.userId ? 'user' : 'system',
|
|
93
|
+
actorId: actor.userId,
|
|
94
|
+
detail: { clientId, name: row.name, redirectUris: row.redirectUris, via },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
client_id: clientId,
|
|
99
|
+
...(clientSecret ? { client_secret: clientSecret, client_secret_expires_at: 0 } : {}),
|
|
100
|
+
client_id_issued_at: Math.floor(row.createdAt.getTime() / 1000),
|
|
101
|
+
client_name: row.name,
|
|
102
|
+
redirect_uris: row.redirectUris,
|
|
103
|
+
token_endpoint_auth_method: input.data.token_endpoint_auth_method,
|
|
104
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
105
|
+
response_types: ['code'],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
client(clientId) {
|
|
109
|
+
const row = this.db.select().from(oauthClients).where(eq(oauthClients.clientId, clientId)).get();
|
|
110
|
+
return row && !row.revokedAt ? row : undefined;
|
|
111
|
+
}
|
|
112
|
+
listClients() {
|
|
113
|
+
return this.db
|
|
114
|
+
.select()
|
|
115
|
+
.from(oauthClients)
|
|
116
|
+
.orderBy(asc(oauthClients.createdAt))
|
|
117
|
+
.all()
|
|
118
|
+
.map(({ clientSecretHash, ...c }) => ({ ...c, confidential: !!clientSecretHash }));
|
|
119
|
+
}
|
|
120
|
+
revokeClient(id, actor = {}) {
|
|
121
|
+
const row = this.db.select().from(oauthClients).where(eq(oauthClients.id, id)).get();
|
|
122
|
+
if (!row)
|
|
123
|
+
throw new NotFoundError('client_not_found', 'No such client');
|
|
124
|
+
const now = this.now();
|
|
125
|
+
this.db.transaction((tx) => {
|
|
126
|
+
tx.update(oauthClients).set({ revokedAt: now }).where(eq(oauthClients.id, id)).run();
|
|
127
|
+
const grantIds = tx
|
|
128
|
+
.select({ id: oauthGrants.id })
|
|
129
|
+
.from(oauthGrants)
|
|
130
|
+
.where(eq(oauthGrants.clientId, id))
|
|
131
|
+
.all()
|
|
132
|
+
.map((g) => g.id);
|
|
133
|
+
if (grantIds.length) {
|
|
134
|
+
tx.update(oauthGrants).set({ revokedAt: now }).where(inArray(oauthGrants.id, grantIds)).run();
|
|
135
|
+
tx.update(oauthTokens).set({ revokedAt: now }).where(inArray(oauthTokens.grantId, grantIds)).run();
|
|
136
|
+
}
|
|
137
|
+
writeAudit(tx, {
|
|
138
|
+
kind: 'auth',
|
|
139
|
+
decision: 'oauth_client_revoked',
|
|
140
|
+
actorKind: 'user',
|
|
141
|
+
actorId: actor.userId,
|
|
142
|
+
detail: { clientId: row.clientId, name: row.name },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Confidential clients must present their secret; public clients must not claim to have one. */
|
|
147
|
+
authenticateClient(clientId, clientSecret) {
|
|
148
|
+
const client = clientId ? this.client(clientId) : undefined;
|
|
149
|
+
if (!client)
|
|
150
|
+
throw new OAuthError('invalid_client', 'Unknown client', 401);
|
|
151
|
+
if (client.clientSecretHash) {
|
|
152
|
+
if (!clientSecret || !safeEqual(sha256(clientSecret), client.clientSecretHash))
|
|
153
|
+
throw new OAuthError('invalid_client', 'Client authentication failed', 401);
|
|
154
|
+
}
|
|
155
|
+
return client;
|
|
156
|
+
}
|
|
157
|
+
// ── grants & codes ──
|
|
158
|
+
listGrants() {
|
|
159
|
+
return this.db
|
|
160
|
+
.select({ grant: oauthGrants, client: oauthClients, user: users })
|
|
161
|
+
.from(oauthGrants)
|
|
162
|
+
.innerJoin(oauthClients, eq(oauthGrants.clientId, oauthClients.id))
|
|
163
|
+
.innerJoin(users, eq(oauthGrants.userId, users.id))
|
|
164
|
+
.orderBy(asc(oauthGrants.createdAt))
|
|
165
|
+
.all()
|
|
166
|
+
.map(({ grant, client, user }) => ({
|
|
167
|
+
...grant,
|
|
168
|
+
client: { id: client.id, clientId: client.clientId, name: client.name },
|
|
169
|
+
user: { id: user.id, username: user.username },
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
revokeGrant(id, actor = {}) {
|
|
173
|
+
const now = this.now();
|
|
174
|
+
this.db.transaction((tx) => {
|
|
175
|
+
const changed = tx.update(oauthGrants).set({ revokedAt: now }).where(eq(oauthGrants.id, id)).run().changes;
|
|
176
|
+
if (!changed)
|
|
177
|
+
throw new NotFoundError('grant_not_found', 'No such grant');
|
|
178
|
+
tx.update(oauthTokens).set({ revokedAt: now }).where(eq(oauthTokens.grantId, id)).run();
|
|
179
|
+
writeAudit(tx, {
|
|
180
|
+
kind: 'auth',
|
|
181
|
+
decision: 'oauth_grant_revoked',
|
|
182
|
+
actorKind: 'user',
|
|
183
|
+
actorId: actor.userId,
|
|
184
|
+
detail: { grantId: id },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The user's password changed or they were disabled: every live grant (and its tokens) is revoked,
|
|
190
|
+
* so a stolen refresh token dies with the old password and re-enabling a user revives nothing.
|
|
191
|
+
*/
|
|
192
|
+
revokeUserGrants(userId, reason, actor = {}) {
|
|
193
|
+
const now = this.now();
|
|
194
|
+
return this.db.transaction((tx) => {
|
|
195
|
+
const live = tx
|
|
196
|
+
.select({ id: oauthGrants.id })
|
|
197
|
+
.from(oauthGrants)
|
|
198
|
+
.where(and(eq(oauthGrants.userId, userId), isNull(oauthGrants.revokedAt)))
|
|
199
|
+
.all()
|
|
200
|
+
.map((g) => g.id);
|
|
201
|
+
if (!live.length)
|
|
202
|
+
return 0;
|
|
203
|
+
tx.update(oauthGrants).set({ revokedAt: now }).where(inArray(oauthGrants.id, live)).run();
|
|
204
|
+
tx.update(oauthTokens).set({ revokedAt: now }).where(inArray(oauthTokens.grantId, live)).run();
|
|
205
|
+
writeAudit(tx, {
|
|
206
|
+
kind: 'auth',
|
|
207
|
+
decision: 'oauth_grants_revoked',
|
|
208
|
+
actorKind: actor.userId ? 'user' : 'system',
|
|
209
|
+
actorId: actor.userId,
|
|
210
|
+
detail: { userId, reason, grants: live.length },
|
|
211
|
+
});
|
|
212
|
+
return live.length;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** After consent: records the grant and returns a single-use code bound to PKCE, redirect and resources. */
|
|
216
|
+
issueCode(input) {
|
|
217
|
+
const code = randomToken(32);
|
|
218
|
+
const now = this.now();
|
|
219
|
+
this.db.transaction((tx) => {
|
|
220
|
+
const grantId = randomUUID();
|
|
221
|
+
tx.insert(oauthGrants)
|
|
222
|
+
.values({
|
|
223
|
+
id: grantId,
|
|
224
|
+
clientId: input.client.id,
|
|
225
|
+
userId: input.userId,
|
|
226
|
+
resources: input.resources,
|
|
227
|
+
instanceIds: input.instanceIds,
|
|
228
|
+
access: input.access,
|
|
229
|
+
createdAt: now,
|
|
230
|
+
})
|
|
231
|
+
.run();
|
|
232
|
+
tx.insert(oauthCodes)
|
|
233
|
+
.values({
|
|
234
|
+
codeHash: sha256(code),
|
|
235
|
+
grantId,
|
|
236
|
+
codeChallenge: input.codeChallenge,
|
|
237
|
+
redirectUri: input.redirectUri,
|
|
238
|
+
resources: input.resources,
|
|
239
|
+
expiresAt: new Date(now.getTime() + CODE_TTL_MS),
|
|
240
|
+
})
|
|
241
|
+
.run();
|
|
242
|
+
writeAudit(tx, {
|
|
243
|
+
kind: 'auth',
|
|
244
|
+
decision: 'oauth_consent_granted',
|
|
245
|
+
actorKind: 'user',
|
|
246
|
+
actorId: input.userId,
|
|
247
|
+
detail: {
|
|
248
|
+
clientId: input.client.clientId,
|
|
249
|
+
name: input.client.name,
|
|
250
|
+
resources: input.resources,
|
|
251
|
+
access: input.access,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return code;
|
|
256
|
+
}
|
|
257
|
+
issueTokens(tx, grantId, resources, familyId, ttl) {
|
|
258
|
+
const now = this.now().getTime();
|
|
259
|
+
const access = `${ACCESS_PREFIX}${randomToken(32)}`;
|
|
260
|
+
const refresh = `${REFRESH_PREFIX}${randomToken(32)}`;
|
|
261
|
+
tx.insert(oauthTokens)
|
|
262
|
+
.values([
|
|
263
|
+
{
|
|
264
|
+
tokenHash: sha256(access),
|
|
265
|
+
grantId,
|
|
266
|
+
kind: 'access',
|
|
267
|
+
familyId,
|
|
268
|
+
resources,
|
|
269
|
+
expiresAt: new Date(now + ttl.accessMs),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
tokenHash: sha256(refresh),
|
|
273
|
+
grantId,
|
|
274
|
+
kind: 'refresh',
|
|
275
|
+
familyId,
|
|
276
|
+
resources,
|
|
277
|
+
expiresAt: new Date(now + ttl.refreshMs),
|
|
278
|
+
},
|
|
279
|
+
])
|
|
280
|
+
.run();
|
|
281
|
+
return {
|
|
282
|
+
access_token: access,
|
|
283
|
+
token_type: 'Bearer',
|
|
284
|
+
expires_in: Math.floor(ttl.accessMs / 1000),
|
|
285
|
+
refresh_token: refresh,
|
|
286
|
+
scope: 'mcp',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
revokeFamily(tx, familyId) {
|
|
290
|
+
tx.update(oauthTokens)
|
|
291
|
+
.set({ revokedAt: this.now() })
|
|
292
|
+
.where(and(eq(oauthTokens.familyId, familyId), isNull(oauthTokens.revokedAt)))
|
|
293
|
+
.run();
|
|
294
|
+
}
|
|
295
|
+
/** `authorization_code` grant. A replayed code revokes everything issued from it (OAuth 2.1 §4.1.2). */
|
|
296
|
+
exchangeCode(input) {
|
|
297
|
+
// Security-relevant state changes (marking the code used, revoking on replay) must commit even
|
|
298
|
+
// when the request fails, so failures are returned from the transaction and thrown afterwards.
|
|
299
|
+
const outcome = this.db.transaction((tx) => {
|
|
300
|
+
const row = tx
|
|
301
|
+
.select()
|
|
302
|
+
.from(oauthCodes)
|
|
303
|
+
.where(eq(oauthCodes.codeHash, sha256(input.code)))
|
|
304
|
+
.get();
|
|
305
|
+
const grant = row && tx.select().from(oauthGrants).where(eq(oauthGrants.id, row.grantId)).get();
|
|
306
|
+
if (!row || !grant || grant.clientId !== input.client.id)
|
|
307
|
+
return new OAuthError('invalid_grant', 'Invalid authorization code');
|
|
308
|
+
if (row.usedAt) {
|
|
309
|
+
tx.update(oauthTokens).set({ revokedAt: this.now() }).where(eq(oauthTokens.grantId, grant.id)).run();
|
|
310
|
+
tx.update(oauthGrants).set({ revokedAt: this.now() }).where(eq(oauthGrants.id, grant.id)).run();
|
|
311
|
+
writeAudit(tx, {
|
|
312
|
+
kind: 'auth',
|
|
313
|
+
decision: 'oauth_code_replayed',
|
|
314
|
+
actorKind: 'system',
|
|
315
|
+
detail: { clientId: input.client.clientId },
|
|
316
|
+
});
|
|
317
|
+
return new OAuthError('invalid_grant', 'Authorization code already used');
|
|
318
|
+
}
|
|
319
|
+
tx.update(oauthCodes).set({ usedAt: this.now() }).where(eq(oauthCodes.codeHash, row.codeHash)).run();
|
|
320
|
+
if (row.expiresAt.getTime() <= this.now().getTime() || grant.revokedAt)
|
|
321
|
+
return new OAuthError('invalid_grant', 'Authorization code expired');
|
|
322
|
+
if (row.redirectUri !== input.redirectUri)
|
|
323
|
+
return new OAuthError('invalid_grant', 'redirect_uri does not match');
|
|
324
|
+
if (!verifyPkceS256(input.codeVerifier, row.codeChallenge))
|
|
325
|
+
return new OAuthError('invalid_grant', 'PKCE verification failed');
|
|
326
|
+
const resources = input.resource?.length ? input.resource.map(canonicalResource) : row.resources;
|
|
327
|
+
if (!resources.every((r) => row.resources.includes(r)))
|
|
328
|
+
return new OAuthError('invalid_target', 'Requested resource was not granted');
|
|
329
|
+
return this.issueTokens(tx, grant.id, resources, randomUUID(), input.ttl);
|
|
330
|
+
});
|
|
331
|
+
if (outcome instanceof OAuthError)
|
|
332
|
+
throw outcome;
|
|
333
|
+
return outcome;
|
|
334
|
+
}
|
|
335
|
+
/** `refresh_token` grant with rotation; presenting an already-rotated token revokes the family. */
|
|
336
|
+
refresh(input) {
|
|
337
|
+
const outcome = this.db.transaction((tx) => {
|
|
338
|
+
const row = tx
|
|
339
|
+
.select()
|
|
340
|
+
.from(oauthTokens)
|
|
341
|
+
.where(and(eq(oauthTokens.tokenHash, sha256(input.refreshToken)), eq(oauthTokens.kind, 'refresh')))
|
|
342
|
+
.get();
|
|
343
|
+
const grant = row && tx.select().from(oauthGrants).where(eq(oauthGrants.id, row.grantId)).get();
|
|
344
|
+
if (!row || !grant || grant.clientId !== input.client.id)
|
|
345
|
+
return new OAuthError('invalid_grant', 'Invalid refresh token');
|
|
346
|
+
const owner = tx.select({ disabled: users.disabled }).from(users).where(eq(users.id, grant.userId)).get();
|
|
347
|
+
if (!owner || owner.disabled)
|
|
348
|
+
return new OAuthError('invalid_grant', 'The user of this grant is disabled');
|
|
349
|
+
if (row.revokedAt) {
|
|
350
|
+
this.revokeFamily(tx, row.familyId);
|
|
351
|
+
writeAudit(tx, {
|
|
352
|
+
kind: 'auth',
|
|
353
|
+
decision: 'oauth_refresh_reuse',
|
|
354
|
+
actorKind: 'system',
|
|
355
|
+
detail: { clientId: input.client.clientId, familyId: row.familyId },
|
|
356
|
+
});
|
|
357
|
+
return new OAuthError('invalid_grant', 'Refresh token was already used');
|
|
358
|
+
}
|
|
359
|
+
if (grant.revokedAt || row.expiresAt.getTime() <= this.now().getTime())
|
|
360
|
+
return new OAuthError('invalid_grant', 'Refresh token expired or revoked');
|
|
361
|
+
const resources = input.resource?.length ? input.resource.map(canonicalResource) : row.resources;
|
|
362
|
+
if (!resources.every((r) => grant.resources.includes(r)))
|
|
363
|
+
return new OAuthError('invalid_target', 'Requested resource was not granted');
|
|
364
|
+
tx.update(oauthTokens).set({ revokedAt: this.now() }).where(eq(oauthTokens.tokenHash, row.tokenHash)).run();
|
|
365
|
+
return this.issueTokens(tx, grant.id, resources, row.familyId, input.ttl);
|
|
366
|
+
});
|
|
367
|
+
if (outcome instanceof OAuthError)
|
|
368
|
+
throw outcome;
|
|
369
|
+
return outcome;
|
|
370
|
+
}
|
|
371
|
+
/** RFC 7009. Unknown tokens are not an error. Revoking a refresh token revokes its family. */
|
|
372
|
+
revokeToken(token, client) {
|
|
373
|
+
this.db.transaction((tx) => {
|
|
374
|
+
const row = tx
|
|
375
|
+
.select()
|
|
376
|
+
.from(oauthTokens)
|
|
377
|
+
.where(eq(oauthTokens.tokenHash, sha256(token)))
|
|
378
|
+
.get();
|
|
379
|
+
const grant = row && tx.select().from(oauthGrants).where(eq(oauthGrants.id, row.grantId)).get();
|
|
380
|
+
if (!row || grant?.clientId !== client.id)
|
|
381
|
+
return;
|
|
382
|
+
if (row.kind === 'refresh')
|
|
383
|
+
this.revokeFamily(tx, row.familyId);
|
|
384
|
+
else
|
|
385
|
+
tx.update(oauthTokens).set({ revokedAt: this.now() }).where(eq(oauthTokens.tokenHash, row.tokenHash)).run();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Validates an access token for an MCP request to one instance. The token's resources are mapped to
|
|
390
|
+
* the instance ids recorded at consent, so a slug that was renamed or re-used matches nothing new.
|
|
391
|
+
*/
|
|
392
|
+
verifyAccess(token, instanceId) {
|
|
393
|
+
if (!token.startsWith(ACCESS_PREFIX))
|
|
394
|
+
return null;
|
|
395
|
+
const found = this.db
|
|
396
|
+
.select({ token: oauthTokens, grant: oauthGrants, client: oauthClients, user: users })
|
|
397
|
+
.from(oauthTokens)
|
|
398
|
+
.innerJoin(oauthGrants, eq(oauthTokens.grantId, oauthGrants.id))
|
|
399
|
+
.innerJoin(oauthClients, eq(oauthGrants.clientId, oauthClients.id))
|
|
400
|
+
.innerJoin(users, eq(oauthGrants.userId, users.id))
|
|
401
|
+
.where(and(eq(oauthTokens.tokenHash, sha256(token)), eq(oauthTokens.kind, 'access')))
|
|
402
|
+
.get();
|
|
403
|
+
if (!found)
|
|
404
|
+
return null;
|
|
405
|
+
const { token: t, grant, client, user } = found;
|
|
406
|
+
if (t.revokedAt || grant.revokedAt || client.revokedAt || user.disabled)
|
|
407
|
+
return null;
|
|
408
|
+
if (t.expiresAt.getTime() <= this.now().getTime())
|
|
409
|
+
return null;
|
|
410
|
+
const ids = grant.instanceIds ?? [];
|
|
411
|
+
const granted = t.resources.map((r) => ids[grant.resources.indexOf(r)]).filter((id) => id !== undefined);
|
|
412
|
+
return {
|
|
413
|
+
inAudience: granted.includes(instanceId),
|
|
414
|
+
client,
|
|
415
|
+
user,
|
|
416
|
+
grantId: grant.id,
|
|
417
|
+
access: grant.access,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** An instance was deleted: grants left with no other instance are revoked with their tokens. */
|
|
421
|
+
forgetInstance(instanceId) {
|
|
422
|
+
const now = this.now();
|
|
423
|
+
this.db.transaction((tx) => {
|
|
424
|
+
for (const g of tx.select().from(oauthGrants).where(isNull(oauthGrants.revokedAt)).all()) {
|
|
425
|
+
if (!g.instanceIds?.includes(instanceId))
|
|
426
|
+
continue;
|
|
427
|
+
if (g.instanceIds.some((id) => id !== instanceId))
|
|
428
|
+
continue;
|
|
429
|
+
tx.update(oauthGrants).set({ revokedAt: now }).where(eq(oauthGrants.id, g.id)).run();
|
|
430
|
+
tx.update(oauthTokens).set({ revokedAt: now }).where(eq(oauthTokens.grantId, g.id)).run();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* One-time upgrade for grants from before `instance_ids`: map each resource URL's slug to the
|
|
436
|
+
* instance that has it now (what those grants effectively meant). Unmatched resources map to nothing.
|
|
437
|
+
*/
|
|
438
|
+
backfillInstanceIds(instances) {
|
|
439
|
+
const bySlug = new Map(instances.map((i) => [i.slug, i.id]));
|
|
440
|
+
let n = 0;
|
|
441
|
+
for (const g of this.db.select().from(oauthGrants).where(isNull(oauthGrants.instanceIds)).all()) {
|
|
442
|
+
// The slug is the last path segment: PUBLIC_MCP_URL may carry a path (`https://host/mcp/<slug>`).
|
|
443
|
+
const slugOf = (r) => new URL(r).pathname.split('/').filter(Boolean).at(-1) ?? '';
|
|
444
|
+
const ids = g.resources.map((r) => bySlug.get(slugOf(r)) ?? '');
|
|
445
|
+
this.db.update(oauthGrants).set({ instanceIds: ids }).where(eq(oauthGrants.id, g.id)).run();
|
|
446
|
+
n++;
|
|
447
|
+
}
|
|
448
|
+
return n;
|
|
449
|
+
}
|
|
450
|
+
purgeExpired() {
|
|
451
|
+
const now = this.now();
|
|
452
|
+
const a = this.db
|
|
453
|
+
.delete(oauthCodes)
|
|
454
|
+
.where(or(lt(oauthCodes.expiresAt, new Date(now.getTime() - 3600_000))))
|
|
455
|
+
.run().changes;
|
|
456
|
+
const b = this.db
|
|
457
|
+
.delete(oauthTokens)
|
|
458
|
+
.where(lt(oauthTokens.expiresAt, new Date(now.getTime() - 24 * 3600_000)))
|
|
459
|
+
.run().changes;
|
|
460
|
+
return a + b;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
export function assertResourceList(raw, allowed) {
|
|
464
|
+
const out = [...new Set(raw.map(canonicalResource))];
|
|
465
|
+
const unknown = out.filter((r) => !allowed.has(r));
|
|
466
|
+
if (unknown.length)
|
|
467
|
+
throw new ValidationError('invalid_target', `Unknown resource(s): ${unknown.join(', ')}`);
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
//# sourceMappingURL=oauth.js.map
|