shutro-erp 0.3.2 → 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 (156) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +156 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +156 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +156 -1
  8. package/dist/integrations/index.js +12 -0
  9. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  10. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  11. package/dist/integrations/web/index.d.ts +1 -0
  12. package/dist/integrations/web/index.js +3 -0
  13. package/dist/integrations/web/messages.js +58 -0
  14. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  15. package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
  16. package/dist/l10n_in/.kosh/bindings.js +1 -1
  17. package/dist/l10n_in/.kosh/schema.d.ts +156 -4
  18. package/dist/l10n_in/module.d.ts +0 -62
  19. package/dist/l10n_in/module.js +2 -4
  20. package/dist/messaging/web/index.js +1 -1
  21. package/dist/notify/web/index.js +1 -1
  22. package/dist/payment/.kosh/bindings.js +1 -1
  23. package/dist/payment/.kosh/schema.d.ts +157 -2
  24. package/dist/payment/bkash.d.ts +28 -0
  25. package/dist/payment/bkash.js +169 -0
  26. package/dist/payment/index.d.ts +4 -0
  27. package/dist/payment/index.js +80 -24
  28. package/dist/payment/module.d.ts +7 -4
  29. package/dist/payment/module.js +3 -0
  30. package/dist/payment/money.d.ts +7 -0
  31. package/dist/payment/money.js +37 -0
  32. package/dist/payment/providers.d.ts +13 -2
  33. package/dist/payment/razorpay.d.ts +12 -0
  34. package/dist/payment/razorpay.js +149 -0
  35. package/dist/payment/sslcommerz.js +1 -1
  36. package/dist/payment/stripe.d.ts +15 -0
  37. package/dist/payment/stripe.js +182 -0
  38. package/dist/permissions/admin.d.ts +3 -0
  39. package/dist/permissions/admin.js +108 -10
  40. package/dist/permissions/index.d.ts +1 -0
  41. package/dist/permissions/index.js +3 -0
  42. package/dist/permissions/roles.d.ts +2 -0
  43. package/dist/permissions/roles.js +236 -0
  44. package/dist/portal/.kosh/bindings.js +1 -1
  45. package/dist/portal/.kosh/schema.d.ts +156 -1
  46. package/dist/product/.kosh/bindings.js +1 -1
  47. package/dist/product/.kosh/schema.d.ts +156 -1
  48. package/dist/project/.kosh/bindings.js +1 -1
  49. package/dist/project/.kosh/schema.d.ts +156 -1
  50. package/dist/project_portal/.kosh/bindings.js +1 -1
  51. package/dist/project_portal/.kosh/schema.d.ts +156 -1
  52. package/dist/purchase/.kosh/bindings.js +1 -1
  53. package/dist/purchase/.kosh/schema.d.ts +156 -1
  54. package/dist/purchase_account/.kosh/bindings.js +1 -1
  55. package/dist/purchase_account/.kosh/schema.d.ts +156 -1
  56. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  57. package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
  58. package/dist/sale_account/.kosh/bindings.js +1 -1
  59. package/dist/sale_account/.kosh/schema.d.ts +156 -1
  60. package/dist/sale_portal/.kosh/bindings.js +1 -1
  61. package/dist/sale_portal/.kosh/schema.d.ts +156 -1
  62. package/dist/sale_project/.kosh/bindings.js +1 -1
  63. package/dist/sale_project/.kosh/schema.d.ts +156 -1
  64. package/dist/sales/.kosh/bindings.js +1 -1
  65. package/dist/sales/.kosh/schema.d.ts +156 -1
  66. package/dist/server/auth/base.js +98 -0
  67. package/dist/server/auth/oauth.d.ts +21 -0
  68. package/dist/server/auth/oauth.js +579 -0
  69. package/dist/server/auth/routes.d.ts +3 -0
  70. package/dist/server/auth/routes.js +29 -0
  71. package/dist/server/auth/service.d.ts +56 -0
  72. package/dist/server/auth/service.js +185 -3
  73. package/dist/server/identity.d.ts +4 -0
  74. package/dist/server/images.d.ts +12 -0
  75. package/dist/server/images.js +139 -0
  76. package/dist/server/index.d.ts +2 -1
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/server.d.ts +4 -0
  79. package/dist/server/server.js +20 -4
  80. package/dist/settings/company.d.ts +34 -0
  81. package/dist/settings/company.js +189 -0
  82. package/dist/settings/index.d.ts +10 -10
  83. package/dist/settings/index.js +159 -30
  84. package/dist/settings/people.d.ts +16 -0
  85. package/dist/settings/people.js +507 -0
  86. package/dist/settings/security.d.ts +13 -0
  87. package/dist/settings/security.js +158 -0
  88. package/dist/settings/web/AuditPage.d.ts +8 -0
  89. package/dist/settings/web/AuditPage.js +74 -0
  90. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  91. package/dist/settings/web/CompaniesPage.js +52 -0
  92. package/dist/settings/web/CompanyPage.d.ts +9 -0
  93. package/dist/settings/web/CompanyPage.js +105 -0
  94. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  95. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  96. package/dist/settings/web/OverviewPage.d.ts +9 -0
  97. package/dist/settings/web/OverviewPage.js +77 -0
  98. package/dist/settings/web/PeoplePage.d.ts +9 -0
  99. package/dist/settings/web/PeoplePage.js +154 -0
  100. package/dist/settings/web/ProfilePage.d.ts +14 -0
  101. package/dist/settings/web/ProfilePage.js +196 -0
  102. package/dist/settings/web/RolesEditor.d.ts +35 -0
  103. package/dist/settings/web/RolesEditor.js +49 -0
  104. package/dist/settings/web/RolesPage.d.ts +9 -0
  105. package/dist/settings/web/RolesPage.js +296 -0
  106. package/dist/settings/web/SecurityPage.d.ts +8 -0
  107. package/dist/settings/web/SecurityPage.js +81 -0
  108. package/dist/settings/web/index.d.ts +10 -0
  109. package/dist/settings/web/index.js +31 -1
  110. package/dist/settings/web/messages.js +594 -0
  111. package/dist/ui/api.d.ts +8 -0
  112. package/dist/ui/app.d.ts +5 -0
  113. package/dist/ui/app.js +143 -33
  114. package/dist/ui/components/AppIcon.js +11 -1
  115. package/dist/ui/components/Avatar.d.ts +21 -0
  116. package/dist/ui/components/Avatar.js +71 -0
  117. package/dist/ui/components/primitives.d.ts +9 -1
  118. package/dist/ui/components/primitives.js +12 -4
  119. package/dist/ui/context.d.ts +10 -1
  120. package/dist/ui/context.js +60 -2
  121. package/dist/ui/i18n/messages.d.ts +76 -0
  122. package/dist/ui/i18n/messages.js +152 -0
  123. package/dist/ui/index.d.ts +3 -1
  124. package/dist/ui/index.js +3 -1
  125. package/dist/ui/lib/accent.d.ts +13 -0
  126. package/dist/ui/lib/accent.js +36 -0
  127. package/dist/ui/registry.d.ts +7 -1
  128. package/dist/ui/registry.js +10 -1
  129. package/dist/ui/search/ViewTabs.d.ts +14 -0
  130. package/dist/ui/search/ViewTabs.js +42 -0
  131. package/dist/ui/shell/Activate.d.ts +6 -0
  132. package/dist/ui/shell/Activate.js +39 -0
  133. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  134. package/dist/ui/shell/CommandPalette.js +16 -2
  135. package/dist/ui/shell/Consent.d.ts +5 -0
  136. package/dist/ui/shell/Consent.js +35 -0
  137. package/dist/ui/shell/Home.js +85 -16
  138. package/dist/ui/shell/Sidebar.d.ts +27 -0
  139. package/dist/ui/shell/Sidebar.js +77 -0
  140. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  141. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  142. package/dist/ui/shell/shortcuts.d.ts +3 -0
  143. package/dist/ui/shell/shortcuts.js +13 -4
  144. package/dist/ui/styles.css +70 -28
  145. package/dist/ui/views/DateField.d.ts +10 -0
  146. package/dist/ui/views/DateField.js +129 -0
  147. package/dist/ui/views/FormView.js +161 -21
  148. package/dist/ui/views/ListView.d.ts +2 -0
  149. package/dist/ui/views/ListView.js +57 -25
  150. package/dist/ui/views/StatusChip.d.ts +10 -0
  151. package/dist/ui/views/StatusChip.js +22 -0
  152. package/dist/ui/views/widgets.d.ts +3 -0
  153. package/dist/ui/views/widgets.js +26 -8
  154. package/dist/views/types.d.ts +7 -0
  155. package/dist/views/validate.js +4 -0
  156. package/package.json +2 -2
@@ -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
+ }
@@ -10,6 +10,9 @@
10
10
  * - `POST /api/v1/auth/2fa/verify` `{ code }` (TOTP or recovery code) completes a pending sign-in
11
11
  * - `POST /api/v1/auth/2fa/disable` `{ password, code }`
12
12
  * - `POST /api/v1/auth/password` `{ current, next }`; other sessions are revoked
13
+ * - `GET /api/v1/auth/profile` your details (RFC 0019); `PATCH` `{ name?, email?, phone?, lang?, tz?, company? }`
14
+ * - `GET /api/v1/auth/sessions` your signed-in devices; `DELETE …/sessions/:id` signs one out
15
+ * - `POST /api/v1/auth/sessions/sign-out-others`
13
16
  */
14
17
  import type { FastifyInstance } from 'fastify';
15
18
  import type { AuthService } from './service.js';
@@ -52,4 +52,33 @@ export function registerAuthRoutes(app, auth, options = {}) {
52
52
  await auth.changePassword(await auth.requireSession(request), text(request, 'current'), text(request, 'next'));
53
53
  return { ok: true };
54
54
  });
55
+ // Your own profile and sessions (RFC 0019 §3.6): a session's, never an API key's.
56
+ app.get('/api/v1/auth/profile', { config: { auth: false } }, async (request) => auth.profile(await auth.requireSession(request)));
57
+ app.patch('/api/v1/auth/profile', { config: { auth: false } }, async (request) => {
58
+ const body = (request.body ?? {});
59
+ const values = {};
60
+ for (const key of ['name', 'email', 'phone', 'lang', 'tz'])
61
+ if (body[key] !== undefined) {
62
+ if (body[key] !== null &&
63
+ (typeof body[key] !== 'string' || body[key].length > 200))
64
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', `'${key}' is a short text.`);
65
+ values[key] = body[key];
66
+ }
67
+ if (body['company'] !== undefined) {
68
+ if (!Number.isSafeInteger(body['company']))
69
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', "'company' is a company id.");
70
+ values['company'] = body['company'];
71
+ }
72
+ return auth.updateProfile(await auth.requireSession(request), values);
73
+ });
74
+ app.get('/api/v1/auth/sessions', { config: { auth: false } }, async (request) => ({
75
+ sessions: await auth.sessions(await auth.requireSession(request)),
76
+ }));
77
+ app.delete('/api/v1/auth/sessions/:id', { config: { auth: false } }, async (request) => {
78
+ const id = Number(request.params.id);
79
+ if (!Number.isSafeInteger(id) || id <= 0)
80
+ throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
81
+ return auth.revokeSessions(await auth.requireSession(request), id);
82
+ });
83
+ app.post('/api/v1/auth/sessions/sign-out-others', { config: { auth: false } }, async (request) => auth.revokeSessions(await auth.requireSession(request), 'others'));
55
84
  }