@adonis-agora/authkit-server 0.68.2 → 0.68.4

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.
@@ -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). */
@@ -27,40 +28,90 @@ export function decodeActiveOrgCookie(value) {
27
28
  return { orgId, orgSlug, orgRole };
28
29
  }
29
30
  /**
30
- * Parseia um valor de cookie possivelmente URL-encoded.
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.
31
37
  *
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.
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.
36
41
  */
37
- function parseActiveOrgCookieValue(raw) {
38
- if (typeof raw !== 'string')
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))
39
53
  return null;
40
- const direct = decodeActiveOrgCookie(raw);
41
- if (direct)
42
- return direct;
43
54
  try {
44
- return decodeActiveOrgCookie(decodeURIComponent(raw));
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;
45
59
  }
46
60
  catch {
47
61
  return null;
48
62
  }
49
63
  }
50
64
  /**
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.
65
+ * Parseia um valor de cookie possivelmente URL-encoded.
66
+ *
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
+ */
73
+ function parseActiveOrgCookieValue(raw, appKey) {
74
+ if (typeof raw !== 'string' || !raw)
75
+ return null;
76
+ // O jar Koa devolve o valor COMO ESTÁ no header, sem URL-decode — e o browser
77
+ // reenvia o cookie exatamente como o host o escreveu, isto é `s%3A<b64>.<hmac>`
78
+ // quando ele é assinado. Normalizar ANTES de decidir o formato é obrigatório:
79
+ // checar `startsWith('s:')` no valor cru nunca casaria.
80
+ let value = raw;
81
+ try {
82
+ const decoded = decodeURIComponent(raw);
83
+ if (decoded)
84
+ value = decoded;
85
+ }
86
+ catch {
87
+ // não era URL-encoded; segue com o cru
88
+ }
89
+ if (value.startsWith('s:')) {
90
+ if (!appKey)
91
+ return null;
92
+ const unsigned = unsignAdonisCookie(value, appKey, ACTIVE_ORG_COOKIE);
93
+ return unsigned ? decodeActiveOrgCookie(unsigned) : null;
94
+ }
95
+ return decodeActiveOrgCookie(value);
96
+ }
97
+ /**
98
+ * Lê a org ativa de um contexto Koa (oidc-provider).
99
+ *
100
+ * O cookie NÃO pode ser lido como "cru": num app Adonis com assinatura de cookie
101
+ * ligada ele chega como `s:<b64>.<hmac>` e precisa ser verificado. Passamos
102
+ * `appKey` e verificamos a assinatura — sem ela o valor é recusado.
103
+ *
104
+ * Por que existe: `loadExistingGrant` roda no authorize (request do browser, com o
105
+ * cookie) e é quem reconcilia o Grant reaproveitado depois que o usuário troca de
106
+ * organização. O consent também grava a org, mas ele só roda UMA vez por grant —
107
+ * sem esta leitura, quem ativa a org depois do primeiro login nunca recebe a claim.
57
108
  *
58
- * NOTA: o oidc-provider ctx.cookies.get() nunca lança — retorna null se ausente.
109
+ * NOTA: `ctx.cookies.get()` nunca lança — retorna null se ausente.
59
110
  */
60
- export function readActiveOrgFromKoaCtx(koaCtx) {
111
+ export function readActiveOrgFromKoaCtx(koaCtx, opts) {
61
112
  try {
62
113
  const raw = koaCtx?.cookies?.get?.(ACTIVE_ORG_COOKIE, { signed: false });
63
- return parseActiveOrgCookieValue(raw);
114
+ return parseActiveOrgCookieValue(raw, opts?.appKey);
64
115
  }
65
116
  catch {
66
117
  return null;
@@ -90,20 +141,20 @@ export function normalizeActiveOrg(value) {
90
141
  * AdonisJS usado pelas `InteractionActions`).
91
142
  *
92
143
  * 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.
144
+ * do mint do id_token no `/token` (server-a-servidor, sem cookies). Aqui o
145
+ * `request.cookie` do AdonisJS já desassina o cookie (quando o host assina), então
146
+ * o valor chega em texto; `appKey` cobre o fallback Koa, caso o ctx recebido seja
147
+ * um ctx Koa e não o HttpContext.
97
148
  */
98
- export function readActiveOrgFromHostCtx(ctx) {
149
+ export function readActiveOrgFromHostCtx(ctx, opts) {
99
150
  try {
100
151
  const raw = ctx?.request?.cookie?.(ACTIVE_ORG_COOKIE);
101
- const parsed = parseActiveOrgCookieValue(raw);
152
+ const parsed = parseActiveOrgCookieValue(raw, opts?.appKey);
102
153
  if (parsed)
103
154
  return parsed;
104
155
  }
105
156
  catch {
106
157
  // contexto sem `request.cookie` — tenta o caminho Koa abaixo
107
158
  }
108
- return readActiveOrgFromKoaCtx(ctx);
159
+ return readActiveOrgFromKoaCtx(ctx, opts);
109
160
  }
@@ -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.2",
3
+ "version": "0.68.4",
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",