@adonis-agora/authkit-server 1.0.0-rc.12 → 1.0.0-rc.13
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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @adonis-agora/authkit-server
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#475](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/475) [`2ebb953`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/2ebb953d1d96eb128febc7cc0c3e6f55a6000cc2) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `agora:doctor`'s `authkit.shield` check now reads the app's `config/shield.ts` and only warns when shield's CSRF check would really reject one of authkit's server-to-server POST endpoints (`{mountPath}/token`, `/token/introspection`, `/token/revocation`, `/authkit/pat/introspect`, and the personal-agent OAuth endpoints when enabled). It mirrors shield: CSRF off or a `methods` list without POST passes, an empty `methods` checks every method, and `exceptRoutes` may be a function (probed with a token-less POST) or a route-pattern array. `authkitCsrfExceptions` now matches the pathname anchored (`/oidc` or `/oidc/...`) instead of a substring, so `/admin/oidc/x`, `/oidc-clients` or `?next=/oidc` are no longer exempted.
|
|
8
|
+
|
|
3
9
|
## 1.0.0-rc.12
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -27,6 +27,11 @@ export interface DoctorInput {
|
|
|
27
27
|
* Provided by the doctor command; undefined = not checked (doctor runs old version).
|
|
28
28
|
*/
|
|
29
29
|
settingsTablePresent?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The app's `config('shield')` (only its `csrf` block is read). `null` = no shield config;
|
|
32
|
+
* `undefined` = not collected (the CSRF check then cannot tell, and says so).
|
|
33
|
+
*/
|
|
34
|
+
shieldConfig?: Record<string, any> | null;
|
|
30
35
|
/**
|
|
31
36
|
* Classes de adapter para detecção de volatilidade (injetadas pelo doctor command
|
|
32
37
|
* em runtime). Quando ausente, o check de volatilidade usa heurística conservadora.
|
|
@@ -71,7 +76,32 @@ export declare function checkAccountStore(input: DoctorInput): Finding[];
|
|
|
71
76
|
export declare function checkAccountStoreColumns(input: DoctorInput): Finding[];
|
|
72
77
|
/** session provider configurado + warn se cookie store com tokenSets grandes. */
|
|
73
78
|
export declare function checkSession(input: DoctorInput): Finding[];
|
|
74
|
-
/**
|
|
79
|
+
/** A POST endpoint authkit mounts that is called without a browser CSRF token. */
|
|
80
|
+
export interface CsrfExemptEndpoint {
|
|
81
|
+
/** A concrete request path (what `ctx.request.url()` returns). */
|
|
82
|
+
path: string;
|
|
83
|
+
/** The route pattern shield compares against an `exceptRoutes` array (`ctx.route.pattern`). */
|
|
84
|
+
pattern: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The server-to-server POST endpoints authkit mounts, which shield's CSRF check must not reject:
|
|
88
|
+
* the OIDC provider's token, introspection and revocation endpoints (under the `${mountPath}/*`
|
|
89
|
+
* wildcard route), PAT introspection, and — with `personalAgents` on — the agents' OAuth
|
|
90
|
+
* endpoints plus every extension's `csrfExemptPaths`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function authkitCsrfExemptEndpoints(authkitConfig: Record<string, any> | null): CsrfExemptEndpoint[];
|
|
93
|
+
/**
|
|
94
|
+
* Whether shield's CSRF check (as configured in `config/shield.ts`) would reject a token-less POST
|
|
95
|
+
* to `endpoint`, mirroring `@adonisjs/shield`: CSRF off → no; a non-empty `methods` without POST →
|
|
96
|
+
* no (an empty or missing `methods` checks every method); `exceptRoutes` as a function → rejected
|
|
97
|
+
* unless it returns a truthy value for that request; as an array → rejected unless it holds the route
|
|
98
|
+
* pattern. `'unknown'` when the function throws on the probe context.
|
|
99
|
+
*/
|
|
100
|
+
export declare function csrfRejects(shieldConfig: Record<string, any> | null | undefined, endpoint: CsrfExemptEndpoint): boolean | 'unknown';
|
|
101
|
+
/**
|
|
102
|
+
* `authkit.shield`: evaluates the app's real `config/shield.ts` against the server-to-server POST
|
|
103
|
+
* endpoints authkit mounts, and warns only about the ones shield would actually reject.
|
|
104
|
+
*/
|
|
75
105
|
export declare function checkShield(input: DoctorInput): Finding;
|
|
76
106
|
/** ally só é necessário quando social está configurado. */
|
|
77
107
|
export declare function checkAlly(input: DoctorInput): Finding;
|
|
@@ -238,7 +238,90 @@ export function checkSession(input) {
|
|
|
238
238
|
}
|
|
239
239
|
return findings;
|
|
240
240
|
}
|
|
241
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* The server-to-server POST endpoints authkit mounts, which shield's CSRF check must not reject:
|
|
243
|
+
* the OIDC provider's token, introspection and revocation endpoints (under the `${mountPath}/*`
|
|
244
|
+
* wildcard route), PAT introspection, and — with `personalAgents` on — the agents' OAuth
|
|
245
|
+
* endpoints plus every extension's `csrfExemptPaths`.
|
|
246
|
+
*/
|
|
247
|
+
export function authkitCsrfExemptEndpoints(authkitConfig) {
|
|
248
|
+
const mount = String(authkitConfig?.mountPath ?? '/oidc').replace(/\/+$/, '') || '/oidc';
|
|
249
|
+
const endpoints = ['token', 'token/introspection', 'token/revocation'].map((rest) => ({ path: `${mount}/${rest}`, pattern: `${mount}/*` }));
|
|
250
|
+
endpoints.push({ path: '/authkit/pat/introspect', pattern: '/authkit/pat/introspect' });
|
|
251
|
+
const agents = authkitConfig?.personalAgents;
|
|
252
|
+
if (agents?.prefix) {
|
|
253
|
+
for (const rest of ['device_authorization', 'token']) {
|
|
254
|
+
const path = `${agents.prefix}/oauth/${rest}`;
|
|
255
|
+
endpoints.push({ path, pattern: path });
|
|
256
|
+
}
|
|
257
|
+
for (const extension of agents.extensions ?? []) {
|
|
258
|
+
for (const path of extension.csrfExemptPaths ?? [])
|
|
259
|
+
endpoints.push({ path, pattern: path });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return endpoints;
|
|
263
|
+
}
|
|
264
|
+
/** A minimal HttpContext for a token-less `POST path`, enough for an `exceptRoutes` function. */
|
|
265
|
+
function csrfProbeContext(endpoint) {
|
|
266
|
+
const [pathname, query = ''] = endpoint.path.split('?');
|
|
267
|
+
const completeUrl = `http://localhost${endpoint.path}`;
|
|
268
|
+
const request = {
|
|
269
|
+
url: (includeQueryString) => includeQueryString && query ? `${pathname}?${query}` : pathname,
|
|
270
|
+
completeUrl: () => completeUrl,
|
|
271
|
+
method: () => 'POST',
|
|
272
|
+
intended: () => 'POST',
|
|
273
|
+
parsedUrl: { pathname, query, href: endpoint.path },
|
|
274
|
+
header: () => undefined,
|
|
275
|
+
headers: () => ({}),
|
|
276
|
+
hostname: () => 'localhost',
|
|
277
|
+
host: () => 'localhost',
|
|
278
|
+
protocol: () => 'http',
|
|
279
|
+
ip: () => '127.0.0.1',
|
|
280
|
+
input: () => undefined,
|
|
281
|
+
qs: () => ({}),
|
|
282
|
+
all: () => ({}),
|
|
283
|
+
matchesRoute: (identifier) => (Array.isArray(identifier) ? identifier : [identifier]).includes(endpoint.pattern),
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
request,
|
|
287
|
+
route: { pattern: endpoint.pattern, methods: ['POST'] },
|
|
288
|
+
routeKey: `POST-${endpoint.pattern}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Whether shield's CSRF check (as configured in `config/shield.ts`) would reject a token-less POST
|
|
293
|
+
* to `endpoint`, mirroring `@adonisjs/shield`: CSRF off → no; a non-empty `methods` without POST →
|
|
294
|
+
* no (an empty or missing `methods` checks every method); `exceptRoutes` as a function → rejected
|
|
295
|
+
* unless it returns a truthy value for that request; as an array → rejected unless it holds the route
|
|
296
|
+
* pattern. `'unknown'` when the function throws on the probe context.
|
|
297
|
+
*/
|
|
298
|
+
export function csrfRejects(shieldConfig, endpoint) {
|
|
299
|
+
const csrf = shieldConfig?.csrf;
|
|
300
|
+
if (!csrf || csrf.enabled !== true)
|
|
301
|
+
return false;
|
|
302
|
+
const methods = Array.isArray(csrf.methods)
|
|
303
|
+
? csrf.methods.map((m) => String(m).toLowerCase())
|
|
304
|
+
: [];
|
|
305
|
+
if (methods.length > 0 && !methods.includes('post'))
|
|
306
|
+
return false;
|
|
307
|
+
const except = csrf.exceptRoutes ?? [];
|
|
308
|
+
if (typeof except === 'function') {
|
|
309
|
+
try {
|
|
310
|
+
// Shield skips validation on any truthy return (`!this.#routesToIgnore(ctx)`).
|
|
311
|
+
return !except(csrfProbeContext(endpoint));
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return 'unknown';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (Array.isArray(except))
|
|
318
|
+
return !except.includes(endpoint.pattern);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* `authkit.shield`: evaluates the app's real `config/shield.ts` against the server-to-server POST
|
|
323
|
+
* endpoints authkit mounts, and warns only about the ones shield would actually reject.
|
|
324
|
+
*/
|
|
242
325
|
export function checkShield(input) {
|
|
243
326
|
if (!input.peers.shield) {
|
|
244
327
|
return {
|
|
@@ -247,9 +330,41 @@ export function checkShield(input) {
|
|
|
247
330
|
};
|
|
248
331
|
}
|
|
249
332
|
const mountPath = input.authkitConfig?.mountPath ?? '/oidc';
|
|
333
|
+
const helper = `authkitCsrfExceptions(ctx.request.url(), { mountPath: '${mountPath}' })`;
|
|
334
|
+
if (input.shieldConfig === undefined) {
|
|
335
|
+
return {
|
|
336
|
+
level: 'warn',
|
|
337
|
+
message: `config('shield') was not read, so the CSRF exemptions could not be checked. Make sure config/shield.ts's csrf.exceptRoutes exempts the IdP POST routes under "${mountPath}" (e.g. with ${helper}).`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const endpoints = authkitCsrfExemptEndpoints(input.authkitConfig);
|
|
341
|
+
const rejected = [];
|
|
342
|
+
const unknown = [];
|
|
343
|
+
for (const endpoint of endpoints) {
|
|
344
|
+
const verdict = csrfRejects(input.shieldConfig, endpoint);
|
|
345
|
+
if (verdict === 'unknown')
|
|
346
|
+
unknown.push(endpoint.path);
|
|
347
|
+
else if (verdict)
|
|
348
|
+
rejected.push(endpoint.path);
|
|
349
|
+
}
|
|
350
|
+
if (rejected.length > 0) {
|
|
351
|
+
return {
|
|
352
|
+
level: 'warn',
|
|
353
|
+
message: `Shield's CSRF check rejects POST ${rejected.join(', ')} — server-to-server calls to these authkit endpoints fail. Exempt them in config/shield.ts's csrf.exceptRoutes, e.g. \`exceptRoutes: (ctx) => ${helper}\` (combine with your own exemptions using ||).`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (unknown.length > 0) {
|
|
357
|
+
return {
|
|
358
|
+
level: 'warn',
|
|
359
|
+
message: `csrf.exceptRoutes in config/shield.ts threw when probed with POST ${unknown.join(', ')}, so the doctor cannot tell whether they are exempt. Make sure it exempts them (e.g. with ${helper}).`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const csrfOn = input.shieldConfig?.csrf?.enabled === true;
|
|
250
363
|
return {
|
|
251
|
-
level: '
|
|
252
|
-
message:
|
|
364
|
+
level: 'ok',
|
|
365
|
+
message: csrfOn
|
|
366
|
+
? `shield's CSRF check exempts authkit's server-to-server POST endpoints (${endpoints.length} checked).`
|
|
367
|
+
: 'shield CSRF is off: authkit endpoints are not affected.',
|
|
253
368
|
};
|
|
254
369
|
}
|
|
255
370
|
/** ally só é necessário quando social está configurado. */
|
|
@@ -47,9 +47,11 @@ export async function buildDoctorInput(app, logger) {
|
|
|
47
47
|
// senão todo campo aparece como "ausente" (provider cru não tem issuer etc.).
|
|
48
48
|
const authkitConfig = await resolveAuthkitConfig(app, config.get('authkit', null));
|
|
49
49
|
const sessionConfig = config.get('session', null) ?? null;
|
|
50
|
+
const shieldConfig = config.get('shield', null) ?? null;
|
|
50
51
|
return {
|
|
51
52
|
authkitConfig,
|
|
52
53
|
sessionConfig,
|
|
54
|
+
shieldConfig,
|
|
53
55
|
peers: {
|
|
54
56
|
session: await canImport('@adonisjs/session', logger),
|
|
55
57
|
shield: await canImport('@adonisjs/shield', logger),
|
package/build/src/host/csrf.d.ts
CHANGED
|
@@ -20,5 +20,8 @@ export interface AuthkitCsrfOptions {
|
|
|
20
20
|
* Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
|
|
21
21
|
* (machine-to-machine). Cobre o mountPath do IdP, a introspecção de PAT e a
|
|
22
22
|
* rota de back-channel logout do client e os endpoints OAuth de personal agents.
|
|
23
|
+
*
|
|
24
|
+
* O match é ANCORADO no pathname: `/oidc` e `/oidc/...` casam, mas `/foo/oidc/x`,
|
|
25
|
+
* `/oidc-admin` ou `/x?next=/oidc` não. A query string é ignorada.
|
|
23
26
|
*/
|
|
24
27
|
export declare function authkitCsrfExceptions(url: string, options?: AuthkitCsrfOptions): boolean;
|
package/build/src/host/csrf.js
CHANGED
|
@@ -11,19 +11,39 @@
|
|
|
11
11
|
* csrf: {
|
|
12
12
|
* enabled: true,
|
|
13
13
|
* exceptRoutes: (ctx) => authkitCsrfExceptions(ctx.request.url(), { mountPath: '/oidc' })
|
|
14
|
-
* || ctx.request.url().
|
|
14
|
+
* || ctx.request.url().startsWith('/api/'),
|
|
15
15
|
* }
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
import { normalizePersonalAgentsPrefix } from '../agents/config.js';
|
|
19
19
|
import { getAuthHostConfig } from './auth_host_config.js';
|
|
20
|
+
/** `path` é `prefix` ou fica debaixo dele (`prefix/...`) — nunca um substring solto. */
|
|
21
|
+
function underPrefix(path, prefix) {
|
|
22
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
23
|
+
}
|
|
24
|
+
/** O pathname de `url`: sem query/hash, aceitando também uma URL absoluta. */
|
|
25
|
+
function pathnameOf(url) {
|
|
26
|
+
let path = url;
|
|
27
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
|
|
28
|
+
try {
|
|
29
|
+
path = new URL(path).pathname;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Segue com a string crua: o match ancorado abaixo simplesmente não casa.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return path.split(/[?#]/, 1)[0] ?? '';
|
|
36
|
+
}
|
|
20
37
|
/**
|
|
21
38
|
* Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
|
|
22
39
|
* (machine-to-machine). Cobre o mountPath do IdP, a introspecção de PAT e a
|
|
23
40
|
* rota de back-channel logout do client e os endpoints OAuth de personal agents.
|
|
41
|
+
*
|
|
42
|
+
* O match é ANCORADO no pathname: `/oidc` e `/oidc/...` casam, mas `/foo/oidc/x`,
|
|
43
|
+
* `/oidc-admin` ou `/x?next=/oidc` não. A query string é ignorada.
|
|
24
44
|
*/
|
|
25
45
|
export function authkitCsrfExceptions(url, options = {}) {
|
|
26
|
-
const mountPath = options.mountPath ?? '/oidc';
|
|
46
|
+
const mountPath = (options.mountPath ?? '/oidc').replace(/\/+$/, '');
|
|
27
47
|
const backchannel = options.backchannelLogoutPath === false
|
|
28
48
|
? null
|
|
29
49
|
: (options.backchannelLogoutPath ?? '/auth/backchannel-logout');
|
|
@@ -33,12 +53,12 @@ export function authkitCsrfExceptions(url, options = {}) {
|
|
|
33
53
|
? normalizePersonalAgentsPrefix(options.personalAgentsPrefix)
|
|
34
54
|
: getAuthHostConfig()?.personalAgents?.prefix;
|
|
35
55
|
const agentsOAuth = agentsPrefix ? `${agentsPrefix}/oauth/` : null;
|
|
36
|
-
const path = url
|
|
56
|
+
const path = pathnameOf(url);
|
|
37
57
|
const extensionExempt = options.personalAgentExtensions !== false &&
|
|
38
58
|
(getAuthHostConfig()?.personalAgents?.extensions ?? []).some((e) => e.csrfExemptPaths?.includes(path));
|
|
39
|
-
return (
|
|
40
|
-
|
|
41
|
-
(backchannel !== null &&
|
|
42
|
-
(agentsOAuth !== null &&
|
|
59
|
+
return ((mountPath !== '' && underPrefix(path, mountPath)) ||
|
|
60
|
+
underPrefix(path, '/authkit/pat') ||
|
|
61
|
+
(backchannel !== null && path === backchannel) ||
|
|
62
|
+
(agentsOAuth !== null && path.startsWith(agentsOAuth)) ||
|
|
43
63
|
extensionExempt);
|
|
44
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.13",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|