@adonis-agora/authkit-server 1.0.0-rc.0 → 1.0.0-rc.2
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 +38 -0
- package/README.md +4 -5
- package/build/index.d.ts +7 -11
- package/build/index.js +5 -9
- package/build/providers/authkit_server_provider.js +14 -8
- package/build/src/agents/config.d.ts +9 -13
- package/build/src/agents/config.js +17 -11
- package/build/src/agents/delegation_service.js +3 -3
- package/build/src/agents/delegation_store.d.ts +3 -2
- package/build/src/agents/delegation_store.js +6 -5
- package/build/src/agents/extension.d.ts +261 -0
- package/build/src/agents/extension.js +40 -0
- package/build/src/agents/middleware.js +1 -1
- package/build/src/agents/protocol.d.ts +4 -4
- package/build/src/agents/registry_store.d.ts +3 -2
- package/build/src/agents/registry_store.js +1 -1
- package/build/src/agents/runtime.d.ts +26 -7
- package/build/src/agents/runtime.js +110 -75
- package/build/src/audit/audit_sink.js +1 -1
- package/build/src/console_authorize.d.ts +56 -0
- package/build/src/console_authorize.js +85 -0
- package/build/src/define_config.d.ts +20 -14
- package/build/src/define_config.js +11 -8
- package/build/src/doctor/checks.d.ts +7 -1
- package/build/src/doctor/checks.js +47 -1
- package/build/src/host/account_api/account_api_controller.js +8 -6
- package/build/src/host/account_session_sign_in.d.ts +12 -0
- package/build/src/host/account_session_sign_in.js +19 -0
- package/build/src/host/admin_clients_service.js +10 -1
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +1 -6
- package/build/src/host/admin_console/console_personal_agents_controller.js +50 -30
- package/build/src/host/admin_console_warning.d.ts +8 -0
- package/build/src/host/admin_console_warning.js +18 -0
- package/build/src/host/admin_sessions_service.js +8 -5
- package/build/src/host/auth_host_config.d.ts +11 -2
- package/build/src/host/console_denied_page.d.ts +6 -0
- package/build/src/host/console_denied_page.js +18 -0
- package/build/src/host/console_sso.d.ts +24 -0
- package/build/src/host/console_sso.js +70 -0
- package/build/src/host/controllers/account_apps_controller.js +15 -11
- package/build/src/host/controllers/account_session_controller.js +10 -1
- package/build/src/host/controllers/console_sso_controller.d.ts +12 -0
- package/build/src/host/controllers/console_sso_controller.js +61 -0
- package/build/src/host/controllers/interaction_controller.js +3 -1
- package/build/src/host/csrf.d.ts +3 -3
- package/build/src/host/csrf.js +3 -13
- package/build/src/host/default_mailer.d.ts +11 -6
- package/build/src/host/default_mailer.js +16 -12
- package/build/src/host/i18n.d.ts +3 -14
- package/build/src/host/i18n.js +15 -13
- package/build/src/host/register_auth_host.d.ts +2 -0
- package/build/src/host/register_auth_host.js +110 -64
- package/build/src/host/ui-dist/assets/index-BqyJYRVX.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +6 -0
- package/build/src/provider/oidc_service.d.ts +1 -1
- package/build/src/provider/oidc_service.js +1 -1
- package/build/src/schema/ensure.d.ts +14 -0
- package/build/src/schema/ensure.js +43 -150
- package/package.json +5 -5
- package/build/src/agents/poppy/client_registry.d.ts +0 -50
- package/build/src/agents/poppy/client_registry.js +0 -260
- package/build/src/agents/poppy/config.d.ts +0 -396
- package/build/src/agents/poppy/config.js +0 -228
- package/build/src/agents/poppy/dpop.d.ts +0 -61
- package/build/src/agents/poppy/dpop.js +0 -158
- package/build/src/agents/poppy/email_code_sign_in.d.ts +0 -27
- package/build/src/agents/poppy/email_code_sign_in.js +0 -140
- package/build/src/agents/poppy/errors.d.ts +0 -25
- package/build/src/agents/poppy/errors.js +0 -25
- package/build/src/agents/poppy/replay.d.ts +0 -25
- package/build/src/agents/poppy/replay.js +0 -106
- package/build/src/agents/poppy/resource.d.ts +0 -100
- package/build/src/agents/poppy/resource.js +0 -129
- package/build/src/agents/poppy/safe_fetch.d.ts +0 -48
- package/build/src/agents/poppy/safe_fetch.js +0 -262
- package/build/src/agents/poppy/service.d.ts +0 -293
- package/build/src/agents/poppy/service.js +0 -1458
- package/build/src/agents/poppy/session_jwt.d.ts +0 -30
- package/build/src/agents/poppy/session_jwt.js +0 -133
- package/build/src/agents/poppy/store.d.ts +0 -106
- package/build/src/agents/poppy/store.js +0 -261
- package/build/src/agents/poppy/web_session.d.ts +0 -39
- package/build/src/agents/poppy/web_session.js +0 -122
- package/build/src/host/controllers/poppy_consent_controller.d.ts +0 -19
- package/build/src/host/controllers/poppy_consent_controller.js +0 -199
- package/build/src/host/controllers/poppy_controller.d.ts +0 -33
- package/build/src/host/controllers/poppy_controller.js +0 -219
- package/build/src/host/poppy_session_key.d.ts +0 -9
- package/build/src/host/poppy_session_key.js +0 -9
- package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +0 -67
|
@@ -1,13 +1,9 @@
|
|
|
1
|
+
import { getBootedApp } from '../../services/booted_app.js';
|
|
1
2
|
import { supportsAccountStatus } from '../accounts/account_store.js';
|
|
2
3
|
import { findMcpResource } from '../mcp/mcp_oauth.js';
|
|
3
4
|
import { PersonalAgentVerifier } from './agent_identity.js';
|
|
4
5
|
import { PersonalAgentDelegation } from './delegation_service.js';
|
|
5
6
|
import { DelegationStore } from './delegation_store.js';
|
|
6
|
-
import { PoppyClientRegistry } from './poppy/client_registry.js';
|
|
7
|
-
import { hmacNonceSource } from './poppy/dpop.js';
|
|
8
|
-
import { databaseReplayStore, memoryLimiter, memoryReplayStore, redisReplayStore, } from './poppy/replay.js';
|
|
9
|
-
import { PoppyService, poppyUrls } from './poppy/service.js';
|
|
10
|
-
import { PoppyStore } from './poppy/store.js';
|
|
11
7
|
import { AgentRegistryStore } from './registry_store.js';
|
|
12
8
|
import { keystoreSigner } from './signer.js';
|
|
13
9
|
/**
|
|
@@ -27,9 +23,10 @@ export function personalAgentUrls(oidcIssuer, prefix) {
|
|
|
27
23
|
};
|
|
28
24
|
}
|
|
29
25
|
const runtimes = new WeakMap();
|
|
30
|
-
/**
|
|
31
|
-
function
|
|
26
|
+
/** Access tokens do oidc-provider para resources MCP (ver {@link PersonalAgentAccessTokens}). */
|
|
27
|
+
function accessTokens(service) {
|
|
32
28
|
return {
|
|
29
|
+
mcpEnabled: !!service.config.mcp?.enabled,
|
|
33
30
|
isMcpResource(url) {
|
|
34
31
|
try {
|
|
35
32
|
return findMcpResource(url, service.config.issuer, service.config.mcp) !== null;
|
|
@@ -42,11 +39,6 @@ function mcpBridge(service, poppyApis) {
|
|
|
42
39
|
const provider = service.provider;
|
|
43
40
|
if (!provider?.AccessToken || !provider?.Grant)
|
|
44
41
|
return null;
|
|
45
|
-
// Só faz sentido quando a integração MCP do authkit está ligada, ou o MCP é de `apis`.
|
|
46
|
-
if (!service.config.mcp?.enabled &&
|
|
47
|
-
!poppyApis.some((a) => a.type === 'mcp' && a.url.replace(/\/+$/, '') === input.resource.replace(/\/+$/, ''))) {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
42
|
const remaining = Math.max(60, Math.floor((input.grantExpiresAt.getTime() - Date.now()) / 1000));
|
|
51
43
|
let grant = await provider.Grant.find(input.grantId);
|
|
52
44
|
if (!grant) {
|
|
@@ -63,13 +55,13 @@ function mcpBridge(service, poppyApis) {
|
|
|
63
55
|
clientId: input.clientId,
|
|
64
56
|
grantId: input.grantId,
|
|
65
57
|
scope: input.scope,
|
|
66
|
-
gty:
|
|
58
|
+
gty: input.grantType,
|
|
67
59
|
expiresIn: input.expiresIn,
|
|
68
60
|
});
|
|
69
61
|
at.aud = input.resource;
|
|
70
62
|
return at.save();
|
|
71
63
|
},
|
|
72
|
-
async
|
|
64
|
+
async revokeGrant(grantId) {
|
|
73
65
|
const provider = service.provider;
|
|
74
66
|
if (!provider?.Grant)
|
|
75
67
|
return;
|
|
@@ -80,18 +72,32 @@ function mcpBridge(service, poppyApis) {
|
|
|
80
72
|
},
|
|
81
73
|
};
|
|
82
74
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
/** O ledger de grants visto por uma extension: só os ids com o prefixo dela. */
|
|
76
|
+
function scopedLedger(store, prefix) {
|
|
77
|
+
const mine = (id) => typeof id === 'string' && id.startsWith(prefix);
|
|
78
|
+
return {
|
|
79
|
+
async insertGrant(grant) {
|
|
80
|
+
if (!mine(grant.id)) {
|
|
81
|
+
throw new Error(`authkit: grant "${grant.id}" fora do prefixo "${prefix}" da extension.`);
|
|
82
|
+
}
|
|
83
|
+
await store.insertGrant(grant);
|
|
84
|
+
},
|
|
85
|
+
async findGrant(id) {
|
|
86
|
+
return mine(id) ? store.findGrant(id) : null;
|
|
87
|
+
},
|
|
88
|
+
async listGrants(accountId) {
|
|
89
|
+
return (await store.listGrants(accountId)).filter((g) => mine(g.id));
|
|
90
|
+
},
|
|
91
|
+
async unrevokedGrantIdsOfClient(clientId) {
|
|
92
|
+
return (await store.unrevokedGrantIdsOfClient(clientId)).filter(mine);
|
|
93
|
+
},
|
|
94
|
+
async revokeGrantById(id, at) {
|
|
95
|
+
if (mine(id))
|
|
96
|
+
await store.revokeGrantById(id, at);
|
|
97
|
+
},
|
|
98
|
+
insertRefreshToken: (token) => store.insertRefreshToken(token),
|
|
99
|
+
findRefreshToken: (tokenHash) => store.findRefreshToken(tokenHash),
|
|
100
|
+
};
|
|
95
101
|
}
|
|
96
102
|
/**
|
|
97
103
|
* Config com o registro do console por cima: um agente BLOQUEADO no console é
|
|
@@ -112,21 +118,7 @@ function withRegistry(config, registry) {
|
|
|
112
118
|
}
|
|
113
119
|
return null;
|
|
114
120
|
};
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
...config,
|
|
118
|
-
resolveAgent,
|
|
119
|
-
poppy: poppy && {
|
|
120
|
-
...poppy,
|
|
121
|
-
clients: {
|
|
122
|
-
...poppy.clients,
|
|
123
|
-
isBlocked: async (id) => (await poppy.clients.isBlocked(id)) ||
|
|
124
|
-
(await registry.find('poppy', id))?.status === 'blocked',
|
|
125
|
-
isRegistered: async (id) => (await poppy.clients.isRegistered(id)) ||
|
|
126
|
-
(await registry.find('poppy', id))?.status === 'allowed',
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
};
|
|
121
|
+
return { ...config, resolveAgent };
|
|
130
122
|
}
|
|
131
123
|
/**
|
|
132
124
|
* Monta (uma vez por `OidcService`) o runtime de personal agents. `null` quando
|
|
@@ -149,7 +141,7 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
149
141
|
};
|
|
150
142
|
let conn = null;
|
|
151
143
|
const connection = service.config.schema.connection;
|
|
152
|
-
if (config.delegation || config.
|
|
144
|
+
if (config.delegation || (config.extensions ?? []).length > 0) {
|
|
153
145
|
const db = await makeDb();
|
|
154
146
|
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
155
147
|
}
|
|
@@ -177,39 +169,53 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
177
169
|
isAccountActive,
|
|
178
170
|
});
|
|
179
171
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
172
|
+
const extensions = new Map();
|
|
173
|
+
const now = () => options.now?.() ?? new Date();
|
|
174
|
+
if (store && conn && registry) {
|
|
175
|
+
const db = conn;
|
|
176
|
+
const tokens = accessTokens(service);
|
|
177
|
+
for (const extension of config.extensions ?? []) {
|
|
178
|
+
const runtime = await extension.setup({
|
|
179
|
+
protocol: extension.id,
|
|
180
|
+
issuer: service.config.issuer,
|
|
181
|
+
db: () => db(),
|
|
182
|
+
grants: scopedLedger(store, extension.grantIdPrefix),
|
|
183
|
+
registryStatus: async (identifier) => (await registry.find(extension.id, identifier))?.status ?? null,
|
|
184
|
+
deriveSecret: (label) => service.deriveSecret(label),
|
|
185
|
+
isAccountActive,
|
|
186
|
+
make: async (binding) => {
|
|
187
|
+
if (!options.make) {
|
|
188
|
+
throw new Error(`authkit: o binding "${binding}" exige o container do app.`);
|
|
189
|
+
}
|
|
190
|
+
return options.make(binding);
|
|
191
|
+
},
|
|
192
|
+
accessTokens: tokens,
|
|
193
|
+
now,
|
|
194
|
+
});
|
|
195
|
+
extensions.set(extension.id, { extension, runtime });
|
|
196
|
+
}
|
|
202
197
|
}
|
|
198
|
+
/** A extension dona de um grant, pelo prefixo do id. */
|
|
199
|
+
const ownerOf = (grantId) => {
|
|
200
|
+
for (const entry of extensions.values()) {
|
|
201
|
+
if (grantId.startsWith(entry.extension.grantIdPrefix))
|
|
202
|
+
return entry;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
};
|
|
206
|
+
const isExtensionGrant = (grantId) => (config.extensions ?? []).some((e) => grantId.startsWith(e.grantIdPrefix));
|
|
203
207
|
const revokeAgentGrant = async (accountId, grantId) => {
|
|
204
|
-
if (grantId
|
|
205
|
-
|
|
208
|
+
if (isExtensionGrant(grantId)) {
|
|
209
|
+
const owner = ownerOf(grantId);
|
|
210
|
+
return owner ? owner.runtime.revokeAccountGrant(accountId, grantId) : false;
|
|
206
211
|
}
|
|
207
212
|
return delegation ? delegation.revokeGrant(accountId, grantId) : false;
|
|
208
213
|
};
|
|
209
214
|
const revokeAllAgentGrants = async (accountId) => {
|
|
210
215
|
let count = 0;
|
|
211
|
-
|
|
212
|
-
count += await
|
|
216
|
+
for (const { runtime } of extensions.values()) {
|
|
217
|
+
count += await runtime.revokeAllAccountGrants(accountId);
|
|
218
|
+
}
|
|
213
219
|
if (delegation)
|
|
214
220
|
count += await delegation.revokeAllGrants(accountId);
|
|
215
221
|
return count;
|
|
@@ -222,10 +228,11 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
222
228
|
// Expirado já não vale: revogar não muda nada (e não vira evento de auditoria).
|
|
223
229
|
if (!grant || grant.revokedAt || grant.expiresAt <= now)
|
|
224
230
|
return null;
|
|
225
|
-
if (grantId
|
|
226
|
-
|
|
231
|
+
if (isExtensionGrant(grantId)) {
|
|
232
|
+
const owner = ownerOf(grantId);
|
|
233
|
+
if (!owner)
|
|
227
234
|
return null;
|
|
228
|
-
await
|
|
235
|
+
await owner.runtime.revokeGrant(grantId);
|
|
229
236
|
}
|
|
230
237
|
else {
|
|
231
238
|
await store.revokeGrantById(grantId, now);
|
|
@@ -235,9 +242,11 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
235
242
|
const disconnectAgent = async (protocol, identifier) => {
|
|
236
243
|
if (!store)
|
|
237
244
|
return 0;
|
|
238
|
-
if (protocol
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
if (protocol !== 'pact') {
|
|
246
|
+
const entry = extensions.get(protocol);
|
|
247
|
+
return entry ? entry.runtime.disconnectAgent(identifier) : 0;
|
|
248
|
+
}
|
|
249
|
+
const ids = (await store.unrevokedGrantIdsOfClient(identifier)).filter((id) => !isExtensionGrant(id));
|
|
241
250
|
const at = options.now?.() ?? new Date();
|
|
242
251
|
for (const id of ids)
|
|
243
252
|
await store.revokeGrantById(id, at);
|
|
@@ -248,7 +257,7 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
248
257
|
urls,
|
|
249
258
|
verifier: new PersonalAgentVerifier(effective),
|
|
250
259
|
delegation,
|
|
251
|
-
|
|
260
|
+
extensions,
|
|
252
261
|
revokeAgentGrant,
|
|
253
262
|
revokeAllAgentGrants,
|
|
254
263
|
registry,
|
|
@@ -270,3 +279,29 @@ export async function personalAgentsFor(ctx) {
|
|
|
270
279
|
make: (binding) => ctx.containerResolver.make(binding),
|
|
271
280
|
});
|
|
272
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The runtime an extension's `setup` built (`personalAgents.extensions`), by protocol id. `null`
|
|
284
|
+
* when the extension is not configured (or there is no database). Takes the request, an explicit
|
|
285
|
+
* source (tests, scripts) or nothing (the booted app).
|
|
286
|
+
*
|
|
287
|
+
* Stable for 1.x — see `extension.ts`.
|
|
288
|
+
*/
|
|
289
|
+
export async function personalAgentExtensionRuntime(id, from) {
|
|
290
|
+
let runtime;
|
|
291
|
+
if (from && 'containerResolver' in from) {
|
|
292
|
+
runtime = await personalAgentsFor(from);
|
|
293
|
+
}
|
|
294
|
+
else if (from) {
|
|
295
|
+
const source = from;
|
|
296
|
+
runtime = await buildPersonalAgentsRuntime(source.service, () => source.db(), {
|
|
297
|
+
make: source.make ? (binding) => source.make(binding) : undefined,
|
|
298
|
+
now: source.now ? () => source.now() : undefined,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const app = getBootedApp();
|
|
303
|
+
const service = (await app.container.make('authkit.server'));
|
|
304
|
+
runtime = await buildPersonalAgentsRuntime(service, () => app.container.make('lucid.db'), { make: (binding) => app.container.make(binding) });
|
|
305
|
+
}
|
|
306
|
+
return runtime?.extensions.get(id)?.runtime ?? null;
|
|
307
|
+
}
|
|
@@ -114,7 +114,7 @@ export const AUDIT_EVENT_TYPES = [
|
|
|
114
114
|
'agent.delegation_denied',
|
|
115
115
|
'agent.grant_revoked',
|
|
116
116
|
// Console admin: um admin revogou a conexão de um usuário, ou liberou/bloqueou um
|
|
117
|
-
// agente (PACT pelo `iss
|
|
117
|
+
// agente (PACT pelo `iss`; uma extension pelo identificador dela).
|
|
118
118
|
'agent.grant_revoked_by_admin',
|
|
119
119
|
'agent.allowed',
|
|
120
120
|
'agent.blocked',
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* What a console `authorize` returns. Same shape in every `@adonis-agora` console.
|
|
4
|
+
*
|
|
5
|
+
* - `true` / `{ allowed: true }` → allowed
|
|
6
|
+
* - `false` / `{ allowed: false }` → 403
|
|
7
|
+
* - `{ allowed: false, reason: 'unauthenticated' }` → 401
|
|
8
|
+
* - `{ redirectTo }` → 302 to that path on a page navigation, 401 JSON on an API call
|
|
9
|
+
*/
|
|
10
|
+
export type ConsoleAuthorizeResult = boolean | {
|
|
11
|
+
allowed: true;
|
|
12
|
+
} | {
|
|
13
|
+
allowed: false;
|
|
14
|
+
reason?: 'unauthenticated' | 'forbidden';
|
|
15
|
+
} | {
|
|
16
|
+
redirectTo: string;
|
|
17
|
+
};
|
|
18
|
+
/** A class form, for an authorizer in its own file (`@inject()` works). */
|
|
19
|
+
export interface ConsoleAuthorizer {
|
|
20
|
+
authorize(ctx: HttpContext): ConsoleAuthorizeResult | Promise<ConsoleAuthorizeResult>;
|
|
21
|
+
}
|
|
22
|
+
export type ConsoleAuthorizerClass = new (...args: any[]) => ConsoleAuthorizer;
|
|
23
|
+
/**
|
|
24
|
+
* Who may open a console: an inline function, an authorizer class, or a lazy
|
|
25
|
+
* import of a module whose default export is an authorizer class.
|
|
26
|
+
*/
|
|
27
|
+
export type ConsoleAuthorize = ((ctx: HttpContext) => ConsoleAuthorizeResult | Promise<ConsoleAuthorizeResult>) | ConsoleAuthorizerClass | (() => Promise<{
|
|
28
|
+
default: ConsoleAuthorizerClass;
|
|
29
|
+
}>);
|
|
30
|
+
/** The normalized outcome of a `ConsoleAuthorize` call. */
|
|
31
|
+
export type ConsoleAuthorizeDecision = {
|
|
32
|
+
allowed: true;
|
|
33
|
+
} | {
|
|
34
|
+
allowed: false;
|
|
35
|
+
status: 401 | 403;
|
|
36
|
+
redirectTo?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function normalizeConsoleAuthorizeResult(result: unknown): ConsoleAuthorizeDecision;
|
|
39
|
+
/**
|
|
40
|
+
* Turns a `ConsoleAuthorize` into a checker returning a normalized decision.
|
|
41
|
+
* A class is built through the container (so `@inject()` works); a lazy import
|
|
42
|
+
* runs once and its class is remembered. A thrown error is a denial (403).
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveConsoleAuthorize(authorize: ConsoleAuthorize): (ctx: HttpContext) => Promise<ConsoleAuthorizeDecision>;
|
|
45
|
+
/**
|
|
46
|
+
* A `ConsoleAuthorize` that lets in requests carrying `Authorization: Bearer <token>`.
|
|
47
|
+
* The comparison is timing-safe. Anything else is `{ allowed: false, reason: 'unauthenticated' }`.
|
|
48
|
+
* Throws when `token` is empty, so a missing env var fails at config time.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* authorize: authorizeByToken({ token: env.get('CONSOLE_TOKEN') })
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function authorizeByToken(options: {
|
|
55
|
+
token: string | undefined | null;
|
|
56
|
+
}): (ctx: HttpContext) => ConsoleAuthorizeResult;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function isAuthorizerClass(value) {
|
|
3
|
+
return (typeof value === 'function' &&
|
|
4
|
+
typeof value.prototype?.authorize === 'function');
|
|
5
|
+
}
|
|
6
|
+
function isLazyModule(value) {
|
|
7
|
+
return (typeof value === 'object' &&
|
|
8
|
+
value !== null &&
|
|
9
|
+
'default' in value &&
|
|
10
|
+
isAuthorizerClass(value.default));
|
|
11
|
+
}
|
|
12
|
+
export function normalizeConsoleAuthorizeResult(result) {
|
|
13
|
+
if (result === true)
|
|
14
|
+
return { allowed: true };
|
|
15
|
+
if (typeof result === 'object' && result !== null) {
|
|
16
|
+
const r = result;
|
|
17
|
+
if (typeof r.redirectTo === 'string' && r.redirectTo.length > 0) {
|
|
18
|
+
return { allowed: false, status: 401, redirectTo: r.redirectTo };
|
|
19
|
+
}
|
|
20
|
+
if (r.allowed === true)
|
|
21
|
+
return { allowed: true };
|
|
22
|
+
if (r.allowed === false && r.reason === 'unauthenticated') {
|
|
23
|
+
return { allowed: false, status: 401 };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// `false`, `{ allowed: false }` and anything unrecognized deny.
|
|
27
|
+
return { allowed: false, status: 403 };
|
|
28
|
+
}
|
|
29
|
+
async function runClass(Authorizer, ctx) {
|
|
30
|
+
const instance = (await ctx.containerResolver.make(Authorizer));
|
|
31
|
+
return instance.authorize(ctx);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Turns a `ConsoleAuthorize` into a checker returning a normalized decision.
|
|
35
|
+
* A class is built through the container (so `@inject()` works); a lazy import
|
|
36
|
+
* runs once and its class is remembered. A thrown error is a denial (403).
|
|
37
|
+
*/
|
|
38
|
+
export function resolveConsoleAuthorize(authorize) {
|
|
39
|
+
let lazyClass;
|
|
40
|
+
return async (ctx) => {
|
|
41
|
+
try {
|
|
42
|
+
if (isAuthorizerClass(authorize)) {
|
|
43
|
+
return normalizeConsoleAuthorizeResult(await runClass(authorize, ctx));
|
|
44
|
+
}
|
|
45
|
+
if (lazyClass)
|
|
46
|
+
return normalizeConsoleAuthorizeResult(await runClass(lazyClass, ctx));
|
|
47
|
+
const result = await authorize(ctx);
|
|
48
|
+
if (isLazyModule(result)) {
|
|
49
|
+
lazyClass = result.default;
|
|
50
|
+
return normalizeConsoleAuthorizeResult(await runClass(lazyClass, ctx));
|
|
51
|
+
}
|
|
52
|
+
return normalizeConsoleAuthorizeResult(result);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
ctx.logger?.warn?.({ err: error }, 'console authorize threw; access denied');
|
|
56
|
+
return { allowed: false, status: 403 };
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A `ConsoleAuthorize` that lets in requests carrying `Authorization: Bearer <token>`.
|
|
62
|
+
* The comparison is timing-safe. Anything else is `{ allowed: false, reason: 'unauthenticated' }`.
|
|
63
|
+
* Throws when `token` is empty, so a missing env var fails at config time.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* authorize: authorizeByToken({ token: env.get('CONSOLE_TOKEN') })
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function authorizeByToken(options) {
|
|
70
|
+
const token = options?.token;
|
|
71
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
72
|
+
throw new Error('authorizeByToken: `token` is empty. Set it (e.g. env CONSOLE_TOKEN).');
|
|
73
|
+
}
|
|
74
|
+
const expected = createHash('sha256').update(token).digest();
|
|
75
|
+
return (ctx) => {
|
|
76
|
+
const header = ctx.request.header('authorization');
|
|
77
|
+
const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null;
|
|
78
|
+
if (match) {
|
|
79
|
+
const given = createHash('sha256').update(match[1]).digest();
|
|
80
|
+
if (timingSafeEqual(given, expected))
|
|
81
|
+
return { allowed: true };
|
|
82
|
+
}
|
|
83
|
+
return { allowed: false, reason: 'unauthenticated' };
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { AccountStore, AuthAccount } from './accounts/account_store.js';
|
|
|
4
4
|
import { type AdapterFactory, adapters, type OidcAdapterClass } from './adapters/factory.js';
|
|
5
5
|
import { type PersonalAgentsConfigInput, type ResolvedPersonalAgentsConfig } from './agents/config.js';
|
|
6
6
|
import type { AuditSink } from './audit/audit_sink.js';
|
|
7
|
+
import type { ConsoleAuthorize } from './console_authorize.js';
|
|
7
8
|
import { type EventsConfigInput } from './events/dispatcher.js';
|
|
8
9
|
import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from './host/bot_protection.js';
|
|
9
10
|
import { type BrandingConfig } from './host/branding.js';
|
|
@@ -111,18 +112,6 @@ export interface MailHooks {
|
|
|
111
112
|
email: string;
|
|
112
113
|
sudoUrl: string;
|
|
113
114
|
}) => Promise<void>;
|
|
114
|
-
/**
|
|
115
|
-
* Código do Mediated Sign-In pronto do Poppy (`poppyEmailCodeSignIn`): um personal
|
|
116
|
-
* agent quer entrar na conta e a pessoa precisa dar o código a ele. Sem o hook, o
|
|
117
|
-
* host-kit envia pelo mailer default, com branding e i18n.
|
|
118
|
-
*/
|
|
119
|
-
onPoppySignInCode?: (data: {
|
|
120
|
-
email: string;
|
|
121
|
-
code: string;
|
|
122
|
-
/** Nome do agente (metadata dele, ou o host do `client_id`). */
|
|
123
|
-
agentName: string;
|
|
124
|
-
expiresInMinutes: number;
|
|
125
|
-
}) => Promise<void>;
|
|
126
115
|
/**
|
|
127
116
|
* Disparado num login bem-sucedido a partir de um IP NOVO para a conta (nenhum
|
|
128
117
|
* `login.success` anterior desse IP no audit sink), quando `notifications.newLoginEmail`
|
|
@@ -708,12 +697,21 @@ export interface AdminConfigInput {
|
|
|
708
697
|
* default é decisão de major, mesma regra de `impersonation`).
|
|
709
698
|
*/
|
|
710
699
|
impersonateAdmins?: boolean;
|
|
700
|
+
/**
|
|
701
|
+
* Access check for the console, run after `roles` (both must pass), on the shell
|
|
702
|
+
* and on every `{prefix}/api/*` route. An inline function, an authorizer class
|
|
703
|
+
* (`@inject()` works), a lazy import (`() => import('#policies/...')`) or
|
|
704
|
+
* `authorizeByToken({ token })`. Omitted: open outside production, 403 in production.
|
|
705
|
+
*/
|
|
706
|
+
authorize?: ConsoleAuthorize;
|
|
711
707
|
}
|
|
712
708
|
export interface ResolvedAdminConfig {
|
|
713
709
|
enabled: boolean;
|
|
714
710
|
roles: string[];
|
|
715
711
|
impersonation: boolean;
|
|
716
712
|
impersonateAdmins: boolean;
|
|
713
|
+
/** `admin.authorize` as configured, or `null` when omitted. */
|
|
714
|
+
authorize: ConsoleAuthorize | null;
|
|
717
715
|
}
|
|
718
716
|
export declare function resolveAdmin(input?: AdminConfigInput): ResolvedAdminConfig;
|
|
719
717
|
/**
|
|
@@ -1225,8 +1223,16 @@ export interface AuthServerConfigInput {
|
|
|
1225
1223
|
* expiração), e o "Sair" do console encerra também a sessão do IdP. Contas
|
|
1226
1224
|
* desabilitadas não entram. Operações sensíveis continuam pedindo sudo.
|
|
1227
1225
|
*
|
|
1228
|
-
* Default `
|
|
1229
|
-
*
|
|
1226
|
+
* Default `true`: o console é mais um app do IdP. Sem sessão, `/account/*` e o
|
|
1227
|
+
* console admin mandam o visitante para o login do IdP (`/auth/console/sso`,
|
|
1228
|
+
* client interno `authkit-console`) e voltam com o console aberto. Um único
|
|
1229
|
+
* login, como no Keycloak, Okta e Google; o que protege as operações
|
|
1230
|
+
* sensíveis é o sudo, não um segundo login.
|
|
1231
|
+
*
|
|
1232
|
+
* `false`: o console só aceita a própria sessão (`POST /account/login`, por
|
|
1233
|
+
* senha). Com `authMethods: { password: false }` isso deixa o console sem
|
|
1234
|
+
* nenhum jeito de entrar, então o boot recusa a combinação, a menos que
|
|
1235
|
+
* `routes.accountLoginUrl` aponte para um login do próprio host.
|
|
1230
1236
|
*/
|
|
1231
1237
|
accountSession?: {
|
|
1232
1238
|
acceptIdpSession?: boolean;
|
|
@@ -6,7 +6,7 @@ import { composeAuditSink, resolveEvents } from './events/dispatcher.js';
|
|
|
6
6
|
import { resolveBotProtection, } from './host/bot_protection.js';
|
|
7
7
|
import { resolveBranding } from './host/branding.js';
|
|
8
8
|
import { deriveLockedRouteOptions, deriveLockedSettingKeys, } from './host/config_locks.js';
|
|
9
|
-
import { resolveMessages } from './host/i18n.js';
|
|
9
|
+
import { registerExtensionMessages, resolveMessages, } from './host/i18n.js';
|
|
10
10
|
import { resolveOtpLoginConfig, } from './host/otp_login.js';
|
|
11
11
|
import { edgeRenderer } from './host/renderers/edge_renderer.js';
|
|
12
12
|
import { warnUnsatisfiableSudoConfig } from './host/sudo/satisfiability.js';
|
|
@@ -193,6 +193,7 @@ export function resolveAdmin(input) {
|
|
|
193
193
|
// registrado). Ver o docblock de `AdminConfigInput.impersonation`.
|
|
194
194
|
impersonation: input?.impersonation !== false,
|
|
195
195
|
impersonateAdmins: input?.impersonateAdmins !== false,
|
|
196
|
+
authorize: input?.authorize ?? null,
|
|
196
197
|
};
|
|
197
198
|
}
|
|
198
199
|
/** Lê API keys de `AUTHKIT_ADMIN_API_KEY` (uma ou várias, separadas por vírgula). */
|
|
@@ -342,7 +343,7 @@ export function defineConfig(config) {
|
|
|
342
343
|
}
|
|
343
344
|
// Personal agents com delegação assinam tokens e recibos com este keystore,
|
|
344
345
|
// e o PACT só admite ES256/RS256 — falha no boot, não na primeira troca de token.
|
|
345
|
-
const personalAgents = resolvePersonalAgentsConfig(config.personalAgents);
|
|
346
|
+
const personalAgents = resolvePersonalAgentsConfig(config.personalAgents, config.issuer);
|
|
346
347
|
if (personalAgents?.delegation)
|
|
347
348
|
assertDelegationSigningKey(jwks);
|
|
348
349
|
// Debaixo do mountPath, a rota curinga do oidc-provider (`${mount}/*`)
|
|
@@ -354,11 +355,13 @@ export function defineConfig(config) {
|
|
|
354
355
|
(personalAgents.prefix === oidcMount || personalAgents.prefix.startsWith(`${oidcMount}/`))) {
|
|
355
356
|
throw new Error(`authkit: personalAgents.prefix "${personalAgents.prefix}" não pode ficar debaixo do mountPath do OIDC ("${oidcMount}").`);
|
|
356
357
|
}
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
358
|
+
for (const extension of personalAgents?.extensions ?? []) {
|
|
359
|
+
if (extension.messages)
|
|
360
|
+
registerExtensionMessages(extension.messages);
|
|
361
|
+
if (oidcMount &&
|
|
362
|
+
(extension.prefix === oidcMount || extension.prefix.startsWith(`${oidcMount}/`))) {
|
|
363
|
+
throw new Error(`authkit: o prefixo "${extension.prefix}" de personalAgents.extensions "${extension.id}" não pode ficar debaixo do mountPath do OIDC ("${oidcMount}").`);
|
|
364
|
+
}
|
|
362
365
|
}
|
|
363
366
|
// BACKSTOP DE SUDO. Um host cujo `sudo.methods` não tem um único método
|
|
364
367
|
// satisfazível por conta sem senha fica bricado para TODA operação sob
|
|
@@ -496,7 +499,7 @@ export function defineConfig(config) {
|
|
|
496
499
|
lockedRouteOptions: deriveLockedRouteOptions(config),
|
|
497
500
|
// Opt-in: ausente = authkit nunca toca `ctx.auth` (comportamento de sempre).
|
|
498
501
|
adonisAuth: config.adonisAuth,
|
|
499
|
-
accountSession: { acceptIdpSession: config.accountSession?.acceptIdpSession
|
|
502
|
+
accountSession: { acceptIdpSession: config.accountSession?.acceptIdpSession !== false },
|
|
500
503
|
};
|
|
501
504
|
});
|
|
502
505
|
}
|
|
@@ -87,7 +87,7 @@ export declare function checkOtpLockout(input: DoctorInput): Finding | null;
|
|
|
87
87
|
export declare function checkSudoMode(_input: DoctorInput): Finding | null;
|
|
88
88
|
/** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
|
|
89
89
|
export declare function checkRateLimit(input: DoctorInput): Finding;
|
|
90
|
-
/** admin.enabled mas sem roles → warn. Reporta o modo de UI ativo. */
|
|
90
|
+
/** admin.enabled mas sem roles, ou sem `admin.authorize` → warn. Reporta o modo de UI ativo. */
|
|
91
91
|
export declare function checkAdmin(input: DoctorInput): Finding | null;
|
|
92
92
|
/** requireVerifiedEmail ligado mas o store não sabe checar verificação → warn. */
|
|
93
93
|
export declare function checkRequireVerifiedEmail(input: DoctorInput): Finding | null;
|
|
@@ -214,6 +214,12 @@ export declare function checkPasskeyAutofill(input: DoctorInput): Finding | null
|
|
|
214
214
|
* de TERCEIROS precisa saber que existe uma lista para fechar.
|
|
215
215
|
*/
|
|
216
216
|
export declare function checkFirstPartyClients(input: DoctorInput): Finding | null;
|
|
217
|
+
/**
|
|
218
|
+
* Como se entra no console de conta e no console admin. O default é o login do
|
|
219
|
+
* IdP (SSO, `accountSession.acceptIdpSession`); desligado, o console só tem o
|
|
220
|
+
* login próprio por senha.
|
|
221
|
+
*/
|
|
222
|
+
export declare function checkConsoleSignIn(input: DoctorInput): Finding | null;
|
|
217
223
|
/**
|
|
218
224
|
* Finding da idade da chave de assinatura managed. `ageDays === null` (sem
|
|
219
225
|
* keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
|
|
@@ -311,7 +311,7 @@ export function checkRateLimit(input) {
|
|
|
311
311
|
}
|
|
312
312
|
return { level: 'ok', message: 'rate-limiting on and @adonisjs/limiter available.' };
|
|
313
313
|
}
|
|
314
|
-
/** admin.enabled mas sem roles → warn. Reporta o modo de UI ativo. */
|
|
314
|
+
/** admin.enabled mas sem roles, ou sem `admin.authorize` → warn. Reporta o modo de UI ativo. */
|
|
315
315
|
export function checkAdmin(input) {
|
|
316
316
|
const admin = input.authkitConfig?.admin;
|
|
317
317
|
if (!admin || admin.enabled !== true)
|
|
@@ -323,6 +323,12 @@ export function checkAdmin(input) {
|
|
|
323
323
|
message: 'admin console on, but no `admin.roles` — nobody will have access (the default ["ADMIN"] was not resolved here).',
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
|
+
if (!admin.authorize) {
|
|
327
|
+
return {
|
|
328
|
+
level: 'warn',
|
|
329
|
+
message: `admin console on for roles: ${roles.join(', ')}, but no \`admin.authorize\` — it is open outside production and denies every request in production. Set \`admin.authorize\` in config/authkit.ts.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
326
332
|
return {
|
|
327
333
|
level: 'ok',
|
|
328
334
|
message: `admin console on for roles: ${roles.join(', ')} (React SPA self-contained — JSON API under {prefix}/api/*).`,
|
|
@@ -908,6 +914,43 @@ export function checkFirstPartyClients(input) {
|
|
|
908
914
|
message: `firstPartyClients: ${allowlist.length} client(s) receive the roles/organization claims (${allowlist.join(', ')}).`,
|
|
909
915
|
};
|
|
910
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* Como se entra no console de conta e no console admin. O default é o login do
|
|
919
|
+
* IdP (SSO, `accountSession.acceptIdpSession`); desligado, o console só tem o
|
|
920
|
+
* login próprio por senha.
|
|
921
|
+
*/
|
|
922
|
+
export function checkConsoleSignIn(input) {
|
|
923
|
+
const cfg = input.authkitConfig;
|
|
924
|
+
if (!cfg)
|
|
925
|
+
return null;
|
|
926
|
+
const sso = cfg.accountSession?.acceptIdpSession !== false;
|
|
927
|
+
const passwordOff = cfg.authMethods?.password === false;
|
|
928
|
+
const routes = typeof cfg.routes === 'object' && cfg.routes ? cfg.routes : undefined;
|
|
929
|
+
if (!sso && passwordOff && !routes?.accountLoginUrl) {
|
|
930
|
+
return {
|
|
931
|
+
level: 'warn',
|
|
932
|
+
message: 'The account and admin consoles have no way to sign in unless `registerAuthHost` gets an ' +
|
|
933
|
+
'`accountLoginUrl`: `accountSession.acceptIdpSession` is false and password login is off ' +
|
|
934
|
+
'(`authMethods: { password: false }`), and route registration refuses to start without ' +
|
|
935
|
+
'one. Remove `acceptIdpSession: false` so the consoles sign in through the IdP, or point ' +
|
|
936
|
+
'`accountLoginUrl` at a login route of your app.',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
if (!sso) {
|
|
940
|
+
return {
|
|
941
|
+
level: 'warn',
|
|
942
|
+
message: 'The consoles use their own password login (`accountSession.acceptIdpSession: false`): ' +
|
|
943
|
+
'users sign in twice, and passwordless accounts cannot open them. The default (SSO through ' +
|
|
944
|
+
'the IdP login) is recommended; sensitive operations still ask for sudo.',
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
level: 'ok',
|
|
949
|
+
message: 'The consoles sign in through the IdP login (SSO). Sensitive operations ask for sudo. ' +
|
|
950
|
+
'Make sure admin accounts have MFA enrolled (or require it for everyone with ' +
|
|
951
|
+
'`mfa.requiredFactors`).',
|
|
952
|
+
};
|
|
953
|
+
}
|
|
911
954
|
/**
|
|
912
955
|
* Finding da idade da chave de assinatura managed. `ageDays === null` (sem
|
|
913
956
|
* keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
|
|
@@ -1007,6 +1050,9 @@ export function runAllChecks(input) {
|
|
|
1007
1050
|
const firstPartyClients = checkFirstPartyClients(input);
|
|
1008
1051
|
if (firstPartyClients)
|
|
1009
1052
|
findings.push(firstPartyClients);
|
|
1053
|
+
const consoleSignIn = checkConsoleSignIn(input);
|
|
1054
|
+
if (consoleSignIn)
|
|
1055
|
+
findings.push(consoleSignIn);
|
|
1010
1056
|
return findings;
|
|
1011
1057
|
}
|
|
1012
1058
|
/** Há algum finding de nível 'error'? (define o exit code). */
|