@coffer-org/server 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ declare module 'fastify' {
6
6
  }
7
7
  }
8
8
  export declare const PUBLIC_API_PATHS: string[];
9
+ export declare function startPasswordSession(reply: FastifyReply, login: string, password: string): Promise<AuthUser | null>;
9
10
  export declare function resolveRequestUser(req: FastifyRequest): Promise<AuthUser | null>;
10
11
  export declare function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean;
11
12
  export declare function registerAuthApi(app: FastifyInstance): Promise<void>;
package/dist/auth-api.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { field } from '@coffer-org/sdk/fields';
2
2
  import { countUsers, createUser, findUserByLogin, findUserById, getPasswordHash, listUsers, updateUser, deleteUser, countAdmins, createSession, resolveSession, deleteSession, createApiToken, resolveApiToken, listApiTokens, revokeApiToken, } from "./auth-store.js";
3
+ import { resolveAccessToken } from "./oauth-store.js";
4
+ import { mcpResource } from "./public-url.js";
3
5
  import { verifyPassword } from "./auth-crypto.js";
4
6
  import { maskSecrets, preserveSecrets } from "./field-masking.js";
5
7
  import { rowMatch } from "./records-api.js";
@@ -24,10 +26,21 @@ function setCookie(reply, value, maxAgeSec) {
24
26
  const secure = process.env['NODE_ENV'] === 'production' ? '; Secure' : '';
25
27
  reply.header('set-cookie', `${COOKIE_NAME}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
26
28
  }
29
+ export async function startPasswordSession(reply, login, password) {
30
+ if (!login || !password)
31
+ return null;
32
+ const row = await findUserByLogin(login);
33
+ if (!row || row.disabled || !(await verifyPassword(password, row.passwordHash)))
34
+ return null;
35
+ const { raw } = await createSession(row.id, SESSION_TTL_MS);
36
+ setCookie(reply, raw, SESSION_TTL_MS / 1000);
37
+ return row;
38
+ }
27
39
  export async function resolveRequestUser(req) {
28
40
  const auth = req.headers.authorization;
29
41
  if (auth?.startsWith('Bearer ')) {
30
- return resolveApiToken(auth.slice('Bearer '.length));
42
+ const raw = auth.slice('Bearer '.length);
43
+ return (await resolveApiToken(raw)) ?? (await resolveAccessToken(raw, await mcpResource(req)));
31
44
  }
32
45
  const sid = readCookie(req, COOKIE_NAME);
33
46
  if (!sid)
@@ -73,13 +86,10 @@ export async function registerAuthApi(app) {
73
86
  const body = (req.body ?? {});
74
87
  if (!body.login || !body.password)
75
88
  return reply.code(422).send({ issues: [{ field: 'login', code: 'required' }] });
76
- const row = await findUserByLogin(body.login);
77
- if (!row || row.disabled || !(await verifyPassword(body.password, row.passwordHash))) {
89
+ const user = await startPasswordSession(reply, body.login, body.password);
90
+ if (!user)
78
91
  return reply.code(401).send({ error: 'invalid_credentials' });
79
- }
80
- const { raw } = await createSession(row.id, SESSION_TTL_MS);
81
- setCookie(reply, raw, SESSION_TTL_MS / 1000);
82
- return publicUser(row);
92
+ return publicUser(user);
83
93
  });
84
94
  app.post('/api/auth/logout', async (req, reply) => {
85
95
  const sid = readCookie(req, COOKIE_NAME);
@@ -22,4 +22,7 @@ export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/c
22
22
  export declare const UserSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
23
23
  export declare const SessionSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
24
24
  export declare const ApiTokenSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
25
+ export declare const OAuthClientSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
26
+ export declare const OAuthCodeSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
27
+ export declare const OAuthTokenSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
25
28
  export declare const systemEntities: EntitySchema[];
@@ -225,9 +225,49 @@ export const ApiTokenSchema = new EntitySchema({
225
225
  revoked: { type: 'integer' },
226
226
  },
227
227
  });
228
+ export const OAuthClientSchema = new EntitySchema({
229
+ name: '_OAuthClient',
230
+ tableName: '_oauth_clients',
231
+ properties: {
232
+ client_id: { type: 'text', primary: true },
233
+ client_name: { type: 'text' },
234
+ redirect_uris: { type: 'text' },
235
+ created_at: { type: 'text' },
236
+ },
237
+ });
238
+ export const OAuthCodeSchema = new EntitySchema({
239
+ name: '_OAuthCode',
240
+ tableName: '_oauth_codes',
241
+ properties: {
242
+ code_hash: { type: 'text', primary: true },
243
+ client_id: { type: 'text' },
244
+ user_id: { type: 'integer' },
245
+ redirect_uri: { type: 'text' },
246
+ code_challenge: { type: 'text' },
247
+ resource: { type: 'text' },
248
+ expires_at: { type: 'text' },
249
+ created_at: { type: 'text' },
250
+ },
251
+ });
252
+ export const OAuthTokenSchema = new EntitySchema({
253
+ name: '_OAuthToken',
254
+ tableName: '_oauth_tokens',
255
+ properties: {
256
+ token_hash: { type: 'text', primary: true },
257
+ kind: { type: 'text' },
258
+ client_id: { type: 'text' },
259
+ user_id: { type: 'integer' },
260
+ resource: { type: 'text' },
261
+ expires_at: { type: 'text' },
262
+ created_at: { type: 'text' },
263
+ last_used_at: { type: 'text', nullable: true },
264
+ revoked: { type: 'integer' },
265
+ },
266
+ });
228
267
  export const systemEntities = [
229
268
  EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
230
269
  EmbeddingSchema, PluginStateSchema, MsgLogSchema,
231
270
  UserSchema, SessionSchema, ApiTokenSchema,
271
+ OAuthClientSchema, OAuthCodeSchema, OAuthTokenSchema,
232
272
  ThreadMessageSchema,
233
273
  ];
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ import { registerPluginsApi } from "./plugins-api.js";
21
21
  import { pluginHooks, HttpError } from "./plugin-hooks.js";
22
22
  import { registerAuthApi, resolveRequestUser, requireAdmin, PUBLIC_API_PATHS } from "./auth-api.js";
23
23
  import { registerMcpHttp } from "./mcp-http.js";
24
+ import { registerOAuthApi } from "./oauth-api.js";
25
+ import { baseUrl } from "./public-url.js";
24
26
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
25
27
  import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
26
28
  import { buildClientSchema } from "./schema-api.js";
@@ -67,14 +69,19 @@ app.addHook('onSend', async (req, reply, payload) => {
67
69
  return payload;
68
70
  });
69
71
  app.addHook('onRequest', async (req, reply) => {
70
- const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/') || req.url === '/mcp' || req.url.startsWith('/mcp?');
72
+ const isMcp = req.url === '/mcp' || req.url.startsWith('/mcp?');
73
+ const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/') || isMcp;
71
74
  if (!gated)
72
75
  return;
73
76
  if (PUBLIC_API_PATHS.some((p) => req.url.startsWith(p)))
74
77
  return;
75
78
  const user = await resolveRequestUser(req);
76
- if (!user)
79
+ if (!user) {
80
+ if (isMcp) {
81
+ reply.header('WWW-Authenticate', `Bearer resource_metadata="${await baseUrl(req)}/.well-known/oauth-protected-resource"`);
82
+ }
77
83
  return reply.code(401).send({ error: 'unauthorized' });
84
+ }
78
85
  req.user = user;
79
86
  });
80
87
  app.post('/api/upload', async (req, reply) => {
@@ -440,6 +447,7 @@ app.delete('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
440
447
  }
441
448
  }));
442
449
  await registerAuthApi(app);
450
+ registerOAuthApi(app);
443
451
  await registerMcpHttp(app);
444
452
  await registerPluginsApi(app);
445
453
  const WEB_DIST = process.env.WEB_DIST;
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function registerOAuthApi(app: FastifyInstance): void;
@@ -0,0 +1,281 @@
1
+ import { createCode, findClient, issueTokens, listGrants, redeemCode, registerClient, revokeGrant, rotateRefresh, } from "./oauth-store.js";
2
+ import { resolveRequestUser, startPasswordSession } from "./auth-api.js";
3
+ import { baseUrl, mcpResource } from "./public-url.js";
4
+ import { getLogger } from "./log.js";
5
+ const log = getLogger('oauth');
6
+ const DEFAULT_REDIRECT_ALLOW = [
7
+ 'https://claude.ai/api/mcp/auth_callback',
8
+ 'https://claude.com/api/mcp/auth_callback',
9
+ ];
10
+ function redirectAllowlist() {
11
+ const extra = (process.env['OAUTH_REDIRECT_ALLOW'] ?? '')
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean);
15
+ return [...DEFAULT_REDIRECT_ALLOW, ...extra];
16
+ }
17
+ function allowInsecure() {
18
+ return process.env['OAUTH_ALLOW_INSECURE'] === '1';
19
+ }
20
+ const hits = new Map();
21
+ const RATE_WINDOW_MS = 60_000;
22
+ function rateLimited(key, max) {
23
+ const now = Date.now();
24
+ const cur = hits.get(key);
25
+ if (!cur || cur.resetAt < now) {
26
+ hits.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
27
+ return false;
28
+ }
29
+ cur.count += 1;
30
+ return cur.count > max;
31
+ }
32
+ function esc(s) {
33
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
34
+ }
35
+ function page(title, body) {
36
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
37
+ <meta name="viewport" content="width=device-width,initial-scale=1">
38
+ <title>${esc(title)}</title>
39
+ <style>
40
+ body{font:16px/1.5 system-ui,sans-serif;background:#f6f6f5;color:#1a1a19;margin:0;
41
+ display:flex;min-height:100vh;align-items:center;justify-content:center}
42
+ .card{background:#fff;border:1px solid #e3e3e0;border-radius:12px;padding:28px;max-width:26rem;width:calc(100% - 2rem)}
43
+ h1{font-size:1.15rem;margin:0 0 .75rem}
44
+ p{margin:.5rem 0;color:#57564f}
45
+ label{display:block;margin:.75rem 0 .25rem;font-size:.85rem;color:#57564f}
46
+ input{width:100%;box-sizing:border-box;padding:.55rem .65rem;border:1px solid #d5d4cf;border-radius:8px;font:inherit}
47
+ button{margin-top:1.25rem;width:100%;padding:.6rem;border:0;border-radius:8px;background:#1a1a19;color:#fff;font:inherit;cursor:pointer}
48
+ .muted{font-size:.85rem}
49
+ .err{color:#b42318}
50
+ @media(prefers-color-scheme:dark){body{background:#1a1a19;color:#f6f6f5}.card{background:#232320;border-color:#3a3a36}
51
+ p,label{color:#a8a79f}input{background:#1a1a19;color:#f6f6f5;border-color:#3a3a36}button{background:#f6f6f5;color:#1a1a19}}
52
+ </style></head><body><div class="card">${body}</div></body></html>`;
53
+ }
54
+ function hidden(params) {
55
+ return Object.entries(params)
56
+ .map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`)
57
+ .join('');
58
+ }
59
+ function readAuthzParams(src) {
60
+ const s = (k) => (typeof src[k] === 'string' ? src[k] : '');
61
+ return {
62
+ client_id: s('client_id'),
63
+ redirect_uri: s('redirect_uri'),
64
+ state: s('state'),
65
+ code_challenge: s('code_challenge'),
66
+ resource: s('resource'),
67
+ };
68
+ }
69
+ function loginForm(p, clientName, error) {
70
+ return page('Sign in to Coffer', `<h1>Sign in to Coffer</h1>
71
+ <p><b>${esc(clientName)}</b> wants access to your data. Sign in to continue.</p>
72
+ ${error ? `<p class="err">${esc(error)}</p>` : ''}
73
+ <form method="post" action="/oauth/authorize">
74
+ ${hidden({ ...p, action: 'login' })}
75
+ <label for="login">Login</label><input id="login" name="login" autocomplete="username" autofocus>
76
+ <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password">
77
+ <button type="submit">Sign in</button>
78
+ </form>`);
79
+ }
80
+ function consentForm(p, clientName, user) {
81
+ const roleLine = user.role === 'admin'
82
+ ? 'Your account is an <b>administrator</b> — the app will also get the admin-only tools of installed plugins, including settings.'
83
+ : 'Your account is a <b>member</b> — the app gets read/write access to your records, but no admin tools.';
84
+ return page('Authorize access', `<h1>Authorize ${esc(clientName)}</h1>
85
+ <p>It will be able to read, create, update and delete records in your Coffer as <b>${esc(user.login)}</b>.</p>
86
+ <p class="muted">${roleLine}</p>
87
+ <form method="post" action="/oauth/authorize">
88
+ ${hidden({ ...p, action: 'approve' })}
89
+ <button type="submit">Authorize</button>
90
+ </form>
91
+ <p class="muted">You can revoke this at any time in Settings → Connected apps.</p>`);
92
+ }
93
+ function redirectWithError(reply, p, error) {
94
+ const url = new URL(p.redirect_uri);
95
+ url.searchParams.set('error', error);
96
+ if (p.state)
97
+ url.searchParams.set('state', p.state);
98
+ return reply.redirect(url.toString(), 302);
99
+ }
100
+ export function registerOAuthApi(app) {
101
+ app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_req, body, done) => {
102
+ try {
103
+ done(null, Object.fromEntries(new URLSearchParams(body)));
104
+ }
105
+ catch (e) {
106
+ done(e, undefined);
107
+ }
108
+ });
109
+ app.get('/.well-known/oauth-protected-resource', async (req) => ({
110
+ resource: await mcpResource(req),
111
+ authorization_servers: [await baseUrl(req)],
112
+ bearer_methods_supported: ['header'],
113
+ }));
114
+ app.get('/.well-known/oauth-protected-resource/mcp', async (req) => ({
115
+ resource: await mcpResource(req),
116
+ authorization_servers: [await baseUrl(req)],
117
+ bearer_methods_supported: ['header'],
118
+ }));
119
+ const asMetadata = async (req) => {
120
+ const base = await baseUrl(req);
121
+ return {
122
+ issuer: base,
123
+ authorization_endpoint: `${base}/oauth/authorize`,
124
+ token_endpoint: `${base}/oauth/token`,
125
+ registration_endpoint: `${base}/oauth/register`,
126
+ response_types_supported: ['code'],
127
+ grant_types_supported: ['authorization_code', 'refresh_token'],
128
+ code_challenge_methods_supported: ['S256'],
129
+ token_endpoint_auth_methods_supported: ['none'],
130
+ };
131
+ };
132
+ app.get('/.well-known/oauth-authorization-server', async (req) => asMetadata(req));
133
+ app.get('/.well-known/oauth-authorization-server/mcp', async (req) => asMetadata(req));
134
+ app.post('/oauth/register', async (req, reply) => {
135
+ if (rateLimited(`reg:${req.ip}`, 10))
136
+ return reply.code(429).send({ error: 'rate_limited' });
137
+ const body = (req.body ?? {});
138
+ const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u) => typeof u === 'string') : [];
139
+ if (uris.length === 0) {
140
+ return reply.code(400).send({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' });
141
+ }
142
+ const allow = redirectAllowlist();
143
+ const bad = uris.find((u) => !allow.includes(u));
144
+ if (bad) {
145
+ log.warn(`registration rejected: redirect_uri not allowlisted (${bad})`);
146
+ return reply
147
+ .code(400)
148
+ .send({ error: 'invalid_redirect_uri', error_description: `redirect_uri not allowed: ${bad}` });
149
+ }
150
+ const client = await registerClient({ clientName: body.client_name?.slice(0, 200) || 'MCP client', redirectUris: uris });
151
+ log.info(`registered client ${client.clientId} (${client.clientName})`);
152
+ return reply.code(201).send({
153
+ client_id: client.clientId,
154
+ client_name: client.clientName,
155
+ redirect_uris: client.redirectUris,
156
+ token_endpoint_auth_method: 'none',
157
+ grant_types: ['authorization_code', 'refresh_token'],
158
+ response_types: ['code'],
159
+ });
160
+ });
161
+ async function validateAuthz(p) {
162
+ if (!p.client_id || !p.redirect_uri) {
163
+ return { ok: false, html: page('Invalid request', '<h1>Invalid request</h1><p>Missing client_id or redirect_uri.</p>') };
164
+ }
165
+ const client = await findClient(p.client_id);
166
+ if (!client) {
167
+ return { ok: false, html: page('Unknown client', '<h1>Unknown client</h1><p>This application is not registered.</p>') };
168
+ }
169
+ if (!client.redirectUris.includes(p.redirect_uri)) {
170
+ return { ok: false, html: page('Invalid redirect', '<h1>Invalid redirect</h1><p>redirect_uri does not match this client.</p>') };
171
+ }
172
+ return { ok: true, clientName: client.clientName };
173
+ }
174
+ app.get('/oauth/authorize', async (req, reply) => {
175
+ const p = readAuthzParams((req.query ?? {}));
176
+ const check = await validateAuthz(p);
177
+ if (!check.ok)
178
+ return reply.code(400).type('text/html').send(check.html);
179
+ const q = (req.query ?? {});
180
+ if (q['response_type'] !== 'code')
181
+ return redirectWithError(reply, p, 'unsupported_response_type');
182
+ if (q['code_challenge_method'] !== 'S256' || !p.code_challenge) {
183
+ return redirectWithError(reply, p, 'invalid_request');
184
+ }
185
+ const user = await resolveRequestUser(req);
186
+ const html = user ? consentForm(p, check.clientName, user) : loginForm(p, check.clientName);
187
+ return reply.type('text/html').send(html);
188
+ });
189
+ app.post('/oauth/authorize', async (req, reply) => {
190
+ const body = (req.body ?? {});
191
+ const p = readAuthzParams(body);
192
+ const check = await validateAuthz(p);
193
+ if (!check.ok)
194
+ return reply.code(400).type('text/html').send(check.html);
195
+ if (body['action'] === 'login') {
196
+ if (rateLimited(`login:${req.ip}`, 20))
197
+ return reply.code(429).type('text/html').send(page('Slow down', '<h1>Too many attempts</h1>'));
198
+ const user = await startPasswordSession(reply, String(body['login'] ?? ''), String(body['password'] ?? ''));
199
+ if (!user)
200
+ return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Wrong login or password.'));
201
+ return reply.type('text/html').send(consentForm(p, check.clientName, user));
202
+ }
203
+ const user = await resolveRequestUser(req);
204
+ if (!user)
205
+ return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Your session expired. Sign in again.'));
206
+ if (body['action'] !== 'approve')
207
+ return redirectWithError(reply, p, 'access_denied');
208
+ if (!p.code_challenge)
209
+ return redirectWithError(reply, p, 'invalid_request');
210
+ const code = await createCode({
211
+ clientId: p.client_id,
212
+ userId: user.id,
213
+ redirectUri: p.redirect_uri,
214
+ codeChallenge: p.code_challenge,
215
+ resource: p.resource || (await mcpResource(req)),
216
+ });
217
+ const url = new URL(p.redirect_uri);
218
+ url.searchParams.set('code', code);
219
+ if (p.state)
220
+ url.searchParams.set('state', p.state);
221
+ log.info(`authorized client ${p.client_id} for user ${user.login}`);
222
+ return reply.redirect(url.toString(), 302);
223
+ });
224
+ app.get('/api/oauth/grants', async (req, reply) => {
225
+ if (!req.user)
226
+ return reply.code(401).send({ error: 'unauthorized' });
227
+ return listGrants(req.user.id);
228
+ });
229
+ app.delete('/api/oauth/grants/:clientId', async (req, reply) => {
230
+ if (!req.user)
231
+ return reply.code(401).send({ error: 'unauthorized' });
232
+ const { clientId } = req.params;
233
+ const revoked = await revokeGrant(req.user.id, clientId);
234
+ if (revoked === 0)
235
+ return reply.code(404).send({ error: 'not_found' });
236
+ log.info(`revoked ${revoked} token(s) of client ${clientId} for user ${req.user.login}`);
237
+ return { ok: true };
238
+ });
239
+ app.post('/oauth/token', async (req, reply) => {
240
+ reply.header('cache-control', 'no-store');
241
+ if (rateLimited(`token:${req.ip}`, 60))
242
+ return reply.code(429).send({ error: 'slow_down' });
243
+ if (!(await baseUrl(req)).startsWith('https://') && !allowInsecure()) {
244
+ log.error('refusing to issue tokens over plain HTTP (set PUBLIC_URL to the https origin, or OAUTH_ALLOW_INSECURE=1 for local testing)');
245
+ return reply.code(400).send({ error: 'invalid_request', error_description: 'https required' });
246
+ }
247
+ const body = (req.body ?? {});
248
+ const s = (k) => (typeof body[k] === 'string' ? body[k] : '');
249
+ const grant = s('grant_type');
250
+ if (grant === 'refresh_token') {
251
+ const tokens = await rotateRefresh(s('refresh_token'), s('client_id'));
252
+ if (!tokens)
253
+ return reply.code(400).send({ error: 'invalid_grant' });
254
+ return reply.send({
255
+ access_token: tokens.accessToken,
256
+ refresh_token: tokens.refreshToken,
257
+ token_type: 'Bearer',
258
+ expires_in: tokens.expiresIn,
259
+ });
260
+ }
261
+ if (grant !== 'authorization_code') {
262
+ return reply.code(400).send({ error: 'unsupported_grant_type' });
263
+ }
264
+ const result = await redeemCode({
265
+ code: s('code'),
266
+ clientId: s('client_id'),
267
+ redirectUri: s('redirect_uri'),
268
+ codeVerifier: s('code_verifier'),
269
+ });
270
+ if (!result.ok)
271
+ return reply.code(400).send({ error: result.error });
272
+ const tokens = await issueTokens({ clientId: s('client_id'), userId: result.user.id, resource: result.resource });
273
+ return reply.send({
274
+ access_token: tokens.accessToken,
275
+ refresh_token: tokens.refreshToken,
276
+ token_type: 'Bearer',
277
+ expires_in: tokens.expiresIn,
278
+ scope: result.user.role,
279
+ });
280
+ });
281
+ }
@@ -0,0 +1,56 @@
1
+ import { type AuthUser } from './auth-store.ts';
2
+ export declare const CODE_TTL_MS = 60000;
3
+ export declare const ACCESS_TTL_MS: number;
4
+ export declare const REFRESH_TTL_MS: number;
5
+ export interface OAuthClient {
6
+ clientId: string;
7
+ clientName: string;
8
+ redirectUris: string[];
9
+ createdAt: string;
10
+ }
11
+ export declare function registerClient(input: {
12
+ clientName: string;
13
+ redirectUris: string[];
14
+ }): Promise<OAuthClient>;
15
+ export declare function findClient(clientId: string): Promise<OAuthClient | null>;
16
+ export declare function createCode(input: {
17
+ clientId: string;
18
+ userId: number;
19
+ redirectUri: string;
20
+ codeChallenge: string;
21
+ resource: string;
22
+ }): Promise<string>;
23
+ export type CodeRedemption = {
24
+ ok: true;
25
+ user: AuthUser;
26
+ resource: string;
27
+ } | {
28
+ ok: false;
29
+ error: 'invalid_grant';
30
+ };
31
+ export declare function redeemCode(input: {
32
+ code: string;
33
+ clientId: string;
34
+ redirectUri: string;
35
+ codeVerifier: string;
36
+ }): Promise<CodeRedemption>;
37
+ export interface IssuedTokens {
38
+ accessToken: string;
39
+ refreshToken: string;
40
+ expiresIn: number;
41
+ }
42
+ export declare function issueTokens(input: {
43
+ clientId: string;
44
+ userId: number;
45
+ resource: string;
46
+ }): Promise<IssuedTokens>;
47
+ export declare function resolveAccessToken(raw: string, expectedResource: string): Promise<AuthUser | null>;
48
+ export declare function rotateRefresh(raw: string, clientId: string): Promise<IssuedTokens | null>;
49
+ export interface Grant {
50
+ clientId: string;
51
+ clientName: string;
52
+ createdAt: string;
53
+ lastUsedAt: string | null;
54
+ }
55
+ export declare function listGrants(userId: number): Promise<Grant[]>;
56
+ export declare function revokeGrant(userId: number, clientId: string): Promise<number>;
@@ -0,0 +1,159 @@
1
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { getEm } from "./db.js";
3
+ import { generateToken, hashToken } from "./auth-crypto.js";
4
+ import { findUserById } from "./auth-store.js";
5
+ export const CODE_TTL_MS = 60_000;
6
+ export const ACCESS_TTL_MS = 60 * 60_000;
7
+ export const REFRESH_TTL_MS = 30 * 24 * 60 * 60_000;
8
+ function toClient(row) {
9
+ return {
10
+ clientId: row.client_id,
11
+ clientName: row.client_name,
12
+ redirectUris: JSON.parse(row.redirect_uris),
13
+ createdAt: row.created_at,
14
+ };
15
+ }
16
+ export async function registerClient(input) {
17
+ const em = getEm().fork();
18
+ const row = {
19
+ client_id: randomUUID(),
20
+ client_name: input.clientName,
21
+ redirect_uris: JSON.stringify(input.redirectUris),
22
+ created_at: new Date().toISOString(),
23
+ };
24
+ em.create('_OAuthClient', row);
25
+ await em.flush();
26
+ return toClient(row);
27
+ }
28
+ export async function findClient(clientId) {
29
+ const em = getEm().fork();
30
+ const row = (await em.findOne('_OAuthClient', { client_id: clientId }));
31
+ return row ? toClient(row) : null;
32
+ }
33
+ export async function createCode(input) {
34
+ const em = getEm().fork();
35
+ const raw = generateToken();
36
+ em.create('_OAuthCode', {
37
+ code_hash: hashToken(raw),
38
+ client_id: input.clientId,
39
+ user_id: input.userId,
40
+ redirect_uri: input.redirectUri,
41
+ code_challenge: input.codeChallenge,
42
+ resource: input.resource,
43
+ expires_at: new Date(Date.now() + CODE_TTL_MS).toISOString(),
44
+ created_at: new Date().toISOString(),
45
+ });
46
+ await em.flush();
47
+ return raw;
48
+ }
49
+ function verifyPkce(verifier, challenge) {
50
+ const computed = createHash('sha256').update(verifier).digest('base64url');
51
+ const a = Buffer.from(computed);
52
+ const b = Buffer.from(challenge);
53
+ return a.length === b.length && timingSafeEqual(a, b);
54
+ }
55
+ export async function redeemCode(input) {
56
+ const em = getEm().fork();
57
+ const row = (await em.findOne('_OAuthCode', { code_hash: hashToken(input.code) }));
58
+ if (!row)
59
+ return { ok: false, error: 'invalid_grant' };
60
+ em.remove(row);
61
+ await em.flush();
62
+ if (row.client_id !== input.clientId)
63
+ return { ok: false, error: 'invalid_grant' };
64
+ if (row.redirect_uri !== input.redirectUri)
65
+ return { ok: false, error: 'invalid_grant' };
66
+ if (new Date(row.expires_at).getTime() < Date.now())
67
+ return { ok: false, error: 'invalid_grant' };
68
+ if (!verifyPkce(input.codeVerifier, row.code_challenge))
69
+ return { ok: false, error: 'invalid_grant' };
70
+ const user = await findUserById(row.user_id);
71
+ if (!user || user.disabled)
72
+ return { ok: false, error: 'invalid_grant' };
73
+ return { ok: true, user, resource: row.resource };
74
+ }
75
+ export async function issueTokens(input) {
76
+ const em = getEm().fork();
77
+ const now = new Date().toISOString();
78
+ const accessToken = generateToken();
79
+ const refreshToken = generateToken();
80
+ for (const [raw, kind, ttl] of [
81
+ [accessToken, 'access', ACCESS_TTL_MS],
82
+ [refreshToken, 'refresh', REFRESH_TTL_MS],
83
+ ]) {
84
+ em.create('_OAuthToken', {
85
+ token_hash: hashToken(raw),
86
+ kind,
87
+ client_id: input.clientId,
88
+ user_id: input.userId,
89
+ resource: input.resource,
90
+ expires_at: new Date(Date.now() + ttl).toISOString(),
91
+ created_at: now,
92
+ last_used_at: null,
93
+ revoked: 0,
94
+ });
95
+ }
96
+ await em.flush();
97
+ return { accessToken, refreshToken, expiresIn: Math.floor(ACCESS_TTL_MS / 1000) };
98
+ }
99
+ async function findLiveToken(raw, kind) {
100
+ const em = getEm().fork();
101
+ const row = (await em.findOne('_OAuthToken', { token_hash: hashToken(raw), kind, revoked: 0 }));
102
+ if (!row)
103
+ return null;
104
+ if (new Date(row.expires_at).getTime() < Date.now())
105
+ return null;
106
+ return row;
107
+ }
108
+ export async function resolveAccessToken(raw, expectedResource) {
109
+ const row = await findLiveToken(raw, 'access');
110
+ if (!row)
111
+ return null;
112
+ if (row.resource !== expectedResource)
113
+ return null;
114
+ const user = await findUserById(row.user_id);
115
+ if (!user || user.disabled)
116
+ return null;
117
+ await getEm()
118
+ .fork()
119
+ .nativeUpdate('_OAuthToken', { token_hash: row.token_hash }, { last_used_at: new Date().toISOString() });
120
+ return user;
121
+ }
122
+ export async function rotateRefresh(raw, clientId) {
123
+ const row = await findLiveToken(raw, 'refresh');
124
+ if (!row || row.client_id !== clientId)
125
+ return null;
126
+ const user = await findUserById(row.user_id);
127
+ if (!user || user.disabled)
128
+ return null;
129
+ await getEm()
130
+ .fork()
131
+ .nativeUpdate('_OAuthToken', { token_hash: row.token_hash }, { revoked: 1 });
132
+ return issueTokens({ clientId, userId: row.user_id, resource: row.resource });
133
+ }
134
+ export async function listGrants(userId) {
135
+ const em = getEm().fork();
136
+ const rows = (await em.find('_OAuthToken', { user_id: userId, revoked: 0 }));
137
+ const byClient = new Map();
138
+ for (const r of rows) {
139
+ const prev = byClient.get(r.client_id);
140
+ const lastUsed = [prev?.lastUsedAt, r.last_used_at].filter(Boolean).sort().pop() ?? null;
141
+ byClient.set(r.client_id, {
142
+ clientId: r.client_id,
143
+ clientName: prev?.clientName ?? r.client_id,
144
+ createdAt: prev && prev.createdAt < r.created_at ? prev.createdAt : r.created_at,
145
+ lastUsedAt: lastUsed,
146
+ });
147
+ }
148
+ for (const [clientId, grant] of byClient) {
149
+ const client = await findClient(clientId);
150
+ if (client)
151
+ grant.clientName = client.clientName;
152
+ }
153
+ return [...byClient.values()];
154
+ }
155
+ export async function revokeGrant(userId, clientId) {
156
+ return getEm()
157
+ .fork()
158
+ .nativeUpdate('_OAuthToken', { user_id: userId, client_id: clientId, revoked: 0 }, { revoked: 1 });
159
+ }
@@ -0,0 +1,4 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ export declare function invalidatePublicUrlCache(): void;
3
+ export declare function baseUrl(req: FastifyRequest): Promise<string>;
4
+ export declare function mcpResource(req: FastifyRequest): Promise<string>;
@@ -0,0 +1,35 @@
1
+ import { getPluginSettings } from "./plugin-runtime.js";
2
+ const CACHE_TTL_MS = 30_000;
3
+ let cached = null;
4
+ export function invalidatePublicUrlCache() {
5
+ cached = null;
6
+ }
7
+ function normalize(raw) {
8
+ return typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : '';
9
+ }
10
+ async function configuredBase() {
11
+ if (cached && Date.now() - cached.readAt < CACHE_TTL_MS)
12
+ return cached.value;
13
+ let value = '';
14
+ try {
15
+ value = normalize((await getPluginSettings('core'))['publicUrl']);
16
+ }
17
+ catch {
18
+ value = '';
19
+ }
20
+ if (!value)
21
+ value = normalize(process.env['PUBLIC_URL']);
22
+ cached = { value, readAt: Date.now() };
23
+ return value;
24
+ }
25
+ export async function baseUrl(req) {
26
+ const configured = await configuredBase();
27
+ if (configured)
28
+ return configured;
29
+ const proto = req.headers['x-forwarded-proto']?.split(',')[0]?.trim() ?? req.protocol;
30
+ const host = req.headers['x-forwarded-host']?.split(',')[0]?.trim() ?? req.headers.host;
31
+ return `${proto}://${host}`;
32
+ }
33
+ export async function mcpResource(req) {
34
+ return `${await baseUrl(req)}/mcp`;
35
+ }
@@ -2,6 +2,7 @@ import { fieldEntries, buildZodObject } from '@coffer-org/sdk/shelf';
2
2
  import { preserveTree, maskTree } from "./field-masking.js";
3
3
  import { ValidationError, NotFoundError, toIssue } from "./mutate.js";
4
4
  import { getPlugins, getPluginSettings } from "./plugin-runtime.js";
5
+ import { invalidatePublicUrlCache } from "./public-url.js";
5
6
  export function describeSettingsFields(fields) {
6
7
  return fieldEntries(fields).map(([key, f]) => ({
7
8
  key,
@@ -35,6 +36,8 @@ export async function writePluginSettings(em, pluginId, incoming, actor, plugins
35
36
  const data = buildSettingsBody(pluginId, fields, incoming, existing);
36
37
  const row = { plugin_id: pluginId, ...data };
37
38
  await em.upsert(`_settings__${pluginId}`, row);
39
+ if (pluginId === 'core')
40
+ invalidatePublicUrlCache();
38
41
  const masked = maskTree(fields, row);
39
42
  em.create('_Event', {
40
43
  ts: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -25,13 +25,14 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@coffer-org/core": "^1.4.0",
28
- "@coffer-org/sdk": "^1.5.0",
28
+ "@coffer-org/sdk": "^1.6.0",
29
29
  "@extractus/oembed-extractor": "^4.1.0",
30
30
  "@fastify/cors": "^11.2.0",
31
31
  "@fastify/multipart": "^10.0.0",
32
32
  "@fastify/static": "^9.1.3",
33
33
  "@mikro-orm/core": "^7.1.6",
34
34
  "@mikro-orm/sqlite": "^7.1.6",
35
+ "@modelcontextprotocol/sdk": "^1.5.0",
35
36
  "fastify": "^5.2.1",
36
37
  "open-graph-scraper": "^6.11.0",
37
38
  "pino": "^9.14.0",