@softure-ai/mcp-access 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +146 -21
- package/adoption/move-app-tables.sql +100 -0
- package/dist/contract.d.ts +13 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +58 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -5
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +28 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +28 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +56 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +28 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +3 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +23 -2
- package/dist/next/actions.js.map +1 -1
- package/dist/next/context.d.ts +7 -1
- package/dist/next/context.d.ts.map +1 -1
- package/dist/next/context.js +12 -10
- package/dist/next/context.js.map +1 -1
- package/dist/next/index.d.ts +4 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +5 -2
- package/dist/next/index.js.map +1 -1
- package/dist/next/oauth-page.d.ts +7 -0
- package/dist/next/oauth-page.d.ts.map +1 -0
- package/dist/next/oauth-page.js +36 -0
- package/dist/next/oauth-page.js.map +1 -0
- package/dist/next/oauth-routes.d.ts +19 -0
- package/dist/next/oauth-routes.d.ts.map +1 -0
- package/dist/next/oauth-routes.js +53 -0
- package/dist/next/oauth-routes.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +13 -2
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +40 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +94 -1
- package/dist/options.js.map +1 -1
- package/dist/origins.d.ts +27 -0
- package/dist/origins.d.ts.map +1 -0
- package/dist/origins.js +32 -0
- package/dist/origins.js.map +1 -0
- package/dist/schema.d.ts +486 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +45 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/context.d.ts +4 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +13 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/endpoint.d.ts +2 -4
- package/dist/server/endpoint.d.ts.map +1 -1
- package/dist/server/endpoint.js +14 -9
- package/dist/server/endpoint.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +5 -3
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/oauth-http.d.ts +127 -0
- package/dist/server/oauth-http.d.ts.map +1 -0
- package/dist/server/oauth-http.js +397 -0
- package/dist/server/oauth-http.js.map +1 -0
- package/dist/server/oauth-validation.d.ts +28 -0
- package/dist/server/oauth-validation.d.ts.map +1 -0
- package/dist/server/oauth-validation.js +88 -0
- package/dist/server/oauth-validation.js.map +1 -0
- package/dist/server/oauth.d.ts +106 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +235 -0
- package/dist/server/oauth.js.map +1 -0
- package/dist/server/options.d.ts +11 -2
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +11 -4
- package/dist/server/options.js.map +1 -1
- package/dist/server/origins.d.ts +8 -0
- package/dist/server/origins.d.ts.map +1 -0
- package/dist/server/origins.js +15 -0
- package/dist/server/origins.js.map +1 -0
- package/dist/server/pkce.d.ts +7 -0
- package/dist/server/pkce.d.ts.map +1 -0
- package/dist/server/pkce.js +24 -0
- package/dist/server/pkce.js.map +1 -0
- package/dist/server/privacy.d.ts +7 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +19 -9
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/scopes.d.ts +5 -0
- package/dist/server/scopes.d.ts.map +1 -0
- package/dist/server/scopes.js +6 -0
- package/dist/server/scopes.js.map +1 -0
- package/dist/server/tokens.d.ts +18 -6
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +60 -25
- package/dist/server/tokens.js.map +1 -1
- package/dist/ui/consent-form.d.ts +31 -0
- package/dist/ui/consent-form.d.ts.map +1 -0
- package/dist/ui/consent-form.js +24 -0
- package/dist/ui/consent-form.js.map +1 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +16 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +6 -2
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0002_create_oauth_grants.sql +68 -0
- package/module.json +18 -4
- package/package.json +2 -1
- package/src/contract.ts +16 -1
- package/src/index.ts +33 -4
- package/src/messages/en.ts +28 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +23 -3
- package/src/next/context.ts +15 -8
- package/src/next/index.ts +12 -2
- package/src/next/oauth-page.tsx +68 -0
- package/src/next/oauth-routes.ts +73 -0
- package/src/next/pages.tsx +16 -3
- package/src/options.ts +107 -1
- package/src/origins.ts +48 -0
- package/src/schema.ts +51 -3
- package/src/server/context.ts +14 -0
- package/src/server/endpoint.ts +14 -9
- package/src/server/health.ts +5 -3
- package/src/server/index.ts +56 -0
- package/src/server/oauth-http.ts +480 -0
- package/src/server/oauth-validation.ts +106 -0
- package/src/server/oauth.ts +315 -0
- package/src/server/options.ts +20 -4
- package/src/server/origins.ts +19 -0
- package/src/server/pkce.ts +27 -0
- package/src/server/privacy.ts +26 -9
- package/src/server/scopes.ts +6 -0
- package/src/server/tokens.ts +62 -25
- package/src/ui/consent-form.tsx +103 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/token-manager.tsx +79 -3
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The OAuth 2.1 authorization server's data layer (an adopting app's OAuth code, generalised onto
|
|
2
|
+
// the module context). Every secret (code, refresh token, client secret) leaves this file once and
|
|
3
|
+
// is stored as its sha256; expiry sits in the queries. An access token issued here is a row of
|
|
4
|
+
// `access_tokens` with its grant, so the endpoint verifies it like any other token.
|
|
5
|
+
import { timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
7
|
+
import { and, desc, eq, gt, isNotNull, isNull, lt, lte, notExists } from "drizzle-orm";
|
|
8
|
+
import type { OAuthGrantView } from "../contract.js";
|
|
9
|
+
import { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants, type OAuthClientRow, type OAuthTokenEndpointAuthMethod } from "../schema.js";
|
|
10
|
+
import { getMcpAccessOptions } from "./options.js";
|
|
11
|
+
import { isPkceVerifierValid } from "./pkce.js";
|
|
12
|
+
import { ACCESS_TOKEN_PREFIX, createSecret, hashAccessToken, type McpAccessContext } from "./tokens.js";
|
|
13
|
+
|
|
14
|
+
/** Refresh tokens: for people and secret scanners, like the access token prefix. */
|
|
15
|
+
export const REFRESH_TOKEN_PREFIX = "sftmcr_";
|
|
16
|
+
/** Authorization codes. */
|
|
17
|
+
export const CONSENT_CODE_PREFIX = "sftmca_";
|
|
18
|
+
/** Client secrets of confidential clients. */
|
|
19
|
+
export const CLIENT_SECRET_PREFIX = "sftmcs_";
|
|
20
|
+
/** Client ids are public (RFC 6749 §2.2); the prefix only helps a person reading a log. */
|
|
21
|
+
const CLIENT_ID_PREFIX = "sftmc_";
|
|
22
|
+
|
|
23
|
+
/** Codes, refresh tokens and client secrets longer than this are not looked up. */
|
|
24
|
+
const MAX_SECRET_LENGTH = 512;
|
|
25
|
+
const MINUTE_MS = 60_000;
|
|
26
|
+
const DAY_MS = 24 * 60 * MINUTE_MS;
|
|
27
|
+
/** A registration without any grant after a day is abandoned (registration is public and free). */
|
|
28
|
+
export const STALE_CLIENT_MS = DAY_MS;
|
|
29
|
+
|
|
30
|
+
type Transaction = Parameters<Parameters<McpAccessContext["db"]["transaction"]>[0]>[0];
|
|
31
|
+
|
|
32
|
+
export interface OAuthClientRegistration {
|
|
33
|
+
/** 1 to 60 characters, trimmed, no control characters (`parseClientRegistration` makes it so). */
|
|
34
|
+
readonly clientName: string;
|
|
35
|
+
readonly redirectUris: readonly string[];
|
|
36
|
+
readonly tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RegisteredOAuthClient {
|
|
40
|
+
readonly client: OAuthClientRow;
|
|
41
|
+
/** The plaintext secret of a confidential client, shown once; null for a public client. */
|
|
42
|
+
readonly clientSecret: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IssuedOAuthTokens {
|
|
46
|
+
readonly accessToken: string;
|
|
47
|
+
readonly refreshToken: string;
|
|
48
|
+
/** Seconds, as `expires_in` counts them (RFC 6749 §5.1). */
|
|
49
|
+
readonly expiresIn: number;
|
|
50
|
+
readonly canWrite: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Registers a client (RFC 7591). A secret only for the methods that use one. Abandoned clients
|
|
55
|
+
* (no grant, older than a day) are deleted first, so a flood of registrations does not pile up.
|
|
56
|
+
*/
|
|
57
|
+
export async function registerMcpClient(ctx: McpAccessContext, input: OAuthClientRegistration): Promise<RegisteredOAuthClient> {
|
|
58
|
+
const now = ctx.clock.now();
|
|
59
|
+
await deleteStaleClients(ctx, now);
|
|
60
|
+
const secret = input.tokenEndpointAuthMethod === "none" ? null : createSecret(CLIENT_SECRET_PREFIX);
|
|
61
|
+
const [client] = await ctx.db
|
|
62
|
+
.insert(oauthClients)
|
|
63
|
+
.values({
|
|
64
|
+
clientId: `${CLIENT_ID_PREFIX}${createSecret("").secret.slice(0, 22)}`,
|
|
65
|
+
clientName: input.clientName,
|
|
66
|
+
redirectUris: [...input.redirectUris],
|
|
67
|
+
tokenEndpointAuthMethod: input.tokenEndpointAuthMethod,
|
|
68
|
+
clientSecretHash: secret?.secretHash ?? null,
|
|
69
|
+
createdAt: now,
|
|
70
|
+
})
|
|
71
|
+
.returning();
|
|
72
|
+
if (client === undefined) throw new Error("@softure-ai/mcp-access: inserting an OAuth client returned no row");
|
|
73
|
+
return { client, clientSecret: secret?.secret ?? null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function findOAuthClient(ctx: McpAccessContext, clientId: string): Promise<OAuthClientRow | null> {
|
|
77
|
+
if (clientId === "" || clientId.length > 255) return null;
|
|
78
|
+
const [client] = await ctx.db.select().from(oauthClients).where(eq(oauthClients.clientId, clientId));
|
|
79
|
+
return client ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the client authenticated: a confidential client with its secret (constant-time
|
|
84
|
+
* compare), a public client by presenting none.
|
|
85
|
+
*/
|
|
86
|
+
export function isClientSecretValid(client: OAuthClientRow, secret: string | null): boolean {
|
|
87
|
+
if (client.clientSecretHash === null) return secret === null;
|
|
88
|
+
if (secret === null || secret.length > MAX_SECRET_LENGTH) return false;
|
|
89
|
+
const expected = Buffer.from(client.clientSecretHash);
|
|
90
|
+
const actual = Buffer.from(hashAccessToken(secret));
|
|
91
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CreateAuthorizationCodeInput {
|
|
95
|
+
/** The client's row id (`OAuthClientRow.id`), not its public client id. */
|
|
96
|
+
readonly clientRowId: string;
|
|
97
|
+
/** The person who consented, from the session. */
|
|
98
|
+
readonly userId: string;
|
|
99
|
+
readonly redirectUri: string;
|
|
100
|
+
readonly codeChallenge: string;
|
|
101
|
+
/** Asked for by the client, allowed by the app and ticked by the person. */
|
|
102
|
+
readonly canWrite: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Issues a code after the person's consent. Expired codes are deleted on the way. */
|
|
106
|
+
export async function createAuthorizationCode(ctx: McpAccessContext, input: CreateAuthorizationCodeInput): Promise<string> {
|
|
107
|
+
const now = ctx.clock.now();
|
|
108
|
+
const lifetimeMs = getMcpAccessOptions(ctx.config).oauth.authorizationCodeLifetimeMinutes * MINUTE_MS;
|
|
109
|
+
const { secret: code, secretHash: codeHash } = createSecret(CONSENT_CODE_PREFIX);
|
|
110
|
+
await ctx.db.delete(oauthAuthorizationCodes).where(lte(oauthAuthorizationCodes.expiresAt, now));
|
|
111
|
+
await ctx.db.insert(oauthAuthorizationCodes).values({
|
|
112
|
+
codeHash,
|
|
113
|
+
clientId: input.clientRowId,
|
|
114
|
+
userId: input.userId,
|
|
115
|
+
redirectUri: input.redirectUri,
|
|
116
|
+
codeChallenge: input.codeChallenge,
|
|
117
|
+
canWrite: input.canWrite,
|
|
118
|
+
expiresAt: new Date(now.getTime() + lifetimeMs),
|
|
119
|
+
createdAt: now,
|
|
120
|
+
});
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ExchangeAuthorizationCodeInput {
|
|
125
|
+
readonly code: string;
|
|
126
|
+
/** The authenticated client. */
|
|
127
|
+
readonly client: OAuthClientRow;
|
|
128
|
+
/** Null when the client left it out; allowed only when it registered exactly one. */
|
|
129
|
+
readonly redirectUri: string | null;
|
|
130
|
+
readonly codeVerifier: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Exchanges a code for tokens (RFC 6749 §4.1.3, PKCE per RFC 7636 §4.6), or null for any refusal.
|
|
135
|
+
*
|
|
136
|
+
* The code is burned first, by a conditional `UPDATE … WHERE used_at IS NULL`: a wrong verifier,
|
|
137
|
+
* another client or another redirect URI leave a dead code, not an invitation to keep guessing,
|
|
138
|
+
* and two parallel exchanges cannot both win. A second use of a used code revokes the grant
|
|
139
|
+
* issued from it (RFC 6749 §4.1.2): two parties saw the code. A new consent of the same client
|
|
140
|
+
* replaces its grant, whose tokens go by cascade.
|
|
141
|
+
*/
|
|
142
|
+
export async function exchangeAuthorizationCode(ctx: McpAccessContext, input: ExchangeAuthorizationCodeInput): Promise<IssuedOAuthTokens | null> {
|
|
143
|
+
if (input.code.length > MAX_SECRET_LENGTH) return null;
|
|
144
|
+
const now = ctx.clock.now();
|
|
145
|
+
const codeHash = hashAccessToken(input.code);
|
|
146
|
+
return ctx.db.transaction(async (tx): Promise<IssuedOAuthTokens | null> => {
|
|
147
|
+
const [code] = await tx
|
|
148
|
+
.update(oauthAuthorizationCodes)
|
|
149
|
+
.set({ usedAt: now })
|
|
150
|
+
.where(and(eq(oauthAuthorizationCodes.codeHash, codeHash), isNull(oauthAuthorizationCodes.usedAt), gt(oauthAuthorizationCodes.expiresAt, now)))
|
|
151
|
+
.returning();
|
|
152
|
+
if (code === undefined) {
|
|
153
|
+
await revokeGrantOfReusedCode(tx, codeHash);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const redirectUri = input.redirectUri ?? (input.client.redirectUris.length === 1 ? (input.client.redirectUris[0] ?? null) : null);
|
|
158
|
+
if (code.clientId !== input.client.id || code.redirectUri !== redirectUri || !isPkceVerifierValid(input.codeVerifier, code.codeChallenge)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await tx.delete(oauthGrants).where(and(eq(oauthGrants.userId, code.userId), eq(oauthGrants.clientId, code.clientId)));
|
|
163
|
+
const { secret: refreshToken, secretHash: refreshTokenHash } = createSecret(REFRESH_TOKEN_PREFIX);
|
|
164
|
+
const [grant] = await tx
|
|
165
|
+
.insert(oauthGrants)
|
|
166
|
+
.values({
|
|
167
|
+
userId: code.userId,
|
|
168
|
+
clientId: code.clientId,
|
|
169
|
+
canWrite: code.canWrite,
|
|
170
|
+
refreshTokenHash,
|
|
171
|
+
refreshExpiresAt: getRefreshExpiry(ctx, now),
|
|
172
|
+
createdAt: now,
|
|
173
|
+
})
|
|
174
|
+
.returning();
|
|
175
|
+
if (grant === undefined) throw new Error("@softure-ai/mcp-access: inserting an OAuth grant returned no row");
|
|
176
|
+
return issueGrantTokens(ctx, tx, { grant, clientName: input.client.clientName, refreshToken, now });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface RefreshOAuthGrantInput {
|
|
181
|
+
readonly refreshToken: string;
|
|
182
|
+
/** The authenticated client: a grant answers only the client it was issued to. */
|
|
183
|
+
readonly client: OAuthClientRow;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Refreshes a grant: a new access token and a new refresh token (rotation, OAuth 2.1 §4.3.1). The
|
|
188
|
+
* presented hash moves to `previous_refresh_token_hash` in the same conditional `UPDATE` that
|
|
189
|
+
* checks it is current; presenting it again later deletes the whole grant, for both parties.
|
|
190
|
+
*/
|
|
191
|
+
export async function refreshMcpGrant(ctx: McpAccessContext, input: RefreshOAuthGrantInput): Promise<IssuedOAuthTokens | null> {
|
|
192
|
+
if (input.refreshToken.length > MAX_SECRET_LENGTH) return null;
|
|
193
|
+
const now = ctx.clock.now();
|
|
194
|
+
const presentedHash = hashAccessToken(input.refreshToken);
|
|
195
|
+
return ctx.db.transaction(async (tx): Promise<IssuedOAuthTokens | null> => {
|
|
196
|
+
const { secret: refreshToken, secretHash: refreshTokenHash } = createSecret(REFRESH_TOKEN_PREFIX);
|
|
197
|
+
const [grant] = await tx
|
|
198
|
+
.update(oauthGrants)
|
|
199
|
+
.set({ refreshTokenHash, previousRefreshTokenHash: presentedHash, refreshExpiresAt: getRefreshExpiry(ctx, now) })
|
|
200
|
+
.where(and(eq(oauthGrants.refreshTokenHash, presentedHash), eq(oauthGrants.clientId, input.client.id), gt(oauthGrants.refreshExpiresAt, now)))
|
|
201
|
+
.returning();
|
|
202
|
+
if (grant === undefined) {
|
|
203
|
+
// A token from before a rotation: two parties hold it, so the grant goes for both.
|
|
204
|
+
await tx.delete(oauthGrants).where(eq(oauthGrants.previousRefreshTokenHash, presentedHash));
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
// The grant's spent access tokens; the current ones stay valid until they expire.
|
|
208
|
+
await tx.delete(accessTokens).where(and(eq(accessTokens.grantId, grant.id), lte(accessTokens.expiresAt, now)));
|
|
209
|
+
return issueGrantTokens(ctx, tx, { grant, clientName: input.client.clientName, refreshToken, now });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getRefreshExpiry(ctx: McpAccessContext, now: Date): Date {
|
|
214
|
+
return new Date(now.getTime() + getMcpAccessOptions(ctx.config).oauth.refreshTokenLifetimeDays * DAY_MS);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The grant's access token, a row of `access_tokens` named after the client. */
|
|
218
|
+
async function issueGrantTokens(
|
|
219
|
+
ctx: McpAccessContext,
|
|
220
|
+
tx: Transaction,
|
|
221
|
+
input: { readonly grant: { readonly id: string; readonly userId: string; readonly canWrite: boolean }; readonly clientName: string; readonly refreshToken: string; readonly now: Date },
|
|
222
|
+
): Promise<IssuedOAuthTokens> {
|
|
223
|
+
const lifetimeMs = getMcpAccessOptions(ctx.config).oauth.accessTokenLifetimeMinutes * MINUTE_MS;
|
|
224
|
+
const { secret: accessToken, secretHash: tokenHash } = createSecret(ACCESS_TOKEN_PREFIX);
|
|
225
|
+
await tx.insert(accessTokens).values({
|
|
226
|
+
userId: input.grant.userId,
|
|
227
|
+
grantId: input.grant.id,
|
|
228
|
+
name: input.clientName,
|
|
229
|
+
tokenHash,
|
|
230
|
+
canWrite: input.grant.canWrite,
|
|
231
|
+
createdAt: input.now,
|
|
232
|
+
expiresAt: new Date(input.now.getTime() + lifetimeMs),
|
|
233
|
+
});
|
|
234
|
+
return { accessToken, refreshToken: input.refreshToken, expiresIn: lifetimeMs / 1000, canWrite: input.grant.canWrite };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A used code presented again revokes the grant its first use produced (same account and client). */
|
|
238
|
+
async function revokeGrantOfReusedCode(tx: Transaction, codeHash: string): Promise<void> {
|
|
239
|
+
const [reused] = await tx
|
|
240
|
+
.select({ userId: oauthAuthorizationCodes.userId, clientId: oauthAuthorizationCodes.clientId })
|
|
241
|
+
.from(oauthAuthorizationCodes)
|
|
242
|
+
.where(and(eq(oauthAuthorizationCodes.codeHash, codeHash), isNotNull(oauthAuthorizationCodes.usedAt)));
|
|
243
|
+
if (reused === undefined) return;
|
|
244
|
+
await tx.delete(oauthGrants).where(and(eq(oauthGrants.userId, reused.userId), eq(oauthGrants.clientId, reused.clientId)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The owner's unexpired grants, newest first: the connected apps list. */
|
|
248
|
+
export async function listOAuthGrants(ctx: McpAccessContext, userId: string): Promise<OAuthGrantView[]> {
|
|
249
|
+
return ctx.db
|
|
250
|
+
.select({
|
|
251
|
+
id: oauthGrants.id,
|
|
252
|
+
clientName: oauthClients.clientName,
|
|
253
|
+
canWrite: oauthGrants.canWrite,
|
|
254
|
+
createdAt: oauthGrants.createdAt,
|
|
255
|
+
lastUsedAt: oauthGrants.lastUsedAt,
|
|
256
|
+
})
|
|
257
|
+
.from(oauthGrants)
|
|
258
|
+
.innerJoin(oauthClients, eq(oauthGrants.clientId, oauthClients.id))
|
|
259
|
+
.where(and(eq(oauthGrants.userId, userId), gt(oauthGrants.refreshExpiresAt, ctx.clock.now())))
|
|
260
|
+
.orderBy(desc(oauthGrants.createdAt), desc(oauthGrants.id));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface RevokeOAuthGrantInput {
|
|
264
|
+
/** The owner, from the session. */
|
|
265
|
+
readonly userId: string;
|
|
266
|
+
/** From the form: any value, including another account's grant id. */
|
|
267
|
+
readonly grantId: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Revokes a grant at once: its refresh token goes with the row and its access tokens by cascade,
|
|
274
|
+
* so the assistant's next request gets 401. The owner is part of the `WHERE`.
|
|
275
|
+
*/
|
|
276
|
+
export async function revokeOAuthGrant(ctx: McpAccessContext, input: RevokeOAuthGrantInput): Promise<Ok<undefined> | Err<"mcp-access.grant_not_found">> {
|
|
277
|
+
if (!UUID_SHAPE.test(input.grantId)) return err("mcp-access.grant_not_found");
|
|
278
|
+
const removed = await ctx.db
|
|
279
|
+
.delete(oauthGrants)
|
|
280
|
+
.where(and(eq(oauthGrants.userId, input.userId), eq(oauthGrants.id, input.grantId)))
|
|
281
|
+
.returning();
|
|
282
|
+
return removed.length > 0 ? ok() : err("mcp-access.grant_not_found");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface PrunedOAuthRecords {
|
|
286
|
+
readonly codes: number;
|
|
287
|
+
readonly grants: number;
|
|
288
|
+
readonly clients: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Deletes expired codes, grants whose refresh token expired (their access tokens by cascade) and
|
|
293
|
+
* abandoned clients, of every account: for a scheduled job, next to `pruneAccessTokens`.
|
|
294
|
+
*/
|
|
295
|
+
export async function pruneOAuthRecords(ctx: McpAccessContext): Promise<PrunedOAuthRecords> {
|
|
296
|
+
const now = ctx.clock.now();
|
|
297
|
+
const codes = await ctx.db.delete(oauthAuthorizationCodes).where(lte(oauthAuthorizationCodes.expiresAt, now)).returning();
|
|
298
|
+
const grants = await ctx.db.delete(oauthGrants).where(lte(oauthGrants.refreshExpiresAt, now)).returning();
|
|
299
|
+
const clients = await deleteStaleClients(ctx, now);
|
|
300
|
+
return { codes: codes.length, grants: grants.length, clients };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Clients without a grant, registered more than a day ago; they give nobody any access. */
|
|
304
|
+
async function deleteStaleClients(ctx: McpAccessContext, now: Date): Promise<number> {
|
|
305
|
+
const removed = await ctx.db
|
|
306
|
+
.delete(oauthClients)
|
|
307
|
+
.where(
|
|
308
|
+
and(
|
|
309
|
+
lt(oauthClients.createdAt, new Date(now.getTime() - STALE_CLIENT_MS)),
|
|
310
|
+
notExists(ctx.db.select({ id: oauthGrants.id }).from(oauthGrants).where(eq(oauthGrants.clientId, oauthClients.id))),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
.returning();
|
|
314
|
+
return removed.length;
|
|
315
|
+
}
|
package/src/server/options.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getModule, type Locale, type SoftureConfig } from "@softure-ai/core";
|
|
3
3
|
import type { McpAccessMessages } from "../messages/index.js";
|
|
4
4
|
import type { McpAccessOptions } from "../options.js";
|
|
5
|
+
import type { McpOrigins } from "../origins.js";
|
|
5
6
|
|
|
6
7
|
const MODULE_ID = "mcp-access";
|
|
7
8
|
|
|
@@ -24,6 +25,14 @@ export interface McpAccessRoutes {
|
|
|
24
25
|
readonly page: string;
|
|
25
26
|
/** `POST` MCP endpoint. */
|
|
26
27
|
readonly endpoint: string;
|
|
28
|
+
/** OAuth consent page (`GET`): the authorization endpoint clients send the person to. */
|
|
29
|
+
readonly oauthConsent: string;
|
|
30
|
+
/** `POST` target of the consent form; answers `303` to the client's redirect URI. */
|
|
31
|
+
readonly oauthDecision: string;
|
|
32
|
+
/** `POST` OAuth token endpoint. */
|
|
33
|
+
readonly oauthToken: string;
|
|
34
|
+
/** `POST` dynamic client registration (RFC 7591). */
|
|
35
|
+
readonly oauthRegister: string;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
|
|
@@ -34,12 +43,19 @@ export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
|
|
|
34
43
|
if (path === undefined) throw new Error(`@softure-ai/mcp-access: route "${name}" is missing from the module manifest`);
|
|
35
44
|
return path;
|
|
36
45
|
};
|
|
37
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
page: read("page"),
|
|
48
|
+
endpoint: read("endpoint"),
|
|
49
|
+
oauthConsent: read("oauthConsent"),
|
|
50
|
+
oauthDecision: read("oauthDecision"),
|
|
51
|
+
oauthToken: read("oauthToken"),
|
|
52
|
+
oauthRegister: read("oauthRegister"),
|
|
53
|
+
};
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
/** The absolute URL MCP clients call: the app's origin and the endpoint route. */
|
|
41
|
-
export function getMcpEndpointUrl(config: SoftureConfig): string {
|
|
42
|
-
return new URL(getMcpAccessRoutes(config).endpoint, config.appOrigin).toString();
|
|
56
|
+
/** The absolute URL MCP clients call: the app's origin (the request's, when given) and the endpoint route. */
|
|
57
|
+
export function getMcpEndpointUrl(config: SoftureConfig, origins?: McpOrigins): string {
|
|
58
|
+
return new URL(getMcpAccessRoutes(config).endpoint, origins?.appOrigin ?? config.appOrigin).toString();
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/** The module's copy in the app's locale, with the app's overrides applied. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The origins of one request, from the app's options: `resolveAppOrigin` when it answers, else the
|
|
2
|
+
// configured `appOrigin`, plus `resourceOrigins`.
|
|
3
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
4
|
+
import { isBareOrigin } from "../options.js";
|
|
5
|
+
import type { McpOriginRequest, McpOrigins } from "../origins.js";
|
|
6
|
+
import { getMcpAccessOptions } from "./options.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The origins for `request`, or the configured ones without a request. A resolver that returns
|
|
10
|
+
* something other than a bare http(s) origin is a setup bug: thrown by name, never served.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveMcpOrigins(config: SoftureConfig, request?: McpOriginRequest | null): McpOrigins {
|
|
13
|
+
const options = getMcpAccessOptions(config);
|
|
14
|
+
const resolved = request === undefined || request === null || options.resolveAppOrigin === undefined ? null : options.resolveAppOrigin(request);
|
|
15
|
+
if (resolved !== null && !isBareOrigin(resolved)) {
|
|
16
|
+
throw new Error(`@softure-ai/mcp-access: resolveAppOrigin returned "${resolved.slice(0, 100)}", which is not an http(s) origin without a path`);
|
|
17
|
+
}
|
|
18
|
+
return { appOrigin: new URL(resolved ?? config.appOrigin).origin, resourceOrigins: options.resourceOrigins };
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// PKCE S256 (RFC 7636 §4.6), the only method accepted: OAuth 2.1 and the MCP authorization spec
|
|
2
|
+
// require it, and `plain` hands the verifier to anyone who saw the authorization URL.
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const PKCE_METHOD = "S256";
|
|
6
|
+
|
|
7
|
+
/** RFC 7636 §4.1: 43 to 128 unreserved characters. A shorter one carries too little entropy. */
|
|
8
|
+
const VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
9
|
+
/** base64url of a sha256 without padding: always 43 characters. */
|
|
10
|
+
const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
11
|
+
|
|
12
|
+
export function isValidCodeChallenge(challenge: string): boolean {
|
|
13
|
+
return CHALLENGE_PATTERN.test(challenge);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `BASE64URL(SHA256(ASCII(code_verifier)))`. */
|
|
17
|
+
export function getCodeChallenge(verifier: string): string {
|
|
18
|
+
return createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether the verifier matches the challenge stored with the code; compared in constant time. */
|
|
22
|
+
export function isPkceVerifierValid(verifier: string, challenge: string): boolean {
|
|
23
|
+
if (!VERIFIER_PATTERN.test(verifier) || !isValidCodeChallenge(challenge)) return false;
|
|
24
|
+
const expected = Buffer.from(challenge);
|
|
25
|
+
const actual = Buffer.from(getCodeChallenge(verifier));
|
|
26
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
27
|
+
}
|
package/src/server/privacy.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The mcp-access part of a GDPR export and deletion (`@softure-ai/privacy`): the user's access
|
|
2
|
-
// tokens, by name and dates. Never a
|
|
3
|
-
// deletes the account, so it does not rest on the foreign
|
|
2
|
+
// tokens and connected apps (OAuth grants), by name and dates. Never a hash. Deletion removes them
|
|
3
|
+
// by name before auth deletes the account, so it does not rest on the foreign keys' cascade alone.
|
|
4
4
|
import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
6
|
-
import { asc, eq } from "drizzle-orm";
|
|
7
|
-
import { accessTokens } from "../schema.js";
|
|
6
|
+
import { and, asc, eq, isNull } from "drizzle-orm";
|
|
7
|
+
import { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants } from "../schema.js";
|
|
8
8
|
|
|
9
9
|
/** What mcp-access holds about one user, as it appears in their export. */
|
|
10
10
|
export interface McpAccessUserData {
|
|
@@ -15,15 +15,22 @@ export interface McpAccessUserData {
|
|
|
15
15
|
readonly expiresAt: Date;
|
|
16
16
|
readonly lastUsedAt: Date | null;
|
|
17
17
|
}[];
|
|
18
|
+
/** Apps connected through OAuth; their short-lived access tokens are not listed one by one. */
|
|
19
|
+
readonly connectedApps: readonly {
|
|
20
|
+
readonly clientName: string;
|
|
21
|
+
readonly canWrite: boolean;
|
|
22
|
+
readonly createdAt: Date;
|
|
23
|
+
readonly lastUsedAt: Date | null;
|
|
24
|
+
}[];
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
28
|
|
|
22
29
|
export async function exportMcpAccessUserData(context: ModuleContext, userId: string): Promise<Ok<McpAccessUserData>> {
|
|
23
|
-
if (!UUID.test(userId)) return ok({ accessTokens: [] });
|
|
30
|
+
if (!UUID.test(userId)) return ok({ accessTokens: [], connectedApps: [] });
|
|
24
31
|
// Core types `db` as unknown; privacy passes the @softure-ai/db handle or its transaction.
|
|
25
32
|
const db = context.db as Queryable;
|
|
26
|
-
const
|
|
33
|
+
const tokens = await db
|
|
27
34
|
.select({
|
|
28
35
|
name: accessTokens.name,
|
|
29
36
|
canWrite: accessTokens.canWrite,
|
|
@@ -32,14 +39,24 @@ export async function exportMcpAccessUserData(context: ModuleContext, userId: st
|
|
|
32
39
|
lastUsedAt: accessTokens.lastUsedAt,
|
|
33
40
|
})
|
|
34
41
|
.from(accessTokens)
|
|
35
|
-
.where(eq(accessTokens.userId, userId))
|
|
42
|
+
.where(and(eq(accessTokens.userId, userId), isNull(accessTokens.grantId)))
|
|
36
43
|
.orderBy(asc(accessTokens.createdAt));
|
|
37
|
-
|
|
44
|
+
const connectedApps = await db
|
|
45
|
+
.select({ clientName: oauthClients.clientName, canWrite: oauthGrants.canWrite, createdAt: oauthGrants.createdAt, lastUsedAt: oauthGrants.lastUsedAt })
|
|
46
|
+
.from(oauthGrants)
|
|
47
|
+
.innerJoin(oauthClients, eq(oauthGrants.clientId, oauthClients.id))
|
|
48
|
+
.where(eq(oauthGrants.userId, userId))
|
|
49
|
+
.orderBy(asc(oauthGrants.createdAt));
|
|
50
|
+
return ok({ accessTokens: tokens, connectedApps });
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
export async function deleteMcpAccessUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
|
|
41
54
|
if (!UUID.test(userId)) return ok();
|
|
42
|
-
|
|
55
|
+
const db = context.db as Queryable;
|
|
56
|
+
await db.delete(oauthAuthorizationCodes).where(eq(oauthAuthorizationCodes.userId, userId));
|
|
57
|
+
// A grant's access tokens go with it by cascade; the next line takes the hand-issued ones.
|
|
58
|
+
await db.delete(oauthGrants).where(eq(oauthGrants.userId, userId));
|
|
59
|
+
await db.delete(accessTokens).where(eq(accessTokens.userId, userId));
|
|
43
60
|
return ok();
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The two scopes of the MCP endpoint, shared by the Bearer check and the OAuth server.
|
|
2
|
+
|
|
3
|
+
/** Every valid token reads. */
|
|
4
|
+
export const MCP_READ_SCOPE = "mcp:read";
|
|
5
|
+
/** A token that may write: issued for writes, and the app allows them. */
|
|
6
|
+
export const MCP_WRITE_SCOPE = "mcp:write";
|
package/src/server/tokens.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Access tokens (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Access tokens (an adopting app's token code, generalised). A token is `sftmcp_` and 32 random
|
|
2
|
+
// bytes; the database holds only its sha256, so a leaked row cannot be replayed. The plaintext
|
|
3
|
+
// leaves this file once, in the result of `issueAccessToken` (or of an OAuth exchange, `oauth.ts`).
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { err, errorLogLabel, ok, type Err, type ModuleContext, type Ok } from "@softure-ai/core";
|
|
6
6
|
import type { Queryable } from "@softure-ai/db";
|
|
7
7
|
import { and, count, desc, eq, gt, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
8
8
|
import type { AccessTokenView } from "../contract.js";
|
|
9
|
-
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
10
|
-
import { accessTokens } from "../schema.js";
|
|
9
|
+
import { MAX_PRESENTED_TOKEN_LENGTH, MAX_TOKEN_NAME_LENGTH, type McpAccessOptions } from "../options.js";
|
|
10
|
+
import { accessTokens, oauthGrants } from "../schema.js";
|
|
11
11
|
import { getMcpAccessOptions } from "./options.js";
|
|
12
12
|
|
|
13
13
|
export type McpAccessContext = ModuleContext<Queryable>;
|
|
@@ -27,14 +27,25 @@ export function hashAccessToken(token: string): string {
|
|
|
27
27
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Whether a presented value
|
|
30
|
+
/** Whether a presented value has the module's token shape. */
|
|
31
31
|
export function isAccessTokenShape(token: string): boolean {
|
|
32
32
|
return TOKEN_SHAPE.test(token);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Whether a presented value is looked up at all: the module's shape, or the app's
|
|
37
|
+
* `legacyTokenPattern` for tokens issued before the module. The length cap runs first, so no
|
|
38
|
+
* pattern ever sees a huge header.
|
|
39
|
+
*/
|
|
40
|
+
function isAcceptedTokenShape(options: McpAccessOptions, token: string): boolean {
|
|
41
|
+
if (token.length > MAX_PRESENTED_TOKEN_LENGTH) return false;
|
|
42
|
+
return isAccessTokenShape(token) || (options.legacyTokenPattern?.test(token) ?? false);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A new secret with the given prefix and its hash; the plaintext is returned once, never stored. */
|
|
46
|
+
export function createSecret(prefix: string): { readonly secret: string; readonly secretHash: string } {
|
|
47
|
+
const secret = `${prefix}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
|
|
48
|
+
return { secret, secretHash: hashAccessToken(secret) };
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
export interface IssueAccessTokenInput {
|
|
@@ -77,10 +88,14 @@ export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccess
|
|
|
77
88
|
return ctx.db.transaction(async (tx): Promise<IssueAccessTokenResult> => {
|
|
78
89
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`mcp-access:${input.userId}`}, 0))`);
|
|
79
90
|
await tx.delete(accessTokens).where(and(eq(accessTokens.userId, input.userId), lte(accessTokens.expiresAt, now)));
|
|
80
|
-
|
|
91
|
+
// OAuth access tokens belong to their grant and rotate every hour: they do not take places.
|
|
92
|
+
const [held] = await tx
|
|
93
|
+
.select({ total: count() })
|
|
94
|
+
.from(accessTokens)
|
|
95
|
+
.where(and(eq(accessTokens.userId, input.userId), isNull(accessTokens.grantId)));
|
|
81
96
|
if ((held?.total ?? 0) >= options.maxTokensPerUser) return err("mcp-access.token_limit_reached");
|
|
82
97
|
|
|
83
|
-
const { token, tokenHash } =
|
|
98
|
+
const { secret: token, secretHash: tokenHash } = createSecret(ACCESS_TOKEN_PREFIX);
|
|
84
99
|
const [row] = await tx
|
|
85
100
|
.insert(accessTokens)
|
|
86
101
|
.values({ userId: input.userId, name, tokenHash, canWrite, createdAt: now, expiresAt })
|
|
@@ -90,7 +105,10 @@ export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccess
|
|
|
90
105
|
});
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The owner's hand-issued tokens, newest first, expired ones included (the list marks them).
|
|
110
|
+
* OAuth access tokens are left out: the page shows their grants instead.
|
|
111
|
+
*/
|
|
94
112
|
export async function listAccessTokens(ctx: McpAccessContext, userId: string): Promise<AccessTokenView[]> {
|
|
95
113
|
return ctx.db
|
|
96
114
|
.select({
|
|
@@ -102,7 +120,7 @@ export async function listAccessTokens(ctx: McpAccessContext, userId: string): P
|
|
|
102
120
|
lastUsedAt: accessTokens.lastUsedAt,
|
|
103
121
|
})
|
|
104
122
|
.from(accessTokens)
|
|
105
|
-
.where(eq(accessTokens.userId, userId))
|
|
123
|
+
.where(and(eq(accessTokens.userId, userId), isNull(accessTokens.grantId)))
|
|
106
124
|
.orderBy(desc(accessTokens.createdAt), desc(accessTokens.id));
|
|
107
125
|
}
|
|
108
126
|
|
|
@@ -114,8 +132,9 @@ export interface RevokeAccessTokenInput {
|
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
/**
|
|
117
|
-
* Deletes a token at once; the next request with it gets 401. The owner is part of the
|
|
118
|
-
* so another account's id matches no row instead of relying on a comparison here.
|
|
135
|
+
* Deletes a hand-issued token at once; the next request with it gets 401. The owner is part of the
|
|
136
|
+
* `WHERE`, so another account's id matches no row instead of relying on a comparison here. An
|
|
137
|
+
* OAuth access token is revoked with its grant (`revokeOAuthGrant`).
|
|
119
138
|
*/
|
|
120
139
|
export async function revokeAccessToken(
|
|
121
140
|
ctx: McpAccessContext,
|
|
@@ -124,7 +143,7 @@ export async function revokeAccessToken(
|
|
|
124
143
|
if (!UUID_SHAPE.test(input.tokenId)) return err("mcp-access.token_not_found");
|
|
125
144
|
const removed = await ctx.db
|
|
126
145
|
.delete(accessTokens)
|
|
127
|
-
.where(and(eq(accessTokens.userId, input.userId), eq(accessTokens.id, input.tokenId)))
|
|
146
|
+
.where(and(eq(accessTokens.userId, input.userId), eq(accessTokens.id, input.tokenId), isNull(accessTokens.grantId)))
|
|
128
147
|
.returning();
|
|
129
148
|
return removed.length > 0 ? ok() : err("mcp-access.token_not_found");
|
|
130
149
|
}
|
|
@@ -136,6 +155,8 @@ export interface VerifiedAccessToken {
|
|
|
136
155
|
/** Effective write access: the token was issued for writes and the app allows them now. */
|
|
137
156
|
readonly canWrite: boolean;
|
|
138
157
|
readonly expiresAt: Date;
|
|
158
|
+
/** The OAuth grant the token was issued from; null for a hand-issued token. */
|
|
159
|
+
readonly grantId: string | null;
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
/**
|
|
@@ -145,39 +166,55 @@ export interface VerifiedAccessToken {
|
|
|
145
166
|
* fail the request. Database failures of the lookup propagate.
|
|
146
167
|
*/
|
|
147
168
|
export async function verifyAccessToken(ctx: McpAccessContext, token: string): Promise<VerifiedAccessToken | null> {
|
|
148
|
-
|
|
169
|
+
const options = getMcpAccessOptions(ctx.config);
|
|
170
|
+
if (!isAcceptedTokenShape(options, token)) return null;
|
|
149
171
|
const now = ctx.clock.now();
|
|
150
172
|
const [row] = await ctx.db
|
|
151
|
-
.select({
|
|
173
|
+
.select({
|
|
174
|
+
id: accessTokens.id,
|
|
175
|
+
userId: accessTokens.userId,
|
|
176
|
+
canWrite: accessTokens.canWrite,
|
|
177
|
+
expiresAt: accessTokens.expiresAt,
|
|
178
|
+
grantId: accessTokens.grantId,
|
|
179
|
+
})
|
|
152
180
|
.from(accessTokens)
|
|
153
181
|
.where(and(eq(accessTokens.tokenHash, hashAccessToken(token)), gt(accessTokens.expiresAt, now)));
|
|
154
182
|
if (row === undefined) return null;
|
|
155
183
|
|
|
156
|
-
await recordUse(ctx, row
|
|
184
|
+
await recordUse(ctx, row, now);
|
|
157
185
|
return {
|
|
158
186
|
tokenId: row.id,
|
|
159
187
|
userId: row.userId,
|
|
160
|
-
canWrite: row.canWrite &&
|
|
188
|
+
canWrite: row.canWrite && options.allowWrites,
|
|
161
189
|
expiresAt: row.expiresAt,
|
|
190
|
+
grantId: row.grantId,
|
|
162
191
|
};
|
|
163
192
|
}
|
|
164
193
|
|
|
165
|
-
|
|
194
|
+
/** Records the use on the token and, for an OAuth token, on its grant (the connected apps list). */
|
|
195
|
+
async function recordUse(ctx: McpAccessContext, token: { readonly id: string; readonly grantId: string | null }, now: Date): Promise<void> {
|
|
166
196
|
const staleBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS);
|
|
167
197
|
try {
|
|
168
198
|
await ctx.db
|
|
169
199
|
.update(accessTokens)
|
|
170
200
|
.set({ lastUsedAt: now })
|
|
171
|
-
.where(and(eq(accessTokens.id,
|
|
201
|
+
.where(and(eq(accessTokens.id, token.id), or(isNull(accessTokens.lastUsedAt), lt(accessTokens.lastUsedAt, staleBefore))));
|
|
202
|
+
if (token.grantId !== null) {
|
|
203
|
+
await ctx.db
|
|
204
|
+
.update(oauthGrants)
|
|
205
|
+
.set({ lastUsedAt: now })
|
|
206
|
+
.where(and(eq(oauthGrants.id, token.grantId), or(isNull(oauthGrants.lastUsedAt), lt(oauthGrants.lastUsedAt, staleBefore))));
|
|
207
|
+
}
|
|
172
208
|
} catch (error) {
|
|
173
209
|
// The token id is not a secret; the token is never logged.
|
|
174
|
-
console.error(`@softure-ai/mcp-access: recording the use of token ${
|
|
210
|
+
console.error(`@softure-ai/mcp-access: recording the use of token ${token.id} failed: ${errorLogLabel(error)}`);
|
|
175
211
|
}
|
|
176
212
|
}
|
|
177
213
|
|
|
178
214
|
/**
|
|
179
|
-
* Deletes every expired token, of every account
|
|
180
|
-
* may call this from a scheduled job so abandoned accounts do not
|
|
215
|
+
* Deletes every expired token, of every account, hand-issued and OAuth alike. Issuing already
|
|
216
|
+
* removes the issuer's own; an app may call this from a scheduled job so abandoned accounts do not
|
|
217
|
+
* keep dead rows (with `pruneOAuthRecords` for codes, grants and clients).
|
|
181
218
|
*/
|
|
182
219
|
export async function pruneAccessTokens(ctx: McpAccessContext): Promise<number> {
|
|
183
220
|
const removed = await ctx.db
|