@adonis-agora/authkit-client 0.15.0 → 0.16.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/providers/authkit_client_provider.d.ts +60 -2
- package/build/providers/authkit_client_provider.js +112 -7
- package/build/src/middleware/backchannel_revocation_middleware.js +3 -3
- package/build/src/register_oidc_client.js +6 -2
- package/build/src/types.d.ts +9 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
2
2
|
import type { ApplicationService } from '@adonisjs/core/types';
|
|
3
3
|
import { Authenticator } from '../src/authenticator.js';
|
|
4
4
|
import type { ResolvedClientConfig } from '../src/define_config.js';
|
|
5
|
+
import type { TokenSet } from '../src/types.js';
|
|
5
6
|
/** Deps injetáveis (relógio/fetch) para testar `maybeRefresh` sem rede/tempo real. */
|
|
6
7
|
export interface RefreshDeps {
|
|
7
8
|
now?: () => number;
|
|
@@ -23,6 +24,9 @@ export declare class AuthkitClientManager {
|
|
|
23
24
|
* renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
|
|
24
25
|
* o id_token / refresh_token anteriores quando o IdP não os reemite.
|
|
25
26
|
*
|
|
27
|
+
* Preserva TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
|
|
28
|
+
* sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
|
|
29
|
+
*
|
|
26
30
|
* Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
|
|
27
31
|
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
|
|
28
32
|
*/
|
|
@@ -43,10 +47,64 @@ export declare class AuthkitClientManager {
|
|
|
43
47
|
* do chamador (ex.: Bouncer) ANTES de chamar este método.
|
|
44
48
|
*/
|
|
45
49
|
impersonate(ctx: HttpContext, requestedSubject: string): Promise<void>;
|
|
46
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Encerra a impersonação restaurando o token set do ator — SE a sessão que pede
|
|
52
|
+
* ainda for aquela para a qual a credencial foi guardada.
|
|
53
|
+
*
|
|
54
|
+
* A credencial parqueada é um refresh token vivo; sem essa conferência o restore
|
|
55
|
+
* a entrega a quem estiver de posse do cookie jar depois (logout do ator + login
|
|
56
|
+
* de outra identidade). A prova é o `impersonationBinding`: um nonce escrito ao
|
|
57
|
+
* mesmo tempo dentro do token set ATIVO e do registro parqueado. Só `impersonate()`
|
|
58
|
+
* o escreve e só o `maybeRefresh` o propaga — qualquer troca de identidade
|
|
59
|
+
* reescreve o slot da sessão e o vínculo some.
|
|
60
|
+
*
|
|
61
|
+
* As conferências ÓBVIAS não servem, e é por isso que o vínculo existe:
|
|
62
|
+
* - `sub === sub do ator`: durante uma impersonação legítima a sessão é a do
|
|
63
|
+
* ALVO, então isso nunca é verdade — trancaria o ator dentro da impersonação;
|
|
64
|
+
* - "o token parqueado é de um admin?": sempre verdade; o atacante não alega
|
|
65
|
+
* ser o ator, ele pede a credencial do ator;
|
|
66
|
+
* - gate por papel: o ator impersonando carrega os papéis do ALVO nesse momento.
|
|
67
|
+
*
|
|
68
|
+
* Recusa é FAIL-CLOSED: a credencial órfã é DESCARTADA (nunca devolvida) e o
|
|
69
|
+
* token set da sessão também cai — uma recusa não é uma tentativa a repetir, e
|
|
70
|
+
* ninguém fica preso impersonando.
|
|
71
|
+
*
|
|
72
|
+
* LIMITE HONESTO: quem controla o cookie jar DURANTE uma impersonação ativa é
|
|
73
|
+
* indistinguível do ator — é literalmente a mesma sessão, e o vínculo viaja nela.
|
|
74
|
+
* Isso é o modelo de sessão, não esta conferência. O que fecha é a janela em que
|
|
75
|
+
* a credencial sobrevive à sessão que a guardou.
|
|
76
|
+
*
|
|
77
|
+
* @returns `true` só quando o ator foi restaurado.
|
|
78
|
+
*/
|
|
47
79
|
stopImpersonating(ctx: HttpContext): Promise<boolean>;
|
|
48
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Indica se a request corrente está impersonando (p/ exibir banner na UI). Exige o
|
|
82
|
+
* mesmo vínculo do `stopImpersonating` — uma credencial órfã de outra sessão não
|
|
83
|
+
* faz o banner aparecer para quem não está impersonando ninguém.
|
|
84
|
+
*/
|
|
49
85
|
isImpersonating(ctx: HttpContext): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Encerra a sessão do AuthKit: remove o token set E qualquer credencial de
|
|
88
|
+
* impersonação parqueada. Todo caminho da lib que derruba a sessão passa por aqui
|
|
89
|
+
* — logout RP-initiated (`registerOidcClient`) e revogação back-channel.
|
|
90
|
+
*
|
|
91
|
+
* Existe porque um `session.forget(sessionKey)` solto deixava o refresh token do
|
|
92
|
+
* ATOR parqueado no browser depois de uma revogação central — o mecanismo que o
|
|
93
|
+
* operador usa justamente para matar a sessão.
|
|
94
|
+
*/
|
|
95
|
+
endSession(ctx: HttpContext): void;
|
|
96
|
+
/**
|
|
97
|
+
* Instala o token set de um login recém-concluído, descartando o que sobrou da
|
|
98
|
+
* sessão anterior no mesmo cookie jar.
|
|
99
|
+
*
|
|
100
|
+
* O `stopImpersonating` já recusa uma credencial órfã — o token set novo nasce sem
|
|
101
|
+
* `impersonationBinding`, então o vínculo não fecha. Mas recusar é só não DEVOLVER:
|
|
102
|
+
* sem esta limpeza o refresh token do ator continua serializado no cookie de sessão
|
|
103
|
+
* de outra identidade até a sessão expirar. Não devolver e não guardar são coisas
|
|
104
|
+
* diferentes, e a segunda é a que mantém a invariante — a credencial parqueada nunca
|
|
105
|
+
* sobrevive à sessão que a parqueou.
|
|
106
|
+
*/
|
|
107
|
+
startSession(ctx: HttpContext, tokenSet: TokenSet): void;
|
|
50
108
|
createAuthenticator(ctx: HttpContext): Promise<Authenticator>;
|
|
51
109
|
}
|
|
52
110
|
declare module '@adonisjs/core/types' {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { configProvider } from '@adonisjs/core';
|
|
2
3
|
import { RuntimeException } from '@adonisjs/core/exceptions';
|
|
3
4
|
import { Authenticator } from '../src/authenticator.js';
|
|
@@ -5,6 +6,17 @@ import { validateLogoutToken } from '../src/backchannel_logout.js';
|
|
|
5
6
|
import { exchangeToken, refreshTokens } from '../src/oidc_login.js';
|
|
6
7
|
/** Margem (ms) antes do `expiresAt` em que o access token é renovado proativamente. */
|
|
7
8
|
const REFRESH_SKEW_MS = 60_000;
|
|
9
|
+
/** Aceita só o formato COM vínculo; o formato legado (TokenSet cru) vira `null`. */
|
|
10
|
+
function readParked(value) {
|
|
11
|
+
if (!value || typeof value !== 'object')
|
|
12
|
+
return null;
|
|
13
|
+
const candidate = value;
|
|
14
|
+
if (typeof candidate.binding !== 'string' || !candidate.binding)
|
|
15
|
+
return null;
|
|
16
|
+
if (!candidate.tokenSet || typeof candidate.tokenSet !== 'object')
|
|
17
|
+
return null;
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
8
20
|
export class AuthkitClientManager {
|
|
9
21
|
config;
|
|
10
22
|
#resolverCache;
|
|
@@ -40,6 +52,9 @@ export class AuthkitClientManager {
|
|
|
40
52
|
* renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
|
|
41
53
|
* o id_token / refresh_token anteriores quando o IdP não os reemite.
|
|
42
54
|
*
|
|
55
|
+
* Preserva TAMBÉM o `impersonationBinding`: renovar o token do alvo não muda a
|
|
56
|
+
* sessão, então não pode invalidar o vínculo e trancar o ator na impersonação.
|
|
57
|
+
*
|
|
43
58
|
* Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
|
|
44
59
|
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
|
|
45
60
|
*/
|
|
@@ -68,6 +83,7 @@ export class AuthkitClientManager {
|
|
|
68
83
|
accessToken: next.accessToken,
|
|
69
84
|
refreshToken: next.refreshToken ?? tokenSet.refreshToken,
|
|
70
85
|
expiresAt: next.expiresAt,
|
|
86
|
+
impersonationBinding: tokenSet.impersonationBinding,
|
|
71
87
|
});
|
|
72
88
|
}
|
|
73
89
|
catch {
|
|
@@ -140,24 +156,113 @@ export class AuthkitClientManager {
|
|
|
140
156
|
requestedSubject,
|
|
141
157
|
resilience: this.config.resilience,
|
|
142
158
|
});
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
// Vínculo: o mesmo nonce nos dois lados. O lado ATIVO mora dentro do token set
|
|
160
|
+
// da sessão, o único slot que todo caminho de fim-de-sessão já limpa.
|
|
161
|
+
const binding = randomUUID();
|
|
162
|
+
session.put(this.#impersonatorKey, {
|
|
163
|
+
tokenSet: current,
|
|
164
|
+
binding,
|
|
165
|
+
});
|
|
166
|
+
session.put(this.config.sessionKey, { ...impersonated, impersonationBinding: binding });
|
|
145
167
|
}
|
|
146
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Encerra a impersonação restaurando o token set do ator — SE a sessão que pede
|
|
170
|
+
* ainda for aquela para a qual a credencial foi guardada.
|
|
171
|
+
*
|
|
172
|
+
* A credencial parqueada é um refresh token vivo; sem essa conferência o restore
|
|
173
|
+
* a entrega a quem estiver de posse do cookie jar depois (logout do ator + login
|
|
174
|
+
* de outra identidade). A prova é o `impersonationBinding`: um nonce escrito ao
|
|
175
|
+
* mesmo tempo dentro do token set ATIVO e do registro parqueado. Só `impersonate()`
|
|
176
|
+
* o escreve e só o `maybeRefresh` o propaga — qualquer troca de identidade
|
|
177
|
+
* reescreve o slot da sessão e o vínculo some.
|
|
178
|
+
*
|
|
179
|
+
* As conferências ÓBVIAS não servem, e é por isso que o vínculo existe:
|
|
180
|
+
* - `sub === sub do ator`: durante uma impersonação legítima a sessão é a do
|
|
181
|
+
* ALVO, então isso nunca é verdade — trancaria o ator dentro da impersonação;
|
|
182
|
+
* - "o token parqueado é de um admin?": sempre verdade; o atacante não alega
|
|
183
|
+
* ser o ator, ele pede a credencial do ator;
|
|
184
|
+
* - gate por papel: o ator impersonando carrega os papéis do ALVO nesse momento.
|
|
185
|
+
*
|
|
186
|
+
* Recusa é FAIL-CLOSED: a credencial órfã é DESCARTADA (nunca devolvida) e o
|
|
187
|
+
* token set da sessão também cai — uma recusa não é uma tentativa a repetir, e
|
|
188
|
+
* ninguém fica preso impersonando.
|
|
189
|
+
*
|
|
190
|
+
* LIMITE HONESTO: quem controla o cookie jar DURANTE uma impersonação ativa é
|
|
191
|
+
* indistinguível do ator — é literalmente a mesma sessão, e o vínculo viaja nela.
|
|
192
|
+
* Isso é o modelo de sessão, não esta conferência. O que fecha é a janela em que
|
|
193
|
+
* a credencial sobrevive à sessão que a guardou.
|
|
194
|
+
*
|
|
195
|
+
* @returns `true` só quando o ator foi restaurado.
|
|
196
|
+
*/
|
|
147
197
|
async stopImpersonating(ctx) {
|
|
148
198
|
const session = ctx.session;
|
|
149
199
|
if (!session)
|
|
150
200
|
return false;
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
201
|
+
const raw = session.get(this.#impersonatorKey);
|
|
202
|
+
if (raw === undefined || raw === null)
|
|
203
|
+
return false;
|
|
204
|
+
const parked = readParked(raw);
|
|
205
|
+
const live = session.get(this.config.sessionKey);
|
|
206
|
+
// `parked === null` cobre o formato pré-0.15 (TokenSet cru, sem vínculo nenhum):
|
|
207
|
+
// é justamente a credencial que não dá pra atribuir. Recusada como qualquer outra.
|
|
208
|
+
if (!parked || !live?.impersonationBinding || live.impersonationBinding !== parked.binding) {
|
|
209
|
+
session.forget(this.#impersonatorKey);
|
|
210
|
+
session.forget(this.config.sessionKey);
|
|
153
211
|
return false;
|
|
212
|
+
}
|
|
213
|
+
const { impersonationBinding: _binding, ...original } = parked.tokenSet;
|
|
154
214
|
session.put(this.config.sessionKey, original);
|
|
155
215
|
session.forget(this.#impersonatorKey);
|
|
156
216
|
return true;
|
|
157
217
|
}
|
|
158
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Indica se a request corrente está impersonando (p/ exibir banner na UI). Exige o
|
|
220
|
+
* mesmo vínculo do `stopImpersonating` — uma credencial órfã de outra sessão não
|
|
221
|
+
* faz o banner aparecer para quem não está impersonando ninguém.
|
|
222
|
+
*/
|
|
159
223
|
isImpersonating(ctx) {
|
|
160
|
-
|
|
224
|
+
const session = ctx.session;
|
|
225
|
+
if (!session)
|
|
226
|
+
return false;
|
|
227
|
+
const parked = readParked(session.get(this.#impersonatorKey));
|
|
228
|
+
if (!parked)
|
|
229
|
+
return false;
|
|
230
|
+
const live = session.get(this.config.sessionKey);
|
|
231
|
+
return live?.impersonationBinding === parked.binding;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Encerra a sessão do AuthKit: remove o token set E qualquer credencial de
|
|
235
|
+
* impersonação parqueada. Todo caminho da lib que derruba a sessão passa por aqui
|
|
236
|
+
* — logout RP-initiated (`registerOidcClient`) e revogação back-channel.
|
|
237
|
+
*
|
|
238
|
+
* Existe porque um `session.forget(sessionKey)` solto deixava o refresh token do
|
|
239
|
+
* ATOR parqueado no browser depois de uma revogação central — o mecanismo que o
|
|
240
|
+
* operador usa justamente para matar a sessão.
|
|
241
|
+
*/
|
|
242
|
+
endSession(ctx) {
|
|
243
|
+
const session = ctx.session;
|
|
244
|
+
if (!session)
|
|
245
|
+
return;
|
|
246
|
+
session.forget(this.config.sessionKey);
|
|
247
|
+
session.forget(this.#impersonatorKey);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Instala o token set de um login recém-concluído, descartando o que sobrou da
|
|
251
|
+
* sessão anterior no mesmo cookie jar.
|
|
252
|
+
*
|
|
253
|
+
* O `stopImpersonating` já recusa uma credencial órfã — o token set novo nasce sem
|
|
254
|
+
* `impersonationBinding`, então o vínculo não fecha. Mas recusar é só não DEVOLVER:
|
|
255
|
+
* sem esta limpeza o refresh token do ator continua serializado no cookie de sessão
|
|
256
|
+
* de outra identidade até a sessão expirar. Não devolver e não guardar são coisas
|
|
257
|
+
* diferentes, e a segunda é a que mantém a invariante — a credencial parqueada nunca
|
|
258
|
+
* sobrevive à sessão que a parqueou.
|
|
259
|
+
*/
|
|
260
|
+
startSession(ctx, tokenSet) {
|
|
261
|
+
const session = ctx.session;
|
|
262
|
+
if (!session)
|
|
263
|
+
return;
|
|
264
|
+
session.forget(this.#impersonatorKey);
|
|
265
|
+
session.put(this.config.sessionKey, tokenSet);
|
|
161
266
|
}
|
|
162
267
|
async createAuthenticator(ctx) {
|
|
163
268
|
const resolver = await this.#getResolver();
|
|
@@ -17,7 +17,6 @@ export default class BackchannelRevocationMiddleware {
|
|
|
17
17
|
const store = manager.clientConfig.backchannelStore;
|
|
18
18
|
if (!store)
|
|
19
19
|
return next();
|
|
20
|
-
const sessionKey = manager.clientConfig.sessionKey;
|
|
21
20
|
const idToken = manager.getIdToken(ctx);
|
|
22
21
|
if (idToken) {
|
|
23
22
|
const claims = decodeJwtPayload(idToken);
|
|
@@ -25,8 +24,9 @@ export default class BackchannelRevocationMiddleware {
|
|
|
25
24
|
const sub = typeof claims?.sub === 'string' ? claims.sub : undefined;
|
|
26
25
|
const authTime = typeof claims?.iat === 'number' ? claims.iat : undefined;
|
|
27
26
|
if ((sid || sub) && (await store.isRevoked({ sid, sub, authTime }))) {
|
|
28
|
-
//
|
|
29
|
-
|
|
27
|
+
// endSession, não `forget(sessionKey)`: uma revogação central não pode deixar
|
|
28
|
+
// a credencial de impersonação (refresh token do ATOR) parqueada no browser.
|
|
29
|
+
manager.endSession(ctx);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
return next();
|
|
@@ -63,7 +63,9 @@ export function registerOidcClient(router, options = {}) {
|
|
|
63
63
|
code,
|
|
64
64
|
codeVerifier: pkce.verifier,
|
|
65
65
|
});
|
|
66
|
-
|
|
66
|
+
// startSession, não `put(sessionKey)`: um login novo no mesmo cookie jar não pode
|
|
67
|
+
// herdar a credencial de impersonação parqueada pela sessão anterior.
|
|
68
|
+
manager.startSession(ctx, tokenSet);
|
|
67
69
|
ctx.session?.forget(pkceKey);
|
|
68
70
|
const authenticator = await manager.createAuthenticator(ctx);
|
|
69
71
|
const identity = await authenticator.getIdentity();
|
|
@@ -81,7 +83,9 @@ export function registerOidcClient(router, options = {}) {
|
|
|
81
83
|
const manager = await ctx.containerResolver.make('authkit.client');
|
|
82
84
|
const cfg = manager.clientConfig;
|
|
83
85
|
const idToken = manager.getIdToken(ctx);
|
|
84
|
-
|
|
86
|
+
// Leva junto a credencial de impersonação parqueada — era este logout que a
|
|
87
|
+
// deixava para trás, viva, para a próxima identidade do mesmo cookie jar.
|
|
88
|
+
manager.endSession(ctx);
|
|
85
89
|
const postLogoutRedirectUri = typeof options.postLogoutRedirect === 'function'
|
|
86
90
|
? options.postLogoutRedirect(ctx)
|
|
87
91
|
: (options.postLogoutRedirect ?? `${new URL(cfg.redirectUri).origin}/`);
|
package/build/src/types.d.ts
CHANGED
|
@@ -3,4 +3,13 @@ export interface TokenSet {
|
|
|
3
3
|
accessToken: string;
|
|
4
4
|
refreshToken?: string;
|
|
5
5
|
expiresAt?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Nonce opaco que amarra ESTE token set à credencial de impersonação parqueada
|
|
8
|
+
* em `${sessionKey}_impersonator`. Escrito só por `impersonate()`; preservado
|
|
9
|
+
* pelo `maybeRefresh`; conferido pelo `stopImpersonating()`. Qualquer outra
|
|
10
|
+
* escrita no slot da sessão (login novo, logout+login) nasce sem ele — que é
|
|
11
|
+
* exatamente o sinal de que a sessão deixou de ser aquela p/ a qual a
|
|
12
|
+
* credencial foi guardada. Não é segredo de autenticação: é um vínculo.
|
|
13
|
+
*/
|
|
14
|
+
impersonationBinding?: string;
|
|
6
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 relying-party (client) toolkit: session-based authentication against an OpenID Connect identity provider, with JWT/PAT user resolvers and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|