@adonis-agora/authkit-server 0.68.0 → 0.68.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.
@@ -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>
@@ -26,3 +26,20 @@ export declare function decodeActiveOrgCookie(value: string | null | undefined):
26
26
  * NOTA: o oidc-provider ctx.cookies.get() nunca lança — retorna null se ausente.
27
27
  */
28
28
  export declare function readActiveOrgFromKoaCtx(koaCtx: any): ActiveOrgInfo | null;
29
+ /**
30
+ * Normaliza um valor potencialmente vindo de um payload PERSISTIDO (o `activeOrg`
31
+ * do Grant) para `ActiveOrgInfo`. Retorna null quando a forma não bate — nunca
32
+ * confiamos num objeto só porque ele veio do banco.
33
+ */
34
+ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null;
35
+ /**
36
+ * Lê a org ativa de um contexto de interaction do HOST (o `HttpContext` do
37
+ * AdonisJS usado pelas `InteractionActions`).
38
+ *
39
+ * 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.
44
+ */
45
+ export declare function readActiveOrgFromHostCtx(ctx: unknown): ActiveOrgInfo | null;
@@ -26,6 +26,27 @@ export function decodeActiveOrgCookie(value) {
26
26
  return null;
27
27
  return { orgId, orgSlug, orgRole };
28
28
  }
29
+ /**
30
+ * Parseia um valor de cookie possivelmente URL-encoded.
31
+ *
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.
36
+ */
37
+ function parseActiveOrgCookieValue(raw) {
38
+ if (typeof raw !== 'string')
39
+ return null;
40
+ const direct = decodeActiveOrgCookie(raw);
41
+ if (direct)
42
+ return direct;
43
+ try {
44
+ return decodeActiveOrgCookie(decodeURIComponent(raw));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
29
50
  /**
30
51
  * Lê a org ativa de um contexto Koa (oidc-provider). O oidc-provider usa o Keygrip
31
52
  * das `cookieKeys` para assinar os cookies — lemos via `ctx.cookies.get(name, { signed: false })`
@@ -39,9 +60,50 @@ export function decodeActiveOrgCookie(value) {
39
60
  export function readActiveOrgFromKoaCtx(koaCtx) {
40
61
  try {
41
62
  const raw = koaCtx?.cookies?.get?.(ACTIVE_ORG_COOKIE, { signed: false });
42
- return decodeActiveOrgCookie(raw);
63
+ return parseActiveOrgCookieValue(raw);
43
64
  }
44
65
  catch {
45
66
  return null;
46
67
  }
47
68
  }
69
+ /**
70
+ * Normaliza um valor potencialmente vindo de um payload PERSISTIDO (o `activeOrg`
71
+ * do Grant) para `ActiveOrgInfo`. Retorna null quando a forma não bate — nunca
72
+ * confiamos num objeto só porque ele veio do banco.
73
+ */
74
+ export function normalizeActiveOrg(value) {
75
+ if (!value || typeof value !== 'object')
76
+ return null;
77
+ const { orgId, orgSlug, orgRole } = value;
78
+ if (typeof orgId !== 'string' ||
79
+ !orgId ||
80
+ typeof orgSlug !== 'string' ||
81
+ !orgSlug ||
82
+ typeof orgRole !== 'string' ||
83
+ !orgRole) {
84
+ return null;
85
+ }
86
+ return { orgId, orgSlug, orgRole };
87
+ }
88
+ /**
89
+ * Lê a org ativa de um contexto de interaction do HOST (o `HttpContext` do
90
+ * AdonisJS usado pelas `InteractionActions`).
91
+ *
92
+ * 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.
97
+ */
98
+ export function readActiveOrgFromHostCtx(ctx) {
99
+ try {
100
+ const raw = ctx?.request?.cookie?.(ACTIVE_ORG_COOKIE);
101
+ const parsed = parseActiveOrgCookieValue(raw);
102
+ if (parsed)
103
+ return parsed;
104
+ }
105
+ catch {
106
+ // contexto sem `request.cookie` — tenta o caminho Koa abaixo
107
+ }
108
+ return readActiveOrgFromKoaCtx(ctx);
109
+ }
@@ -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' })
@@ -1,5 +1,6 @@
1
1
  import * as oidc from 'oidc-provider';
2
2
  import { pickModelAdapterClass } from '../adapters/factory.js';
3
+ import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
3
4
  import { createDeviceSources } from './device_sources.js';
4
5
  import { createLogoutSources } from './logout_sources.js';
5
6
  /** Atualiza o holder mutável do TTL de sessão com os valores da setting. */
@@ -117,6 +118,35 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
117
118
  : {}),
118
119
  })),
119
120
  findAccount: options.findAccount,
121
+ // Grant REAPROVEITADO (consent já lembrado): o org capturado no primeiro
122
+ // consent ficaria VELHO se o usuário trocasse de org no console e voltasse a
123
+ // autenticar sem novo consent. Esta função roda numa request do BROWSER (o
124
+ // authorize), então relemos o cookie aqui e atualizamos o Grant quando
125
+ // divergir — inclusive LIMPANDO quando o cookie some (org desativada). Sem
126
+ // isto o token continuaria emitindo a org antiga até o grant expirar.
127
+ // Assinatura/default confirmados no fonte instalado: `actions/authorization/
128
+ // session.js` chama `configuration.loadExistingGrant(ctx)`.
129
+ loadExistingGrant: async (ctx) => {
130
+ const grantId = ctx.oidc.result?.consent?.grantId || ctx.oidc.session.grantIdFor(ctx.oidc.client.clientId);
131
+ if (!grantId)
132
+ return undefined;
133
+ const grant = await ctx.oidc.provider.Grant.find(grantId);
134
+ if (!grant)
135
+ return undefined;
136
+ const activeOrg = readActiveOrgFromKoaCtx(ctx);
137
+ const current = normalizeActiveOrg(grant.activeOrg);
138
+ const changed = (activeOrg?.orgId ?? null) !== (current?.orgId ?? null) ||
139
+ (activeOrg?.orgSlug ?? null) !== (current?.orgSlug ?? null) ||
140
+ (activeOrg?.orgRole ?? null) !== (current?.orgRole ?? null);
141
+ if (changed) {
142
+ if (activeOrg)
143
+ grant.activeOrg = activeOrg;
144
+ else
145
+ delete grant.activeOrg;
146
+ await grant.save();
147
+ }
148
+ return grant;
149
+ },
120
150
  jwks: config.jwks,
121
151
  cookies: {
122
152
  keys: cookieKeys,
@@ -220,6 +250,29 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
220
250
  url: (_ctx, interaction) => `/auth/interaction/${interaction.uid}`,
221
251
  },
222
252
  });
253
+ // ── Modelo Grant estendido: persiste a org ativa ─────────────────────────────
254
+ // O oidc-provider v9 NÃO expõe uma opção `models` no config (confirmado no fonte
255
+ // instalado: `provider.js` instancia os modelos por `models.getGrant(this)` e
256
+ // `helpers/defaults.js` não tem chave alguma `models`), e o `Grant` filtra o
257
+ // payload por `IN_PAYLOAD` (`lib/models/grant.js` + `base_model.js`), então
258
+ // qualquer prop de topo fora da lista é DESCARTADA no `save()`/`find()`.
259
+ // Estendemos `IN_PAYLOAD` com `activeOrg` e injetamos a subclasse na INSTÂNCIA
260
+ // do provider via `Object.defineProperty`, que sombreia o getter `Grant` do
261
+ // protótipo. O `name` é fixado em `'Grant'` para que o nome do ADAPTER e o
262
+ // `kind` persistido continuem sendo os MESMOS registros (`BaseModel` usa
263
+ // `constructor.name` para ambos) — sem isso, grants novos/antigos divergiriam.
264
+ const BaseGrant = provider.Grant;
265
+ const GrantWithOrg = class GrantWithOrg extends BaseGrant {
266
+ static get IN_PAYLOAD() {
267
+ return [...BaseGrant.IN_PAYLOAD, 'activeOrg'];
268
+ }
269
+ };
270
+ Object.defineProperty(GrantWithOrg, 'name', { value: 'Grant', configurable: true });
271
+ Object.defineProperty(provider, 'Grant', {
272
+ value: GrantWithOrg,
273
+ writable: true,
274
+ configurable: true,
275
+ });
223
276
  provider.proxy = true;
224
277
  return provider;
225
278
  }
@@ -1,3 +1,4 @@
1
+ import { readActiveOrgFromHostCtx } from '../host/active_org_cookie.js';
1
2
  import { InteractionSessionLostException, isInteractionSessionLost, } from '../host/interaction_recovery.js';
2
3
  /**
3
4
  * Choke point ÚNICO de `interactionDetails`: converte o `SessionNotFound` do
@@ -51,9 +52,15 @@ export function createInteractionActions(provider, deps) {
51
52
  },
52
53
  async consent(ctx) {
53
54
  const details = await interactionDetailsOrRecover(provider, ctx);
55
+ // O consent roda numa request do BROWSER, então a org ativa (cookie) está
56
+ // disponível AQUI — ao contrário do mint do id_token no /token, que é
57
+ // server-a-servidor e não carrega os cookies do usuário. Persistimos a org
58
+ // no Grant para que o fluxo authorization code volte a emitir org_*.
59
+ const activeOrg = readActiveOrgFromHostCtx(ctx);
54
60
  const grant = new provider.Grant({
55
61
  accountId: details.session.accountId,
56
62
  clientId: details.params.client_id,
63
+ ...(activeOrg ? { activeOrg } : {}),
57
64
  });
58
65
  grant.addOIDCScope(String(details.params.scope ?? 'openid'));
59
66
  // Resource Indicators (RFC 8707): quando o authorize/token pede um `resource`
@@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { NoopRecorder } from '@adonis-agora/authkit-core';
3
3
  import Koa from 'koa';
4
4
  import mount from 'koa-mount';
5
- import { readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
5
+ import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
6
6
  import { isFirstPartyClient } from '../host/branding.js';
7
7
  import { listKeyInfos, signingKeyAgeDays } from '../keys/keystore.js';
8
8
  import { wireProviderEvents } from '../observability/wire_provider_events.js';
@@ -82,8 +82,13 @@ export class OidcService {
82
82
  const user = await config.findAccount(sub);
83
83
  if (!user)
84
84
  return undefined;
85
- // Lê a org ativa do cookie de sessão (se organizations estiver disponível).
86
- const activeOrg = readActiveOrgFromKoaCtx(ctx);
85
+ // Fonte PRIMÁRIA: a org persistida no Grant no consent (request do
86
+ // browser). No fluxo authorization code o id_token é mintado no /token
87
+ // — server-a-servidor, SEM cookies — então o cookie abaixo não existe
88
+ // ali. O cookie continua como FALLBACK para fluxos em que o id_token
89
+ // sai no próprio authorize (implicit/hybrid), onde a request é do browser.
90
+ const activeOrg = normalizeActiveOrg(ctx?.oidc?.entities?.Grant?.activeOrg) ??
91
+ readActiveOrgFromKoaCtx(ctx);
87
92
  // GATE de least-privilege: roles globais e claims de org só são emitidas
88
93
  // para clients FIRST-PARTY. Capturamos o clientId aqui (fora do closure
89
94
  // `claims()`, pois o `ctx` da request vive neste escopo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.68.0",
3
+ "version": "0.68.2",
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",