@adonis-agora/authkit-server 0.72.0 → 0.74.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/README.md +4 -0
- package/build/commands/clients_create.d.ts +2 -0
- package/build/commands/clients_create.js +41 -13
- package/build/commands/commands.json +7 -0
- package/build/index.d.ts +6 -0
- package/build/index.js +7 -0
- package/build/src/commands/import_clients.js +2 -0
- package/build/src/host/access_token_verifier.d.ts +100 -0
- package/build/src/host/access_token_verifier.js +240 -0
- 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_clients_controller.d.ts +4 -1
- package/build/src/host/admin_api/api_clients_controller.js +25 -2
- package/build/src/host/admin_api/api_orgs_controller.js +20 -20
- package/build/src/host/admin_api/dto.d.ts +1 -0
- package/build/src/host/admin_api/dto.js +1 -0
- package/build/src/host/admin_clients_service.d.ts +9 -0
- package/build/src/host/admin_clients_service.js +21 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +3 -1
- package/build/src/host/admin_console/console_clients_controller.js +25 -2
- 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/admin_validators.d.ts +11 -1
- package/build/src/host/admin_validators.js +36 -8
- package/build/src/host/bearer_account.d.ts +9 -0
- package/build/src/host/bearer_account.js +28 -0
- package/build/src/host/client_metadata.d.ts +51 -0
- package/build/src/host/client_metadata.js +98 -0
- package/build/src/host/console_session.d.ts +14 -3
- package/build/src/host/console_session.js +21 -9
- 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/oidc_bearer_guard.d.ts +113 -0
- package/build/src/host/oidc_bearer_guard.js +223 -0
- package/build/src/host/oidc_rp_guard.d.ts +3 -1
- package/build/src/host/oidc_rp_guard.js +6 -2
- package/build/src/host/org_access_revocation.d.ts +20 -0
- package/build/src/host/org_access_revocation.js +41 -0
- package/build/src/host/ui-dist/assets/{index-Dct63ai-.js → index-CIqtoNcO.js} +1 -1
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +42 -17
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +23 -4
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import vine from '@vinejs/vine';
|
|
2
|
+
import { APPLICATION_TYPES, redirectUriProblem } from './client_metadata.js';
|
|
2
3
|
import { normalizeEmailIdentifier } from './email_identifier.js';
|
|
3
4
|
/**
|
|
4
5
|
* Validators VineJS dos recursos administrativos (Admin REST API + console admin).
|
|
@@ -42,8 +43,25 @@ const authMethod = vine.enum(['client_secret_basic', 'client_secret_post', 'none
|
|
|
42
43
|
* (`require_tld:false`) p/ não rejeitar `localhost` ou hostnames internos legítimos.
|
|
43
44
|
*/
|
|
44
45
|
const URL_OPTS = { require_protocol: true, require_tld: false, protocols: ['http', 'https'] };
|
|
45
|
-
/**
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Regra de redirect URI ciente do `applicationType` do MESMO payload (RFC 8252):
|
|
48
|
+
* client web continua restrito a http/https (L10); client nativo aceita esquema
|
|
49
|
+
* privado (`com.example.app:/cb`), https claimed e loopback http. Sem o tipo no
|
|
50
|
+
* payload (PATCH parcial) só as regras universais valem aqui — URI absoluta, sem
|
|
51
|
+
* fragmento, sem `javascript:`/`data:`/… — e o `AdminClientsService` confere o
|
|
52
|
+
* resto contra o tipo EFETIVO do client (ver `client_metadata.ts`).
|
|
53
|
+
*/
|
|
54
|
+
const redirectUriRule = vine.createRule((value, _options, field) => {
|
|
55
|
+
if (typeof value !== 'string')
|
|
56
|
+
return;
|
|
57
|
+
const declared = field.data?.applicationType;
|
|
58
|
+
const type = declared === 'web' || declared === 'native' ? declared : undefined;
|
|
59
|
+
const problem = redirectUriProblem(value, type);
|
|
60
|
+
if (problem)
|
|
61
|
+
field.report(`The {{ field }} field ${problem}`, 'redirectUri', field);
|
|
62
|
+
});
|
|
63
|
+
/** Array de redirect URIs (ver {@link redirectUriRule}). Rejeita strings que não são URI válida. */
|
|
64
|
+
const redirectUriArray = vine.array(vine.string().trim().use(redirectUriRule()));
|
|
47
65
|
/**
|
|
48
66
|
* Allowlist de grant_types aceitos no registro/edição de client (M10). Bloqueia
|
|
49
67
|
* `implicit` (tokens no fragment da URL — sem PKCE/refresh) e qualquer valor fora
|
|
@@ -70,10 +88,12 @@ const grantTypeArray = vine.array(vine.enum(ALLOWED_GRANT_TYPES));
|
|
|
70
88
|
*/
|
|
71
89
|
export const clientInputValidator = vine.compile(vine.object({
|
|
72
90
|
clientId: vine.string().trim().optional(),
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
// `web` (default) ou `native` (RFC 8252 — app mobile/desktop, sempre público).
|
|
92
|
+
applicationType: vine.enum(APPLICATION_TYPES).optional(),
|
|
93
|
+
// redirect/postLogout: URIs ABSOLUTAS (L10), http/https para client web;
|
|
94
|
+
// esquema privado/loopback só para `native` (ver `redirectUriRule`).
|
|
95
|
+
redirectUris: redirectUriArray.optional(),
|
|
96
|
+
postLogoutRedirectUris: redirectUriArray.optional(),
|
|
77
97
|
// grant_types restrito à allowlist; `implicit` e desconhecidos viram 422 (M10).
|
|
78
98
|
grantTypes: grantTypeArray.optional(),
|
|
79
99
|
grants: grantTypeArray.optional(),
|
|
@@ -82,14 +102,20 @@ export const clientInputValidator = vine.compile(vine.object({
|
|
|
82
102
|
backchannelLogoutUri: vine.string().trim().url(URL_OPTS).optional(),
|
|
83
103
|
backchannelLogoutSessionRequired: vine.boolean().optional(),
|
|
84
104
|
}));
|
|
85
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Mapeia o input validado para um {@link ClientInput} COMPLETO (create), com defaults.
|
|
107
|
+
* Client nativo sem `tokenEndpointAuthMethod` explícito vira público (`none`); um
|
|
108
|
+
* nativo confidencial explícito é recusado pelo service (422).
|
|
109
|
+
*/
|
|
86
110
|
export function clientCreateInput(v) {
|
|
111
|
+
const applicationType = v.applicationType ?? 'web';
|
|
87
112
|
return {
|
|
88
113
|
clientId: v.clientId?.trim() || undefined,
|
|
114
|
+
applicationType,
|
|
89
115
|
redirectUris: v.redirectUris ?? [],
|
|
90
116
|
postLogoutRedirectUris: v.postLogoutRedirectUris ?? [],
|
|
91
117
|
grantTypes: v.grantTypes ?? v.grants ?? [],
|
|
92
|
-
tokenEndpointAuthMethod: v.tokenEndpointAuthMethod ?? 'client_secret_basic',
|
|
118
|
+
tokenEndpointAuthMethod: v.tokenEndpointAuthMethod ?? (applicationType === 'native' ? 'none' : 'client_secret_basic'),
|
|
93
119
|
backchannelLogoutUri: v.backchannelLogoutUri || undefined,
|
|
94
120
|
backchannelLogoutSessionRequired: v.backchannelLogoutSessionRequired,
|
|
95
121
|
};
|
|
@@ -100,6 +126,8 @@ export function clientCreateInput(v) {
|
|
|
100
126
|
*/
|
|
101
127
|
export function clientPartialInput(v) {
|
|
102
128
|
const out = {};
|
|
129
|
+
if (v.applicationType !== undefined)
|
|
130
|
+
out.applicationType = v.applicationType;
|
|
103
131
|
if (v.redirectUris !== undefined)
|
|
104
132
|
out.redirectUris = v.redirectUris;
|
|
105
133
|
if (v.postLogoutRedirectUris !== undefined)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Registra a conta autenticada via bearer nesta request. */
|
|
2
|
+
export declare function setBearerAccountId(ctx: object, accountId: string): void;
|
|
3
|
+
/** Esquece a conta bearer desta request (falha de autenticação). */
|
|
4
|
+
export declare function clearBearerAccountId(ctx: object): void;
|
|
5
|
+
/**
|
|
6
|
+
* Id da conta autenticada via bearer nesta request, ou `null` quando nenhum
|
|
7
|
+
* `oidcBearerGuard` autenticou (ainda) a request.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bearerAccountId(ctx: object): string | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conta autenticada por `Authorization: Bearer` NESTA request — gravada pelo
|
|
3
|
+
* `oidcBearerGuard` quando ele autentica e lida pelo `getAccountId` como
|
|
4
|
+
* fallback da sessão.
|
|
5
|
+
*
|
|
6
|
+
* Um `WeakMap` keyed pelo próprio `HttpContext` (e não uma prop em `ctx`): nada
|
|
7
|
+
* de augmentation de `HttpContext` vazando pelo barrel (ver o porquê em
|
|
8
|
+
* `account_session_key.ts`), e o valor morre junto com a request.
|
|
9
|
+
*
|
|
10
|
+
* Mantenha este arquivo sem imports: `console_session.ts` (reexportado pelo
|
|
11
|
+
* barrel) depende dele.
|
|
12
|
+
*/
|
|
13
|
+
const bearerAccounts = new WeakMap();
|
|
14
|
+
/** Registra a conta autenticada via bearer nesta request. */
|
|
15
|
+
export function setBearerAccountId(ctx, accountId) {
|
|
16
|
+
bearerAccounts.set(ctx, accountId);
|
|
17
|
+
}
|
|
18
|
+
/** Esquece a conta bearer desta request (falha de autenticação). */
|
|
19
|
+
export function clearBearerAccountId(ctx) {
|
|
20
|
+
bearerAccounts.delete(ctx);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Id da conta autenticada via bearer nesta request, ou `null` quando nenhum
|
|
24
|
+
* `oidcBearerGuard` autenticou (ainda) a request.
|
|
25
|
+
*/
|
|
26
|
+
export function bearerAccountId(ctx) {
|
|
27
|
+
return bearerAccounts.get(ctx) ?? null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regras de metadata de client que dependem do TIPO de aplicação (OIDC
|
|
3
|
+
* Registration §2, `application_type`) — compartilhadas pelo
|
|
4
|
+
* {@link AdminClientsService} (console admin, Admin REST API, CLI, import) e pelo
|
|
5
|
+
* validator Vine de `admin_validators.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Espelham as regras que o próprio oidc-provider aplica ao montar o `Client`
|
|
8
|
+
* (`helpers/client_schema.js#redirectUris`). Checamos ANTES de persistir porque o
|
|
9
|
+
* provider só valida no `Client.find` — um client inválido gravado no adapter
|
|
10
|
+
* vira "client não encontrado" em runtime, sem mensagem útil para quem o criou.
|
|
11
|
+
*
|
|
12
|
+
* `native` segue a RFC 8252 (OAuth 2.0 for Native Apps):
|
|
13
|
+
* - esquema privado (`com.example.app:/callback`, `myapp://auth`) — §7.1;
|
|
14
|
+
* - https "claimed" (universal links / app links), nunca em loopback — §7.2;
|
|
15
|
+
* - loopback `http://127.0.0.1:<qualquer porta>/…` (e `localhost`/`[::1]`) — §7.3.
|
|
16
|
+
* O provider ignora a porta do loopback na comparação do redirect (§7.3), então
|
|
17
|
+
* basta registrar `http://127.0.0.1/callback`.
|
|
18
|
+
* - client PÚBLICO: um segredo embarcado num app instalado não é segredo (§8.5).
|
|
19
|
+
* PKCE (S256) já é obrigatório para todo client neste issuer.
|
|
20
|
+
*/
|
|
21
|
+
/** Tipo de aplicação do client. `web` é o default histórico. */
|
|
22
|
+
export type ApplicationType = 'web' | 'native';
|
|
23
|
+
export declare const APPLICATION_TYPES: readonly ['web', 'native'];
|
|
24
|
+
/**
|
|
25
|
+
* Metadata inválida para o tipo de client (ex.: client nativo com secret, redirect
|
|
26
|
+
* `myapp://` num client web). Os controllers traduzem em 422.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ClientMetadataError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Problema de UMA redirect URI para o tipo de client, ou `null` quando ok.
|
|
33
|
+
*
|
|
34
|
+
* `applicationType` ausente = "ainda não sei" (PATCH que não mandou o tipo): só as
|
|
35
|
+
* regras universais (URI absoluta, sem fragmento, esquema não proibido). A regra
|
|
36
|
+
* por tipo roda depois, no service, com o tipo efetivo do client.
|
|
37
|
+
*/
|
|
38
|
+
export declare function redirectUriProblem(uri: string, applicationType?: ApplicationType): string | null;
|
|
39
|
+
/** Entrada mínima para {@link assertClientMetadata}. */
|
|
40
|
+
export interface ClientMetadataInput {
|
|
41
|
+
applicationType: ApplicationType;
|
|
42
|
+
tokenEndpointAuthMethod: string;
|
|
43
|
+
grantTypes: string[];
|
|
44
|
+
redirectUris: string[];
|
|
45
|
+
postLogoutRedirectUris: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Valida a combinação tipo × autenticação × grants × redirects. Lança
|
|
49
|
+
* {@link ClientMetadataError} na primeira violação.
|
|
50
|
+
*/
|
|
51
|
+
export declare function assertClientMetadata(input: ClientMetadataInput): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regras de metadata de client que dependem do TIPO de aplicação (OIDC
|
|
3
|
+
* Registration §2, `application_type`) — compartilhadas pelo
|
|
4
|
+
* {@link AdminClientsService} (console admin, Admin REST API, CLI, import) e pelo
|
|
5
|
+
* validator Vine de `admin_validators.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Espelham as regras que o próprio oidc-provider aplica ao montar o `Client`
|
|
8
|
+
* (`helpers/client_schema.js#redirectUris`). Checamos ANTES de persistir porque o
|
|
9
|
+
* provider só valida no `Client.find` — um client inválido gravado no adapter
|
|
10
|
+
* vira "client não encontrado" em runtime, sem mensagem útil para quem o criou.
|
|
11
|
+
*
|
|
12
|
+
* `native` segue a RFC 8252 (OAuth 2.0 for Native Apps):
|
|
13
|
+
* - esquema privado (`com.example.app:/callback`, `myapp://auth`) — §7.1;
|
|
14
|
+
* - https "claimed" (universal links / app links), nunca em loopback — §7.2;
|
|
15
|
+
* - loopback `http://127.0.0.1:<qualquer porta>/…` (e `localhost`/`[::1]`) — §7.3.
|
|
16
|
+
* O provider ignora a porta do loopback na comparação do redirect (§7.3), então
|
|
17
|
+
* basta registrar `http://127.0.0.1/callback`.
|
|
18
|
+
* - client PÚBLICO: um segredo embarcado num app instalado não é segredo (§8.5).
|
|
19
|
+
* PKCE (S256) já é obrigatório para todo client neste issuer.
|
|
20
|
+
*/
|
|
21
|
+
export const APPLICATION_TYPES = ['web', 'native'];
|
|
22
|
+
/** Hosts loopback reconhecidos (mesma lista do oidc-provider). */
|
|
23
|
+
const LOOPBACKS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
24
|
+
/** Esquemas NUNCA aceitos como redirect, qualquer que seja o tipo de client. */
|
|
25
|
+
const FORBIDDEN_SCHEMES = new Set([
|
|
26
|
+
'javascript:',
|
|
27
|
+
'vbscript:',
|
|
28
|
+
'data:',
|
|
29
|
+
'blob:',
|
|
30
|
+
'file:',
|
|
31
|
+
'about:',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Metadata inválida para o tipo de client (ex.: client nativo com secret, redirect
|
|
35
|
+
* `myapp://` num client web). Os controllers traduzem em 422.
|
|
36
|
+
*/
|
|
37
|
+
export class ClientMetadataError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'ClientMetadataError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Problema de UMA redirect URI para o tipo de client, ou `null` quando ok.
|
|
45
|
+
*
|
|
46
|
+
* `applicationType` ausente = "ainda não sei" (PATCH que não mandou o tipo): só as
|
|
47
|
+
* regras universais (URI absoluta, sem fragmento, esquema não proibido). A regra
|
|
48
|
+
* por tipo roda depois, no service, com o tipo efetivo do client.
|
|
49
|
+
*/
|
|
50
|
+
export function redirectUriProblem(uri, applicationType) {
|
|
51
|
+
const parsed = URL.parse(uri);
|
|
52
|
+
if (!parsed)
|
|
53
|
+
return 'deve ser uma URI absoluta';
|
|
54
|
+
if (uri.includes('#'))
|
|
55
|
+
return 'não pode conter fragmento (#)';
|
|
56
|
+
const { protocol, hostname } = parsed;
|
|
57
|
+
if (FORBIDDEN_SCHEMES.has(protocol))
|
|
58
|
+
return `não pode usar o esquema ${protocol.slice(0, -1)}`;
|
|
59
|
+
if (applicationType === 'web') {
|
|
60
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
61
|
+
return 'client web só aceita URIs http/https (use applicationType "native" para apps mobile)';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (applicationType === 'native') {
|
|
65
|
+
if (protocol === 'http:' && !LOOPBACKS.has(hostname)) {
|
|
66
|
+
return 'client nativo com http só aceita loopback (127.0.0.1, [::1] ou localhost)';
|
|
67
|
+
}
|
|
68
|
+
if (protocol === 'https:' && LOOPBACKS.has(hostname)) {
|
|
69
|
+
return 'client nativo com https "claimed" não pode usar host loopback';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Valida a combinação tipo × autenticação × grants × redirects. Lança
|
|
76
|
+
* {@link ClientMetadataError} na primeira violação.
|
|
77
|
+
*/
|
|
78
|
+
export function assertClientMetadata(input) {
|
|
79
|
+
const type = input.applicationType;
|
|
80
|
+
if (type === 'native') {
|
|
81
|
+
if (input.tokenEndpointAuthMethod !== 'none') {
|
|
82
|
+
throw new ClientMetadataError('client nativo precisa ser público (tokenEndpointAuthMethod "none", sem secret) — RFC 8252 §8.5');
|
|
83
|
+
}
|
|
84
|
+
if (input.grantTypes.includes('client_credentials')) {
|
|
85
|
+
throw new ClientMetadataError('client nativo não pode usar client_credentials (não há secret para autenticar)');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [label, uris] of [
|
|
89
|
+
['redirectUris', input.redirectUris],
|
|
90
|
+
['postLogoutRedirectUris', input.postLogoutRedirectUris],
|
|
91
|
+
]) {
|
|
92
|
+
for (const uri of uris) {
|
|
93
|
+
const problem = redirectUriProblem(uri, type);
|
|
94
|
+
if (problem)
|
|
95
|
+
throw new ClientMetadataError(`${label}: "${uri}" ${problem}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -9,8 +9,18 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
9
9
|
* sessão mudar.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* Retorna o id da conta que o request está representando
|
|
13
|
-
*
|
|
12
|
+
* Retorna o id da conta que o request está representando (ou `null` quando não
|
|
13
|
+
* há identidade).
|
|
14
|
+
*
|
|
15
|
+
* Fonte, nesta ordem:
|
|
16
|
+
* 1. a sessão do console (`ACCOUNT_SESSION_KEY`) — o comportamento de sempre;
|
|
17
|
+
* 2. SEM sessão logada: a conta que um `oidcBearerGuard` autenticou NESTA
|
|
18
|
+
* request via `Authorization: Bearer` (app nativo, SPA, serviço).
|
|
19
|
+
*
|
|
20
|
+
* O fallback (2) só existe depois que o guard bearer RODOU na request (ex.:
|
|
21
|
+
* `middleware.auth({ guards: ['web', 'api'] })` ou `auth.authenticateUsing`);
|
|
22
|
+
* um app que não usa o guard bearer continua exatamente como antes. A sessão
|
|
23
|
+
* sempre ganha: request com cookie de sessão E bearer responde pela sessão.
|
|
14
24
|
*
|
|
15
25
|
* ATENÇÃO: com impersonation ativa isto é a conta PERSONIFICADA, não o admin
|
|
16
26
|
* que a personificou. É o comportamento certo para "como qual conta este
|
|
@@ -50,7 +60,8 @@ export declare function getAccountId(ctx: HttpContext): string | null;
|
|
|
50
60
|
*/
|
|
51
61
|
export declare function realAccountId(ctx: HttpContext): string | null;
|
|
52
62
|
/**
|
|
53
|
-
* `true` quando o request carrega uma sessão de conta do console.
|
|
63
|
+
* `true` quando o request carrega uma sessão de conta do console. Só a SESSÃO:
|
|
64
|
+
* uma identidade bearer (`oidcBearerGuard`) não conta — use `getAccountId`.
|
|
54
65
|
*/
|
|
55
66
|
export declare function hasAccountSession(ctx: HttpContext): boolean;
|
|
56
67
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAccountLoginUrl } from './account_login_url.js';
|
|
2
2
|
import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
|
|
3
|
+
import { bearerAccountId } from './bearer_account.js';
|
|
3
4
|
import { impersonationState } from './impersonation_session.js';
|
|
4
5
|
/**
|
|
5
6
|
* Helpers públicos para integrar a sessão do console do AuthKit com
|
|
@@ -11,8 +12,18 @@ import { impersonationState } from './impersonation_session.js';
|
|
|
11
12
|
* sessão mudar.
|
|
12
13
|
*/
|
|
13
14
|
/**
|
|
14
|
-
* Retorna o id da conta que o request está representando
|
|
15
|
-
*
|
|
15
|
+
* Retorna o id da conta que o request está representando (ou `null` quando não
|
|
16
|
+
* há identidade).
|
|
17
|
+
*
|
|
18
|
+
* Fonte, nesta ordem:
|
|
19
|
+
* 1. a sessão do console (`ACCOUNT_SESSION_KEY`) — o comportamento de sempre;
|
|
20
|
+
* 2. SEM sessão logada: a conta que um `oidcBearerGuard` autenticou NESTA
|
|
21
|
+
* request via `Authorization: Bearer` (app nativo, SPA, serviço).
|
|
22
|
+
*
|
|
23
|
+
* O fallback (2) só existe depois que o guard bearer RODOU na request (ex.:
|
|
24
|
+
* `middleware.auth({ guards: ['web', 'api'] })` ou `auth.authenticateUsing`);
|
|
25
|
+
* um app que não usa o guard bearer continua exatamente como antes. A sessão
|
|
26
|
+
* sempre ganha: request com cookie de sessão E bearer responde pela sessão.
|
|
16
27
|
*
|
|
17
28
|
* ATENÇÃO: com impersonation ativa isto é a conta PERSONIFICADA, não o admin
|
|
18
29
|
* que a personificou. É o comportamento certo para "como qual conta este
|
|
@@ -21,7 +32,7 @@ import { impersonationState } from './impersonation_session.js';
|
|
|
21
32
|
*/
|
|
22
33
|
export function getAccountId(ctx) {
|
|
23
34
|
const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
|
|
24
|
-
return accountId ??
|
|
35
|
+
return accountId ?? bearerAccountId(ctx);
|
|
25
36
|
}
|
|
26
37
|
/**
|
|
27
38
|
* Retorna o id do HUMANO real por trás do request — o id que uma decisão de
|
|
@@ -54,18 +65,19 @@ export function getAccountId(ctx) {
|
|
|
54
65
|
* if (!id || !(await authz.hasRole(id, 'admin'))) throw new Error('forbidden')
|
|
55
66
|
*/
|
|
56
67
|
export function realAccountId(ctx) {
|
|
57
|
-
// Sem sessão não há
|
|
58
|
-
// `
|
|
59
|
-
//
|
|
68
|
+
// Sem sessão não há impersonation: mesma tolerância do `getAccountId` (que usa
|
|
69
|
+
// `ctx.session?.`), pois `impersonationState` assume uma sessão presente. Resta
|
|
70
|
+
// a identidade bearer (se o `oidcBearerGuard` autenticou a request).
|
|
60
71
|
if (!ctx.session)
|
|
61
|
-
return
|
|
72
|
+
return bearerAccountId(ctx);
|
|
62
73
|
return impersonationState(ctx).impersonatorId ?? getAccountId(ctx);
|
|
63
74
|
}
|
|
64
75
|
/**
|
|
65
|
-
* `true` quando o request carrega uma sessão de conta do console.
|
|
76
|
+
* `true` quando o request carrega uma sessão de conta do console. Só a SESSÃO:
|
|
77
|
+
* uma identidade bearer (`oidcBearerGuard`) não conta — use `getAccountId`.
|
|
66
78
|
*/
|
|
67
79
|
export function hasAccountSession(ctx) {
|
|
68
|
-
return
|
|
80
|
+
return ctx.session?.get(ACCOUNT_SESSION_KEY) != null;
|
|
69
81
|
}
|
|
70
82
|
/**
|
|
71
83
|
* URL do login do console, com `return_to` opcional de volta ao destino
|
|
@@ -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);
|