@adonis-agora/authkit-server 0.68.1 → 0.68.3

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.
@@ -20,14 +20,17 @@
20
20
  @if(org.isActive)
21
21
  <span>{{ t('account.orgs.active_badge') }}</span>
22
22
  <form method="POST" action="{{ accountPaths?.orgs ?? '/account/orgs' }}/deactivate">
23
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
23
24
  <button>{{ t('account.orgs.deactivate') }}</button>
24
25
  </form>
25
26
  @else
26
27
  <form method="POST" action="{{ accountPaths?.orgs ?? '/account/orgs' }}/{{ org.id }}/activate">
28
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
27
29
  <button>{{ t('account.orgs.activate') }}</button>
28
30
  </form>
29
31
  @end
30
32
  <form method="POST" action="{{ accountPaths?.orgs ?? '/account/orgs' }}/{{ org.id }}/leave">
33
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
31
34
  <button>{{ t('account.orgs.leave') }}</button>
32
35
  </form>
33
36
  </div>
@@ -40,6 +43,7 @@
40
43
  <div>
41
44
  {{ t('account.orgs.invitation_from', { orgName: inv.orgName, role: inv.role }) }}
42
45
  <form method="POST" action="{{ accountPaths?.orgs ?? '/account/orgs' }}/invitations/{{ inv.id }}/accept">
46
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
43
47
  <button>{{ t('account.orgs.invitation_accept') }}</button>
44
48
  </form>
45
49
  </div>
@@ -49,6 +53,7 @@
49
53
  @if(allowSelfCreate)
50
54
  <h2>{{ t('account.orgs.create_section') }}</h2>
51
55
  <form method="POST" action="{{ accountPaths?.orgs ?? '/account/orgs' }}">
56
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
52
57
  <input name="name" placeholder="{{ t('account.orgs.create_name_label') }}">
53
58
  <input name="slug" placeholder="{{ t('account.orgs.create_slug_label') }}">
54
59
  <button>{{ t('account.orgs.create_submit') }}</button>
@@ -16,16 +16,22 @@ export declare function encodeActiveOrgCookie(info: ActiveOrgInfo): string;
16
16
  */
17
17
  export declare function decodeActiveOrgCookie(value: string | null | undefined): ActiveOrgInfo | null;
18
18
  /**
19
- * Lê a org ativa de um contexto Koa (oidc-provider). O oidc-provider usa o Keygrip
20
- * das `cookieKeys` para assinar os cookies — lemos via `ctx.cookies.get(name, { signed: false })`
21
- * (o oidc-provider não assina cookies da aplicação; apenas verifica os seus). A
22
- * validação de assinatura para este cookie de aplicação é feita no controller AdonisJS
23
- * ao gravar (via `ctx.response.cookie` com `signed: true`). Aqui fazemos best-effort:
24
- * se o valor estiver presente e parseable, usamos; caso contrário retorna null.
19
+ * Lê a org ativa de um contexto Koa (oidc-provider).
25
20
  *
26
- * NOTA: o oidc-provider ctx.cookies.get() nunca lança — retorna null se ausente.
21
+ * O cookie NÃO pode ser lido como "cru": num app Adonis com assinatura de cookie
22
+ * ligada ele chega como `s:<b64>.<hmac>` e precisa ser verificado. Passamos
23
+ * `appKey` e verificamos a assinatura — sem ela o valor é recusado.
24
+ *
25
+ * Por que existe: `loadExistingGrant` roda no authorize (request do browser, com o
26
+ * cookie) e é quem reconcilia o Grant reaproveitado depois que o usuário troca de
27
+ * organização. O consent também grava a org, mas ele só roda UMA vez por grant —
28
+ * sem esta leitura, quem ativa a org depois do primeiro login nunca recebe a claim.
29
+ *
30
+ * NOTA: `ctx.cookies.get()` nunca lança — retorna null se ausente.
27
31
  */
28
- export declare function readActiveOrgFromKoaCtx(koaCtx: any): ActiveOrgInfo | null;
32
+ export declare function readActiveOrgFromKoaCtx(koaCtx: any, opts?: {
33
+ appKey?: string;
34
+ }): ActiveOrgInfo | null;
29
35
  /**
30
36
  * Normaliza um valor potencialmente vindo de um payload PERSISTIDO (o `activeOrg`
31
37
  * do Grant) para `ActiveOrgInfo`. Retorna null quando a forma não bate — nunca
@@ -37,9 +43,11 @@ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null
37
43
  * AdonisJS usado pelas `InteractionActions`).
38
44
  *
39
45
  * No consent a request É do browser, então o cookie está presente — ao contrário
40
- * do mint do id_token no `/token` (server-a-servidor, sem cookies). O cookie é
41
- * gravado UNSIGNED por `account_orgs_controller.activate` e lido aqui via
42
- * `request.cookie` (o MESMO caminho das leituras do console/account API), com
43
- * fallback para o caminho Koa caso o contexto recebido seja um ctx Koa.
46
+ * do mint do id_token no `/token` (server-a-servidor, sem cookies). Aqui o
47
+ * `request.cookie` do AdonisJS já desassina o cookie (quando o host assina), então
48
+ * o valor chega em texto; `appKey` cobre o fallback Koa, caso o ctx recebido seja
49
+ * um ctx Koa e não o HttpContext.
44
50
  */
45
- export declare function readActiveOrgFromHostCtx(ctx: unknown): ActiveOrgInfo | null;
51
+ export declare function readActiveOrgFromHostCtx(ctx: unknown, opts?: {
52
+ appKey?: string;
53
+ }): ActiveOrgInfo | null;
@@ -1,3 +1,4 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
1
2
  /** Nome do cookie da org ativa. HttpOnly, SameSite=Lax, Secure em prod. */
2
3
  export const ACTIVE_ORG_COOKIE = 'authkit_active_org';
3
4
  /** TTL máximo do cookie da org ativa (30 dias em segundos). */
@@ -26,17 +27,59 @@ export function decodeActiveOrgCookie(value) {
26
27
  return null;
27
28
  return { orgId, orgSlug, orgRole };
28
29
  }
30
+ /**
31
+ * Desfaz o envelope de cookie ASSINADO do AdonisJS: `s:<base64url>.<hmac>`.
32
+ *
33
+ * O host grava o cookie via `ctx.response.cookie`, e o AdonisJS assina com o
34
+ * `MessageVerifier` (`@boringnode/encryption`): HMAC-SHA256 sobre o base64url do
35
+ * payload, com a chave derivada de `sha256(appKey)`, e `purpose` = NOME do cookie.
36
+ * O `message` do payload é o valor.
37
+ *
38
+ * Verificamos a assinatura — não basta decodificar. Este cookie decide a claim de
39
+ * organização, então aceitar um valor não assinado deixaria qualquer usuário trocar
40
+ * de tenant forjando o cookie. Sem `appKey` não há como verificar: devolve null.
41
+ */
42
+ function unsignAdonisCookie(signedRaw, appKey, purpose) {
43
+ if (!signedRaw.startsWith('s:'))
44
+ return null;
45
+ const [encoded, hash] = signedRaw.slice(2).split('.');
46
+ if (!encoded || !hash)
47
+ return null;
48
+ const key = createHash('sha256').update(appKey).digest();
49
+ const expected = createHmac('sha256', key).update(encoded).digest('base64url');
50
+ const a = Buffer.from(expected);
51
+ const b = Buffer.from(hash);
52
+ if (a.length !== b.length || !timingSafeEqual(a, b))
53
+ return null;
54
+ try {
55
+ const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
56
+ if (payload?.purpose !== purpose)
57
+ return null;
58
+ return typeof payload.message === 'string' ? payload.message : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
29
64
  /**
30
65
  * Parseia um valor de cookie possivelmente URL-encoded.
31
66
  *
32
- * O jar Koa do oidc-provider (`cookies`) devolve o valor COMO ESTÁ no header — sem
33
- * URL-decode. Como o host grava o cookie via `response.cookie` (que serializa com
34
- * `encodeURIComponent`, transformando os TABs em `%09`), é preciso tentar decodificar.
35
- * Tentamos o valor cru primeiro (hosts que gravem sem encode) e o decodificado depois.
67
+ * Aceita três formas, nesta ordem:
68
+ * 1. **Assinado pelo Adonis** (`s:<b64>.<hmac>`), quando `appKey` é conhecida —
69
+ * verificado com `unsignAdonisCookie`. É a forma real quando o host grava via
70
+ * `ctx.response.cookie` num app com assinatura de cookie ligada.
71
+ * 2. Valor cru (hosts que gravem sem encode e sem assinatura).
72
+ * 3. Valor cru URL-decoded (o jar Koa devolve como está no header; os TABs viram `%09`).
36
73
  */
37
- function parseActiveOrgCookieValue(raw) {
38
- if (typeof raw !== 'string')
74
+ function parseActiveOrgCookieValue(raw, appKey) {
75
+ if (typeof raw !== 'string' || !raw)
39
76
  return null;
77
+ if (raw.startsWith('s:')) {
78
+ if (!appKey)
79
+ return null;
80
+ const unsigned = unsignAdonisCookie(raw, appKey, ACTIVE_ORG_COOKIE);
81
+ return unsigned ? decodeActiveOrgCookie(unsigned) : null;
82
+ }
40
83
  const direct = decodeActiveOrgCookie(raw);
41
84
  if (direct)
42
85
  return direct;
@@ -48,19 +91,23 @@ function parseActiveOrgCookieValue(raw) {
48
91
  }
49
92
  }
50
93
  /**
51
- * Lê a org ativa de um contexto Koa (oidc-provider). O oidc-provider usa o Keygrip
52
- * das `cookieKeys` para assinar os cookies — lemos via `ctx.cookies.get(name, { signed: false })`
53
- * (o oidc-provider não assina cookies da aplicação; apenas verifica os seus). A
54
- * validação de assinatura para este cookie de aplicação é feita no controller AdonisJS
55
- * ao gravar (via `ctx.response.cookie` com `signed: true`). Aqui fazemos best-effort:
56
- * se o valor estiver presente e parseable, usamos; caso contrário retorna null.
94
+ * Lê a org ativa de um contexto Koa (oidc-provider).
95
+ *
96
+ * O cookie NÃO pode ser lido como "cru": num app Adonis com assinatura de cookie
97
+ * ligada ele chega como `s:<b64>.<hmac>` e precisa ser verificado. Passamos
98
+ * `appKey` e verificamos a assinatura — sem ela o valor é recusado.
99
+ *
100
+ * Por que existe: `loadExistingGrant` roda no authorize (request do browser, com o
101
+ * cookie) e é quem reconcilia o Grant reaproveitado depois que o usuário troca de
102
+ * organização. O consent também grava a org, mas ele só roda UMA vez por grant —
103
+ * sem esta leitura, quem ativa a org depois do primeiro login nunca recebe a claim.
57
104
  *
58
- * NOTA: o oidc-provider ctx.cookies.get() nunca lança — retorna null se ausente.
105
+ * NOTA: `ctx.cookies.get()` nunca lança — retorna null se ausente.
59
106
  */
60
- export function readActiveOrgFromKoaCtx(koaCtx) {
107
+ export function readActiveOrgFromKoaCtx(koaCtx, opts) {
61
108
  try {
62
109
  const raw = koaCtx?.cookies?.get?.(ACTIVE_ORG_COOKIE, { signed: false });
63
- return parseActiveOrgCookieValue(raw);
110
+ return parseActiveOrgCookieValue(raw, opts?.appKey);
64
111
  }
65
112
  catch {
66
113
  return null;
@@ -90,20 +137,20 @@ export function normalizeActiveOrg(value) {
90
137
  * AdonisJS usado pelas `InteractionActions`).
91
138
  *
92
139
  * No consent a request É do browser, então o cookie está presente — ao contrário
93
- * do mint do id_token no `/token` (server-a-servidor, sem cookies). O cookie é
94
- * gravado UNSIGNED por `account_orgs_controller.activate` e lido aqui via
95
- * `request.cookie` (o MESMO caminho das leituras do console/account API), com
96
- * fallback para o caminho Koa caso o contexto recebido seja um ctx Koa.
140
+ * do mint do id_token no `/token` (server-a-servidor, sem cookies). Aqui o
141
+ * `request.cookie` do AdonisJS já desassina o cookie (quando o host assina), então
142
+ * o valor chega em texto; `appKey` cobre o fallback Koa, caso o ctx recebido seja
143
+ * um ctx Koa e não o HttpContext.
97
144
  */
98
- export function readActiveOrgFromHostCtx(ctx) {
145
+ export function readActiveOrgFromHostCtx(ctx, opts) {
99
146
  try {
100
147
  const raw = ctx?.request?.cookie?.(ACTIVE_ORG_COOKIE);
101
- const parsed = parseActiveOrgCookieValue(raw);
148
+ const parsed = parseActiveOrgCookieValue(raw, opts?.appKey);
102
149
  if (parsed)
103
150
  return parsed;
104
151
  }
105
152
  catch {
106
153
  // contexto sem `request.cookie` — tenta o caminho Koa abaixo
107
154
  }
108
- return readActiveOrgFromKoaCtx(ctx);
155
+ return readActiveOrgFromKoaCtx(ctx, opts);
109
156
  }
@@ -24,7 +24,11 @@ export default class AccountOrgsController {
24
24
  const messages = cfg.messages;
25
25
  if (!supportsOrganizations(store)) {
26
26
  return cfg.render
27
- ? cfg.render(ctx, 'account/orgs', { supported: false, messages })
27
+ ? cfg.render(ctx, 'account/orgs', {
28
+ supported: false,
29
+ messages,
30
+ csrfToken: ctx.request.csrfToken,
31
+ })
28
32
  : response.notFound();
29
33
  }
30
34
  const accountId = session.get(ACCOUNT_SESSION_KEY);
@@ -54,6 +58,7 @@ export default class AccountOrgsController {
54
58
  allowSelfCreate: cfg.organizations.allowSelfCreate,
55
59
  availableRoles: cfg.organizations.roles,
56
60
  messages,
61
+ csrfToken: ctx.request.csrfToken,
57
62
  };
58
63
  return cfg.render ? cfg.render(ctx, 'account/orgs', props) : response.notFound();
59
64
  }
@@ -252,6 +257,7 @@ export default class AccountOrgsController {
252
257
  invitation,
253
258
  token: params.token,
254
259
  messages: cfg.messages,
260
+ csrfToken: ctx.request.csrfToken,
255
261
  };
256
262
  return cfg.render
257
263
  ? cfg.render(ctx, 'account/orgs', { ...props, subview: 'accept-invitation' })
@@ -133,7 +133,13 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
133
133
  const grant = await ctx.oidc.provider.Grant.find(grantId);
134
134
  if (!grant)
135
135
  return undefined;
136
- const activeOrg = readActiveOrgFromKoaCtx(ctx);
136
+ // `appKey` é obrigatória aqui: o host grava o cookie de org via
137
+ // `ctx.response.cookie` e o Adonis o ASSINA (`s:<b64>.<hmac>`). Sem a chave
138
+ // não há como verificar a assinatura e o valor é recusado — foi assim que a
139
+ // org deixou de chegar ao token quando o usuário ativava a org DEPOIS do
140
+ // primeiro login (o consent só roda uma vez por grant; quem reconcilia é
141
+ // este hook, e ele roda no ctx Koa).
142
+ const activeOrg = readActiveOrgFromKoaCtx(ctx, { appKey: options.appKey });
137
143
  const current = normalizeActiveOrg(grant.activeOrg);
138
144
  const changed = (activeOrg?.orgId ?? null) !== (current?.orgId ?? null) ||
139
145
  (activeOrg?.orgSlug ?? null) !== (current?.orgSlug ?? null) ||
@@ -3,6 +3,12 @@ export interface InteractionDeps {
3
3
  verifyCredentials?: (email: string, password: string) => Promise<{
4
4
  id: string;
5
5
  } | null>;
6
+ /**
7
+ * Chave do app, usada para VERIFICAR o cookie de org ativa quando o ctx recebido
8
+ * é um Koa ctx (o caminho Adonis já desassina sozinho). Sem ela, um cookie
9
+ * assinado é recusado — e a org não entra no Grant.
10
+ */
11
+ appKey?: string;
6
12
  }
7
13
  /** Detalhes opcionais de login (step-up auth): acr alcançado + amr (métodos). */
8
14
  export interface CompleteLoginExtra {
@@ -56,7 +56,7 @@ export function createInteractionActions(provider, deps) {
56
56
  // disponível AQUI — ao contrário do mint do id_token no /token, que é
57
57
  // server-a-servidor e não carrega os cookies do usuário. Persistimos a org
58
58
  // no Grant para que o fluxo authorization code volte a emitir org_*.
59
- const activeOrg = readActiveOrgFromHostCtx(ctx);
59
+ const activeOrg = readActiveOrgFromHostCtx(ctx, { appKey: deps.appKey });
60
60
  const grant = new provider.Grant({
61
61
  accountId: details.session.accountId,
62
62
  clientId: details.params.client_id,
@@ -190,6 +190,7 @@ export class OidcService {
190
190
  }
191
191
  const interactions = createInteractionActions(provider, {
192
192
  verifyCredentials: config.verifyCredentials,
193
+ appKey: this.#appKey,
193
194
  });
194
195
  // Atribuição atômica no final: um throw antes deste ponto não corrompe o estado.
195
196
  this.#provider = provider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.68.1",
3
+ "version": "0.68.3",
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",