@interface-db/mcp 1.0.67
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.md +202 -0
- package/NOTICE +4 -0
- package/README.md +105 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +45 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/instant.perms.d.ts +9 -0
- package/dist/db/instant.perms.d.ts.map +1 -0
- package/dist/db/instant.perms.js +10 -0
- package/dist/db/instant.perms.js.map +1 -0
- package/dist/db/instant.schema.d.ts +162 -0
- package/dist/db/instant.schema.d.ts.map +1 -0
- package/dist/db/instant.schema.js +167 -0
- package/dist/db/instant.schema.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.html.d.ts +3 -0
- package/dist/index.html.d.ts.map +1 -0
- package/dist/index.html.js +111 -0
- package/dist/index.html.js.map +1 -0
- package/dist/index.js +456 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth-service-provider.d.ts +37 -0
- package/dist/oauth-service-provider.d.ts.map +1 -0
- package/dist/oauth-service-provider.js +603 -0
- package/dist/oauth-service-provider.js.map +1 -0
- package/dist/schema.d.ts +29 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +52 -0
- package/dist/schema.js.map +1 -0
- package/dist/tools.d.ts +12 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +72 -0
- package/dist/tools.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { urlencoded, } from 'express';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import { id, lookup, } from '@interface-db/admin';
|
|
4
|
+
import { decrypt, encrypt, hash } from "./crypto.js";
|
|
5
|
+
import { exchangeCodeForToken } from '@interface-db/platform';
|
|
6
|
+
import { PlatformApi } from '@interface-db/platform';
|
|
7
|
+
import cookieParser from 'cookie-parser';
|
|
8
|
+
import { InvalidRequestError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
9
|
+
export async function tokensOfBearerToken(db, token) {
|
|
10
|
+
const queryRes = await db.query({
|
|
11
|
+
mcpTokens: {
|
|
12
|
+
$: {
|
|
13
|
+
where: {
|
|
14
|
+
tokenHash: hash(token),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
client: {},
|
|
18
|
+
instantToken: {},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const tokenEnt = queryRes.mcpTokens[0];
|
|
22
|
+
if (!tokenEnt) {
|
|
23
|
+
throw new InvalidTokenError('Token not found.');
|
|
24
|
+
}
|
|
25
|
+
return { mcpToken: tokenEnt, instantToken: tokenEnt.instantToken };
|
|
26
|
+
}
|
|
27
|
+
export function makeApiAuth(oauthConfig, key, db, instantTokenEnt) {
|
|
28
|
+
return {
|
|
29
|
+
accessToken: decrypt({
|
|
30
|
+
key,
|
|
31
|
+
enc: instantTokenEnt.accessToken,
|
|
32
|
+
aad: instantTokenEnt.id,
|
|
33
|
+
}),
|
|
34
|
+
refreshToken: decrypt({
|
|
35
|
+
key,
|
|
36
|
+
enc: instantTokenEnt.refreshToken,
|
|
37
|
+
aad: instantTokenEnt.id,
|
|
38
|
+
}),
|
|
39
|
+
clientId: oauthConfig.clientId,
|
|
40
|
+
clientSecret: oauthConfig.clientSecret,
|
|
41
|
+
onRefresh: async ({ accessToken, expiresAt }) => {
|
|
42
|
+
await db.transact(db.tx.instantTokens[instantTokenEnt.id].update({
|
|
43
|
+
accessToken: encrypt({
|
|
44
|
+
key,
|
|
45
|
+
aad: instantTokenEnt.id,
|
|
46
|
+
plaintext: accessToken,
|
|
47
|
+
}),
|
|
48
|
+
expiresAt: expiresAt.getTime(),
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function patchClientForScopes(client) {
|
|
54
|
+
if (!client.scope ||
|
|
55
|
+
// https://github.com/modelcontextprotocol/modelcontextprotocol/issues/653
|
|
56
|
+
// Anthropic says it's fixed, but it doesn't seem like it
|
|
57
|
+
client.scope?.includes('claudeai') ||
|
|
58
|
+
// Fix for mcp-remote. Unclear why it can't find the scopes
|
|
59
|
+
// and falls back to its default
|
|
60
|
+
client.scope === 'openid email profile') {
|
|
61
|
+
return { ...client, scope: 'apps-read apps-write' };
|
|
62
|
+
}
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
export class ServiceProvider {
|
|
66
|
+
#db;
|
|
67
|
+
#oauthConfig;
|
|
68
|
+
#keyConfig;
|
|
69
|
+
constructor(db, oauthConfig, keyConfig) {
|
|
70
|
+
this.#db = db;
|
|
71
|
+
this.#oauthConfig = oauthConfig;
|
|
72
|
+
this.#keyConfig = keyConfig;
|
|
73
|
+
}
|
|
74
|
+
get clientsStore() {
|
|
75
|
+
return {
|
|
76
|
+
getClient: async (clientId) => {
|
|
77
|
+
const res = await this.#db.query({
|
|
78
|
+
clients: { $: { where: { client_id: clientId } } },
|
|
79
|
+
});
|
|
80
|
+
const client = res.clients[0];
|
|
81
|
+
if (!client) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
...client,
|
|
86
|
+
...(client.client_secret
|
|
87
|
+
? {
|
|
88
|
+
client_secret: decrypt({
|
|
89
|
+
key: this.#keyConfig,
|
|
90
|
+
enc: client.client_secret,
|
|
91
|
+
aad: client.client_id,
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
registerClient: async (rawClient) => {
|
|
98
|
+
const client = {
|
|
99
|
+
...patchClientForScopes(rawClient),
|
|
100
|
+
};
|
|
101
|
+
await this.#db.transact(this.#db.tx.clients[id()].update({
|
|
102
|
+
...client,
|
|
103
|
+
...(client.client_secret
|
|
104
|
+
? {
|
|
105
|
+
client_secret: encrypt({
|
|
106
|
+
key: this.#keyConfig,
|
|
107
|
+
aad: client.client_id,
|
|
108
|
+
plaintext: client.client_secret,
|
|
109
|
+
}),
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
}));
|
|
113
|
+
return client;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async authorize(client, params, res) {
|
|
118
|
+
// Tag the cookie with a prefix in case multiple have the same key
|
|
119
|
+
const cookie = `_imcp_${crypto.randomUUID()}`;
|
|
120
|
+
const cookieHash = hash(cookie);
|
|
121
|
+
const state = crypto.randomUUID();
|
|
122
|
+
await this.#db.transact(this.#db.tx.redirects[id()]
|
|
123
|
+
.update({
|
|
124
|
+
cookieHash,
|
|
125
|
+
authParams: params,
|
|
126
|
+
state,
|
|
127
|
+
clientToken: crypto.randomUUID(),
|
|
128
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 10).getTime(),
|
|
129
|
+
})
|
|
130
|
+
.link({ client: lookup('client_id', client.client_id) }));
|
|
131
|
+
res
|
|
132
|
+
.cookie('__session', cookie, {
|
|
133
|
+
httpOnly: true,
|
|
134
|
+
secure: this.#oauthConfig.serverOrigin.startsWith('https'),
|
|
135
|
+
sameSite: 'lax',
|
|
136
|
+
path: '/oauth',
|
|
137
|
+
expires: new Date(Date.now() + 1000 * 60 * 5),
|
|
138
|
+
})
|
|
139
|
+
.redirect('/oauth/start');
|
|
140
|
+
}
|
|
141
|
+
async challengeForAuthorizationCode(_client, authorizationCode) {
|
|
142
|
+
const mcpCodeHash = hash(authorizationCode);
|
|
143
|
+
const queryRes = await this.#db.query({
|
|
144
|
+
redirects: { $: { where: { mcpCodeHash } } },
|
|
145
|
+
});
|
|
146
|
+
const redirect = queryRes.redirects[0];
|
|
147
|
+
if (!redirect) {
|
|
148
|
+
throw new Error('Could not find OAuth request.');
|
|
149
|
+
}
|
|
150
|
+
if (!redirect.exchangedForInstantCode) {
|
|
151
|
+
throw new Error('OAuth flow is in an invalid state. Expected to exchange a code for a token first.');
|
|
152
|
+
}
|
|
153
|
+
return redirect.authParams.codeChallenge;
|
|
154
|
+
}
|
|
155
|
+
async exchangeAuthorizationCode(client, authorizationCode,
|
|
156
|
+
// Already checked in `challengeForAuthorizationCode`
|
|
157
|
+
_codeVerifier, redirectUri) {
|
|
158
|
+
const mcpCodeHash = hash(authorizationCode);
|
|
159
|
+
const queryRes = await this.#db.query({
|
|
160
|
+
redirects: { $: { where: { mcpCodeHash } } },
|
|
161
|
+
});
|
|
162
|
+
const redirect = queryRes.redirects[0];
|
|
163
|
+
if (!redirect) {
|
|
164
|
+
throw new InvalidRequestError('Could not find OAuth request.');
|
|
165
|
+
}
|
|
166
|
+
await this.#db.transact(this.#db.tx.redirects[redirect.id].delete());
|
|
167
|
+
const originalRedirectUri = redirect.authParams.redirectUri;
|
|
168
|
+
if (originalRedirectUri !== redirectUri) {
|
|
169
|
+
throw new InvalidRequestError('Invalid redirect_uri.');
|
|
170
|
+
}
|
|
171
|
+
if (!redirect.exchangedForInstantCode || !redirect.instantCode) {
|
|
172
|
+
throw new InvalidRequestError('OAuth flow is in an invalid state. Expected to exchange a code for a token first.');
|
|
173
|
+
}
|
|
174
|
+
const code = redirect.instantCode;
|
|
175
|
+
const tokenInfo = await exchangeCodeForToken({
|
|
176
|
+
code,
|
|
177
|
+
clientId: this.#oauthConfig.clientId,
|
|
178
|
+
clientSecret: this.#oauthConfig.clientSecret,
|
|
179
|
+
redirectUri: `${this.#oauthConfig.serverOrigin}/oauth/external-redirect`,
|
|
180
|
+
});
|
|
181
|
+
const instantTokenExpiresAt = tokenInfo.expiresAt;
|
|
182
|
+
const mcpTokenExpiresAt = new Date(instantTokenExpiresAt.getTime() - 1000 * 60 * 60);
|
|
183
|
+
const mcpToken = `at_${crypto.randomUUID()}`;
|
|
184
|
+
const mcpRefreshToken = `rt_${crypto.randomUUID()}`;
|
|
185
|
+
const mcpTokenId = id();
|
|
186
|
+
const mcpRefreshTokenId = id();
|
|
187
|
+
const instantTokenId = id();
|
|
188
|
+
await this.#db.transact([
|
|
189
|
+
this.#db.tx.instantTokens[instantTokenId]
|
|
190
|
+
.update({
|
|
191
|
+
accessToken: encrypt({
|
|
192
|
+
key: this.#keyConfig,
|
|
193
|
+
aad: instantTokenId,
|
|
194
|
+
plaintext: tokenInfo.accessToken,
|
|
195
|
+
}),
|
|
196
|
+
expiresAt: instantTokenExpiresAt.getTime(),
|
|
197
|
+
refreshToken: encrypt({
|
|
198
|
+
key: this.#keyConfig,
|
|
199
|
+
aad: instantTokenId,
|
|
200
|
+
plaintext: tokenInfo.refreshToken,
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
.link({ client: lookup('client_id', client.client_id) }),
|
|
204
|
+
this.#db.tx.mcpTokens[mcpTokenId]
|
|
205
|
+
.update({
|
|
206
|
+
tokenHash: hash(mcpToken),
|
|
207
|
+
expiresAt: mcpTokenExpiresAt.getTime(),
|
|
208
|
+
scope: tokenInfo.scopes,
|
|
209
|
+
})
|
|
210
|
+
.link({ instantToken: instantTokenId })
|
|
211
|
+
.link({ mcpRefreshToken: mcpRefreshTokenId })
|
|
212
|
+
.link({ client: lookup('client_id', client.client_id) }),
|
|
213
|
+
this.#db.tx.mcpRefreshTokens[mcpRefreshTokenId]
|
|
214
|
+
.update({
|
|
215
|
+
tokenHash: hash(mcpRefreshToken),
|
|
216
|
+
scope: tokenInfo.scopes,
|
|
217
|
+
})
|
|
218
|
+
.link({ instantToken: instantTokenId })
|
|
219
|
+
.link({ client: lookup('client_id', client.client_id) }),
|
|
220
|
+
]);
|
|
221
|
+
return {
|
|
222
|
+
access_token: mcpToken,
|
|
223
|
+
token_type: 'bearer',
|
|
224
|
+
expires_in: Math.floor((mcpTokenExpiresAt.getTime() - Date.now()) / 1000),
|
|
225
|
+
scope: tokenInfo.scopes,
|
|
226
|
+
refresh_token: mcpRefreshToken,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async exchangeRefreshToken(client, refreshToken, _scopes) {
|
|
230
|
+
const queryRes = await this.#db.query({
|
|
231
|
+
mcpRefreshTokens: {
|
|
232
|
+
$: {
|
|
233
|
+
where: {
|
|
234
|
+
tokenHash: hash(refreshToken),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
client: {},
|
|
238
|
+
instantToken: {},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const tokenEnt = queryRes.mcpRefreshTokens[0];
|
|
242
|
+
if (!tokenEnt) {
|
|
243
|
+
throw new InvalidTokenError('Token not found.');
|
|
244
|
+
}
|
|
245
|
+
if (client.client_id !== tokenEnt.client.client_id) {
|
|
246
|
+
throw new InvalidTokenError('Refresh token does not belong to client.');
|
|
247
|
+
}
|
|
248
|
+
const mcpToken = `at_${crypto.randomUUID()}`;
|
|
249
|
+
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14).getTime();
|
|
250
|
+
await this.#db.transact(this.#db.tx.mcpTokens[id()]
|
|
251
|
+
.update({
|
|
252
|
+
tokenHash: hash(mcpToken),
|
|
253
|
+
expiresAt,
|
|
254
|
+
scope: tokenEnt.scope,
|
|
255
|
+
})
|
|
256
|
+
// instantToken is required, so we should be able to fix the types
|
|
257
|
+
// so we don't need the `!`s
|
|
258
|
+
.link({ instantToken: tokenEnt.instantToken.id })
|
|
259
|
+
.link({ mcpRefreshToken: tokenEnt.id })
|
|
260
|
+
.link({ client: lookup('client_id', client.client_id) }));
|
|
261
|
+
return {
|
|
262
|
+
access_token: mcpToken,
|
|
263
|
+
token_type: 'bearer',
|
|
264
|
+
expires_in: Math.floor((Date.now() - expiresAt) / 1000),
|
|
265
|
+
refresh_token: refreshToken,
|
|
266
|
+
scope: tokenEnt.scope,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async verifyAccessToken(token) {
|
|
270
|
+
const { instantToken, mcpToken } = await tokensOfBearerToken(this.#db, token);
|
|
271
|
+
const api = new PlatformApi({
|
|
272
|
+
auth: makeApiAuth(this.#oauthConfig, this.#keyConfig, this.#db, instantToken),
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
await api.tokenInfo();
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
throw new InvalidTokenError(e instanceof Error ? e.message : 'Invalid token');
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
clientId: mcpToken.client.client_id,
|
|
282
|
+
scopes: mcpToken.scope.split(' '),
|
|
283
|
+
token,
|
|
284
|
+
expiresAt: new Date(mcpToken.expiresAt).getTime(),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async revokeToken(_client, request) {
|
|
288
|
+
const tokenHash = hash(request.token);
|
|
289
|
+
await this.#db.transact([
|
|
290
|
+
this.#db.tx.mcpTokens[lookup('tokenHash', tokenHash)].delete(),
|
|
291
|
+
this.#db.tx.mcpRefreshTokens[lookup('tokenHash', tokenHash)].delete(),
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function useRedirectFromCookie(db) {
|
|
296
|
+
return async (req, res, next) => {
|
|
297
|
+
const cookie = req.cookies.__session;
|
|
298
|
+
if (!cookie) {
|
|
299
|
+
res.status(400).send('Missing cookie, cannot complete OAuth flow.');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const cookieHash = hash(cookie);
|
|
303
|
+
const queryRes = await db.query({
|
|
304
|
+
redirects: {
|
|
305
|
+
$: { where: { cookieHash } },
|
|
306
|
+
client: {},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const redirect = queryRes.redirects[0];
|
|
310
|
+
if (!redirect) {
|
|
311
|
+
res.status(400).send('Could not find OAuth flow, please try again.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (new Date(redirect.expiresAt) < new Date()) {
|
|
315
|
+
await db.transact(db.tx.redirects[redirect.id].delete());
|
|
316
|
+
res.status(400).send('OAuth flow is expired, please try again.');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
req.oauthRedirect = redirect;
|
|
320
|
+
next();
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function cleanupRedirect(db, redirect) {
|
|
324
|
+
await db.transact(db.tx.redirects[redirect.id].delete());
|
|
325
|
+
}
|
|
326
|
+
function oauthStartHtml(redirect) {
|
|
327
|
+
const clientName = redirect.client?.client_name || 'Unknown client';
|
|
328
|
+
const redirectUri = encodeURI(redirect.authParams.redirectUri);
|
|
329
|
+
return /* HTML */ `<!DOCTYPE html>
|
|
330
|
+
<html lang="en">
|
|
331
|
+
<head>
|
|
332
|
+
<meta charset="UTF-8" />
|
|
333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
334
|
+
<title>Authorize ${clientName}</title>
|
|
335
|
+
<style>
|
|
336
|
+
:root {
|
|
337
|
+
--primary-color: #007bff;
|
|
338
|
+
--primary-hover-color: #0056b3;
|
|
339
|
+
--secondary-color: #6c757d;
|
|
340
|
+
--secondary-hover-color: #5a6268;
|
|
341
|
+
--bg-color: #f8f9fa;
|
|
342
|
+
--card-bg-color: #ffffff;
|
|
343
|
+
--text-color: #212529;
|
|
344
|
+
--border-color: #dee2e6;
|
|
345
|
+
--uri-bg-color: #e9ecef;
|
|
346
|
+
--uri-border-color: #ced4da;
|
|
347
|
+
--border-radius: 8px;
|
|
348
|
+
--shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
body {
|
|
352
|
+
font-family: sans-serif;
|
|
353
|
+
background-color: var(--bg-color);
|
|
354
|
+
color: var(--text-color);
|
|
355
|
+
display: flex;
|
|
356
|
+
justify-content: center;
|
|
357
|
+
align-items: center;
|
|
358
|
+
min-height: 100vh;
|
|
359
|
+
margin: 0;
|
|
360
|
+
padding: 1rem;
|
|
361
|
+
box-sizing: border-box;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.container {
|
|
365
|
+
background-color: var(--card-bg-color);
|
|
366
|
+
padding: 2.5rem;
|
|
367
|
+
border-radius: var(--border-radius);
|
|
368
|
+
box-shadow: var(--shadow);
|
|
369
|
+
max-width: 520px;
|
|
370
|
+
width: 100%;
|
|
371
|
+
/* CHANGED: Text is now left-aligned */
|
|
372
|
+
text-align: left;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
h1 {
|
|
376
|
+
font-size: 1.75rem;
|
|
377
|
+
font-weight: 700;
|
|
378
|
+
margin-top: 0;
|
|
379
|
+
margin-bottom: 1rem;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
p {
|
|
383
|
+
line-height: 1.6;
|
|
384
|
+
margin-bottom: 1rem; /* Adjusted margin for new layout */
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.client-name {
|
|
388
|
+
font-weight: 700;
|
|
389
|
+
color: var(--primary-color);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* NEW: Styling for the dedicated URL block */
|
|
393
|
+
.uri-display {
|
|
394
|
+
background-color: var(--uri-bg-color);
|
|
395
|
+
padding: 0.75rem 1rem;
|
|
396
|
+
margin-top: 0.5rem;
|
|
397
|
+
margin-bottom: 2rem;
|
|
398
|
+
border: 1px solid var(--uri-border-color);
|
|
399
|
+
border-radius: 6px;
|
|
400
|
+
font-family: 'Source Code Pro', monospace;
|
|
401
|
+
word-break: break-all;
|
|
402
|
+
font-size: 0.9rem;
|
|
403
|
+
color: var(--text-color);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.actions {
|
|
407
|
+
display: flex;
|
|
408
|
+
gap: 1rem;
|
|
409
|
+
justify-content: flex-start; /* Aligns buttons to the left */
|
|
410
|
+
margin-top: 2rem;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.actions form {
|
|
414
|
+
flex: 1 1 0;
|
|
415
|
+
display: flex;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.btn {
|
|
419
|
+
display: inline-block;
|
|
420
|
+
font-family: inherit;
|
|
421
|
+
font-size: 1rem;
|
|
422
|
+
font-weight: 500;
|
|
423
|
+
padding: 0.75rem 1rem;
|
|
424
|
+
border-radius: var(--border-radius);
|
|
425
|
+
border: 1px solid transparent;
|
|
426
|
+
cursor: pointer;
|
|
427
|
+
transition:
|
|
428
|
+
background-color 0.2s ease-in-out,
|
|
429
|
+
color 0.2s ease-in-out,
|
|
430
|
+
border-color 0.2s ease-in-out;
|
|
431
|
+
width: 100%;
|
|
432
|
+
text-decoration: none;
|
|
433
|
+
text-align: center;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.btn-primary {
|
|
437
|
+
background-color: var(--primary-color);
|
|
438
|
+
color: white;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.btn-primary:hover {
|
|
442
|
+
background-color: var(--primary-hover-color);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.btn-secondary {
|
|
446
|
+
background-color: transparent;
|
|
447
|
+
color: var(--secondary-color);
|
|
448
|
+
border-color: var(--border-color);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.btn-secondary:hover {
|
|
452
|
+
background-color: var(--secondary-color);
|
|
453
|
+
color: white;
|
|
454
|
+
border-color: var(--secondary-color);
|
|
455
|
+
}
|
|
456
|
+
</style>
|
|
457
|
+
</head>
|
|
458
|
+
<body>
|
|
459
|
+
<div class="container">
|
|
460
|
+
<h1>Authorize Application</h1>
|
|
461
|
+
<p>
|
|
462
|
+
The application
|
|
463
|
+
<strong class="client-name">${clientName}</strong> is requesting
|
|
464
|
+
permission to access your InstantDB account.
|
|
465
|
+
</p>
|
|
466
|
+
|
|
467
|
+
<p>
|
|
468
|
+
If you approve, you will first be sent to InstantDB to confirm, then
|
|
469
|
+
you will be redirected to the application at the following address:
|
|
470
|
+
</p>
|
|
471
|
+
|
|
472
|
+
<div class="uri-display">${redirectUri}</div>
|
|
473
|
+
|
|
474
|
+
<div class="actions">
|
|
475
|
+
<form method="POST" action="/oauth/deny">
|
|
476
|
+
<button type="submit" class="btn btn-secondary">Deny</button>
|
|
477
|
+
</form>
|
|
478
|
+
<form method="POST" action="/oauth/redirect-from-start">
|
|
479
|
+
<input
|
|
480
|
+
type="hidden"
|
|
481
|
+
name="clientToken"
|
|
482
|
+
value="${redirect.clientToken}"
|
|
483
|
+
/>
|
|
484
|
+
<button type="submit" class="btn btn-primary">Authorize</button>
|
|
485
|
+
</form>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</body>
|
|
489
|
+
</html>`;
|
|
490
|
+
}
|
|
491
|
+
async function oauthStart(db, req, res) {
|
|
492
|
+
const redirect = req.oauthRedirect;
|
|
493
|
+
if (redirect.shownConfirmPage) {
|
|
494
|
+
await cleanupRedirect(db, redirect);
|
|
495
|
+
res
|
|
496
|
+
.status(400)
|
|
497
|
+
.send('OAuth request is in an invalid state. Please try again.');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
await db.transact(db.tx.redirects[redirect.id].update({ shownConfirmPage: true }));
|
|
501
|
+
res
|
|
502
|
+
.status(200)
|
|
503
|
+
.set('Content-Type', 'text/html; charset=UTF-8')
|
|
504
|
+
.send(oauthStartHtml(redirect));
|
|
505
|
+
}
|
|
506
|
+
async function oauthRedirectFromStart(db, oauthConfig, req, res) {
|
|
507
|
+
const redirect = req.oauthRedirect;
|
|
508
|
+
if (!redirect.shownConfirmPage) {
|
|
509
|
+
await cleanupRedirect(db, redirect);
|
|
510
|
+
res
|
|
511
|
+
.status(400)
|
|
512
|
+
.send('OAuth request is in an invalid state. Please try again.');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!req.body.clientToken ||
|
|
516
|
+
!crypto.timingSafeEqual(Buffer.from(req.body.clientToken, 'utf-8'), Buffer.from(redirect.clientToken, 'utf-8'))) {
|
|
517
|
+
await cleanupRedirect(db, redirect);
|
|
518
|
+
res.status(400).send('Invalid OAuth request. Please try again.');
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const externalAuthUrl = new URL(`https://api.interfacedb.com/platform/oauth/start`);
|
|
522
|
+
externalAuthUrl.searchParams.set('client_id', oauthConfig.clientId);
|
|
523
|
+
externalAuthUrl.searchParams.set('response_type', 'code');
|
|
524
|
+
externalAuthUrl.searchParams.set('state', redirect.state);
|
|
525
|
+
externalAuthUrl.searchParams.set('scope', redirect.authParams.scopes?.join(' ') ||
|
|
526
|
+
redirect.client.scope ||
|
|
527
|
+
'apps-read apps-write');
|
|
528
|
+
externalAuthUrl.searchParams.set('redirect_uri', `${oauthConfig.serverOrigin}/oauth/external-redirect`);
|
|
529
|
+
res.redirect(externalAuthUrl.toString());
|
|
530
|
+
}
|
|
531
|
+
async function oauthExternalRedirect(db, req, res) {
|
|
532
|
+
const redirect = req.oauthRedirect;
|
|
533
|
+
if (redirect.exchangedForInstantCode) {
|
|
534
|
+
await cleanupRedirect(db, redirect);
|
|
535
|
+
res.status(400).send('OAuth flow is expired, please try again.');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (req.query.error) {
|
|
539
|
+
await cleanupRedirect(db, redirect);
|
|
540
|
+
const redirectUri = new URL(redirect.authParams.redirectUri);
|
|
541
|
+
redirectUri.searchParams.set('error', req.query.error);
|
|
542
|
+
if (req.query.error_description) {
|
|
543
|
+
redirectUri.searchParams.set('error_description', req.query.error_description);
|
|
544
|
+
}
|
|
545
|
+
res.redirect(redirectUri.toString());
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const instantCode = req.query.code;
|
|
549
|
+
if (!instantCode) {
|
|
550
|
+
await cleanupRedirect(db, redirect);
|
|
551
|
+
res
|
|
552
|
+
.status(400)
|
|
553
|
+
.send('Could not complete OAuth flow, missing code param. Please try again.');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const state = req.query.state;
|
|
557
|
+
if (!state) {
|
|
558
|
+
await cleanupRedirect(db, redirect);
|
|
559
|
+
res
|
|
560
|
+
.status(400)
|
|
561
|
+
.send('Could not complete OAuth flow, missing state param. Please try again.');
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!crypto.timingSafeEqual(Buffer.from(state), Buffer.from(redirect.state))) {
|
|
565
|
+
await cleanupRedirect(db, redirect);
|
|
566
|
+
res
|
|
567
|
+
.status(400)
|
|
568
|
+
.send('Could not complete OAuth flow, invalid state param. Please try again.');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const mcpCode = crypto.randomUUID();
|
|
572
|
+
const mcpCodeHash = hash(mcpCode);
|
|
573
|
+
await db.transact(db.tx.redirects[redirect.id].update({
|
|
574
|
+
instantCode,
|
|
575
|
+
mcpCodeHash,
|
|
576
|
+
exchangedForInstantCode: true,
|
|
577
|
+
}));
|
|
578
|
+
const mcpRedirectUri = new URL(redirect.authParams.redirectUri);
|
|
579
|
+
mcpRedirectUri.searchParams.set('code', mcpCode);
|
|
580
|
+
if (redirect.authParams.state) {
|
|
581
|
+
mcpRedirectUri.searchParams.set('state', redirect.authParams.state);
|
|
582
|
+
}
|
|
583
|
+
res.redirect(mcpRedirectUri.toString());
|
|
584
|
+
}
|
|
585
|
+
export function addOAuthRoutes(app, db, oauthConfig) {
|
|
586
|
+
app.get('/oauth/start', cookieParser(), useRedirectFromCookie(db), async (req, res) => {
|
|
587
|
+
return await oauthStart(db, req, res);
|
|
588
|
+
});
|
|
589
|
+
app.post('/oauth/redirect-from-start', cookieParser(), useRedirectFromCookie(db), urlencoded({ extended: true }), async (req, res) => {
|
|
590
|
+
return await oauthRedirectFromStart(db, oauthConfig, req, res);
|
|
591
|
+
});
|
|
592
|
+
app.post('/oauth/deny', cookieParser(), useRedirectFromCookie(db), async (req, res) => {
|
|
593
|
+
const redirect = req.oauthRedirect;
|
|
594
|
+
await db.transact(db.tx.redirects[redirect.id].delete());
|
|
595
|
+
const redirectUri = new URL(redirect.authParams.redirectUri);
|
|
596
|
+
redirectUri.searchParams.set('error', 'access_denied');
|
|
597
|
+
res.redirect(redirectUri.toString());
|
|
598
|
+
});
|
|
599
|
+
app.get('/oauth/external-redirect', cookieParser(), useRedirectFromCookie(db), async (req, res) => {
|
|
600
|
+
return await oauthExternalRedirect(db, req, res);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
//# sourceMappingURL=oauth-service-provider.js.map
|