@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
- /** Hint de exceções de CSRF do shield para o mountPath. */
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
- /** Hint de exceções de CSRF do shield para o mountPath. */
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: 'warn',
252
- message: `Make sure the IdP POST routes under "${mountPath}" are in the shield CSRF exceptions (e.g. the /token endpoint), otherwise server-to-server calls fail. Use the exported authkitCsrfExceptions(url, { mountPath: '${mountPath}' }) helper in config/shield.ts's exceptRoutes instead of hand-writing the route-pattern check.`,
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),
@@ -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;
@@ -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().includes('/api'),
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.split('?')[0];
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 (url.includes(mountPath) ||
40
- url.includes('/authkit/pat') ||
41
- (backchannel !== null && url === backchannel) ||
42
- (agentsOAuth !== null && url.startsWith(agentsOAuth)) ||
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.12",
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",