@adonis-agora/authkit-server 0.68.2 → 0.68.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/host/active_org_cookie.d.ts +21 -13
- package/build/src/host/active_org_cookie.js +78 -27
- package/build/src/provider/build_provider.js +7 -1
- package/build/src/provider/interaction_actions.d.ts +6 -0
- package/build/src/provider/interaction_actions.js +1 -1
- package/build/src/provider/oidc_service.js +1 -0
- package/package.json +1 -1
|
@@ -16,16 +16,22 @@ export declare function encodeActiveOrgCookie(info: ActiveOrgInfo): string;
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function decodeActiveOrgCookie(value: string | null | undefined): ActiveOrgInfo | null;
|
|
18
18
|
/**
|
|
19
|
-
* Lê a org ativa de um contexto Koa (oidc-provider).
|
|
20
|
-
* das `cookieKeys` para assinar os cookies — lemos via `ctx.cookies.get(name, { signed: false })`
|
|
21
|
-
* (o oidc-provider não assina cookies da aplicação; apenas verifica os seus). A
|
|
22
|
-
* validação de assinatura para este cookie de aplicação é feita no controller AdonisJS
|
|
23
|
-
* ao gravar (via `ctx.response.cookie` com `signed: true`). Aqui fazemos best-effort:
|
|
24
|
-
* se o valor estiver presente e parseable, usamos; caso contrário retorna null.
|
|
19
|
+
* Lê a org ativa de um contexto Koa (oidc-provider).
|
|
25
20
|
*
|
|
26
|
-
*
|
|
21
|
+
* O cookie NÃO pode ser lido como "cru": num app Adonis com assinatura de cookie
|
|
22
|
+
* ligada ele chega como `s:<b64>.<hmac>` e precisa ser verificado. Passamos
|
|
23
|
+
* `appKey` e verificamos a assinatura — sem ela o valor é recusado.
|
|
24
|
+
*
|
|
25
|
+
* Por que existe: `loadExistingGrant` roda no authorize (request do browser, com o
|
|
26
|
+
* cookie) e é quem reconcilia o Grant reaproveitado depois que o usuário troca de
|
|
27
|
+
* organização. O consent também grava a org, mas ele só roda UMA vez por grant —
|
|
28
|
+
* sem esta leitura, quem ativa a org depois do primeiro login nunca recebe a claim.
|
|
29
|
+
*
|
|
30
|
+
* NOTA: `ctx.cookies.get()` nunca lança — retorna null se ausente.
|
|
27
31
|
*/
|
|
28
|
-
export declare function readActiveOrgFromKoaCtx(koaCtx: any
|
|
32
|
+
export declare function readActiveOrgFromKoaCtx(koaCtx: any, opts?: {
|
|
33
|
+
appKey?: string;
|
|
34
|
+
}): ActiveOrgInfo | null;
|
|
29
35
|
/**
|
|
30
36
|
* Normaliza um valor potencialmente vindo de um payload PERSISTIDO (o `activeOrg`
|
|
31
37
|
* do Grant) para `ActiveOrgInfo`. Retorna null quando a forma não bate — nunca
|
|
@@ -37,9 +43,11 @@ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null
|
|
|
37
43
|
* AdonisJS usado pelas `InteractionActions`).
|
|
38
44
|
*
|
|
39
45
|
* No consent a request É do browser, então o cookie está presente — ao contrário
|
|
40
|
-
* do mint do id_token no `/token` (server-a-servidor, sem cookies).
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
43
|
-
*
|
|
46
|
+
* do mint do id_token no `/token` (server-a-servidor, sem cookies). Aqui o
|
|
47
|
+
* `request.cookie` do AdonisJS já desassina o cookie (quando o host assina), então
|
|
48
|
+
* o valor chega em texto; `appKey` cobre o fallback Koa, caso o ctx recebido seja
|
|
49
|
+
* um ctx Koa e não o HttpContext.
|
|
44
50
|
*/
|
|
45
|
-
export declare function readActiveOrgFromHostCtx(ctx: unknown
|
|
51
|
+
export declare function readActiveOrgFromHostCtx(ctx: unknown, opts?: {
|
|
52
|
+
appKey?: string;
|
|
53
|
+
}): ActiveOrgInfo | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
/** Nome do cookie da org ativa. HttpOnly, SameSite=Lax, Secure em prod. */
|
|
2
3
|
export const ACTIVE_ORG_COOKIE = 'authkit_active_org';
|
|
3
4
|
/** TTL máximo do cookie da org ativa (30 dias em segundos). */
|
|
@@ -27,40 +28,90 @@ export function decodeActiveOrgCookie(value) {
|
|
|
27
28
|
return { orgId, orgSlug, orgRole };
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
+
* Desfaz o envelope de cookie ASSINADO do AdonisJS: `s:<base64url>.<hmac>`.
|
|
32
|
+
*
|
|
33
|
+
* O host grava o cookie via `ctx.response.cookie`, e o AdonisJS assina com o
|
|
34
|
+
* `MessageVerifier` (`@boringnode/encryption`): HMAC-SHA256 sobre o base64url do
|
|
35
|
+
* payload, com a chave derivada de `sha256(appKey)`, e `purpose` = NOME do cookie.
|
|
36
|
+
* O `message` do payload é o valor.
|
|
31
37
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Tentamos o valor cru primeiro (hosts que gravem sem encode) e o decodificado depois.
|
|
38
|
+
* Verificamos a assinatura — não basta decodificar. Este cookie decide a claim de
|
|
39
|
+
* organização, então aceitar um valor não assinado deixaria qualquer usuário trocar
|
|
40
|
+
* de tenant forjando o cookie. Sem `appKey` não há como verificar: devolve null.
|
|
36
41
|
*/
|
|
37
|
-
function
|
|
38
|
-
if (
|
|
42
|
+
function unsignAdonisCookie(signedRaw, appKey, purpose) {
|
|
43
|
+
if (!signedRaw.startsWith('s:'))
|
|
44
|
+
return null;
|
|
45
|
+
const [encoded, hash] = signedRaw.slice(2).split('.');
|
|
46
|
+
if (!encoded || !hash)
|
|
47
|
+
return null;
|
|
48
|
+
const key = createHash('sha256').update(appKey).digest();
|
|
49
|
+
const expected = createHmac('sha256', key).update(encoded).digest('base64url');
|
|
50
|
+
const a = Buffer.from(expected);
|
|
51
|
+
const b = Buffer.from(hash);
|
|
52
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
39
53
|
return null;
|
|
40
|
-
const direct = decodeActiveOrgCookie(raw);
|
|
41
|
-
if (direct)
|
|
42
|
-
return direct;
|
|
43
54
|
try {
|
|
44
|
-
|
|
55
|
+
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
56
|
+
if (payload?.purpose !== purpose)
|
|
57
|
+
return null;
|
|
58
|
+
return typeof payload.message === 'string' ? payload.message : null;
|
|
45
59
|
}
|
|
46
60
|
catch {
|
|
47
61
|
return null;
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
64
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
65
|
+
* Parseia um valor de cookie possivelmente URL-encoded.
|
|
66
|
+
*
|
|
67
|
+
* Aceita três formas, nesta ordem:
|
|
68
|
+
* 1. **Assinado pelo Adonis** (`s:<b64>.<hmac>`), quando `appKey` é conhecida —
|
|
69
|
+
* verificado com `unsignAdonisCookie`. É a forma real quando o host grava via
|
|
70
|
+
* `ctx.response.cookie` num app com assinatura de cookie ligada.
|
|
71
|
+
* 2. Valor cru (hosts que gravem sem encode e sem assinatura).
|
|
72
|
+
*/
|
|
73
|
+
function parseActiveOrgCookieValue(raw, appKey) {
|
|
74
|
+
if (typeof raw !== 'string' || !raw)
|
|
75
|
+
return null;
|
|
76
|
+
// O jar Koa devolve o valor COMO ESTÁ no header, sem URL-decode — e o browser
|
|
77
|
+
// reenvia o cookie exatamente como o host o escreveu, isto é `s%3A<b64>.<hmac>`
|
|
78
|
+
// quando ele é assinado. Normalizar ANTES de decidir o formato é obrigatório:
|
|
79
|
+
// checar `startsWith('s:')` no valor cru nunca casaria.
|
|
80
|
+
let value = raw;
|
|
81
|
+
try {
|
|
82
|
+
const decoded = decodeURIComponent(raw);
|
|
83
|
+
if (decoded)
|
|
84
|
+
value = decoded;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// não era URL-encoded; segue com o cru
|
|
88
|
+
}
|
|
89
|
+
if (value.startsWith('s:')) {
|
|
90
|
+
if (!appKey)
|
|
91
|
+
return null;
|
|
92
|
+
const unsigned = unsignAdonisCookie(value, appKey, ACTIVE_ORG_COOKIE);
|
|
93
|
+
return unsigned ? decodeActiveOrgCookie(unsigned) : null;
|
|
94
|
+
}
|
|
95
|
+
return decodeActiveOrgCookie(value);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Lê a org ativa de um contexto Koa (oidc-provider).
|
|
99
|
+
*
|
|
100
|
+
* O cookie NÃO pode ser lido como "cru": num app Adonis com assinatura de cookie
|
|
101
|
+
* ligada ele chega como `s:<b64>.<hmac>` e precisa ser verificado. Passamos
|
|
102
|
+
* `appKey` e verificamos a assinatura — sem ela o valor é recusado.
|
|
103
|
+
*
|
|
104
|
+
* Por que existe: `loadExistingGrant` roda no authorize (request do browser, com o
|
|
105
|
+
* cookie) e é quem reconcilia o Grant reaproveitado depois que o usuário troca de
|
|
106
|
+
* organização. O consent também grava a org, mas ele só roda UMA vez por grant —
|
|
107
|
+
* sem esta leitura, quem ativa a org depois do primeiro login nunca recebe a claim.
|
|
57
108
|
*
|
|
58
|
-
* NOTA:
|
|
109
|
+
* NOTA: `ctx.cookies.get()` nunca lança — retorna null se ausente.
|
|
59
110
|
*/
|
|
60
|
-
export function readActiveOrgFromKoaCtx(koaCtx) {
|
|
111
|
+
export function readActiveOrgFromKoaCtx(koaCtx, opts) {
|
|
61
112
|
try {
|
|
62
113
|
const raw = koaCtx?.cookies?.get?.(ACTIVE_ORG_COOKIE, { signed: false });
|
|
63
|
-
return parseActiveOrgCookieValue(raw);
|
|
114
|
+
return parseActiveOrgCookieValue(raw, opts?.appKey);
|
|
64
115
|
}
|
|
65
116
|
catch {
|
|
66
117
|
return null;
|
|
@@ -90,20 +141,20 @@ export function normalizeActiveOrg(value) {
|
|
|
90
141
|
* AdonisJS usado pelas `InteractionActions`).
|
|
91
142
|
*
|
|
92
143
|
* No consent a request É do browser, então o cookie está presente — ao contrário
|
|
93
|
-
* do mint do id_token no `/token` (server-a-servidor, sem cookies).
|
|
94
|
-
*
|
|
95
|
-
* `
|
|
96
|
-
*
|
|
144
|
+
* do mint do id_token no `/token` (server-a-servidor, sem cookies). Aqui o
|
|
145
|
+
* `request.cookie` do AdonisJS já desassina o cookie (quando o host assina), então
|
|
146
|
+
* o valor chega em texto; `appKey` cobre o fallback Koa, caso o ctx recebido seja
|
|
147
|
+
* um ctx Koa e não o HttpContext.
|
|
97
148
|
*/
|
|
98
|
-
export function readActiveOrgFromHostCtx(ctx) {
|
|
149
|
+
export function readActiveOrgFromHostCtx(ctx, opts) {
|
|
99
150
|
try {
|
|
100
151
|
const raw = ctx?.request?.cookie?.(ACTIVE_ORG_COOKIE);
|
|
101
|
-
const parsed = parseActiveOrgCookieValue(raw);
|
|
152
|
+
const parsed = parseActiveOrgCookieValue(raw, opts?.appKey);
|
|
102
153
|
if (parsed)
|
|
103
154
|
return parsed;
|
|
104
155
|
}
|
|
105
156
|
catch {
|
|
106
157
|
// contexto sem `request.cookie` — tenta o caminho Koa abaixo
|
|
107
158
|
}
|
|
108
|
-
return readActiveOrgFromKoaCtx(ctx);
|
|
159
|
+
return readActiveOrgFromKoaCtx(ctx, opts);
|
|
109
160
|
}
|
|
@@ -133,7 +133,13 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
133
133
|
const grant = await ctx.oidc.provider.Grant.find(grantId);
|
|
134
134
|
if (!grant)
|
|
135
135
|
return undefined;
|
|
136
|
-
|
|
136
|
+
// `appKey` é obrigatória aqui: o host grava o cookie de org via
|
|
137
|
+
// `ctx.response.cookie` e o Adonis o ASSINA (`s:<b64>.<hmac>`). Sem a chave
|
|
138
|
+
// não há como verificar a assinatura e o valor é recusado — foi assim que a
|
|
139
|
+
// org deixou de chegar ao token quando o usuário ativava a org DEPOIS do
|
|
140
|
+
// primeiro login (o consent só roda uma vez por grant; quem reconcilia é
|
|
141
|
+
// este hook, e ele roda no ctx Koa).
|
|
142
|
+
const activeOrg = readActiveOrgFromKoaCtx(ctx, { appKey: options.appKey });
|
|
137
143
|
const current = normalizeActiveOrg(grant.activeOrg);
|
|
138
144
|
const changed = (activeOrg?.orgId ?? null) !== (current?.orgId ?? null) ||
|
|
139
145
|
(activeOrg?.orgSlug ?? null) !== (current?.orgSlug ?? null) ||
|
|
@@ -3,6 +3,12 @@ export interface InteractionDeps {
|
|
|
3
3
|
verifyCredentials?: (email: string, password: string) => Promise<{
|
|
4
4
|
id: string;
|
|
5
5
|
} | null>;
|
|
6
|
+
/**
|
|
7
|
+
* Chave do app, usada para VERIFICAR o cookie de org ativa quando o ctx recebido
|
|
8
|
+
* é um Koa ctx (o caminho Adonis já desassina sozinho). Sem ela, um cookie
|
|
9
|
+
* assinado é recusado — e a org não entra no Grant.
|
|
10
|
+
*/
|
|
11
|
+
appKey?: string;
|
|
6
12
|
}
|
|
7
13
|
/** Detalhes opcionais de login (step-up auth): acr alcançado + amr (métodos). */
|
|
8
14
|
export interface CompleteLoginExtra {
|
|
@@ -56,7 +56,7 @@ export function createInteractionActions(provider, deps) {
|
|
|
56
56
|
// disponível AQUI — ao contrário do mint do id_token no /token, que é
|
|
57
57
|
// server-a-servidor e não carrega os cookies do usuário. Persistimos a org
|
|
58
58
|
// no Grant para que o fluxo authorization code volte a emitir org_*.
|
|
59
|
-
const activeOrg = readActiveOrgFromHostCtx(ctx);
|
|
59
|
+
const activeOrg = readActiveOrgFromHostCtx(ctx, { appKey: deps.appKey });
|
|
60
60
|
const grant = new provider.Grant({
|
|
61
61
|
accountId: details.session.accountId,
|
|
62
62
|
clientId: details.params.client_id,
|
|
@@ -190,6 +190,7 @@ export class OidcService {
|
|
|
190
190
|
}
|
|
191
191
|
const interactions = createInteractionActions(provider, {
|
|
192
192
|
verifyCredentials: config.verifyCredentials,
|
|
193
|
+
appKey: this.#appKey,
|
|
193
194
|
});
|
|
194
195
|
// Atribuição atômica no final: um throw antes deste ponto não corrompe o estado.
|
|
195
196
|
this.#provider = provider;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.68.
|
|
3
|
+
"version": "0.68.4",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|