@softure-ai/mcp-access 0.1.6 → 0.1.7

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +110 -21
  3. package/adoption/move-app-tables.sql +100 -0
  4. package/dist/contract.d.ts +13 -1
  5. package/dist/contract.d.ts.map +1 -1
  6. package/dist/index.d.ts +51 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +25 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/messages/en.d.ts +28 -0
  11. package/dist/messages/en.d.ts.map +1 -1
  12. package/dist/messages/en.js +28 -0
  13. package/dist/messages/en.js.map +1 -1
  14. package/dist/messages/index.d.ts +56 -0
  15. package/dist/messages/index.d.ts.map +1 -1
  16. package/dist/messages/pl.d.ts.map +1 -1
  17. package/dist/messages/pl.js +28 -0
  18. package/dist/messages/pl.js.map +1 -1
  19. package/dist/next/actions.d.ts +3 -1
  20. package/dist/next/actions.d.ts.map +1 -1
  21. package/dist/next/actions.js +19 -0
  22. package/dist/next/actions.js.map +1 -1
  23. package/dist/next/index.d.ts +3 -1
  24. package/dist/next/index.d.ts.map +1 -1
  25. package/dist/next/index.js +4 -2
  26. package/dist/next/index.js.map +1 -1
  27. package/dist/next/oauth-page.d.ts +7 -0
  28. package/dist/next/oauth-page.d.ts.map +1 -0
  29. package/dist/next/oauth-page.js +35 -0
  30. package/dist/next/oauth-page.js.map +1 -0
  31. package/dist/next/oauth-routes.d.ts +16 -0
  32. package/dist/next/oauth-routes.d.ts.map +1 -0
  33. package/dist/next/oauth-routes.js +50 -0
  34. package/dist/next/oauth-routes.js.map +1 -0
  35. package/dist/next/pages.d.ts.map +1 -1
  36. package/dist/next/pages.js +13 -2
  37. package/dist/next/pages.js.map +1 -1
  38. package/dist/options.d.ts +16 -0
  39. package/dist/options.d.ts.map +1 -1
  40. package/dist/options.js +28 -1
  41. package/dist/options.js.map +1 -1
  42. package/dist/schema.d.ts +486 -0
  43. package/dist/schema.d.ts.map +1 -1
  44. package/dist/schema.js +45 -3
  45. package/dist/schema.js.map +1 -1
  46. package/dist/server/endpoint.d.ts +2 -4
  47. package/dist/server/endpoint.d.ts.map +1 -1
  48. package/dist/server/endpoint.js +10 -7
  49. package/dist/server/endpoint.js.map +1 -1
  50. package/dist/server/health.d.ts.map +1 -1
  51. package/dist/server/health.js +5 -3
  52. package/dist/server/health.js.map +1 -1
  53. package/dist/server/index.d.ts +4 -0
  54. package/dist/server/index.d.ts.map +1 -1
  55. package/dist/server/index.js +4 -0
  56. package/dist/server/index.js.map +1 -1
  57. package/dist/server/oauth-http.d.ts +113 -0
  58. package/dist/server/oauth-http.d.ts.map +1 -0
  59. package/dist/server/oauth-http.js +374 -0
  60. package/dist/server/oauth-http.js.map +1 -0
  61. package/dist/server/oauth-validation.d.ts +28 -0
  62. package/dist/server/oauth-validation.d.ts.map +1 -0
  63. package/dist/server/oauth-validation.js +88 -0
  64. package/dist/server/oauth-validation.js.map +1 -0
  65. package/dist/server/oauth.d.ts +106 -0
  66. package/dist/server/oauth.d.ts.map +1 -0
  67. package/dist/server/oauth.js +235 -0
  68. package/dist/server/oauth.js.map +1 -0
  69. package/dist/server/options.d.ts +8 -0
  70. package/dist/server/options.d.ts.map +1 -1
  71. package/dist/server/options.js +8 -1
  72. package/dist/server/options.js.map +1 -1
  73. package/dist/server/pkce.d.ts +7 -0
  74. package/dist/server/pkce.d.ts.map +1 -0
  75. package/dist/server/pkce.js +24 -0
  76. package/dist/server/pkce.js.map +1 -0
  77. package/dist/server/privacy.d.ts +7 -0
  78. package/dist/server/privacy.d.ts.map +1 -1
  79. package/dist/server/privacy.js +19 -9
  80. package/dist/server/privacy.js.map +1 -1
  81. package/dist/server/scopes.d.ts +5 -0
  82. package/dist/server/scopes.d.ts.map +1 -0
  83. package/dist/server/scopes.js +6 -0
  84. package/dist/server/scopes.js.map +1 -0
  85. package/dist/server/tokens.d.ts +18 -6
  86. package/dist/server/tokens.d.ts.map +1 -1
  87. package/dist/server/tokens.js +60 -25
  88. package/dist/server/tokens.js.map +1 -1
  89. package/dist/ui/consent-form.d.ts +31 -0
  90. package/dist/ui/consent-form.d.ts.map +1 -0
  91. package/dist/ui/consent-form.js +24 -0
  92. package/dist/ui/consent-form.js.map +1 -0
  93. package/dist/ui/index.d.ts +2 -1
  94. package/dist/ui/index.d.ts.map +1 -1
  95. package/dist/ui/index.js +1 -0
  96. package/dist/ui/index.js.map +1 -1
  97. package/dist/ui/token-manager.d.ts +16 -2
  98. package/dist/ui/token-manager.d.ts.map +1 -1
  99. package/dist/ui/token-manager.js +6 -2
  100. package/dist/ui/token-manager.js.map +1 -1
  101. package/migrations/0002_create_oauth_grants.sql +68 -0
  102. package/module.json +18 -4
  103. package/package.json +2 -1
  104. package/src/contract.ts +16 -1
  105. package/src/index.ts +28 -4
  106. package/src/messages/en.ts +28 -0
  107. package/src/messages/pl.ts +28 -0
  108. package/src/next/actions.ts +19 -1
  109. package/src/next/index.ts +11 -2
  110. package/src/next/oauth-page.tsx +67 -0
  111. package/src/next/oauth-routes.ts +65 -0
  112. package/src/next/pages.tsx +16 -3
  113. package/src/options.ts +32 -1
  114. package/src/schema.ts +51 -3
  115. package/src/server/endpoint.ts +10 -7
  116. package/src/server/health.ts +5 -3
  117. package/src/server/index.ts +50 -0
  118. package/src/server/oauth-http.ts +431 -0
  119. package/src/server/oauth-validation.ts +106 -0
  120. package/src/server/oauth.ts +315 -0
  121. package/src/server/options.ts +16 -1
  122. package/src/server/pkce.ts +27 -0
  123. package/src/server/privacy.ts +26 -9
  124. package/src/server/scopes.ts +6 -0
  125. package/src/server/tokens.ts +62 -25
  126. package/src/ui/consent-form.tsx +103 -0
  127. package/src/ui/index.ts +3 -0
  128. 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
+ }
@@ -24,6 +24,14 @@ export interface McpAccessRoutes {
24
24
  readonly page: string;
25
25
  /** `POST` MCP endpoint. */
26
26
  readonly endpoint: string;
27
+ /** OAuth consent page (`GET`): the authorization endpoint clients send the person to. */
28
+ readonly oauthConsent: string;
29
+ /** `POST` target of the consent form; answers `303` to the client's redirect URI. */
30
+ readonly oauthDecision: string;
31
+ /** `POST` OAuth token endpoint. */
32
+ readonly oauthToken: string;
33
+ /** `POST` dynamic client registration (RFC 7591). */
34
+ readonly oauthRegister: string;
27
35
  }
28
36
 
29
37
  export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
@@ -34,7 +42,14 @@ export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
34
42
  if (path === undefined) throw new Error(`@softure-ai/mcp-access: route "${name}" is missing from the module manifest`);
35
43
  return path;
36
44
  };
37
- return { page: read("page"), endpoint: read("endpoint") };
45
+ return {
46
+ page: read("page"),
47
+ endpoint: read("endpoint"),
48
+ oauthConsent: read("oauthConsent"),
49
+ oauthDecision: read("oauthDecision"),
50
+ oauthToken: read("oauthToken"),
51
+ oauthRegister: read("oauthRegister"),
52
+ };
38
53
  }
39
54
 
40
55
  /** The absolute URL MCP clients call: the app's origin and the endpoint route. */
@@ -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
+ }
@@ -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 token hash. Deletion removes the tokens by name before auth
3
- // deletes the account, so it does not rest on the foreign key's cascade alone.
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 rows = await db
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
- return ok({ accessTokens: rows });
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
- await (context.db as Queryable).delete(accessTokens).where(eq(accessTokens.userId, userId));
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";
@@ -1,13 +1,13 @@
1
- // Access tokens (FIRE_TRACKER `src/db/access-tokens.ts`, generalised). A token is `sftmcp_` and 32
2
- // random bytes; the database holds only its sha256, so a leaked row cannot be replayed. The
3
- // plaintext leaves this file once, in the result of `issueAccessToken`.
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 could be a token at all; anything else is not looked up. */
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
- function createAccessTokenSecret(): { readonly token: string; readonly tokenHash: string } {
36
- const token = `${ACCESS_TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
37
- return { token, tokenHash: hashAccessToken(token) };
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
- const [held] = await tx.select({ total: count() }).from(accessTokens).where(eq(accessTokens.userId, input.userId));
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 } = createAccessTokenSecret();
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
- /** The owner's tokens, newest first, expired ones included (the list marks them). */
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 `WHERE`,
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
- if (!isAccessTokenShape(token)) return null;
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({ id: accessTokens.id, userId: accessTokens.userId, canWrite: accessTokens.canWrite, expiresAt: accessTokens.expiresAt })
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.id, now);
184
+ await recordUse(ctx, row, now);
157
185
  return {
158
186
  tokenId: row.id,
159
187
  userId: row.userId,
160
- canWrite: row.canWrite && getMcpAccessOptions(ctx.config).allowWrites,
188
+ canWrite: row.canWrite && options.allowWrites,
161
189
  expiresAt: row.expiresAt,
190
+ grantId: row.grantId,
162
191
  };
163
192
  }
164
193
 
165
- async function recordUse(ctx: McpAccessContext, tokenId: string, now: Date): Promise<void> {
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, tokenId), or(isNull(accessTokens.lastUsedAt), lt(accessTokens.lastUsedAt, staleBefore))));
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 ${tokenId} failed: ${errorLogLabel(error)}`);
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. Issuing already removes the issuer's own; an app
180
- * may call this from a scheduled job so abandoned accounts do not keep dead rows.
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