shutro-erp 0.3.3 → 0.3.4

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 (85) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +128 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +128 -1
  5. package/dist/contacts/.kosh/bindings.js +1 -1
  6. package/dist/contacts/.kosh/schema.d.ts +128 -1
  7. package/dist/integrations/index.js +12 -0
  8. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  9. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  10. package/dist/integrations/web/index.d.ts +1 -0
  11. package/dist/integrations/web/index.js +3 -0
  12. package/dist/integrations/web/messages.js +58 -0
  13. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  14. package/dist/l10n_bd/.kosh/schema.d.ts +128 -1
  15. package/dist/l10n_in/.kosh/bindings.js +1 -1
  16. package/dist/l10n_in/.kosh/schema.d.ts +128 -1
  17. package/dist/messaging/web/index.js +1 -1
  18. package/dist/notify/web/index.js +1 -1
  19. package/dist/payment/.kosh/bindings.js +1 -1
  20. package/dist/payment/.kosh/schema.d.ts +128 -1
  21. package/dist/portal/.kosh/bindings.js +1 -1
  22. package/dist/portal/.kosh/schema.d.ts +128 -1
  23. package/dist/product/.kosh/bindings.js +1 -1
  24. package/dist/product/.kosh/schema.d.ts +128 -1
  25. package/dist/project/.kosh/bindings.js +1 -1
  26. package/dist/project/.kosh/schema.d.ts +128 -1
  27. package/dist/project_portal/.kosh/bindings.js +1 -1
  28. package/dist/project_portal/.kosh/schema.d.ts +128 -1
  29. package/dist/purchase/.kosh/bindings.js +1 -1
  30. package/dist/purchase/.kosh/schema.d.ts +128 -1
  31. package/dist/purchase_account/.kosh/bindings.js +1 -1
  32. package/dist/purchase_account/.kosh/schema.d.ts +128 -1
  33. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  34. package/dist/purchase_portal/.kosh/schema.d.ts +128 -1
  35. package/dist/sale_account/.kosh/bindings.js +1 -1
  36. package/dist/sale_account/.kosh/schema.d.ts +128 -1
  37. package/dist/sale_portal/.kosh/bindings.js +1 -1
  38. package/dist/sale_portal/.kosh/schema.d.ts +128 -1
  39. package/dist/sale_project/.kosh/bindings.js +1 -1
  40. package/dist/sale_project/.kosh/schema.d.ts +128 -1
  41. package/dist/sales/.kosh/bindings.js +1 -1
  42. package/dist/sales/.kosh/schema.d.ts +128 -1
  43. package/dist/server/auth/base.js +76 -0
  44. package/dist/server/auth/oauth.d.ts +21 -0
  45. package/dist/server/auth/oauth.js +579 -0
  46. package/dist/server/auth/service.d.ts +4 -0
  47. package/dist/server/auth/service.js +17 -0
  48. package/dist/server/identity.d.ts +2 -0
  49. package/dist/server/images.d.ts +12 -0
  50. package/dist/server/images.js +139 -0
  51. package/dist/server/index.d.ts +1 -0
  52. package/dist/server/index.js +1 -0
  53. package/dist/server/server.d.ts +4 -0
  54. package/dist/server/server.js +9 -1
  55. package/dist/settings/company.js +3 -1
  56. package/dist/settings/people.js +3 -1
  57. package/dist/settings/web/CompaniesPage.js +2 -2
  58. package/dist/settings/web/CompanyPage.js +13 -2
  59. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  60. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  61. package/dist/settings/web/OverviewPage.js +12 -1
  62. package/dist/settings/web/PeoplePage.js +3 -12
  63. package/dist/settings/web/ProfilePage.js +9 -7
  64. package/dist/settings/web/index.d.ts +1 -0
  65. package/dist/settings/web/index.js +5 -0
  66. package/dist/settings/web/messages.js +28 -0
  67. package/dist/ui/api.d.ts +4 -0
  68. package/dist/ui/app.js +49 -16
  69. package/dist/ui/components/AppIcon.js +11 -1
  70. package/dist/ui/components/Avatar.d.ts +21 -0
  71. package/dist/ui/components/Avatar.js +71 -0
  72. package/dist/ui/i18n/messages.d.ts +30 -0
  73. package/dist/ui/i18n/messages.js +60 -0
  74. package/dist/ui/index.d.ts +1 -0
  75. package/dist/ui/index.js +1 -0
  76. package/dist/ui/registry.d.ts +7 -1
  77. package/dist/ui/registry.js +10 -1
  78. package/dist/ui/search/ViewTabs.d.ts +14 -0
  79. package/dist/ui/search/ViewTabs.js +42 -0
  80. package/dist/ui/shell/Consent.d.ts +5 -0
  81. package/dist/ui/shell/Consent.js +35 -0
  82. package/dist/ui/shell/Home.js +85 -16
  83. package/dist/ui/shell/Sidebar.js +15 -22
  84. package/dist/ui/views/ListView.js +49 -22
  85. package/package.json +1 -1
@@ -0,0 +1,579 @@
1
+ /**
2
+ * Shutro ERP as an OAuth 2.0 provider (RFC 0017 §5.2): third-party apps act for a user who consented, within scopes
3
+ * — the API keys' scopes (`*`, `read:<app>`, `write:<app>`), narrowing the user's own rights, never widening them.
4
+ * Authorization code with PKCE (S256; required of public apps), access tokens for an hour, refresh tokens rotated at
5
+ * each use (a reused one revokes the grant), everything stored as digests and audited.
6
+ *
7
+ * - `GET /.well-known/oauth-authorization-server` discovery (RFC 8414)
8
+ * - `GET /api/v1/oauth/authorize?…` the consent screen's data: the app, the scopes in words
9
+ * - `POST /api/v1/oauth/authorize` `{ …the same, decision: 'allow' | 'deny' }` → `{ redirect }`
10
+ * - `POST /api/v1/oauth/token` `authorization_code` and `refresh_token` grants (form or JSON)
11
+ * - `POST /api/v1/oauth/revoke` an access or refresh token (RFC 7009)
12
+ * - `GET /api/v1/auth/oauth/grants` the apps you allowed; `POST …/grants/:id/revoke`
13
+ * - `GET / POST /api/v1/oauth/apps`, `PATCH …/:id`, `POST …/:id/secret` the apps (administrators and
14
+ * Integrations managers, with a session)
15
+ *
16
+ * The consent page itself is the back-office's `/oauth/authorize` (the address in the discovery document).
17
+ */
18
+ import { createHash, timingSafeEqual } from 'node:crypto';
19
+ import { audit } from '../audit.js';
20
+ import { HttpProblem } from '../errors.js';
21
+ import { digest, randomToken } from './crypto.js';
22
+ import { parseScopes } from './scopes.js';
23
+ import { timeOf } from './service.js';
24
+ export const ACCESS_SECONDS = 3600;
25
+ const REFRESH_DAYS = 30;
26
+ const CODE_MINUTES = 10;
27
+ /** Access tokens start with it: the server tells them from sessions and API keys at a glance. */
28
+ export const ACCESS_PREFIX = 'oat_';
29
+ const REFRESH_PREFIX = 'ort_';
30
+ const model = (env, name) => env.model(name);
31
+ async function find(env, name, where, keys) {
32
+ const set = await model(env, name).search({ where, order: ['id asc'] });
33
+ return set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
34
+ }
35
+ const words = (text) => String(text ?? '')
36
+ .split(/\s+/)
37
+ .filter((w) => w !== '');
38
+ const same = (a, b) => {
39
+ const x = Buffer.from(a);
40
+ const y = Buffer.from(b);
41
+ return x.length === y.length && timingSafeEqual(x, y);
42
+ };
43
+ const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url');
44
+ /** An OAuth error as RFC 6749 §5.2 shapes it. */
45
+ class OAuthError extends Error {
46
+ code;
47
+ description;
48
+ status;
49
+ constructor(code, description, status = 400) {
50
+ super(description);
51
+ this.code = code;
52
+ this.description = description;
53
+ this.status = status;
54
+ }
55
+ }
56
+ /** Finds the access token `token` (`oat_…`), live, of an active app and a grant not revoked. */
57
+ export async function resolveAccessToken(env, token) {
58
+ const [row] = await find(env, 'oauth.token', { accessHash: digest(token), revoked: false }, [
59
+ 'grant',
60
+ 'accessExpiresAt',
61
+ ]);
62
+ if (row === undefined || (timeOf(row['accessExpiresAt']) ?? 0) <= Date.now())
63
+ return null;
64
+ const [grant] = await find(env, 'oauth.grant', { id: row['grant'], revoked: false }, [
65
+ 'user',
66
+ 'app',
67
+ 'scopes',
68
+ 'lastUsedAt',
69
+ ]);
70
+ if (grant === undefined)
71
+ return null;
72
+ const [app] = await find(env, 'oauth.app', { id: grant['app'], active: true }, ['name']);
73
+ // Archived users' tokens stop working with them.
74
+ if (app === undefined ||
75
+ (await model(env, 'res.users').search({ where: { id: grant['user'] } })).length === 0)
76
+ return null;
77
+ if ((timeOf(grant['lastUsedAt']) ?? 0) + 5 * 60_000 < Date.now())
78
+ await model(env, 'oauth.grant')
79
+ .browse([grant['id']])
80
+ .write({ lastUsedAt: new Date() });
81
+ return {
82
+ token: row['id'],
83
+ user: grant['user'],
84
+ app: grant['app'],
85
+ scopes: words(grant['scopes']),
86
+ };
87
+ }
88
+ export function registerOAuthRoutes(app, auth, shutro, registry, options) {
89
+ const system = (reason, fn) => shutro().bootstrap({ reason: `oauth: ${reason}` }, fn);
90
+ const installed = () => new Set(registry.loaded.modules.map((m) => m.name));
91
+ const appLabel = (name) => registry.loaded.modules.find((m) => m.name === name)?.app?.label ?? name;
92
+ /** A scope in words, for the consent screen. */
93
+ const describe = (scope) => {
94
+ if (scope === '*')
95
+ return 'Everything you may do';
96
+ const [access, name] = scope.split(':');
97
+ return access === 'read' ? `Read ${appLabel(name)}` : `Read and change ${appLabel(name)}`;
98
+ };
99
+ const base = options.baseUrl.replace(/\/$/, '');
100
+ // ------------------------------------------------------------------------------------- discovery
101
+ app.get('/.well-known/oauth-authorization-server', { config: { auth: false } }, async (request) => {
102
+ const api = `${request.protocol}://${request.host}`;
103
+ return {
104
+ issuer: base,
105
+ authorization_endpoint: `${base}/oauth/authorize`,
106
+ token_endpoint: `${api}/api/v1/oauth/token`,
107
+ revocation_endpoint: `${api}/api/v1/oauth/revoke`,
108
+ response_types_supported: ['code'],
109
+ grant_types_supported: ['authorization_code', 'refresh_token'],
110
+ code_challenge_methods_supported: ['S256'],
111
+ token_endpoint_auth_methods_supported: [
112
+ 'client_secret_basic',
113
+ 'client_secret_post',
114
+ 'none',
115
+ ],
116
+ scopes_supported: ['*', ...[...installed()].flatMap((m) => [`read:${m}`, `write:${m}`])],
117
+ };
118
+ });
119
+ // ------------------------------------------------------------------------------------- consent
120
+ /** Checks an authorization request; problems before the redirect is trusted are 400s, never redirects. */
121
+ const checkRequest = async (env, q) => {
122
+ const bad = (detail) => new HttpProblem(400, 'invalid-request', 'Invalid request', detail);
123
+ if (q['response_type'] !== 'code')
124
+ throw bad("response_type must be 'code'.");
125
+ const [client] = await find(env, 'oauth.app', { clientId: String(q['client_id'] ?? ''), active: true }, ['name', 'homepage', 'confidential', 'redirectUris', 'scopes']);
126
+ if (client === undefined)
127
+ throw bad('Unknown or inactive client_id.');
128
+ const redirect = String(q['redirect_uri'] ?? '');
129
+ if (!words(client['redirectUris']).includes(redirect))
130
+ throw bad('redirect_uri is not one of the app’s registered addresses.');
131
+ const allowed = new Set(words(client['scopes']));
132
+ const asked = q['scope'] === undefined || q['scope'] === ''
133
+ ? [...allowed]
134
+ : parseScopes(String(q['scope']), installed());
135
+ const outside = asked.filter((s) => !allowed.has(s));
136
+ if (outside.length > 0)
137
+ throw bad(`The app may not ask for ${outside.join(', ')}.`);
138
+ const challenge = typeof q['code_challenge'] === 'string' ? q['code_challenge'] : '';
139
+ if (challenge === '' && client['confidential'] !== true)
140
+ throw bad('A public app must send a PKCE code_challenge (S256).');
141
+ if (challenge !== '' && q['code_challenge_method'] !== 'S256')
142
+ throw bad("code_challenge_method must be 'S256'.");
143
+ if (challenge !== '' && !/^[A-Za-z0-9_-]{43}$/.test(challenge))
144
+ throw bad('code_challenge is a base64url SHA-256 (43 characters).');
145
+ return {
146
+ client,
147
+ redirect,
148
+ scopes: asked.sort(),
149
+ challenge,
150
+ state: typeof q['state'] === 'string' ? q['state'] : undefined,
151
+ };
152
+ };
153
+ const redirectTo = (uri, params) => {
154
+ const url = new URL(uri);
155
+ for (const [k, v] of Object.entries(params))
156
+ if (v !== undefined)
157
+ url.searchParams.set(k, v);
158
+ return url.toString();
159
+ };
160
+ app.get('/api/v1/oauth/authorize', { config: { auth: false } }, async (request) => {
161
+ const session = await auth.requireSession(request);
162
+ return system('consent screen', async (env) => {
163
+ const r = await checkRequest(env, (request.query ?? {}));
164
+ const [grant] = await find(env, 'oauth.grant', { user: session.user, app: r.client['id'], revoked: false }, ['scopes']);
165
+ const granted = new Set(words(grant?.['scopes']));
166
+ return {
167
+ app: {
168
+ name: String(r.client['name']),
169
+ homepage: typeof r.client['homepage'] === 'string' ? r.client['homepage'] : null,
170
+ },
171
+ redirectHost: new URL(r.redirect).host,
172
+ scopes: r.scopes.map((scope) => ({ scope, label: describe(scope) })),
173
+ // Everything asked was allowed before: the screen may say so.
174
+ alreadyAllowed: r.scopes.every((s) => granted.has(s)),
175
+ };
176
+ });
177
+ });
178
+ app.post('/api/v1/oauth/authorize', { config: { auth: false } }, async (request) => {
179
+ const session = await auth.requireSession(request);
180
+ const body = (request.body ?? {});
181
+ return system('consent', async (env) => {
182
+ const r = await checkRequest(env, body);
183
+ if (body['decision'] !== 'allow')
184
+ return {
185
+ redirect: redirectTo(r.redirect, {
186
+ error: 'access_denied',
187
+ error_description: 'The user declined.',
188
+ state: r.state,
189
+ }),
190
+ };
191
+ const grants = await model(env, 'oauth.grant').search({
192
+ where: { user: session.user, app: r.client['id'] },
193
+ });
194
+ let grant;
195
+ if (grants.length === 0)
196
+ grant = (await model(env, 'oauth.grant').create([
197
+ { user: session.user, app: r.client['id'], scopes: r.scopes.join(' ') },
198
+ ])).ids[0];
199
+ else {
200
+ grant = grants.ids[0];
201
+ await model(env, 'oauth.grant')
202
+ .browse([grant])
203
+ .write({ scopes: r.scopes.join(' '), revoked: false });
204
+ }
205
+ const code = randomToken();
206
+ await model(env, 'oauth.code').create([
207
+ {
208
+ codeHash: digest(code),
209
+ grant,
210
+ redirectUri: r.redirect,
211
+ challenge: r.challenge === '' ? null : r.challenge,
212
+ expiresAt: new Date(Date.now() + CODE_MINUTES * 60_000),
213
+ },
214
+ ]);
215
+ await audit(env, {
216
+ kind: 'oauth.consent',
217
+ summary: `Allowed ${String(r.client['name'])}: ${r.scopes.join(' ')}.`,
218
+ actor: session.user,
219
+ targetModel: 'oauth.app',
220
+ targetId: r.client['id'],
221
+ ip: request.ip,
222
+ });
223
+ return { redirect: redirectTo(r.redirect, { code, state: r.state }) };
224
+ });
225
+ });
226
+ // ------------------------------------------------------------------------------------- tokens
227
+ /** The client of a token or revoke request: `client_secret_basic`, `client_secret_post`, or public (`none`). */
228
+ const clientOf = async (env, request, body) => {
229
+ let id = typeof body['client_id'] === 'string' ? body['client_id'] : '';
230
+ let secret = typeof body['client_secret'] === 'string' ? body['client_secret'] : '';
231
+ const basic = /^Basic\s+(\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
232
+ if (basic !== undefined) {
233
+ const [user, pass] = Buffer.from(basic, 'base64').toString('utf8').split(':');
234
+ id = decodeURIComponent(user ?? '');
235
+ secret = decodeURIComponent(pass ?? '');
236
+ }
237
+ const [client] = await find(env, 'oauth.app', { clientId: id, active: true }, [
238
+ 'name',
239
+ 'confidential',
240
+ 'secretHash',
241
+ ]);
242
+ if (client === undefined)
243
+ throw new OAuthError('invalid_client', 'Unknown client.', 401);
244
+ if (client['confidential'] === true) {
245
+ if (secret === '' || !same(digest(secret), String(client['secretHash'] ?? '')))
246
+ throw new OAuthError('invalid_client', 'Wrong client credentials.', 401);
247
+ }
248
+ return client;
249
+ };
250
+ const issue = async (env, grant) => {
251
+ const access = `${ACCESS_PREFIX}${randomToken()}`;
252
+ const refresh = `${REFRESH_PREFIX}${randomToken()}`;
253
+ const now = Date.now();
254
+ await model(env, 'oauth.token').create([
255
+ {
256
+ grant,
257
+ accessHash: digest(access),
258
+ refreshHash: digest(refresh),
259
+ accessExpiresAt: new Date(now + ACCESS_SECONDS * 1000),
260
+ refreshExpiresAt: new Date(now + REFRESH_DAYS * 86_400_000),
261
+ },
262
+ ]);
263
+ const [row] = await find(env, 'oauth.grant', { id: grant }, ['scopes']);
264
+ return {
265
+ access_token: access,
266
+ token_type: 'Bearer',
267
+ expires_in: ACCESS_SECONDS,
268
+ refresh_token: refresh,
269
+ scope: String(row?.['scopes'] ?? ''),
270
+ };
271
+ };
272
+ /** Revokes a grant's tokens (a replayed code or refresh token: someone else may hold them). */
273
+ const revokeGrantTokens = async (env, grant) => {
274
+ const tokens = await model(env, 'oauth.token').search({ where: { grant, revoked: false } });
275
+ if (tokens.length > 0)
276
+ await tokens.write({ revoked: true });
277
+ };
278
+ const tokenEndpoint = async (request, body) => {
279
+ const out = await system('token', async (env) => {
280
+ const client = await clientOf(env, request, body);
281
+ if (body['grant_type'] === 'authorization_code') {
282
+ const [code] = await find(env, 'oauth.code', { codeHash: digest(String(body['code'] ?? '')) }, ['grant', 'redirectUri', 'challenge', 'expiresAt', 'used']);
283
+ if (code === undefined)
284
+ throw new OAuthError('invalid_grant', 'Unknown code.');
285
+ const [grant] = await find(env, 'oauth.grant', { id: code['grant'] }, [
286
+ 'app',
287
+ 'user',
288
+ 'revoked',
289
+ ]);
290
+ if (grant === undefined || grant['app'] !== client['id'])
291
+ throw new OAuthError('invalid_grant', 'The code was issued to another client.');
292
+ if (code['used'] === true) {
293
+ // A code used twice: its tokens may be in the wrong hands.
294
+ await revokeGrantTokens(env, code['grant']);
295
+ return { failure: new OAuthError('invalid_grant', 'The code was already used.') };
296
+ }
297
+ if ((timeOf(code['expiresAt']) ?? 0) <= Date.now() || grant['revoked'] === true)
298
+ throw new OAuthError('invalid_grant', 'The code expired.');
299
+ if (body['redirect_uri'] !== code['redirectUri'])
300
+ throw new OAuthError('invalid_grant', 'redirect_uri differs from the authorization request.');
301
+ if (typeof code['challenge'] === 'string') {
302
+ const verifier = typeof body['code_verifier'] === 'string' ? body['code_verifier'] : '';
303
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) ||
304
+ !same(s256(verifier), code['challenge']))
305
+ throw new OAuthError('invalid_grant', 'The PKCE code_verifier does not match.');
306
+ }
307
+ await model(env, 'oauth.code')
308
+ .browse([code['id']])
309
+ .write({ used: true });
310
+ return issue(env, code['grant']);
311
+ }
312
+ if (body['grant_type'] === 'refresh_token') {
313
+ const [token] = await find(env, 'oauth.token', { refreshHash: digest(String(body['refresh_token'] ?? '')) }, ['grant', 'refreshExpiresAt', 'revoked', 'rotated']);
314
+ if (token === undefined)
315
+ throw new OAuthError('invalid_grant', 'Unknown refresh token.');
316
+ const [grant] = await find(env, 'oauth.grant', { id: token['grant'] }, [
317
+ 'app',
318
+ 'revoked',
319
+ ]);
320
+ if (grant === undefined || grant['app'] !== client['id'])
321
+ throw new OAuthError('invalid_grant', 'The token was issued to another client.');
322
+ if (token['rotated'] === true) {
323
+ // A rotated refresh token used again: revoke the grant's tokens (RFC 9700 §4.14.2).
324
+ await revokeGrantTokens(env, token['grant']);
325
+ await audit(env, {
326
+ kind: 'oauth.refresh_reused',
327
+ summary: `A used refresh token of ${String(client['name'])} came back: its tokens are revoked.`,
328
+ targetModel: 'oauth.app',
329
+ targetId: client['id'],
330
+ ip: request.ip,
331
+ });
332
+ return {
333
+ failure: new OAuthError('invalid_grant', 'The refresh token was already used.'),
334
+ };
335
+ }
336
+ if (token['revoked'] === true ||
337
+ (timeOf(token['refreshExpiresAt']) ?? 0) <= Date.now() ||
338
+ grant['revoked'] === true)
339
+ throw new OAuthError('invalid_grant', 'The refresh token expired or was revoked.');
340
+ await model(env, 'oauth.token')
341
+ .browse([token['id']])
342
+ .write({ revoked: true, rotated: true });
343
+ return issue(env, token['grant']);
344
+ }
345
+ throw new OAuthError('unsupported_grant_type', 'Use authorization_code or refresh_token.');
346
+ });
347
+ if ('failure' in out)
348
+ throw out.failure;
349
+ return out;
350
+ };
351
+ // The token and revoke endpoints take form bodies (RFC 6749 §4.1.3) as well as JSON.
352
+ app.register(async (scope) => {
353
+ scope.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string', bodyLimit: 16_384 }, (_req, body, done) => done(null, Object.fromEntries(new URLSearchParams(String(body)))));
354
+ const answer = async (reply, run) => {
355
+ reply.header('cache-control', 'no-store').header('pragma', 'no-cache');
356
+ try {
357
+ return await run();
358
+ }
359
+ catch (e) {
360
+ if (!(e instanceof OAuthError))
361
+ throw e;
362
+ if (e.status === 401)
363
+ reply.header('www-authenticate', 'Basic realm="oauth"');
364
+ return reply.status(e.status).send({ error: e.code, error_description: e.description });
365
+ }
366
+ };
367
+ scope.post('/api/v1/oauth/token', { config: { auth: false } }, async (request, reply) => answer(reply, () => tokenEndpoint(request, (request.body ?? {}))));
368
+ scope.post('/api/v1/oauth/revoke', { config: { auth: false } }, async (request, reply) => answer(reply, () => system('revoke', async (env) => {
369
+ const body = (request.body ?? {});
370
+ const client = await clientOf(env, request, body);
371
+ const hash = digest(String(body['token'] ?? ''));
372
+ for (const where of [{ accessHash: hash }, { refreshHash: hash }]) {
373
+ const [token] = await find(env, 'oauth.token', where, ['grant']);
374
+ if (token === undefined)
375
+ continue;
376
+ const [grant] = await find(env, 'oauth.grant', { id: token['grant'] }, ['app']);
377
+ // Another client's token: answered the same way (RFC 7009 §2.2), left alone.
378
+ if (grant?.['app'] === client['id'])
379
+ await model(env, 'oauth.token')
380
+ .browse([token['id']])
381
+ .write({ revoked: true });
382
+ }
383
+ return {};
384
+ })));
385
+ });
386
+ // ------------------------------------------------------------------------------------- the user's grants
387
+ app.get('/api/v1/auth/oauth/grants', { config: { auth: false } }, async (request) => {
388
+ const session = await auth.requireSession(request);
389
+ return system('grants', async (env) => {
390
+ const grants = await find(env, 'oauth.grant', { user: session.user, revoked: false }, [
391
+ 'app',
392
+ 'scopes',
393
+ 'createdAt',
394
+ 'lastUsedAt',
395
+ ]);
396
+ const apps = new Map((await find(env, 'oauth.app', { id: { in: grants.map((g) => g['app']) } }, [
397
+ 'name',
398
+ 'homepage',
399
+ ])).map((a) => [a['id'], a]));
400
+ const iso = (v) => timeOf(v) === null ? null : new Date(timeOf(v)).toISOString();
401
+ return {
402
+ grants: grants.map((g) => ({
403
+ id: g['id'],
404
+ app: String(apps.get(g['app'])?.['name'] ?? ''),
405
+ homepage: apps.get(g['app'])?.['homepage'] ?? null,
406
+ scopes: words(g['scopes']).map((scope) => ({ scope, label: describe(scope) })),
407
+ createdAt: iso(g['createdAt']),
408
+ lastUsedAt: iso(g['lastUsedAt']),
409
+ })),
410
+ };
411
+ });
412
+ });
413
+ app.post('/api/v1/auth/oauth/grants/:id/revoke', { config: { auth: false } }, async (request, reply) => {
414
+ const session = await auth.requireSession(request);
415
+ const id = Number(request.params.id);
416
+ await system('revoke grant', async (env) => {
417
+ const grants = await model(env, 'oauth.grant').search({
418
+ where: { id, user: session.user },
419
+ });
420
+ if (grants.length === 0)
421
+ throw new HttpProblem(404, 'missing-record', 'Not found', `You have no grant ${id}.`);
422
+ await grants.write({ revoked: true });
423
+ await revokeGrantTokens(env, id);
424
+ await audit(env, {
425
+ kind: 'oauth.revoked',
426
+ summary: `Access of an app revoked (grant ${id}).`,
427
+ actor: session.user,
428
+ targetModel: 'oauth.grant',
429
+ targetId: id,
430
+ ip: request.ip,
431
+ });
432
+ });
433
+ return reply.status(204).send();
434
+ });
435
+ // ------------------------------------------------------------------------------------- the apps
436
+ /** Administrators and Integrations managers, with a session (no API key or token manages apps). */
437
+ const manager = async (request) => {
438
+ if (request.identity?.scopes !== undefined)
439
+ throw new HttpProblem(403, 'session-only', 'With a session only', 'OAuth apps are managed with a session.');
440
+ const session = await auth.requireSession(request);
441
+ const allowed = await request.env(async (env) => env.administrator || env.groups.has('integrations.manager'));
442
+ if (!allowed)
443
+ throw new HttpProblem(403, 'access', 'OAuth apps', 'Managing OAuth apps needs the Administrator role or the Integrations manager access.');
444
+ return session;
445
+ };
446
+ const appKeys = [
447
+ 'name',
448
+ 'clientId',
449
+ 'confidential',
450
+ 'redirectUris',
451
+ 'scopes',
452
+ 'homepage',
453
+ 'active',
454
+ ];
455
+ const shape = (a) => ({
456
+ id: a['id'],
457
+ name: String(a['name']),
458
+ clientId: String(a['clientId']),
459
+ confidential: a['confidential'] === true,
460
+ redirectUris: words(a['redirectUris']),
461
+ scopes: words(a['scopes']),
462
+ homepage: a['homepage'] ?? null,
463
+ active: a['active'] === true,
464
+ });
465
+ const appValues = (body, creating) => {
466
+ const values = {};
467
+ const bad = (detail) => new HttpProblem(422, 'invalid-app', 'Invalid app', detail);
468
+ if (creating || body['name'] !== undefined) {
469
+ const name = String(body['name'] ?? '').trim();
470
+ if (name === '' || name.length > 120)
471
+ throw bad('The app needs a name.');
472
+ values['name'] = name;
473
+ }
474
+ if (creating || body['redirectUris'] !== undefined) {
475
+ const uris = Array.isArray(body['redirectUris']) ? body['redirectUris'].map(String) : [];
476
+ if (uris.length === 0)
477
+ throw bad('Give at least one redirect address.');
478
+ for (const uri of uris) {
479
+ let url;
480
+ try {
481
+ url = new URL(uri);
482
+ }
483
+ catch {
484
+ throw bad(`"${uri}" is not an address.`);
485
+ }
486
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
487
+ if (url.hash !== '' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)))
488
+ throw bad(`"${uri}" must be https (http only for localhost), without a #fragment.`);
489
+ }
490
+ values['redirectUris'] = uris.join('\n');
491
+ }
492
+ if (creating || body['scopes'] !== undefined)
493
+ values['scopes'] = parseScopes(Array.isArray(body['scopes']) ? body['scopes'].map(String) : String(body['scopes'] ?? ''), installed()).join(' ');
494
+ if (body['homepage'] !== undefined)
495
+ values['homepage'] = body['homepage'] === null ? null : String(body['homepage']);
496
+ if (body['active'] !== undefined)
497
+ values['active'] = body['active'] === true;
498
+ return values;
499
+ };
500
+ app.get('/api/v1/oauth/apps', async (request) => {
501
+ await manager(request);
502
+ return system('apps', async (env) => ({
503
+ apps: (await find(env, 'oauth.app', {}, appKeys)).map(shape),
504
+ }));
505
+ });
506
+ app.post('/api/v1/oauth/apps', async (request, reply) => {
507
+ const session = await manager(request);
508
+ const body = (request.body ?? {});
509
+ const values = appValues(body, true);
510
+ const confidential = body['confidential'] !== false;
511
+ const secret = confidential ? `ks_${randomToken()}` : null;
512
+ const made = await system('new app', async (env) => {
513
+ const created = await model(env, 'oauth.app').create([
514
+ {
515
+ ...values,
516
+ clientId: `kc_${randomToken(12)}`,
517
+ confidential,
518
+ secretHash: secret === null ? null : digest(secret),
519
+ owner: session.user,
520
+ },
521
+ ]);
522
+ const id = created.ids[0];
523
+ await audit(env, {
524
+ kind: 'oauth.app_created',
525
+ summary: `OAuth app ${String(values['name'])} created.`,
526
+ actor: session.user,
527
+ targetModel: 'oauth.app',
528
+ targetId: id,
529
+ ip: request.ip,
530
+ });
531
+ const [row] = await find(env, 'oauth.app', { id }, appKeys);
532
+ return shape(row);
533
+ });
534
+ // The secret once: only its digest is kept.
535
+ return reply.status(201).send({ ...made, clientSecret: secret });
536
+ });
537
+ app.patch('/api/v1/oauth/apps/:id', async (request) => {
538
+ const session = await manager(request);
539
+ const id = Number(request.params.id);
540
+ const values = appValues((request.body ?? {}), false);
541
+ return system('change app', async (env) => {
542
+ const set = await model(env, 'oauth.app').search({ where: { id } });
543
+ if (set.length === 0)
544
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no app ${id}.`);
545
+ if (Object.keys(values).length > 0)
546
+ await set.write(values);
547
+ await audit(env, {
548
+ kind: 'oauth.app_changed',
549
+ summary: `OAuth app ${id} changed: ${Object.keys(values).join(', ')}.`,
550
+ actor: session.user,
551
+ targetModel: 'oauth.app',
552
+ targetId: id,
553
+ ip: request.ip,
554
+ });
555
+ const [row] = await find(env, 'oauth.app', { id }, appKeys);
556
+ return shape(row);
557
+ });
558
+ });
559
+ app.post('/api/v1/oauth/apps/:id/secret', async (request) => {
560
+ const session = await manager(request);
561
+ const id = Number(request.params.id);
562
+ const secret = `ks_${randomToken()}`;
563
+ await system('new secret', async (env) => {
564
+ const set = await model(env, 'oauth.app').search({ where: { id, confidential: true } });
565
+ if (set.length === 0)
566
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no confidential app ${id}.`);
567
+ await set.write({ secretHash: digest(secret) });
568
+ await audit(env, {
569
+ kind: 'oauth.app_secret',
570
+ summary: `A new client secret for OAuth app ${id}; the old one stops working.`,
571
+ actor: session.user,
572
+ targetModel: 'oauth.app',
573
+ targetId: id,
574
+ ip: request.ip,
575
+ });
576
+ });
577
+ return { clientSecret: secret };
578
+ });
579
+ }
@@ -41,6 +41,10 @@ export interface UserInfo {
41
41
  readonly groupings: Readonly<Record<string, 'international' | 'lakh'>>;
42
42
  /** The brand colour of each of the user's companies that has one (RFC 0018): `#rrggbb`. */
43
43
  readonly accents: Readonly<Record<string, string>>;
44
+ /** The user's photo version (`/api/v1/images/user/<id>?v=<version>`), or null without one. */
45
+ readonly avatar: string | null;
46
+ /** Each of the user's companies' logo version, when it has one. */
47
+ readonly logos: Readonly<Record<string, string>>;
44
48
  readonly twoFactorEnabled: boolean;
45
49
  /** Holds an administrator role (in some company). */
46
50
  readonly administrator: boolean;
@@ -1,7 +1,9 @@
1
1
  import { audit } from '../audit.js';
2
2
  import { HttpProblem } from '../errors.js';
3
+ import { imageVersions } from '../images.js';
3
4
  import { keepingAdministrator } from './admins.js';
4
5
  import { digest, hashPassword, newTotpSecret, otpauthUri, randomToken, verifyPassword, verifyTotp, } from './crypto.js';
6
+ import { resolveAccessToken } from './oauth.js';
5
7
  export const SESSION_COOKIE = 'kosh_session';
6
8
  const MIN_PASSWORD = 12;
7
9
  const model = (env, name) => env.model(name);
@@ -228,6 +230,18 @@ export class AuthService {
228
230
  scopes: key.scopes,
229
231
  apiKey: key.id,
230
232
  });
233
+ // An OAuth access token (RFC 0017 §5.2): the consenting user, within the grant's scopes.
234
+ const bearer = /^Bearer\s+(oat_\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
235
+ if (bearer !== undefined) {
236
+ const access = await this.#system('oauth token', (env) => resolveAccessToken(env, bearer));
237
+ if (access === null)
238
+ throw unauthenticated('invalid-token', 'The access token is unknown, expired or revoked.', 'Refresh it, or ask the user to allow the app again.');
239
+ return this.#identity(request, access.user, {
240
+ elevated: false,
241
+ scopes: access.scopes,
242
+ oauthToken: access.token,
243
+ });
244
+ }
231
245
  const session = await this.session(request);
232
246
  if (session === null)
233
247
  return null;
@@ -270,6 +284,7 @@ export class AuthService {
270
284
  tz: info.tz,
271
285
  ...(options.scopes === undefined ? {} : { scopes: options.scopes }),
272
286
  ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
287
+ ...(options.oauthToken === undefined ? {} : { oauthToken: options.oauthToken }),
273
288
  // A session without the second factor the policy asks of everyone (RFC 0019 §3.5): the server allows only
274
289
  // setting it up. API keys are not sessions: their owner signed in to make them.
275
290
  ...(options.session === true &&
@@ -574,6 +589,8 @@ export class AuthService {
574
589
  companies: companyIds,
575
590
  groupings,
576
591
  accents,
592
+ avatar: (await imageVersions(env, 'user', [session.user]))[String(session.user)] ?? null,
593
+ logos: await imageVersions(env, 'company', companyIds),
577
594
  twoFactorEnabled,
578
595
  administrator,
579
596
  administratorNeedsSecondFactor: administrator && !(session.secondFactor && twoFactorEnabled),
@@ -24,6 +24,8 @@ export interface RequestIdentity {
24
24
  readonly scopes?: readonly string[];
25
25
  /** The API key the request carries. */
26
26
  readonly apiKey?: number;
27
+ /** The OAuth access token the request carries (RFC 0017 §5.2): its app acts for the user, within `scopes`. */
28
+ readonly oauthToken?: number;
27
29
  /** A session without the second factor the security policy asks of everyone (RFC 0019 §3.5). */
28
30
  readonly twoFactorMissing?: true;
29
31
  }
@@ -0,0 +1,12 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AuthService } from './auth/service.js';
3
+ import type { Env } from './orm.js';
4
+ declare const KINDS: {
5
+ readonly user: 'res.users';
6
+ readonly company: 'res.company';
7
+ };
8
+ type Kind = keyof typeof KINDS;
9
+ /** The image versions of `ids` of a kind (a short digest), for URLs that change when the image does. */
10
+ export declare function imageVersions(env: Env, kind: Kind, ids: readonly number[]): Promise<Record<string, string>>;
11
+ export declare function registerImageRoutes(app: FastifyInstance, auth: AuthService): void;
12
+ export {};