@adonis-agora/authkit-server 0.72.0 → 0.73.0
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/build/src/host/account_api/account_orgs_api_controller.js +6 -1
- package/build/src/host/active_org_cookie.d.ts +21 -1
- package/build/src/host/active_org_cookie.js +38 -0
- package/build/src/host/admin_api/admin_orgs_service.d.ts +7 -1
- package/build/src/host/admin_api/admin_orgs_service.js +11 -1
- package/build/src/host/admin_api/api_orgs_controller.js +20 -20
- package/build/src/host/admin_console/console_orgs_controller.js +1 -1
- package/build/src/host/admin_sessions_service.d.ts +21 -0
- package/build/src/host/admin_sessions_service.js +49 -0
- package/build/src/host/controllers/account_orgs_controller.js +3 -0
- package/build/src/host/controllers/interaction_controller.d.ts +18 -1
- package/build/src/host/controllers/interaction_controller.js +55 -14
- package/build/src/host/org_access_revocation.d.ts +20 -0
- package/build/src/host/org_access_revocation.js +41 -0
- package/build/src/provider/oidc_service.js +12 -3
- package/package.json +1 -1
|
@@ -52,6 +52,7 @@ import { accountPath } from '../account_paths.js';
|
|
|
52
52
|
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
53
53
|
import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
|
|
54
54
|
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
55
|
+
import { revokeOrgAccess } from '../org_access_revocation.js';
|
|
55
56
|
// Política efetiva: o MESMO módulo que o console HTML usa. Duas cópias da
|
|
56
57
|
// resolução são como o espelho JSON acabaria mais frouxo que o formulário.
|
|
57
58
|
import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
|
|
@@ -213,6 +214,8 @@ export default class AccountOrgsApiController {
|
|
|
213
214
|
}
|
|
214
215
|
return ctx.response.notFound(apiErr('not_found', 'Membership not found.'));
|
|
215
216
|
}
|
|
217
|
+
// Os grants que carregam esta org para a conta caem já — não no refresh.
|
|
218
|
+
await revokeOrgAccess(c.service, orgId, c.accountId);
|
|
216
219
|
await c.cfg.audit?.record({
|
|
217
220
|
type: 'organization.member_removed',
|
|
218
221
|
accountId: c.accountId,
|
|
@@ -335,6 +338,8 @@ export default class AccountOrgsApiController {
|
|
|
335
338
|
}
|
|
336
339
|
return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
|
|
337
340
|
}
|
|
341
|
+
// Os grants que carregam esta org para a conta caem já — não no refresh.
|
|
342
|
+
await revokeOrgAccess(c.service, orgId, targetId);
|
|
338
343
|
await c.cfg.audit?.record({
|
|
339
344
|
type: 'organization.member_removed',
|
|
340
345
|
actorId: c.accountId,
|
|
@@ -446,7 +451,7 @@ export default class AccountOrgsApiController {
|
|
|
446
451
|
ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
|
|
447
452
|
return null;
|
|
448
453
|
}
|
|
449
|
-
return { cfg, store, accountId };
|
|
454
|
+
return { cfg, store, accountId, service };
|
|
450
455
|
}
|
|
451
456
|
/**
|
|
452
457
|
* Exige que o ator seja owner/admin NA ORG DO PATH. Responde 403 e devolve
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActiveOrgInfo } from '../accounts/account_store.js';
|
|
1
|
+
import type { AccountStore, ActiveOrgInfo } from '../accounts/account_store.js';
|
|
2
2
|
/** Nome do cookie da org ativa. HttpOnly, SameSite=Lax, Secure em prod. */
|
|
3
3
|
export declare const ACTIVE_ORG_COOKIE = "authkit_active_org";
|
|
4
4
|
/** TTL máximo do cookie da org ativa (30 dias em segundos). */
|
|
@@ -51,3 +51,23 @@ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null
|
|
|
51
51
|
export declare function readActiveOrgFromHostCtx(ctx: unknown, opts?: {
|
|
52
52
|
appKey?: string;
|
|
53
53
|
}): ActiveOrgInfo | null;
|
|
54
|
+
/**
|
|
55
|
+
* Confere, NO STORE, se a org que a sessão diz ser a ativa ainda vale para a conta,
|
|
56
|
+
* e devolve os dados ATUAIS dela — ou `null` quando não vale mais.
|
|
57
|
+
*
|
|
58
|
+
* O `activeOrg` que chega aqui é um RETRATO: foi gravado no Grant no consent (ou lido
|
|
59
|
+
* do cookie) e o refresh token o reemite por até 30 dias. Sem esta conferência, quem
|
|
60
|
+
* é removido da equipe continua recebendo `org_*` até o grant expirar, e um rebaixado
|
|
61
|
+
* continua recebendo o papel antigo. Por isso:
|
|
62
|
+
*
|
|
63
|
+
* - a conta precisa ser membro da org AGORA (`getOrgMembership`);
|
|
64
|
+
* - a org precisa existir AGORA (`findOrgById`) — o slug sai daqui, não do retrato;
|
|
65
|
+
* - o papel é o da membership ATUAL, nunca o `orgRole` do retrato.
|
|
66
|
+
*
|
|
67
|
+
* Store sem `findOrgById`/`getOrgMembership`: não há como conferir, então não há
|
|
68
|
+
* claim (fail-closed). As rotas que gravam o cookie de org só existem com a capacidade.
|
|
69
|
+
*
|
|
70
|
+
* Erro do store PROPAGA: um banco fora do ar não pode virar, em silêncio, um token
|
|
71
|
+
* sem org (que o app leria como "usuário sem tenant") nem um token com a org velha.
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyActiveOrgMembership(store: AccountStore, accountId: string, claimed: ActiveOrgInfo | null): Promise<ActiveOrgInfo | null>;
|
|
@@ -158,3 +158,41 @@ export function readActiveOrgFromHostCtx(ctx, opts) {
|
|
|
158
158
|
}
|
|
159
159
|
return readActiveOrgFromKoaCtx(ctx, opts);
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Confere, NO STORE, se a org que a sessão diz ser a ativa ainda vale para a conta,
|
|
163
|
+
* e devolve os dados ATUAIS dela — ou `null` quando não vale mais.
|
|
164
|
+
*
|
|
165
|
+
* O `activeOrg` que chega aqui é um RETRATO: foi gravado no Grant no consent (ou lido
|
|
166
|
+
* do cookie) e o refresh token o reemite por até 30 dias. Sem esta conferência, quem
|
|
167
|
+
* é removido da equipe continua recebendo `org_*` até o grant expirar, e um rebaixado
|
|
168
|
+
* continua recebendo o papel antigo. Por isso:
|
|
169
|
+
*
|
|
170
|
+
* - a conta precisa ser membro da org AGORA (`getOrgMembership`);
|
|
171
|
+
* - a org precisa existir AGORA (`findOrgById`) — o slug sai daqui, não do retrato;
|
|
172
|
+
* - o papel é o da membership ATUAL, nunca o `orgRole` do retrato.
|
|
173
|
+
*
|
|
174
|
+
* Store sem `findOrgById`/`getOrgMembership`: não há como conferir, então não há
|
|
175
|
+
* claim (fail-closed). As rotas que gravam o cookie de org só existem com a capacidade.
|
|
176
|
+
*
|
|
177
|
+
* Erro do store PROPAGA: um banco fora do ar não pode virar, em silêncio, um token
|
|
178
|
+
* sem org (que o app leria como "usuário sem tenant") nem um token com a org velha.
|
|
179
|
+
*/
|
|
180
|
+
export async function verifyActiveOrgMembership(store, accountId, claimed) {
|
|
181
|
+
if (!claimed)
|
|
182
|
+
return null;
|
|
183
|
+
// Sonda os DOIS métodos que esta função usa — não o `createOrg` do
|
|
184
|
+
// `supportsOrganizations`. Um store com a capacidade PARCIAL (sem um deles)
|
|
185
|
+
// derrubaria todo mint com TypeError; aqui ele só deixa de emitir a claim.
|
|
186
|
+
const orgs = store;
|
|
187
|
+
if (typeof orgs.findOrgById !== 'function' || typeof orgs.getOrgMembership !== 'function') {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
// Org primeiro: apagada, nem pergunta pela membership.
|
|
191
|
+
const org = await orgs.findOrgById(claimed.orgId);
|
|
192
|
+
if (!org)
|
|
193
|
+
return null;
|
|
194
|
+
const membership = await orgs.getOrgMembership(claimed.orgId, accountId);
|
|
195
|
+
if (!membership || typeof membership.role !== 'string' || !membership.role)
|
|
196
|
+
return null;
|
|
197
|
+
return { orgId: org.id, orgSlug: org.slug, orgRole: membership.role };
|
|
198
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
2
|
import type { OrgInvitation, OrgMember, OrgSummary } from '../../accounts/account_store.js';
|
|
3
3
|
import type { ResolvedServerConfig } from '../../define_config.js';
|
|
4
|
+
import type { OidcService } from '../../provider/oidc_service.js';
|
|
4
5
|
import type { SettingsCapability } from '../runtime_settings.js';
|
|
5
6
|
import type { AdminActor } from './admin_users_service.js';
|
|
6
7
|
export interface OrgWithMemberCount extends OrgSummary {
|
|
@@ -52,7 +53,12 @@ export type InvalidRoleResult = {
|
|
|
52
53
|
*/
|
|
53
54
|
export declare class AdminOrgsService {
|
|
54
55
|
private cfg;
|
|
55
|
-
|
|
56
|
+
private oidc?;
|
|
57
|
+
/**
|
|
58
|
+
* @param oidc Serviço OIDC — onde vivem os grants a revogar quando um membro sai
|
|
59
|
+
* ou a org é apagada ({@link revokeOrgAccess}). Opcional para quem só lê.
|
|
60
|
+
*/
|
|
61
|
+
constructor(cfg: ResolvedServerConfig, oidc?: Pick<OidcService, 'config'> | undefined);
|
|
56
62
|
get supported(): boolean;
|
|
57
63
|
/**
|
|
58
64
|
* Resolve o catálogo efetivo de roles de org (runtime settings → config →
|
|
@@ -2,6 +2,7 @@ import { supportsOrganizations } from '../../accounts/account_store.js';
|
|
|
2
2
|
import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
|
|
3
3
|
import { accountPath } from '../account_paths.js';
|
|
4
4
|
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
5
|
+
import { revokeOrgAccess } from '../org_access_revocation.js';
|
|
5
6
|
import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalogList, } from '../runtime_toggles.js';
|
|
6
7
|
/**
|
|
7
8
|
* Lógica de gestão de organizações compartilhada entre o console admin (HTML)
|
|
@@ -10,8 +11,14 @@ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalo
|
|
|
10
11
|
*/
|
|
11
12
|
export class AdminOrgsService {
|
|
12
13
|
cfg;
|
|
13
|
-
|
|
14
|
+
oidc;
|
|
15
|
+
/**
|
|
16
|
+
* @param oidc Serviço OIDC — onde vivem os grants a revogar quando um membro sai
|
|
17
|
+
* ou a org é apagada ({@link revokeOrgAccess}). Opcional para quem só lê.
|
|
18
|
+
*/
|
|
19
|
+
constructor(cfg, oidc) {
|
|
14
20
|
this.cfg = cfg;
|
|
21
|
+
this.oidc = oidc;
|
|
15
22
|
}
|
|
16
23
|
get supported() {
|
|
17
24
|
return supportsOrganizations(this.cfg.accountStore);
|
|
@@ -157,6 +164,8 @@ export class AdminOrgsService {
|
|
|
157
164
|
if (!existing)
|
|
158
165
|
return { ok: false, reason: 'not_found' };
|
|
159
166
|
await store.deleteOrg(orgId);
|
|
167
|
+
// Grants que carregam esta org (de qualquer conta) caem já — não no refresh.
|
|
168
|
+
await revokeOrgAccess(this.oidc, orgId);
|
|
160
169
|
await this.cfg.audit?.record({
|
|
161
170
|
type: 'organization.deleted',
|
|
162
171
|
actorId: actor.actorId,
|
|
@@ -210,6 +219,7 @@ export class AdminOrgsService {
|
|
|
210
219
|
return { ok: false, reason: 'last_owner' };
|
|
211
220
|
return { ok: false, reason: 'member_not_found' };
|
|
212
221
|
}
|
|
222
|
+
await revokeOrgAccess(this.oidc, orgId, accountId);
|
|
213
223
|
await this.cfg.audit?.record({
|
|
214
224
|
type: 'organization.member_removed',
|
|
215
225
|
actorId: actor.actorId,
|
|
@@ -22,8 +22,8 @@ function notSupported(ctx) {
|
|
|
22
22
|
export default class ApiOrgsController {
|
|
23
23
|
/** GET /organizations — lista todas as orgs com contagem de membros. */
|
|
24
24
|
async index(ctx) {
|
|
25
|
-
const { cfg } = await ctxBits(ctx);
|
|
26
|
-
const svc = new AdminOrgsService(cfg);
|
|
25
|
+
const { cfg, service } = await ctxBits(ctx);
|
|
26
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
27
27
|
const result = await svc.listOrgs();
|
|
28
28
|
if (!Array.isArray(result)) {
|
|
29
29
|
if (result.reason === 'not_supported')
|
|
@@ -36,8 +36,8 @@ export default class ApiOrgsController {
|
|
|
36
36
|
}
|
|
37
37
|
/** POST /organizations — cria uma org. Body: { name, slug, ownerAccountId, logoUrl? } */
|
|
38
38
|
async store(ctx) {
|
|
39
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
40
|
-
const svc = new AdminOrgsService(cfg);
|
|
39
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
40
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
41
41
|
const { name, slug, ownerAccountId, logoUrl } = await ctx.request.validateUsing(orgCreateValidator);
|
|
42
42
|
const result = await svc.createOrg({ name, slug, logoUrl: logoUrl ?? null, ownerAccountId }, actor);
|
|
43
43
|
if ('ok' in result && result.ok === false) {
|
|
@@ -50,8 +50,8 @@ export default class ApiOrgsController {
|
|
|
50
50
|
}
|
|
51
51
|
/** GET /organizations/:id — obtém uma org com membros e convites. */
|
|
52
52
|
async show(ctx) {
|
|
53
|
-
const { cfg } = await ctxBits(ctx);
|
|
54
|
-
const svc = new AdminOrgsService(cfg);
|
|
53
|
+
const { cfg, service } = await ctxBits(ctx);
|
|
54
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
55
55
|
const id = ctx.request.param('id');
|
|
56
56
|
const result = await svc.getOrg(id);
|
|
57
57
|
if ('ok' in result && result.ok === false) {
|
|
@@ -63,8 +63,8 @@ export default class ApiOrgsController {
|
|
|
63
63
|
}
|
|
64
64
|
/** PATCH /organizations/:id — atualiza nome e/ou logo. */
|
|
65
65
|
async update(ctx) {
|
|
66
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
67
|
-
const svc = new AdminOrgsService(cfg);
|
|
66
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
67
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
68
68
|
const id = ctx.request.param('id');
|
|
69
69
|
const { name, logoUrl } = await ctx.request.validateUsing(orgUpdateValidator);
|
|
70
70
|
const patch = {};
|
|
@@ -84,8 +84,8 @@ export default class ApiOrgsController {
|
|
|
84
84
|
}
|
|
85
85
|
/** DELETE /organizations/:id */
|
|
86
86
|
async destroy(ctx) {
|
|
87
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
88
|
-
const svc = new AdminOrgsService(cfg);
|
|
87
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
88
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
89
89
|
const id = ctx.request.param('id');
|
|
90
90
|
const result = await svc.deleteOrg(id, actor);
|
|
91
91
|
if (!result.ok) {
|
|
@@ -97,8 +97,8 @@ export default class ApiOrgsController {
|
|
|
97
97
|
}
|
|
98
98
|
/** POST /organizations/:id/members — adiciona membro. Body: { accountId, role } */
|
|
99
99
|
async addMember(ctx) {
|
|
100
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
101
|
-
const svc = new AdminOrgsService(cfg);
|
|
100
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
101
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
102
102
|
const orgId = ctx.request.param('id');
|
|
103
103
|
const { accountId, role } = await ctx.request.validateUsing(orgAddMemberValidator);
|
|
104
104
|
const result = await svc.addMember(orgId, { accountId, role: role ?? 'member' }, actor, await resolveRuntimeSettings(ctx));
|
|
@@ -116,8 +116,8 @@ export default class ApiOrgsController {
|
|
|
116
116
|
}
|
|
117
117
|
/** DELETE /organizations/:id/members/:accountId */
|
|
118
118
|
async removeMember(ctx) {
|
|
119
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
120
|
-
const svc = new AdminOrgsService(cfg);
|
|
119
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
120
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
121
121
|
const orgId = ctx.request.param('id');
|
|
122
122
|
const accountId = ctx.request.param('accountId');
|
|
123
123
|
const result = await svc.removeMember(orgId, accountId, actor);
|
|
@@ -134,8 +134,8 @@ export default class ApiOrgsController {
|
|
|
134
134
|
}
|
|
135
135
|
/** PATCH /organizations/:id/members/:accountId — troca o papel. Body: { role } */
|
|
136
136
|
async updateMemberRole(ctx) {
|
|
137
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
138
|
-
const svc = new AdminOrgsService(cfg);
|
|
137
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
138
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
139
139
|
const orgId = ctx.request.param('id');
|
|
140
140
|
const accountId = ctx.request.param('accountId');
|
|
141
141
|
const { role } = await ctx.request.validateUsing(orgMemberRoleValidator);
|
|
@@ -155,8 +155,8 @@ export default class ApiOrgsController {
|
|
|
155
155
|
}
|
|
156
156
|
/** POST /organizations/:id/invitations — cria convite. Body: { email, role } */
|
|
157
157
|
async createInvitation(ctx) {
|
|
158
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
159
|
-
const svc = new AdminOrgsService(cfg);
|
|
158
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
159
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
160
160
|
const orgId = ctx.request.param('id');
|
|
161
161
|
const { email, role } = await ctx.request.validateUsing(orgInvitationValidator);
|
|
162
162
|
const origin = authkitOrigin(cfg);
|
|
@@ -173,8 +173,8 @@ export default class ApiOrgsController {
|
|
|
173
173
|
}
|
|
174
174
|
/** DELETE /organizations/:id/invitations/:invitationId */
|
|
175
175
|
async revokeInvitation(ctx) {
|
|
176
|
-
const { cfg, actor } = await ctxBits(ctx);
|
|
177
|
-
const svc = new AdminOrgsService(cfg);
|
|
176
|
+
const { cfg, actor, service } = await ctxBits(ctx);
|
|
177
|
+
const svc = new AdminOrgsService(cfg, service);
|
|
178
178
|
const orgId = ctx.request.param('id');
|
|
179
179
|
const invitationId = ctx.request.param('invitationId');
|
|
180
180
|
const result = await svc.revokeInvitation(orgId, invitationId, actor);
|
|
@@ -23,7 +23,7 @@ import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
|
23
23
|
export default class ConsoleOrgsController {
|
|
24
24
|
async svc(ctx) {
|
|
25
25
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
26
|
-
return new AdminOrgsService(service.config);
|
|
26
|
+
return new AdminOrgsService(service.config, service);
|
|
27
27
|
}
|
|
28
28
|
actor(ctx) {
|
|
29
29
|
return {
|
|
@@ -118,4 +118,25 @@ export declare class AdminSessionsService {
|
|
|
118
118
|
* contagens do que foi removido.
|
|
119
119
|
*/
|
|
120
120
|
revokeClientGrants(accountId: string, clientId: string): Promise<RevokeResult>;
|
|
121
|
+
/**
|
|
122
|
+
* Revoga os grants que carregam a org `orgId` como org ativa (`Grant.activeOrg`,
|
|
123
|
+
* gravado no consent) — de UMA conta (`accountId`) ou de TODAS (omitido, usado
|
|
124
|
+
* quando a org é apagada). Destrói os AT/RT desses grants e os próprios grants,
|
|
125
|
+
* como {@link revokeClientGrants}; depois grava a revogação por `sub` de cada conta
|
|
126
|
+
* afetada, para que clients COOKIE-BASED derrubem a sessão já na próxima request.
|
|
127
|
+
*
|
|
128
|
+
* Por que existe: o `org_*` do token sai do grant. Quem é removido da org (ou
|
|
129
|
+
* perde a org inteira) seguiria com o access token e o refresh token daquele grant
|
|
130
|
+
* até o fim do TTL. A emissão já confere a membership no store a cada mint (ver
|
|
131
|
+
* `verifyActiveOrgMembership`); esta revogação faz a mudança valer AGORA — o
|
|
132
|
+
* access token opaco para de introspectar, o refresh falha e o client volta ao
|
|
133
|
+
* authorize, onde o novo grant sai sem a org.
|
|
134
|
+
*
|
|
135
|
+
* Grants de OUTRAS orgs (e sem org) ficam intactos. Sessões do IdP não carregam
|
|
136
|
+
* org (a org vive no grant e no cookie), então não são tocadas.
|
|
137
|
+
*
|
|
138
|
+
* Sem enumeração no adapter não há como achar os grants: devolve zeros e fica
|
|
139
|
+
* valendo só a conferência da emissão (no próximo refresh).
|
|
140
|
+
*/
|
|
141
|
+
revokeOrgGrants(orgId: string, accountId?: string): Promise<RevokeResult>;
|
|
121
142
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getBootedApp } from '../../services/booted_app.js';
|
|
2
2
|
import { pickModelAdapterClass } from '../adapters/factory.js';
|
|
3
|
+
import { normalizeActiveOrg } from './active_org_cookie.js';
|
|
3
4
|
/**
|
|
4
5
|
* Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta.
|
|
5
6
|
* Lê/escreve via `pickModelAdapterClass` (a MESMA regra do provider): com
|
|
@@ -302,6 +303,54 @@ export class AdminSessionsService {
|
|
|
302
303
|
}
|
|
303
304
|
return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
|
|
304
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Revoga os grants que carregam a org `orgId` como org ativa (`Grant.activeOrg`,
|
|
308
|
+
* gravado no consent) — de UMA conta (`accountId`) ou de TODAS (omitido, usado
|
|
309
|
+
* quando a org é apagada). Destrói os AT/RT desses grants e os próprios grants,
|
|
310
|
+
* como {@link revokeClientGrants}; depois grava a revogação por `sub` de cada conta
|
|
311
|
+
* afetada, para que clients COOKIE-BASED derrubem a sessão já na próxima request.
|
|
312
|
+
*
|
|
313
|
+
* Por que existe: o `org_*` do token sai do grant. Quem é removido da org (ou
|
|
314
|
+
* perde a org inteira) seguiria com o access token e o refresh token daquele grant
|
|
315
|
+
* até o fim do TTL. A emissão já confere a membership no store a cada mint (ver
|
|
316
|
+
* `verifyActiveOrgMembership`); esta revogação faz a mudança valer AGORA — o
|
|
317
|
+
* access token opaco para de introspectar, o refresh falha e o client volta ao
|
|
318
|
+
* authorize, onde o novo grant sai sem a org.
|
|
319
|
+
*
|
|
320
|
+
* Grants de OUTRAS orgs (e sem org) ficam intactos. Sessões do IdP não carregam
|
|
321
|
+
* org (a org vive no grant e no cookie), então não são tocadas.
|
|
322
|
+
*
|
|
323
|
+
* Sem enumeração no adapter não há como achar os grants: devolve zeros e fica
|
|
324
|
+
* valendo só a conferência da emissão (no próximo refresh).
|
|
325
|
+
*/
|
|
326
|
+
async revokeOrgGrants(orgId, accountId) {
|
|
327
|
+
const grantAdapter = this.#adapter('Grant');
|
|
328
|
+
const rows = await this.#listModel('Grant');
|
|
329
|
+
const grants = rows.filter((r) => {
|
|
330
|
+
if (normalizeActiveOrg(r.payload.activeOrg)?.orgId !== orgId)
|
|
331
|
+
return false;
|
|
332
|
+
return accountId === undefined || r.payload.accountId === accountId;
|
|
333
|
+
});
|
|
334
|
+
if (grants.length === 0) {
|
|
335
|
+
return { sessions: 0, grants: 0, accessTokens: 0, refreshTokens: 0 };
|
|
336
|
+
}
|
|
337
|
+
const grantIds = new Set(grants.map((g) => g.id));
|
|
338
|
+
const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
|
|
339
|
+
const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
|
|
340
|
+
for (const g of grants) {
|
|
341
|
+
await grantAdapter.revokeByGrantId(g.id);
|
|
342
|
+
await grantAdapter.destroy(g.id);
|
|
343
|
+
}
|
|
344
|
+
// Clients cookie-based não têm refresh para falhar: sem a linha por `sub`, o
|
|
345
|
+
// id_token com a org velha seguiria valendo na sessão do client até expirar.
|
|
346
|
+
const accounts = new Set(grants
|
|
347
|
+
.map((g) => g.payload.accountId)
|
|
348
|
+
.filter((id) => typeof id === 'string' && id.length > 0));
|
|
349
|
+
for (const id of accounts) {
|
|
350
|
+
await this.recordSubRevocation(id);
|
|
351
|
+
}
|
|
352
|
+
return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
|
|
353
|
+
}
|
|
305
354
|
/** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
|
|
306
355
|
async #destroyTokensOfGrants(model, grantIds) {
|
|
307
356
|
const adapter = this.#adapter(model);
|
|
@@ -6,6 +6,7 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
|
6
6
|
import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
|
|
7
7
|
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
8
8
|
import { ensureConsoleSession } from '../idp_session_bridge.js';
|
|
9
|
+
import { revokeOrgAccess } from '../org_access_revocation.js';
|
|
9
10
|
// Política efetiva compartilhada com o espelho JSON (`account_orgs_api_controller`):
|
|
10
11
|
// ponto de verdade único, para as duas superfícies nunca divergirem.
|
|
11
12
|
import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
|
|
@@ -154,6 +155,7 @@ export default class AccountOrgsController {
|
|
|
154
155
|
return response.forbidden();
|
|
155
156
|
const result = await store.removeOrgMember(params.id, accountId);
|
|
156
157
|
if (result.ok) {
|
|
158
|
+
await revokeOrgAccess(service, params.id, accountId);
|
|
157
159
|
await cfg.audit?.record({
|
|
158
160
|
type: 'organization.member_removed',
|
|
159
161
|
accountId,
|
|
@@ -314,6 +316,7 @@ export default class AccountOrgsController {
|
|
|
314
316
|
}
|
|
315
317
|
const result = await store.removeOrgMember(params.id, params.accountId);
|
|
316
318
|
if (result.ok) {
|
|
319
|
+
await revokeOrgAccess(service, params.id, params.accountId);
|
|
317
320
|
await cfg.audit?.record({
|
|
318
321
|
type: 'organization.member_removed',
|
|
319
322
|
actorId,
|
|
@@ -41,6 +41,22 @@ export default class AuthInteractionController {
|
|
|
41
41
|
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
42
42
|
*/
|
|
43
43
|
private stepUpExtra;
|
|
44
|
+
/**
|
|
45
|
+
* acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
|
|
46
|
+
* `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
|
|
47
|
+
* `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
|
|
48
|
+
* nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
|
|
49
|
+
* carimbando o `acr`.
|
|
50
|
+
*
|
|
51
|
+
* `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
|
|
52
|
+
* o amr sai sem o primário, em vez de inventar um.
|
|
53
|
+
*/
|
|
54
|
+
private mfaCompletion;
|
|
55
|
+
/**
|
|
56
|
+
* Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
|
|
57
|
+
* dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
|
|
58
|
+
*/
|
|
59
|
+
private takeMfaPending;
|
|
44
60
|
/**
|
|
45
61
|
* Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
|
|
46
62
|
* mágico, código por e-mail e troca forçada de senha. Antes disto só o login
|
|
@@ -60,7 +76,8 @@ export default class AuthInteractionController {
|
|
|
60
76
|
* instrução de enrolar (não há o que desafiar);
|
|
61
77
|
* - fator presente, trusted-device válido e sem step-up → `trusted`; quem
|
|
62
78
|
* chamou decide como finalizar, porque o amr muda conforme o caminho;
|
|
63
|
-
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY
|
|
79
|
+
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
|
|
80
|
+
* primário (`primary`) em `MFA_PRIMARY_KEY`.
|
|
64
81
|
*/
|
|
65
82
|
private secondFactorGate;
|
|
66
83
|
/**
|
|
@@ -45,6 +45,12 @@ function accountStatusErrorKey(reason) {
|
|
|
45
45
|
const SESSION_KEY = 'authkit_login_email';
|
|
46
46
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
47
47
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
48
|
+
/**
|
|
49
|
+
* Fator PRIMÁRIO que levou ao desafio do 2º fator (`pwd` ou `email`), guardado JUNTO
|
|
50
|
+
* do {@link MFA_PENDING_KEY}. Sem ele, quem completa o 2º fator não sabe como o login
|
|
51
|
+
* começou e o `amr` do id_token perdia o primeiro fator (ver `mfaCompletion`).
|
|
52
|
+
*/
|
|
53
|
+
const MFA_PRIMARY_KEY = 'authkit_mfa_primary';
|
|
48
54
|
/** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
|
|
49
55
|
const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
|
|
50
56
|
/**
|
|
@@ -408,7 +414,7 @@ export default class AuthInteractionController {
|
|
|
408
414
|
// Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
|
|
409
415
|
// `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
|
|
410
416
|
// accountId pendente e devolve a tela.
|
|
411
|
-
const gate = await this.secondFactorGate(ctx, cfg, acc.id, details);
|
|
417
|
+
const gate = await this.secondFactorGate(ctx, cfg, acc.id, details, 'pwd');
|
|
412
418
|
if (gate.kind === 'challenge')
|
|
413
419
|
return gate.response;
|
|
414
420
|
if (gate.kind === 'trusted') {
|
|
@@ -556,7 +562,7 @@ export default class AuthInteractionController {
|
|
|
556
562
|
// Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
|
|
557
563
|
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
558
564
|
// Finaliza a interaction para o accountId pendente.
|
|
559
|
-
|
|
565
|
+
const { primary } = this.takeMfaPending(ctx);
|
|
560
566
|
forgetLoginEmail(ctx);
|
|
561
567
|
await notifyLoginSuccess(ctx, cfg, {
|
|
562
568
|
accountId,
|
|
@@ -564,9 +570,9 @@ export default class AuthInteractionController {
|
|
|
564
570
|
clientId,
|
|
565
571
|
metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
|
|
566
572
|
});
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
await service.interactions.completeLogin(ctx, accountId, this.
|
|
573
|
+
// Um 2º fator foi de fato verificado: amr = [primário, 'mfa', método]. Com
|
|
574
|
+
// step-up (mfaAcr solicitado nesta requisição), carimba também o acr.
|
|
575
|
+
await service.interactions.completeLogin(ctx, accountId, this.mfaCompletion(cfg, details, primary, usedRecovery ? 'recovery' : 'totp'));
|
|
570
576
|
}
|
|
571
577
|
/**
|
|
572
578
|
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
@@ -591,6 +597,31 @@ export default class AuthInteractionController {
|
|
|
591
597
|
return undefined;
|
|
592
598
|
return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
|
|
593
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
|
|
602
|
+
* `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
|
|
603
|
+
* `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
|
|
604
|
+
* nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
|
|
605
|
+
* carimbando o `acr`.
|
|
606
|
+
*
|
|
607
|
+
* `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
|
|
608
|
+
* o amr sai sem o primário, em vez de inventar um.
|
|
609
|
+
*/
|
|
610
|
+
mfaCompletion(cfg, details, primary, method) {
|
|
611
|
+
const amr = [...(primary ? [primary] : []), 'mfa', method];
|
|
612
|
+
const stepUp = this.stepUpExtra(cfg, details, method);
|
|
613
|
+
return stepUp ? { acr: stepUp.acr, amr } : { amr };
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
|
|
617
|
+
* dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
|
|
618
|
+
*/
|
|
619
|
+
takeMfaPending(ctx) {
|
|
620
|
+
const raw = ctx.session.get(MFA_PRIMARY_KEY);
|
|
621
|
+
ctx.session.forget(MFA_PENDING_KEY);
|
|
622
|
+
ctx.session.forget(MFA_PRIMARY_KEY);
|
|
623
|
+
return { primary: raw === 'pwd' || raw === 'email' ? raw : undefined };
|
|
624
|
+
}
|
|
594
625
|
/**
|
|
595
626
|
* Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
|
|
596
627
|
* mágico, código por e-mail e troca forçada de senha. Antes disto só o login
|
|
@@ -610,9 +641,10 @@ export default class AuthInteractionController {
|
|
|
610
641
|
* instrução de enrolar (não há o que desafiar);
|
|
611
642
|
* - fator presente, trusted-device válido e sem step-up → `trusted`; quem
|
|
612
643
|
* chamou decide como finalizar, porque o amr muda conforme o caminho;
|
|
613
|
-
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY
|
|
644
|
+
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
|
|
645
|
+
* primário (`primary`) em `MFA_PRIMARY_KEY`.
|
|
614
646
|
*/
|
|
615
|
-
async secondFactorGate(ctx, cfg, accountId, details) {
|
|
647
|
+
async secondFactorGate(ctx, cfg, accountId, details, primary) {
|
|
616
648
|
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
617
649
|
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
618
650
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
@@ -647,6 +679,9 @@ export default class AuthInteractionController {
|
|
|
647
679
|
return { kind: 'trusted' };
|
|
648
680
|
}
|
|
649
681
|
ctx.session.put(MFA_PENDING_KEY, accountId);
|
|
682
|
+
// O fator primário viaja junto: quem completa o desafio (TOTP, recovery ou
|
|
683
|
+
// passkey) precisa dele para montar o `amr` inteiro.
|
|
684
|
+
ctx.session.put(MFA_PRIMARY_KEY, primary);
|
|
650
685
|
return {
|
|
651
686
|
kind: 'challenge',
|
|
652
687
|
response: await render(ctx, 'mfa-challenge', {
|
|
@@ -971,7 +1006,7 @@ export default class AuthInteractionController {
|
|
|
971
1006
|
});
|
|
972
1007
|
}
|
|
973
1008
|
// Conta com segundo fator não termina o login só com o e-mail.
|
|
974
|
-
const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx));
|
|
1009
|
+
const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx), 'email');
|
|
975
1010
|
if (magicGate.kind === 'challenge')
|
|
976
1011
|
return magicGate.response;
|
|
977
1012
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -1093,7 +1128,7 @@ export default class AuthInteractionController {
|
|
|
1093
1128
|
clientId,
|
|
1094
1129
|
});
|
|
1095
1130
|
// Conta com segundo fator não termina o login só com o código do e-mail.
|
|
1096
|
-
const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx));
|
|
1131
|
+
const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx), 'email');
|
|
1097
1132
|
if (otpGate.kind === 'challenge')
|
|
1098
1133
|
return otpGate.response;
|
|
1099
1134
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -1350,7 +1385,10 @@ export default class AuthInteractionController {
|
|
|
1350
1385
|
}
|
|
1351
1386
|
// Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
|
|
1352
1387
|
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
1353
|
-
|
|
1388
|
+
// Passkey como 2º fator (havia desafio pendente) ou passkey-first (não havia)?
|
|
1389
|
+
// Lido ANTES de esquecer: é o que decide o amr abaixo.
|
|
1390
|
+
const wasSecondFactor = ctx.session.get(MFA_PENDING_KEY) === accountId;
|
|
1391
|
+
const { primary } = this.takeMfaPending(ctx);
|
|
1354
1392
|
forgetLoginEmail(ctx);
|
|
1355
1393
|
await notifyLoginSuccess(ctx, cfg, {
|
|
1356
1394
|
accountId,
|
|
@@ -1358,9 +1396,12 @@ export default class AuthInteractionController {
|
|
|
1358
1396
|
clientId,
|
|
1359
1397
|
metadata: { mfa: 'webauthn' },
|
|
1360
1398
|
});
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1399
|
+
// 2º fator: amr = [primário, 'mfa', 'webauthn'] (+ acr no step-up), igual ao
|
|
1400
|
+
// TOTP. Passkey-first: a passkey É o login (amr `['webauthn']`); o step-up
|
|
1401
|
+
// carimba acr/amr quando solicitado.
|
|
1402
|
+
await service.interactions.completeLogin(ctx, accountId, wasSecondFactor
|
|
1403
|
+
? this.mfaCompletion(cfg, details, primary, 'webauthn')
|
|
1404
|
+
: (this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] }));
|
|
1364
1405
|
}
|
|
1365
1406
|
/**
|
|
1366
1407
|
* GET /auth/interaction/:uid/switch
|
|
@@ -1441,7 +1482,7 @@ export default class AuthInteractionController {
|
|
|
1441
1482
|
// Senha trocada: limpa o step e finaliza o login.
|
|
1442
1483
|
ctx.session.forget(PASSWORD_EXPIRED_KEY);
|
|
1443
1484
|
// Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
|
|
1444
|
-
const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details);
|
|
1485
|
+
const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details, 'pwd');
|
|
1445
1486
|
if (expiredGate.kind === 'challenge')
|
|
1446
1487
|
return expiredGate.response;
|
|
1447
1488
|
await service.interactions.completeLogin(ctx, accountId);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OidcService } from '../provider/oidc_service.js';
|
|
2
|
+
import { type RevokeResult } from './admin_sessions_service.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derruba os grants (e os tokens deles) que carregam a org `orgId` — de uma conta
|
|
5
|
+
* (membro removido / saiu da org) ou de todas (org apagada, `accountId` omitido).
|
|
6
|
+
*
|
|
7
|
+
* Chamado DEPOIS que o store confirmou a remoção. Os quatro caminhos que removem
|
|
8
|
+
* membro (form `/account/orgs`, `/account/api/orgs`, console admin, Admin API) e o
|
|
9
|
+
* `deleteOrg` passam por aqui, para que a regra seja uma só.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort, e nunca silencioso: a membership JÁ saiu do store, então falhar a
|
|
12
|
+
* request aqui só mentiria para quem removeu ("deu erro", mas o membro saiu). A
|
|
13
|
+
* conferência da emissão (`verifyActiveOrgMembership`) continua sendo a rede de
|
|
14
|
+
* segurança — no pior caso, o claim cai no próximo refresh. A falha é logada em
|
|
15
|
+
* `error`, porque uma revogação que não aconteceu precisa aparecer.
|
|
16
|
+
*
|
|
17
|
+
* `service` sem `config.AdapterClass` (um ctx de teste que só carrega a config)
|
|
18
|
+
* não tem adapter OIDC onde revogar: devolve `null` sem tentar.
|
|
19
|
+
*/
|
|
20
|
+
export declare function revokeOrgAccess(service: Pick<OidcService, 'config'> | undefined, orgId: string, accountId?: string): Promise<RevokeResult | null>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getBootedApp } from '../../services/booted_app.js';
|
|
2
|
+
import { AdminSessionsService } from './admin_sessions_service.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derruba os grants (e os tokens deles) que carregam a org `orgId` — de uma conta
|
|
5
|
+
* (membro removido / saiu da org) ou de todas (org apagada, `accountId` omitido).
|
|
6
|
+
*
|
|
7
|
+
* Chamado DEPOIS que o store confirmou a remoção. Os quatro caminhos que removem
|
|
8
|
+
* membro (form `/account/orgs`, `/account/api/orgs`, console admin, Admin API) e o
|
|
9
|
+
* `deleteOrg` passam por aqui, para que a regra seja uma só.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort, e nunca silencioso: a membership JÁ saiu do store, então falhar a
|
|
12
|
+
* request aqui só mentiria para quem removeu ("deu erro", mas o membro saiu). A
|
|
13
|
+
* conferência da emissão (`verifyActiveOrgMembership`) continua sendo a rede de
|
|
14
|
+
* segurança — no pior caso, o claim cai no próximo refresh. A falha é logada em
|
|
15
|
+
* `error`, porque uma revogação que não aconteceu precisa aparecer.
|
|
16
|
+
*
|
|
17
|
+
* `service` sem `config.AdapterClass` (um ctx de teste que só carrega a config)
|
|
18
|
+
* não tem adapter OIDC onde revogar: devolve `null` sem tentar.
|
|
19
|
+
*/
|
|
20
|
+
export async function revokeOrgAccess(service, orgId, accountId) {
|
|
21
|
+
if (!service?.config?.AdapterClass)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return await new AdminSessionsService(service).revokeOrgGrants(orgId, accountId);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
await logFailure(error, orgId, accountId);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function logFailure(error, orgId, accountId) {
|
|
32
|
+
const msg = 'authkit: falha ao revogar os grants da org — o claim de org cai só no próximo refresh';
|
|
33
|
+
try {
|
|
34
|
+
const logger = await getBootedApp().container.make('logger');
|
|
35
|
+
logger.error({ err: error, orgId, accountId }, msg);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// eslint-disable-next-line no-console
|
|
39
|
+
console.error(msg, { orgId, accountId, error });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -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 { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
|
|
5
|
+
import { normalizeActiveOrg, readActiveOrgFromKoaCtx, verifyActiveOrgMembership, } 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';
|
|
@@ -87,8 +87,15 @@ export class OidcService {
|
|
|
87
87
|
// — server-a-servidor, SEM cookies — então o cookie abaixo não existe
|
|
88
88
|
// ali. O cookie continua como FALLBACK para fluxos em que o id_token
|
|
89
89
|
// sai no próprio authorize (implicit/hybrid), onde a request é do browser.
|
|
90
|
-
const
|
|
90
|
+
const claimedOrg = normalizeActiveOrg(ctx?.oidc?.entities?.Grant?.activeOrg) ??
|
|
91
91
|
readActiveOrgFromKoaCtx(ctx);
|
|
92
|
+
// O `claimedOrg` é um RETRATO do consent (ou do cookie): o refresh o
|
|
93
|
+
// reemite por até 30 dias. Antes de virar claim, ele é conferido no store
|
|
94
|
+
// — membership e org ATUAIS, papel ATUAL (ver `verifyActiveOrgMembership`).
|
|
95
|
+
// Memoizado por chamada de `findAccount`: `claims()` pode rodar mais de uma
|
|
96
|
+
// vez no mesmo mint (id_token + userinfo) e a pergunta é a mesma.
|
|
97
|
+
let verifiedOrg;
|
|
98
|
+
const currentOrg = () => (verifiedOrg ??= verifyActiveOrgMembership(config.accountStore, user.id, claimedOrg));
|
|
92
99
|
// GATE de least-privilege: roles globais e claims de org só são emitidas
|
|
93
100
|
// para clients FIRST-PARTY. Capturamos o clientId aqui (fora do closure
|
|
94
101
|
// `claims()`, pois o `ctx` da request vive neste escopo).
|
|
@@ -119,10 +126,12 @@ export class OidcService {
|
|
|
119
126
|
};
|
|
120
127
|
// roles/org_* são dados de autorização interna: só para first-party.
|
|
121
128
|
if (firstParty) {
|
|
129
|
+
const activeOrg = await currentOrg();
|
|
122
130
|
base[config.globalRolesClaim] = config.resolveTokenRoles
|
|
123
131
|
? await config.resolveTokenRoles(user, { clientId, activeOrg })
|
|
124
132
|
: (user.globalRoles ?? []);
|
|
125
|
-
// Emite claims de org somente quando há uma org ativa na sessão
|
|
133
|
+
// Emite claims de org somente quando há uma org ativa na sessão E a
|
|
134
|
+
// conta ainda é membro dela (conferido no store, papel atual).
|
|
126
135
|
if (activeOrg) {
|
|
127
136
|
base.org_id = activeOrg.orgId;
|
|
128
137
|
base.org_slug = activeOrg.orgSlug;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
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",
|