@adonis-agora/authkit-server 1.0.0-rc.4 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/build/commands/agora_doctor.d.ts +22 -0
- package/build/commands/agora_doctor.js +109 -0
- package/build/commands/commands.json +64 -0
- package/build/commands/doctor.js +8 -74
- package/build/host/views/agents/consent.edge +11 -2
- package/build/host/views/agents/done.edge +1 -1
- package/build/index.d.ts +1 -1
- package/build/providers/authkit_server_provider.js +17 -0
- package/build/src/dev_tools/contract.d.ts +72 -0
- package/build/src/dev_tools/contract.js +10 -0
- package/build/src/dev_tools/gate.d.ts +17 -0
- package/build/src/dev_tools/gate.js +69 -0
- package/build/src/dev_tools/register.d.ts +11 -0
- package/build/src/dev_tools/register.js +25 -0
- package/build/src/dev_tools/tools.d.ts +24 -0
- package/build/src/dev_tools/tools.js +384 -0
- package/build/src/doctor/agora_checks.d.ts +15 -0
- package/build/src/doctor/agora_checks.js +150 -0
- package/build/src/doctor/agora_doctor.d.ts +115 -0
- package/build/src/doctor/agora_doctor.js +692 -0
- package/build/src/doctor/doctor_input.d.ts +27 -0
- package/build/src/doctor/doctor_input.js +86 -0
- package/build/src/doctor/index.d.ts +5 -0
- package/build/src/doctor/index.js +4 -0
- package/build/src/host/admin_api/admin_users_service.d.ts +5 -2
- package/build/src/host/admin_authorize.d.ts +8 -0
- package/build/src/host/admin_authorize.js +25 -0
- package/build/src/host/i18n.d.ts +2 -0
- package/build/src/host/i18n.js +2 -0
- package/build/src/host/register_auth_host.js +1 -24
- package/build/src/host/screen_props.d.ts +11 -8
- package/package.json +5 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { supportsAccountStatus, supportsMfa, supportsProviderIdentity, } from '../accounts/account_store.js';
|
|
2
|
+
import { AdminOrgsService } from '../host/admin_api/admin_orgs_service.js';
|
|
3
|
+
import { AdminUsersService } from '../host/admin_api/admin_users_service.js';
|
|
4
|
+
import { auditDto, orgDetailDto, orgDto, sessionDto, userDtoWithRoles, } from '../host/admin_api/dto.js';
|
|
5
|
+
import { AdminClientsService } from '../host/admin_clients_service.js';
|
|
6
|
+
import { AdminSessionsService } from '../host/admin_sessions_service.js';
|
|
7
|
+
import { computeAdminStats } from '../host/admin_stats_service.js';
|
|
8
|
+
import { enrichSessionsWithContext } from '../host/session_context.js';
|
|
9
|
+
import { LIST_FIRST_PAGE } from '../pagination.js';
|
|
10
|
+
/** Rows a list tool returns when `limit` is omitted. */
|
|
11
|
+
export const DEV_TOOLS_DEFAULT_LIMIT = 20;
|
|
12
|
+
/** The largest `limit` a list tool accepts. */
|
|
13
|
+
export const DEV_TOOLS_MAX_LIMIT = 100;
|
|
14
|
+
const page = {
|
|
15
|
+
type: 'integer',
|
|
16
|
+
minimum: 1,
|
|
17
|
+
description: 'Page number, 1-based (default 1). The result says the next one in `next`.',
|
|
18
|
+
};
|
|
19
|
+
const limit = {
|
|
20
|
+
type: 'integer',
|
|
21
|
+
minimum: 1,
|
|
22
|
+
maximum: DEV_TOOLS_MAX_LIMIT,
|
|
23
|
+
description: `Rows per page (default ${DEV_TOOLS_DEFAULT_LIMIT}, max ${DEV_TOOLS_MAX_LIMIT}).`,
|
|
24
|
+
};
|
|
25
|
+
const accountId = { type: 'string', description: 'The account id.' };
|
|
26
|
+
function pageOf(input) {
|
|
27
|
+
const p = Math.trunc(Number(input.page));
|
|
28
|
+
const l = Math.trunc(Number(input.limit));
|
|
29
|
+
return {
|
|
30
|
+
page: Number.isFinite(p) && p >= LIST_FIRST_PAGE ? p : LIST_FIRST_PAGE,
|
|
31
|
+
limit: Number.isFinite(l) && l >= 1 ? Math.min(l, DEV_TOOLS_MAX_LIMIT) : DEV_TOOLS_DEFAULT_LIMIT,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function nextPage(current, size, total) {
|
|
35
|
+
return current * size < total ? current + 1 : null;
|
|
36
|
+
}
|
|
37
|
+
/** One page of an array the service returns whole. */
|
|
38
|
+
function slice(rows, p) {
|
|
39
|
+
const start = (p.page - 1) * p.limit;
|
|
40
|
+
return {
|
|
41
|
+
items: rows.slice(start, start + p.limit),
|
|
42
|
+
total: rows.length,
|
|
43
|
+
next: nextPage(p.page, p.limit, rows.length),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function text(input, key) {
|
|
47
|
+
const value = input[key];
|
|
48
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
49
|
+
}
|
|
50
|
+
function required(input, key) {
|
|
51
|
+
const value = text(input, key);
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
throw new Error(`"${key}" is required`);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/** A session as the console shows it, without its id (an id is a handle on the session). */
|
|
57
|
+
function sessionView(session) {
|
|
58
|
+
const { id: _id, ...rest } = sessionDto(session);
|
|
59
|
+
return rest;
|
|
60
|
+
}
|
|
61
|
+
function grantView(grant) {
|
|
62
|
+
return {
|
|
63
|
+
accountId: grant.accountId,
|
|
64
|
+
clientId: grant.clientId ?? null,
|
|
65
|
+
accessTokens: grant.accessTokens,
|
|
66
|
+
refreshTokens: grant.refreshTokens,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The audit actor of an action taken over MCP. */
|
|
70
|
+
function mcpActor(call) {
|
|
71
|
+
return `mcp:${call.caller.id}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* authkit's developer tools for the Agora dev MCP endpoint: the admin console's reads, and two
|
|
75
|
+
* destructive actions (revoke sessions, disable a user) that the app must opt into on the endpoint.
|
|
76
|
+
* Every tool goes through the same services as the console; outputs never carry session ids, client
|
|
77
|
+
* secrets, key material, token values or MFA secrets.
|
|
78
|
+
*/
|
|
79
|
+
export function authkitDevTools(deps = {}) {
|
|
80
|
+
const service = (ctx) => deps.service ? deps.service(ctx) : ctx.containerResolver.make('authkit.server');
|
|
81
|
+
const sessions = (svc) => deps.sessions ? deps.sessions(svc) : new AdminSessionsService(svc);
|
|
82
|
+
const clients = (svc) => (deps.clients ? deps.clients(svc) : new AdminClientsService(svc));
|
|
83
|
+
async function findAccount(cfg, id) {
|
|
84
|
+
const account = await cfg.accountStore.findById(id);
|
|
85
|
+
if (!account)
|
|
86
|
+
throw new Error(`No account with id "${id}"`);
|
|
87
|
+
return account;
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
name: 'authkit_get_overview',
|
|
92
|
+
kind: 'read',
|
|
93
|
+
description: 'Identity provider summary: total users, active sessions, monthly active users, sign-ins and sign-ups per day (last 30 days), number of OIDC clients, audit total and the 5 latest audit events.',
|
|
94
|
+
input: { type: 'object', properties: {} },
|
|
95
|
+
async run(_input, call) {
|
|
96
|
+
const svc = await service(call.ctx);
|
|
97
|
+
const cfg = svc.config;
|
|
98
|
+
const stats = await computeAdminStats(cfg, sessions(svc));
|
|
99
|
+
const clientSvc = clients(svc);
|
|
100
|
+
const clientsCount = clientSvc.canList ? (await clientSvc.list()).length : 0;
|
|
101
|
+
const recent = typeof cfg.audit?.list === 'function'
|
|
102
|
+
? await cfg.audit.list({ page: LIST_FIRST_PAGE, size: 5 })
|
|
103
|
+
: null;
|
|
104
|
+
return {
|
|
105
|
+
usersTotal: stats.totalUsers,
|
|
106
|
+
activeSessions: stats.activeSessions,
|
|
107
|
+
mau: stats.mau,
|
|
108
|
+
signInsTotal: stats.signInsTotal,
|
|
109
|
+
signUpsTotal: stats.signUpsTotal,
|
|
110
|
+
signInsPerDay: stats.signInsPerDay,
|
|
111
|
+
signUpsPerDay: stats.signUpsPerDay,
|
|
112
|
+
windowDays: stats.windowDays,
|
|
113
|
+
auditSupported: stats.auditSupported,
|
|
114
|
+
clientsCount,
|
|
115
|
+
auditTotal: recent?.total ?? 0,
|
|
116
|
+
recentEvents: (recent?.data ?? []).map(auditDto),
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'authkit_list_users',
|
|
122
|
+
kind: 'read',
|
|
123
|
+
description: 'Accounts, newest first as the admin console lists them: id, email, name, stored global roles, effective roles and whether the account is disabled. `search` filters by email. Returns { items, total, next }.',
|
|
124
|
+
input: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
search: { type: 'string', description: 'Part of an email address.' },
|
|
128
|
+
page,
|
|
129
|
+
limit,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
async run(input, call) {
|
|
133
|
+
const cfg = (await service(call.ctx)).config;
|
|
134
|
+
const p = pageOf(input);
|
|
135
|
+
const result = await cfg.accountStore.listAccounts({
|
|
136
|
+
search: text(input, 'search') ?? '',
|
|
137
|
+
page: p.page,
|
|
138
|
+
size: p.limit,
|
|
139
|
+
});
|
|
140
|
+
const users = new AdminUsersService(cfg);
|
|
141
|
+
const items = await Promise.all(result.data.map(async (u) => userDtoWithRoles(cfg, u, await users.isDisabled(u.id))));
|
|
142
|
+
return { items, total: result.total, next: nextPage(p.page, p.limit, result.total) };
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'authkit_get_user',
|
|
147
|
+
kind: 'read',
|
|
148
|
+
description: 'One account: profile, stored and effective roles, disabled flag, MFA on/off (and whether an authenticator app is set up), linked sign-in providers, and how many active sessions and app grants it has. No session ids.',
|
|
149
|
+
input: { type: 'object', properties: { id: accountId }, required: ['id'] },
|
|
150
|
+
async run(input, call) {
|
|
151
|
+
const svc = await service(call.ctx);
|
|
152
|
+
const cfg = svc.config;
|
|
153
|
+
const id = required(input, 'id');
|
|
154
|
+
const account = await findAccount(cfg, id);
|
|
155
|
+
const users = new AdminUsersService(cfg);
|
|
156
|
+
const store = cfg.accountStore;
|
|
157
|
+
const sessionSvc = sessions(svc);
|
|
158
|
+
const [disabled, mfa, identities, sessionList, grants] = await Promise.all([
|
|
159
|
+
users.isDisabled(id),
|
|
160
|
+
supportsMfa(store) ? store.getMfaState(id) : Promise.resolve(null),
|
|
161
|
+
supportsProviderIdentity(store) ? store.listProviderIdentities(id) : Promise.resolve([]),
|
|
162
|
+
sessionSvc.canList ? sessionSvc.listSessions(id) : Promise.resolve(null),
|
|
163
|
+
sessionSvc.canList ? sessionSvc.listGrants(id) : Promise.resolve(null),
|
|
164
|
+
]);
|
|
165
|
+
return {
|
|
166
|
+
...(await userDtoWithRoles(cfg, account, disabled)),
|
|
167
|
+
mfa: mfa === null ? null : { enabled: mfa.enabled, totp: mfa.totp ?? null },
|
|
168
|
+
identities: identities.map((i) => ({
|
|
169
|
+
provider: i.provider,
|
|
170
|
+
providerUserId: i.providerUserId,
|
|
171
|
+
email: i.email ?? null,
|
|
172
|
+
})),
|
|
173
|
+
sessionCount: sessionList === null ? null : sessionList.length,
|
|
174
|
+
grantCount: grants === null ? null : grants.length,
|
|
175
|
+
statusSupported: supportsAccountStatus(store),
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'authkit_list_user_sessions',
|
|
181
|
+
kind: 'read',
|
|
182
|
+
description: "An account's active sign-in sessions as the admin console shows them: sign-in time, methods (amr), browser, OS, IP and approximate location. No session ids. Returns { supported, items, total, next }.",
|
|
183
|
+
input: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: { accountId, page, limit },
|
|
186
|
+
required: ['accountId'],
|
|
187
|
+
},
|
|
188
|
+
async run(input, call) {
|
|
189
|
+
const svc = await service(call.ctx);
|
|
190
|
+
const id = required(input, 'accountId');
|
|
191
|
+
await findAccount(svc.config, id);
|
|
192
|
+
const sessionSvc = sessions(svc);
|
|
193
|
+
if (!sessionSvc.canList)
|
|
194
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
195
|
+
const raw = await sessionSvc.listSessions(id);
|
|
196
|
+
const enriched = await enrichSessionsWithContext(svc.config, id, raw);
|
|
197
|
+
return { supported: true, ...slice(enriched.map(sessionView), pageOf(input)) };
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'authkit_list_user_grants',
|
|
202
|
+
kind: 'read',
|
|
203
|
+
description: 'The apps (OIDC clients) an account granted access to, with how many access and refresh tokens are live for each. Token values are never returned. Returns { supported, items, total, next }.',
|
|
204
|
+
input: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: { accountId, page, limit },
|
|
207
|
+
required: ['accountId'],
|
|
208
|
+
},
|
|
209
|
+
async run(input, call) {
|
|
210
|
+
const svc = await service(call.ctx);
|
|
211
|
+
const id = required(input, 'accountId');
|
|
212
|
+
await findAccount(svc.config, id);
|
|
213
|
+
const sessionSvc = sessions(svc);
|
|
214
|
+
if (!sessionSvc.canList)
|
|
215
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
216
|
+
const grants = await sessionSvc.listGrants(id);
|
|
217
|
+
return { supported: true, ...slice(grants.map(grantView), pageOf(input)) };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'authkit_list_orgs',
|
|
222
|
+
kind: 'read',
|
|
223
|
+
description: 'Organizations with their member count. Returns { supported, items, total, next }; `supported: false` when the account store has no organizations.',
|
|
224
|
+
input: { type: 'object', properties: { page, limit } },
|
|
225
|
+
async run(input, call) {
|
|
226
|
+
const svc = await service(call.ctx);
|
|
227
|
+
const orgs = new AdminOrgsService(svc.config, svc);
|
|
228
|
+
if (!orgs.supported)
|
|
229
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
230
|
+
const result = await orgs.listOrgs();
|
|
231
|
+
if (!Array.isArray(result))
|
|
232
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
233
|
+
return {
|
|
234
|
+
supported: true,
|
|
235
|
+
...slice(result.map((o) => orgDto(o)), pageOf(input)),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'authkit_get_org',
|
|
241
|
+
kind: 'read',
|
|
242
|
+
description: 'One organization: name, slug, members (account id, email, role, joined at) and pending invitations.',
|
|
243
|
+
input: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: { id: { type: 'string', description: 'The organization id.' } },
|
|
246
|
+
required: ['id'],
|
|
247
|
+
},
|
|
248
|
+
async run(input, call) {
|
|
249
|
+
const svc = await service(call.ctx);
|
|
250
|
+
const orgs = new AdminOrgsService(svc.config, svc);
|
|
251
|
+
if (!orgs.supported)
|
|
252
|
+
throw new Error('The account store has no organizations');
|
|
253
|
+
const result = await orgs.getOrg(required(input, 'id'));
|
|
254
|
+
if ('ok' in result && result.ok === false) {
|
|
255
|
+
throw new Error(result.reason === 'not_found'
|
|
256
|
+
? `No organization with id "${input.id}"`
|
|
257
|
+
: 'The account store has no organizations');
|
|
258
|
+
}
|
|
259
|
+
return orgDetailDto(result);
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'authkit_list_clients',
|
|
264
|
+
kind: 'read',
|
|
265
|
+
description: 'OIDC clients (apps that sign in through this identity provider): client id, name, confidential or public, grant types, redirect URIs, auth method, disabled flag. Never the client secret. Returns { supported, items, total, next }.',
|
|
266
|
+
input: { type: 'object', properties: { page, limit } },
|
|
267
|
+
async run(input, call) {
|
|
268
|
+
const svc = await service(call.ctx);
|
|
269
|
+
const clientSvc = clients(svc);
|
|
270
|
+
if (!clientSvc.canList)
|
|
271
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
272
|
+
const list = await clientSvc.list();
|
|
273
|
+
const items = list.map((c) => ({
|
|
274
|
+
clientId: c.clientId,
|
|
275
|
+
clientName: c.clientName ?? null,
|
|
276
|
+
confidential: c.confidential,
|
|
277
|
+
applicationType: c.applicationType ?? null,
|
|
278
|
+
grantTypes: c.grantTypes ?? [],
|
|
279
|
+
redirectUris: c.redirectUris ?? [],
|
|
280
|
+
postLogoutRedirectUris: c.postLogoutRedirectUris ?? [],
|
|
281
|
+
authMethod: c.tokenEndpointAuthMethod ?? null,
|
|
282
|
+
backchannelLogoutUri: c.backchannelLogoutUri ?? null,
|
|
283
|
+
logoUri: c.logoUri ?? null,
|
|
284
|
+
disabled: c.disabled === true,
|
|
285
|
+
serviceTenant: c.serviceTenant ?? null,
|
|
286
|
+
}));
|
|
287
|
+
return { supported: true, ...slice(items, pageOf(input)) };
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'authkit_list_audit',
|
|
292
|
+
kind: 'read',
|
|
293
|
+
description: 'The audit log, newest first: event type, account, actor, client, IP, metadata, time. Filter by `type` (e.g. "login.failure") and `subject` (an account id). Returns { supported, items, total, next }.',
|
|
294
|
+
input: {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: {
|
|
297
|
+
type: { type: 'string', description: 'Event type, e.g. "login.success".' },
|
|
298
|
+
subject: { type: 'string', description: 'Account id the events are about.' },
|
|
299
|
+
page,
|
|
300
|
+
limit,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
async run(input, call) {
|
|
304
|
+
const cfg = (await service(call.ctx)).config;
|
|
305
|
+
const sink = cfg.audit;
|
|
306
|
+
if (!sink || typeof sink.list !== 'function') {
|
|
307
|
+
return { supported: false, items: [], total: 0, next: null };
|
|
308
|
+
}
|
|
309
|
+
const p = pageOf(input);
|
|
310
|
+
const result = await sink.list({
|
|
311
|
+
page: p.page,
|
|
312
|
+
size: p.limit,
|
|
313
|
+
type: text(input, 'type'),
|
|
314
|
+
subject: text(input, 'subject'),
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
supported: true,
|
|
318
|
+
items: result.data.map(auditDto),
|
|
319
|
+
total: result.total,
|
|
320
|
+
next: nextPage(p.page, p.limit, result.total),
|
|
321
|
+
};
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'authkit_revoke_user_sessions',
|
|
326
|
+
kind: 'action',
|
|
327
|
+
destructive: true,
|
|
328
|
+
description: "Signs an account out everywhere: ends every session and revokes every app grant and its tokens. Recorded in authkit's audit log (session.revoked_all) with the MCP caller as actor. Returns the counts revoked.",
|
|
329
|
+
input: { type: 'object', properties: { accountId }, required: ['accountId'] },
|
|
330
|
+
async run(input, call) {
|
|
331
|
+
const svc = await service(call.ctx);
|
|
332
|
+
const cfg = svc.config;
|
|
333
|
+
const id = required(input, 'accountId');
|
|
334
|
+
await findAccount(cfg, id);
|
|
335
|
+
const result = await sessions(svc).revokeAll(id);
|
|
336
|
+
await cfg.audit?.record({
|
|
337
|
+
type: 'session.revoked_all',
|
|
338
|
+
accountId: id,
|
|
339
|
+
actorId: mcpActor(call),
|
|
340
|
+
ip: call.ctx.request?.ip?.() ?? null,
|
|
341
|
+
metadata: {
|
|
342
|
+
sessions: result.sessions,
|
|
343
|
+
grants: result.grants,
|
|
344
|
+
accessTokens: result.accessTokens,
|
|
345
|
+
refreshTokens: result.refreshTokens,
|
|
346
|
+
source: 'mcp',
|
|
347
|
+
actor: 'mcp',
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return { ok: true, accountId: id, ...result };
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: 'authkit_disable_user',
|
|
355
|
+
kind: 'action',
|
|
356
|
+
destructive: true,
|
|
357
|
+
description: "Disables an account: it can no longer sign in (re-enable it from the admin console). Recorded in authkit's audit log (user.disabled) with the MCP caller as actor. Only listed when the account store supports disabling accounts.",
|
|
358
|
+
input: { type: 'object', properties: { accountId }, required: ['accountId'] },
|
|
359
|
+
async available() {
|
|
360
|
+
if (!deps.resolve)
|
|
361
|
+
return true;
|
|
362
|
+
try {
|
|
363
|
+
return supportsAccountStatus((await deps.resolve()).config.accountStore);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
async run(input, call) {
|
|
370
|
+
const cfg = (await service(call.ctx)).config;
|
|
371
|
+
const id = required(input, 'accountId');
|
|
372
|
+
await findAccount(cfg, id);
|
|
373
|
+
const applied = await new AdminUsersService(cfg).setStatus(id, true, {
|
|
374
|
+
actorId: mcpActor(call),
|
|
375
|
+
ip: call.ctx.request?.ip?.() ?? null,
|
|
376
|
+
source: 'mcp',
|
|
377
|
+
});
|
|
378
|
+
if (!applied)
|
|
379
|
+
throw new Error('The account store cannot disable accounts');
|
|
380
|
+
return { ok: true, accountId: id, disabled: true };
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgoraDoctorCheck, AgoraDoctorResult } from './agora_doctor.js';
|
|
2
|
+
import { type DoctorInput, type Finding } from './checks.js';
|
|
3
|
+
export declare const AUTHKIT_DOCTOR_DOCS = "https://davidecarvalho.github.io/agora/docs/authkit/health-check";
|
|
4
|
+
type CheckFn = (input: DoctorInput) => Finding | Finding[] | null;
|
|
5
|
+
/**
|
|
6
|
+
* The `authkit:doctor` checks, in `runAllChecks` order, with their frozen `agora:doctor` ids
|
|
7
|
+
* (`authkit.` + the function name without `check`, in kebab case). Ids are public: never rename.
|
|
8
|
+
*/
|
|
9
|
+
export declare const AUTHKIT_DOCTOR_TABLE: ReadonlyArray<readonly [id: string, title: string, fn: CheckFn]>;
|
|
10
|
+
export declare const AUTHKIT_DOCTOR_PACKAGE = "@adonis-agora/authkit-server";
|
|
11
|
+
/** `admin.authorize` omitted: open outside production, denies every request in production. */
|
|
12
|
+
export declare function consoleGateResult(authkitConfig: Record<string, any> | null, inProduction: boolean): AgoraDoctorResult;
|
|
13
|
+
/** The checks authkit registers into `agora:doctor`. */
|
|
14
|
+
export declare function authkitDoctorChecks(): AgoraDoctorCheck[];
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { checkAccessTokens, checkAccountExpiration, checkAccountStore, checkAccountStoreColumns, checkAdapterVolatility, checkAdmin, checkAlly, checkAuthMethodsSetting, checkBotProtection, checkClients, checkConfigResolves, checkConsoleSignIn, checkEmailChange, checkFirstPartyClients, checkIssuer, checkJwks, checkOrganizations, checkOtpLockout, checkPasskeyAutofill, checkPasswordExpiration, checkPasswordHistory, checkPasswordPepper, checkPasswordPolicy, checkRateLimit, checkRequireVerifiedEmail, checkRolesCatalog, checkSecurityNotifications, checkSession, checkSessionPolicy, checkSettings, checkShield, checkSudoMode, checkWebauthn, } from './checks.js';
|
|
2
|
+
export const AUTHKIT_DOCTOR_DOCS = 'https://davidecarvalho.github.io/agora/docs/authkit/health-check';
|
|
3
|
+
/**
|
|
4
|
+
* The `authkit:doctor` checks, in `runAllChecks` order, with their frozen `agora:doctor` ids
|
|
5
|
+
* (`authkit.` + the function name without `check`, in kebab case). Ids are public: never rename.
|
|
6
|
+
*/
|
|
7
|
+
export const AUTHKIT_DOCTOR_TABLE = [
|
|
8
|
+
['authkit.config-resolves', 'config/authkit.ts resolves', checkConfigResolves],
|
|
9
|
+
['authkit.issuer', 'Issuer and mount path', checkIssuer],
|
|
10
|
+
['authkit.clients', 'OIDC clients', checkClients],
|
|
11
|
+
['authkit.adapter-volatility', 'Persistent adapter', checkAdapterVolatility],
|
|
12
|
+
['authkit.account-store', 'Account store capabilities', checkAccountStore],
|
|
13
|
+
['authkit.account-store-columns', 'Account store columns', checkAccountStoreColumns],
|
|
14
|
+
['authkit.session', 'Session', checkSession],
|
|
15
|
+
['authkit.shield', 'Shield (CSRF)', checkShield],
|
|
16
|
+
['authkit.ally', 'Social login (ally)', checkAlly],
|
|
17
|
+
['authkit.rate-limit', 'Rate limiting', checkRateLimit],
|
|
18
|
+
['authkit.admin', 'Admin console', checkAdmin],
|
|
19
|
+
['authkit.require-verified-email', 'Verified e-mail gate', checkRequireVerifiedEmail],
|
|
20
|
+
['authkit.bot-protection', 'Bot protection', checkBotProtection],
|
|
21
|
+
['authkit.webauthn', 'Passkeys (WebAuthn)', checkWebauthn],
|
|
22
|
+
['authkit.password-policy', 'Password policy', checkPasswordPolicy],
|
|
23
|
+
['authkit.jwks', 'Signing keys (JWKS)', checkJwks],
|
|
24
|
+
['authkit.access-tokens', 'Access tokens', checkAccessTokens],
|
|
25
|
+
['authkit.organizations', 'Organizations', checkOrganizations],
|
|
26
|
+
['authkit.settings', 'Runtime settings', checkSettings],
|
|
27
|
+
['authkit.auth-methods-setting', 'Sign-in methods setting', checkAuthMethodsSetting],
|
|
28
|
+
['authkit.email-change', 'E-mail change', checkEmailChange],
|
|
29
|
+
['authkit.security-notifications', 'Security notifications', checkSecurityNotifications],
|
|
30
|
+
['authkit.password-pepper', 'Password pepper', checkPasswordPepper],
|
|
31
|
+
['authkit.password-history', 'Password history', checkPasswordHistory],
|
|
32
|
+
['authkit.password-expiration', 'Password expiration', checkPasswordExpiration],
|
|
33
|
+
['authkit.session-policy', 'Session policy', checkSessionPolicy],
|
|
34
|
+
['authkit.roles-catalog', 'Roles catalog', checkRolesCatalog],
|
|
35
|
+
['authkit.otp-lockout', 'OTP lockout', checkOtpLockout],
|
|
36
|
+
['authkit.sudo-mode', 'Sudo mode', checkSudoMode],
|
|
37
|
+
['authkit.account-expiration', 'Account expiration', checkAccountExpiration],
|
|
38
|
+
['authkit.passkey-autofill', 'Passkey autofill', checkPasskeyAutofill],
|
|
39
|
+
['authkit.first-party-clients', 'First-party clients', checkFirstPartyClients],
|
|
40
|
+
['authkit.console-sign-in', 'Console sign-in', checkConsoleSignIn],
|
|
41
|
+
];
|
|
42
|
+
export const AUTHKIT_DOCTOR_PACKAGE = '@adonis-agora/authkit-server';
|
|
43
|
+
/** One input per app per doctor run: every check reads the same resolved config. */
|
|
44
|
+
const prepared = new WeakMap();
|
|
45
|
+
function prepare(app) {
|
|
46
|
+
let entry = prepared.get(app);
|
|
47
|
+
const state = app.getState?.();
|
|
48
|
+
if (state === 'created' || state === 'initiated') {
|
|
49
|
+
return Promise.resolve({ input: null, error: null, notBooted: true });
|
|
50
|
+
}
|
|
51
|
+
if (!entry) {
|
|
52
|
+
// Loaded on demand: the provider imports this module, and the input builder pulls in adapters.
|
|
53
|
+
entry = import('./doctor_input.js')
|
|
54
|
+
.then(({ buildDoctorInput }) => buildDoctorInput(app))
|
|
55
|
+
.then((input) => ({ input, error: null }), (error) => ({
|
|
56
|
+
input: null,
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
}));
|
|
59
|
+
prepared.set(app, entry);
|
|
60
|
+
}
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
function toResults(found) {
|
|
64
|
+
const list = found === null ? [] : Array.isArray(found) ? found : [found];
|
|
65
|
+
if (list.length === 0)
|
|
66
|
+
return [{ status: 'skip', message: 'not applicable to this config' }];
|
|
67
|
+
return list.map((finding) => ({
|
|
68
|
+
status: finding.level,
|
|
69
|
+
message: finding.message,
|
|
70
|
+
...(finding.level === 'ok' ? {} : { docs: AUTHKIT_DOCTOR_DOCS }),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/** `admin.authorize` omitted: open outside production, denies every request in production. */
|
|
74
|
+
export function consoleGateResult(authkitConfig, inProduction) {
|
|
75
|
+
const admin = authkitConfig?.admin;
|
|
76
|
+
if (!admin || admin.enabled !== true) {
|
|
77
|
+
return { status: 'skip', message: 'admin console is off' };
|
|
78
|
+
}
|
|
79
|
+
if (admin.authorize) {
|
|
80
|
+
return { status: 'ok', message: 'admin console has `admin.authorize`' };
|
|
81
|
+
}
|
|
82
|
+
const fix = 'Set `admin.authorize` in config/authkit.ts';
|
|
83
|
+
return inProduction
|
|
84
|
+
? {
|
|
85
|
+
status: 'error',
|
|
86
|
+
message: 'admin console has no `admin.authorize`: it denies every request in production',
|
|
87
|
+
fix,
|
|
88
|
+
docs: AUTHKIT_DOCTOR_DOCS,
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
status: 'warn',
|
|
92
|
+
message: 'admin console has no `admin.authorize`: open here, denies every request in production',
|
|
93
|
+
fix,
|
|
94
|
+
docs: AUTHKIT_DOCTOR_DOCS,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The checks authkit registers into `agora:doctor`. */
|
|
98
|
+
export function authkitDoctorChecks() {
|
|
99
|
+
const checks = AUTHKIT_DOCTOR_TABLE.map(([id, title, fn]) => ({
|
|
100
|
+
id,
|
|
101
|
+
lib: 'authkit',
|
|
102
|
+
package: AUTHKIT_DOCTOR_PACKAGE,
|
|
103
|
+
title,
|
|
104
|
+
async run(app) {
|
|
105
|
+
const { input, error, notBooted } = await prepare(app);
|
|
106
|
+
if (notBooted)
|
|
107
|
+
return [{ status: 'skip', message: 'needs a booted app (run without --no-boot)' }];
|
|
108
|
+
if (input === null) {
|
|
109
|
+
return id === 'authkit.config-resolves'
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
status: 'error',
|
|
113
|
+
message: `config/authkit.ts failed to resolve: ${error}`,
|
|
114
|
+
docs: AUTHKIT_DOCTOR_DOCS,
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
: [{ status: 'skip', message: 'config/authkit.ts did not resolve' }];
|
|
118
|
+
}
|
|
119
|
+
return toResults(fn(input));
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
checks.push({
|
|
123
|
+
id: 'authkit.signing-key-age',
|
|
124
|
+
lib: 'authkit',
|
|
125
|
+
package: AUTHKIT_DOCTOR_PACKAGE,
|
|
126
|
+
title: 'Signing key age',
|
|
127
|
+
async run(app) {
|
|
128
|
+
const { input, notBooted } = await prepare(app);
|
|
129
|
+
if (notBooted) {
|
|
130
|
+
return { status: 'skip', message: 'needs a booted app (run without --no-boot)' };
|
|
131
|
+
}
|
|
132
|
+
if (!input)
|
|
133
|
+
return toResults(null);
|
|
134
|
+
const { managedSigningKeyAgeFinding } = await import('./doctor_input.js');
|
|
135
|
+
return toResults(await managedSigningKeyAgeFinding(app, input.authkitConfig));
|
|
136
|
+
},
|
|
137
|
+
}, {
|
|
138
|
+
id: 'authkit.console-gate',
|
|
139
|
+
lib: 'authkit',
|
|
140
|
+
package: AUTHKIT_DOCTOR_PACKAGE,
|
|
141
|
+
title: 'Admin console access in production',
|
|
142
|
+
async run(app) {
|
|
143
|
+
const { input, notBooted } = await prepare(app);
|
|
144
|
+
if (notBooted)
|
|
145
|
+
return { status: 'skip', message: 'needs a booted app (run without --no-boot)' };
|
|
146
|
+
return consoleGateResult(input?.authkitConfig ?? null, app.inProduction);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return checks;
|
|
150
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ApplicationService } from '@adonisjs/core/types';
|
|
2
|
+
/** The container binding the registry lives at. */
|
|
3
|
+
export declare const AGORA_DOCTOR_BINDING = "agora.doctor";
|
|
4
|
+
/** Bound as a VALUE under `'agora.doctor'`. Data only. */
|
|
5
|
+
export interface AgoraDoctorRegistry {
|
|
6
|
+
readonly version: 1;
|
|
7
|
+
readonly checks: AgoraDoctorCheck[];
|
|
8
|
+
}
|
|
9
|
+
export type AgoraDoctorStatus = 'ok' | 'warn' | 'error' | 'skip';
|
|
10
|
+
export interface AgoraDoctorCheck {
|
|
11
|
+
/** `'<lib>.<kebab>'`, unique and frozen for 1.x: `'durable.verify-tenant'`. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** `'durable'`. The package is `@adonis-agora/<lib>` unless {@link package} says otherwise. */
|
|
14
|
+
lib: string;
|
|
15
|
+
/** The npm package, when it is not `@adonis-agora/<lib>`. */
|
|
16
|
+
package?: string;
|
|
17
|
+
title: string;
|
|
18
|
+
/** `'cheap'` (default) reads config, env and files only; `'deep'` touches a DB, Redis or the network. */
|
|
19
|
+
cost?: 'cheap' | 'deep';
|
|
20
|
+
run(app: ApplicationService): AgoraDoctorResult | AgoraDoctorResult[] | Promise<AgoraDoctorResult | AgoraDoctorResult[]>;
|
|
21
|
+
}
|
|
22
|
+
export interface AgoraDoctorResult {
|
|
23
|
+
status: AgoraDoctorStatus;
|
|
24
|
+
/** One line, no secrets, no stack. */
|
|
25
|
+
message: string;
|
|
26
|
+
/** One line: the exact change to make. */
|
|
27
|
+
fix?: string;
|
|
28
|
+
/** Absolute URL of the lib docs page. */
|
|
29
|
+
docs?: string;
|
|
30
|
+
}
|
|
31
|
+
/** One reported result. */
|
|
32
|
+
export interface AgoraDoctorReportEntry extends AgoraDoctorResult {
|
|
33
|
+
id: string;
|
|
34
|
+
lib: string;
|
|
35
|
+
title: string;
|
|
36
|
+
ms: number;
|
|
37
|
+
}
|
|
38
|
+
/** What `agora:doctor --json` prints. */
|
|
39
|
+
export interface AgoraDoctorReport {
|
|
40
|
+
version: 1;
|
|
41
|
+
env: string;
|
|
42
|
+
summary: Record<AgoraDoctorStatus, number>;
|
|
43
|
+
libs: Array<{
|
|
44
|
+
lib: string;
|
|
45
|
+
package: string;
|
|
46
|
+
version: string | null;
|
|
47
|
+
}>;
|
|
48
|
+
results: AgoraDoctorReportEntry[];
|
|
49
|
+
}
|
|
50
|
+
export interface AgoraDoctorOptions {
|
|
51
|
+
libs?: string[];
|
|
52
|
+
only?: string;
|
|
53
|
+
deep?: boolean;
|
|
54
|
+
/** The app was not booted (`--no-boot`): deep checks are reported as skipped, never run. */
|
|
55
|
+
noBoot?: boolean;
|
|
56
|
+
strict?: boolean;
|
|
57
|
+
/** Per-check budget in ms (default 5000, deep checks 15000). */
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
deepTimeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
/** A doctor problem that stops the run itself (exit 2). */
|
|
62
|
+
export declare class AgoraDoctorUsageError extends Error {
|
|
63
|
+
}
|
|
64
|
+
interface ContainerLike {
|
|
65
|
+
hasBinding(binding: string): boolean;
|
|
66
|
+
bindValue(binding: string, value: unknown): void;
|
|
67
|
+
make(binding: string): Promise<unknown>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Add a lib's checks to the app's doctor registry, creating it when absent. Call it from a provider's
|
|
71
|
+
* `boot()` (Adonis does not await `register()`, so an async registration there can be lost). A
|
|
72
|
+
* registry of another version is left alone; a duplicate id is skipped.
|
|
73
|
+
*/
|
|
74
|
+
export declare function registerDoctorChecks(container: ContainerLike, checks: readonly AgoraDoctorCheck[]): Promise<void>;
|
|
75
|
+
/** The registered checks; throws {@link AgoraDoctorUsageError} on a registry of another version. */
|
|
76
|
+
export declare function readDoctorChecks(container: ContainerLike): Promise<AgoraDoctorCheck[]>;
|
|
77
|
+
/**
|
|
78
|
+
* The checks of every installed `@adonis-agora/*` package that publishes a `./doctor` entry
|
|
79
|
+
* (`export const agoraDoctorChecks`), imported from the app root without booting providers. A
|
|
80
|
+
* package whose entry fails to load is reported as an error (`<lib>.doctor-entry`), never skipped.
|
|
81
|
+
*/
|
|
82
|
+
export declare function discoverDoctorChecks(app: ApplicationService): Promise<AgoraDoctorCheck[]>;
|
|
83
|
+
/** The directory of `name` as Node would find it from `from` (walking up `node_modules`), real path. */
|
|
84
|
+
export declare function packageDir(name: string, from: string): Promise<string | null>;
|
|
85
|
+
/** The installed version of `name` resolved from `from`, or `null` when it is not installed. */
|
|
86
|
+
export declare function installedVersion(name: string, from: string): Promise<string | null>;
|
|
87
|
+
/**
|
|
88
|
+
* Whether `version` satisfies `range`, with node-semver's grammar and prereleases included (like
|
|
89
|
+
* `semver.satisfies(version, range, { includePrerelease: true })`). An unparsable range is `false`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function satisfies(version: string, range: string): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The environment shown in the report, from the app itself: `production` when `app.inProduction`,
|
|
94
|
+
* `test` when `app.inTest`, else the app's node environment, and `development` when that is unset.
|
|
95
|
+
*/
|
|
96
|
+
export declare function environmentOf(app: Pick<ApplicationService, 'inProduction' | 'inTest' | 'nodeEnvironment'>): string;
|
|
97
|
+
/** Run the checks and build the report. Throws {@link AgoraDoctorUsageError} for an unknown `--only`. */
|
|
98
|
+
export declare function runAgoraDoctor(app: ApplicationService, registered: readonly AgoraDoctorCheck[], options?: AgoraDoctorOptions): Promise<{
|
|
99
|
+
report: AgoraDoctorReport;
|
|
100
|
+
exitCode: 0 | 1;
|
|
101
|
+
}>;
|
|
102
|
+
export interface Paint {
|
|
103
|
+
green(text: string): string;
|
|
104
|
+
yellow(text: string): string;
|
|
105
|
+
red(text: string): string;
|
|
106
|
+
dim(text: string): string;
|
|
107
|
+
bold(text: string): string;
|
|
108
|
+
}
|
|
109
|
+
/** The human report. `fancy` = colors and symbols (a TTY without `NO_COLOR`). */
|
|
110
|
+
export declare function renderAgoraDoctor(report: AgoraDoctorReport, options?: {
|
|
111
|
+
verbose?: boolean;
|
|
112
|
+
fancy?: boolean;
|
|
113
|
+
paint?: Paint;
|
|
114
|
+
}): string;
|
|
115
|
+
export {};
|