@adonis-agora/authkit-server 0.76.0 → 0.78.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 +149 -0
- package/build/host/views/agents/consent.edge +13 -2
- package/build/host/views/agents/done.edge +3 -0
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/index.d.ts +19 -1
- package/build/index.js +13 -1
- package/build/providers/authkit_server_provider.js +15 -1
- package/build/src/accounts/account_store.d.ts +2 -0
- package/build/src/accounts/lucid_account_store.js +2 -0
- package/build/src/adapters/adapter_contract.d.ts +2 -0
- package/build/src/adapters/database_adapter.d.ts +1 -0
- package/build/src/adapters/database_adapter.js +26 -0
- package/build/src/adapters/redis_adapter.d.ts +1 -0
- package/build/src/adapters/redis_adapter.js +30 -0
- package/build/src/agents/config.d.ts +20 -3
- package/build/src/agents/config.js +19 -4
- package/build/src/agents/delegation_service.js +18 -8
- package/build/src/agents/middleware.js +2 -1
- package/build/src/agents/poppy/client_registry.d.ts +45 -0
- package/build/src/agents/poppy/client_registry.js +226 -0
- package/build/src/agents/poppy/config.d.ts +362 -0
- package/build/src/agents/poppy/config.js +201 -0
- package/build/src/agents/poppy/dpop.d.ts +61 -0
- package/build/src/agents/poppy/dpop.js +158 -0
- package/build/src/agents/poppy/errors.d.ts +20 -0
- package/build/src/agents/poppy/errors.js +22 -0
- package/build/src/agents/poppy/replay.d.ts +25 -0
- package/build/src/agents/poppy/replay.js +106 -0
- package/build/src/agents/poppy/resource.d.ts +95 -0
- package/build/src/agents/poppy/resource.js +125 -0
- package/build/src/agents/poppy/safe_fetch.d.ts +48 -0
- package/build/src/agents/poppy/safe_fetch.js +256 -0
- package/build/src/agents/poppy/service.d.ts +280 -0
- package/build/src/agents/poppy/service.js +1452 -0
- package/build/src/agents/poppy/store.d.ts +109 -0
- package/build/src/agents/poppy/store.js +260 -0
- package/build/src/agents/poppy/web_session.d.ts +32 -0
- package/build/src/agents/poppy/web_session.js +104 -0
- package/build/src/agents/protocol.d.ts +8 -4
- package/build/src/agents/runtime.d.ts +17 -1
- package/build/src/agents/runtime.js +138 -13
- package/build/src/define_config.d.ts +24 -0
- package/build/src/define_config.js +14 -0
- package/build/src/host/account_api/account_api_controller.d.ts +12 -1
- package/build/src/host/account_api/account_api_controller.js +10 -5
- package/build/src/host/admin_sessions_service.js +8 -5
- package/build/src/host/auth_host_config.d.ts +2 -1
- package/build/src/host/controllers/account_apps_controller.js +13 -3
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +352 -24
- package/build/src/host/controllers/poppy_consent_controller.d.ts +19 -0
- package/build/src/host/controllers/poppy_consent_controller.js +173 -0
- package/build/src/host/controllers/poppy_controller.d.ts +25 -0
- package/build/src/host/controllers/poppy_controller.js +194 -0
- package/build/src/host/csrf.d.ts +7 -0
- package/build/src/host/csrf.js +16 -1
- package/build/src/host/custom_login.d.ts +36 -0
- package/build/src/host/custom_login.js +76 -0
- package/build/src/host/custom_login_completion.d.ts +4 -0
- package/build/src/host/custom_login_completion.js +33 -0
- package/build/src/host/custom_mfa.d.ts +77 -0
- package/build/src/host/custom_mfa.js +268 -0
- package/build/src/host/i18n.d.ts +8 -0
- package/build/src/host/i18n.js +14 -6
- package/build/src/host/idp_session_bridge.d.ts +5 -3
- package/build/src/host/idp_session_bridge.js +5 -3
- package/build/src/host/oidc_rp_guard.d.ts +5 -1
- package/build/src/host/oidc_rp_guard.js +15 -2
- package/build/src/host/persistent_rp_session.d.ts +8 -0
- package/build/src/host/persistent_rp_session.js +74 -0
- package/build/src/host/poppy_session_key.d.ts +9 -0
- package/build/src/host/poppy_session_key.js +9 -0
- package/build/src/host/register_auth_host.js +72 -0
- package/build/src/host/whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_code_sender.js +9 -0
- package/build/src/host/whatsapp_senders/http.d.ts +5 -0
- package/build/src/host/whatsapp_senders/http.js +33 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
- package/build/src/provider/oidc_service.d.ts +5 -0
- package/build/src/provider/oidc_service.js +15 -4
- package/build/src/schema/ensure.js +124 -0
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verificação de provas DPoP (RFC 9449) — utilitário genérico para o token
|
|
3
|
+
* endpoint e para resource servers (§4.3 do Poppy).
|
|
4
|
+
*
|
|
5
|
+
* Confere: um único header `DPoP`; `typ` `dpop+jwt`; algoritmo permitido; `jwk`
|
|
6
|
+
* público no header e a assinatura com ele; `jti`, `htm`, `htu` (sem query nem
|
|
7
|
+
* fragmento), `iat` dentro da janela; `ath` = SHA-256 do access token; o
|
|
8
|
+
* `nonce` quando exigido; replay do `jti` na janela; e, num resource server, que
|
|
9
|
+
* a chave é a do token (`cnf.jkt`).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { calculateJwkThumbprint, decodeProtectedHeader, EmbeddedJWK, jwtVerify, } from 'jose';
|
|
13
|
+
import { replayKey } from './replay.js';
|
|
14
|
+
/**
|
|
15
|
+
* Nonces sem estado: `base64url(ts).base64url(hmac(ts))`, válidos por
|
|
16
|
+
* `ttlSeconds` (default 300). Qualquer instância com o mesmo segredo valida.
|
|
17
|
+
*
|
|
18
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
19
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
20
|
+
*/
|
|
21
|
+
export function hmacNonceSource(secret, options = {}) {
|
|
22
|
+
const ttl = (options.ttlSeconds ?? 300) * 1000;
|
|
23
|
+
const now = options.now ?? Date.now;
|
|
24
|
+
const mac = (ts) => createHmac('sha256', secret).update(`dpop-nonce:${ts}`).digest('base64url');
|
|
25
|
+
return {
|
|
26
|
+
issue() {
|
|
27
|
+
const ts = Buffer.from(String(now())).toString('base64url');
|
|
28
|
+
return `${ts}.${mac(ts)}`;
|
|
29
|
+
},
|
|
30
|
+
valid(nonce) {
|
|
31
|
+
const [ts, sig] = nonce.split('.');
|
|
32
|
+
if (!ts || !sig)
|
|
33
|
+
return false;
|
|
34
|
+
const expected = Buffer.from(mac(ts));
|
|
35
|
+
const given = Buffer.from(sig);
|
|
36
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
37
|
+
return false;
|
|
38
|
+
const issuedAt = Number(Buffer.from(ts, 'base64url').toString());
|
|
39
|
+
const t = now();
|
|
40
|
+
return Number.isFinite(issuedAt) && issuedAt <= t + 5000 && t - issuedAt <= ttl;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** `ath`: base64url(SHA-256(access token)). */
|
|
45
|
+
export function accessTokenHash(token) {
|
|
46
|
+
return createHash('sha256').update(token).digest('base64url');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `htu` normalizado para comparação: esquema e host minúsculos, sem porta
|
|
50
|
+
* default, sem query nem fragmento. `null` se não for URL http(s).
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeHtu(value) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(value);
|
|
55
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
56
|
+
return null;
|
|
57
|
+
url.hash = '';
|
|
58
|
+
url.search = '';
|
|
59
|
+
return url.toString();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function failure(error, description) {
|
|
66
|
+
return { ok: false, error, description };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
70
|
+
*/
|
|
71
|
+
export async function verifyDpopProof(input) {
|
|
72
|
+
const raw = input.proof;
|
|
73
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
74
|
+
return failure('invalid_dpop_proof', 'DPoP proof is missing');
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(raw) ? raw.length !== 1 : raw.includes(',')) {
|
|
77
|
+
return failure('invalid_dpop_proof', 'Exactly one DPoP proof is allowed');
|
|
78
|
+
}
|
|
79
|
+
const proof = Array.isArray(raw) ? raw[0] : raw;
|
|
80
|
+
let header;
|
|
81
|
+
try {
|
|
82
|
+
header = decodeProtectedHeader(proof);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return failure('invalid_dpop_proof', 'Malformed DPoP proof');
|
|
86
|
+
}
|
|
87
|
+
if (header.typ !== 'dpop+jwt')
|
|
88
|
+
return failure('invalid_dpop_proof', 'typ must be dpop+jwt');
|
|
89
|
+
if (!header.alg || !input.algorithms.includes(header.alg)) {
|
|
90
|
+
return failure('invalid_dpop_proof', 'Unsupported DPoP algorithm');
|
|
91
|
+
}
|
|
92
|
+
const jwk = header.jwk;
|
|
93
|
+
if (!jwk || typeof jwk !== 'object')
|
|
94
|
+
return failure('invalid_dpop_proof', 'jwk header is required');
|
|
95
|
+
if ('d' in jwk || jwk.kty === 'oct') {
|
|
96
|
+
return failure('invalid_dpop_proof', 'jwk must be a public key');
|
|
97
|
+
}
|
|
98
|
+
const nowMs = (input.now ?? Date.now)();
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
({ payload } = await jwtVerify(proof, EmbeddedJWK, {
|
|
102
|
+
algorithms: input.algorithms,
|
|
103
|
+
typ: 'dpop+jwt',
|
|
104
|
+
currentDate: new Date(nowMs),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return failure('invalid_dpop_proof', 'DPoP proof signature is invalid');
|
|
109
|
+
}
|
|
110
|
+
const { jti, htm, htu, iat, ath, nonce } = payload;
|
|
111
|
+
if (typeof jti !== 'string' || jti.length < 8 || jti.length > 256) {
|
|
112
|
+
return failure('invalid_dpop_proof', 'jti is required');
|
|
113
|
+
}
|
|
114
|
+
if (typeof htm !== 'string' || htm !== input.method.toUpperCase()) {
|
|
115
|
+
return failure('invalid_dpop_proof', 'htm does not match the request method');
|
|
116
|
+
}
|
|
117
|
+
const expectedHtu = normalizeHtu(input.url);
|
|
118
|
+
if (typeof htu !== 'string' || !expectedHtu || normalizeHtu(htu) !== expectedHtu) {
|
|
119
|
+
return failure('invalid_dpop_proof', 'htu does not match the request URL');
|
|
120
|
+
}
|
|
121
|
+
const nowS = Math.floor(nowMs / 1000);
|
|
122
|
+
const skew = input.clockSkew ?? 5;
|
|
123
|
+
if (typeof iat !== 'number' || iat > nowS + skew || iat < nowS - input.maxAge) {
|
|
124
|
+
return failure('invalid_dpop_proof', 'iat is outside the acceptance window');
|
|
125
|
+
}
|
|
126
|
+
if (input.accessToken !== undefined) {
|
|
127
|
+
if (typeof ath !== 'string' || ath !== accessTokenHash(input.accessToken)) {
|
|
128
|
+
return failure('invalid_dpop_proof', 'ath does not match the access token');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let jkt;
|
|
132
|
+
try {
|
|
133
|
+
jkt = await calculateJwkThumbprint(jwk, 'sha256');
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return failure('invalid_dpop_proof', 'Invalid jwk');
|
|
137
|
+
}
|
|
138
|
+
if (input.expectedJkt !== undefined && jkt !== input.expectedJkt) {
|
|
139
|
+
return failure('invalid_dpop_proof', 'DPoP key does not match the token binding');
|
|
140
|
+
}
|
|
141
|
+
if (input.nonce?.required) {
|
|
142
|
+
if (typeof nonce !== 'string' || !input.nonce.source.valid(nonce)) {
|
|
143
|
+
return failure('use_dpop_nonce', 'A DPoP nonce issued by the server is required');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (nonce !== undefined && input.nonce && typeof nonce === 'string') {
|
|
147
|
+
// Nonce mandado sem ser exigido: se vier, tem de ser um nosso e válido.
|
|
148
|
+
if (!input.nonce.source.valid(nonce)) {
|
|
149
|
+
return failure('use_dpop_nonce', 'The DPoP nonce is invalid or expired');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Replay por último: uma prova inválida não queima o `jti`.
|
|
153
|
+
const expiresAt = new Date((iat + input.maxAge + skew) * 1000);
|
|
154
|
+
if (!(await input.replay.claim(replayKey('dpop', jkt, jti), expiresAt))) {
|
|
155
|
+
return failure('invalid_dpop_proof', 'DPoP proof was already used');
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, jkt, jti };
|
|
158
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erros OAuth do Poppy (§4.2, §4.4, §4.6, RFC 6749 §5.2, RFC 8628 §3.5, RFC 9449)
|
|
3
|
+
* — viram `{ error, error_description }` com o status da spec.
|
|
4
|
+
*/
|
|
5
|
+
export type PoppyErrorCode = 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'invalid_session' | 'account_mismatch' | 'invalid_dpop_proof' | 'use_dpop_nonce' | 'rate_limited' | 'invalid_scope' | 'invalid_target' | 'unsupported_grant_type' | 'unsupported_token_type' | 'unauthorized_client' | 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token';
|
|
6
|
+
export declare class PoppyError extends Error {
|
|
7
|
+
readonly code: PoppyErrorCode;
|
|
8
|
+
readonly description: string;
|
|
9
|
+
readonly status: number;
|
|
10
|
+
/** Headers extras da resposta (`DPoP-Nonce`, `Retry-After`). */
|
|
11
|
+
readonly headers: Record<string, string>;
|
|
12
|
+
constructor(code: PoppyErrorCode, description: string, options?: {
|
|
13
|
+
status?: number;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
});
|
|
16
|
+
toJSON(): {
|
|
17
|
+
error: PoppyErrorCode;
|
|
18
|
+
error_description: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const STATUS = {
|
|
2
|
+
invalid_client: 401,
|
|
3
|
+
rate_limited: 429,
|
|
4
|
+
};
|
|
5
|
+
export class PoppyError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
description;
|
|
8
|
+
status;
|
|
9
|
+
/** Headers extras da resposta (`DPoP-Nonce`, `Retry-After`). */
|
|
10
|
+
headers;
|
|
11
|
+
constructor(code, description, options = {}) {
|
|
12
|
+
super(description);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.description = description;
|
|
15
|
+
this.name = 'PoppyError';
|
|
16
|
+
this.status = options.status ?? STATUS[code] ?? 400;
|
|
17
|
+
this.headers = options.headers ?? {};
|
|
18
|
+
}
|
|
19
|
+
toJSON() {
|
|
20
|
+
return { error: this.code, error_description: this.description };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PoppyLimiter, PoppyReplayStore } from './config.js';
|
|
2
|
+
export declare const JTI_TABLE = "auth_poppy_jtis";
|
|
3
|
+
/** Chave curta e de tamanho fixo para um `jti` de um tipo/agente. */
|
|
4
|
+
export declare function replayKey(kind: string, ...parts: string[]): string;
|
|
5
|
+
/** Em memória, por processo. Só serve com uma instância. */
|
|
6
|
+
export declare function memoryReplayStore(options?: {
|
|
7
|
+
max?: number;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}): PoppyReplayStore;
|
|
10
|
+
/**
|
|
11
|
+
* Na tabela lib-owned `auth_poppy_jtis` (PK = chave): o INSERT de uma chave que
|
|
12
|
+
* já existe falha — atômico entre instâncias. Linhas vencidas saem aos poucos.
|
|
13
|
+
*/
|
|
14
|
+
export declare function databaseReplayStore(conn: () => any, options?: {
|
|
15
|
+
now?: () => Date;
|
|
16
|
+
}): PoppyReplayStore;
|
|
17
|
+
/** Numa conexão Redis (`SET key 1 PX ttl NX`). */
|
|
18
|
+
export declare function redisReplayStore(redis: {
|
|
19
|
+
set: (...args: any[]) => Promise<unknown>;
|
|
20
|
+
}): PoppyReplayStore;
|
|
21
|
+
/** Janelas fixas em memória, por processo: `sessionsPerMinute` por agente, `mediatedPerHour` por (agente, usuário). */
|
|
22
|
+
export declare function memoryLimiter(limits: {
|
|
23
|
+
sessionsPerMinute: number;
|
|
24
|
+
mediatedPerHour: number;
|
|
25
|
+
}, now?: () => number): PoppyLimiter;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proteção contra replay (`jti` de asserções, client assertions, provas DPoP e
|
|
3
|
+
* asserções de navegador) e limites de taxa do Poppy.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
export const JTI_TABLE = 'auth_poppy_jtis';
|
|
7
|
+
/** Chave curta e de tamanho fixo para um `jti` de um tipo/agente. */
|
|
8
|
+
export function replayKey(kind, ...parts) {
|
|
9
|
+
return `${kind}:${createHash('sha256').update(parts.join('\u0000')).digest('base64url')}`;
|
|
10
|
+
}
|
|
11
|
+
/** Em memória, por processo. Só serve com uma instância. */
|
|
12
|
+
export function memoryReplayStore(options = {}) {
|
|
13
|
+
const seen = new Map();
|
|
14
|
+
const max = options.max ?? 100_000;
|
|
15
|
+
const now = options.now ?? Date.now;
|
|
16
|
+
let claims = 0;
|
|
17
|
+
return {
|
|
18
|
+
async claim(key, expiresAt) {
|
|
19
|
+
const t = now();
|
|
20
|
+
if (++claims % 1000 === 0 || seen.size >= max) {
|
|
21
|
+
for (const [k, exp] of seen)
|
|
22
|
+
if (exp <= t)
|
|
23
|
+
seen.delete(k);
|
|
24
|
+
}
|
|
25
|
+
const existing = seen.get(key);
|
|
26
|
+
if (existing !== undefined && existing > t)
|
|
27
|
+
return false;
|
|
28
|
+
if (seen.size >= max)
|
|
29
|
+
seen.delete(seen.keys().next().value);
|
|
30
|
+
seen.set(key, expiresAt.getTime());
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Na tabela lib-owned `auth_poppy_jtis` (PK = chave): o INSERT de uma chave que
|
|
37
|
+
* já existe falha — atômico entre instâncias. Linhas vencidas saem aos poucos.
|
|
38
|
+
*/
|
|
39
|
+
export function databaseReplayStore(conn, options = {}) {
|
|
40
|
+
const now = options.now ?? (() => new Date());
|
|
41
|
+
let claims = 0;
|
|
42
|
+
return {
|
|
43
|
+
async claim(key, expiresAt) {
|
|
44
|
+
const t = now();
|
|
45
|
+
if (++claims % 200 === 1) {
|
|
46
|
+
await conn().from(JTI_TABLE).where('expires_at', '<', t).delete();
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await conn().table(JTI_TABLE).insert({ key, expires_at: expiresAt });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Chave já usada — a não ser que a linha vencida ainda não tenha saído.
|
|
54
|
+
const row = await conn().from(JTI_TABLE).where('key', key).first();
|
|
55
|
+
if (!row)
|
|
56
|
+
throw error;
|
|
57
|
+
const exp = row.expires_at instanceof Date
|
|
58
|
+
? row.expires_at
|
|
59
|
+
: new Date(Number(row.expires_at) || row.expires_at);
|
|
60
|
+
if (exp.getTime() > t.getTime())
|
|
61
|
+
return false;
|
|
62
|
+
const updated = await conn()
|
|
63
|
+
.from(JTI_TABLE)
|
|
64
|
+
.where('key', key)
|
|
65
|
+
.where('expires_at', '<=', t)
|
|
66
|
+
.update({ expires_at: expiresAt });
|
|
67
|
+
return ((typeof updated === 'number' ? updated : Array.isArray(updated) ? updated.length : 0) > 0);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Numa conexão Redis (`SET key 1 PX ttl NX`). */
|
|
73
|
+
export function redisReplayStore(redis) {
|
|
74
|
+
return {
|
|
75
|
+
async claim(key, expiresAt) {
|
|
76
|
+
const ttl = Math.max(1000, expiresAt.getTime() - Date.now());
|
|
77
|
+
const result = await redis.set(`authkit:poppy:jti:${key}`, '1', 'PX', ttl, 'NX');
|
|
78
|
+
return result === 'OK';
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Janelas fixas em memória, por processo: `sessionsPerMinute` por agente, `mediatedPerHour` por (agente, usuário). */
|
|
83
|
+
export function memoryLimiter(limits, now = Date.now) {
|
|
84
|
+
const windows = new Map();
|
|
85
|
+
return async ({ action, key }) => {
|
|
86
|
+
const [limit, windowMs] = action === 'session'
|
|
87
|
+
? [limits.sessionsPerMinute, 60_000]
|
|
88
|
+
: [limits.mediatedPerHour, 3_600_000];
|
|
89
|
+
const t = now();
|
|
90
|
+
const id = `${action}:${key}`;
|
|
91
|
+
let w = windows.get(id);
|
|
92
|
+
if (!w || t - w.start >= windowMs) {
|
|
93
|
+
w = { start: t, count: 0 };
|
|
94
|
+
windows.set(id, w);
|
|
95
|
+
if (windows.size > 50_000) {
|
|
96
|
+
for (const [k, v] of windows)
|
|
97
|
+
if (t - v.start >= 3_600_000)
|
|
98
|
+
windows.delete(k);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (w.count >= limit)
|
|
102
|
+
return Math.ceil((w.start + windowMs - t) / 1000);
|
|
103
|
+
w.count++;
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lado resource server do Poppy (§4.3, §6): verificar o Session Token de uma
|
|
3
|
+
* request às APIs/conversas do app — e o slot GLOBAL que o `@adonis-agora/agent`
|
|
4
|
+
* usa para autenticar o endpoint de conversas sem importar este pacote.
|
|
5
|
+
*
|
|
6
|
+
* Contrato do slot (o mesmo padrão do `Symbol.for('@adonis-agora/oauth:resources')`):
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const authenticate = globalThis[Symbol.for('@adonis-agora/poppy:authenticate')]
|
|
10
|
+
* const result = await authenticate({ method, url, headers }, { scopes?, signedIn?, resource? })
|
|
11
|
+
* // { ok: true, principal } | { ok: false, status, error, scope?, wwwAuthenticate, headers }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
15
|
+
import type { PoppyPrincipalBase } from './config.js';
|
|
16
|
+
import type { PoppyAuthError, PoppyService, PoppyVerifyOptions } from './service.js';
|
|
17
|
+
/** Símbolo do slot global de autenticação Poppy. */
|
|
18
|
+
export declare const POPPY_AUTHENTICATE_SLOT: unique symbol;
|
|
19
|
+
/** O principal de um Session Token válido. *
|
|
20
|
+
/** O principal de um Session Token válido. * @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
21
|
+
/** O principal de um Session Token válido. * de forma incompatível fora de majors enquanto ela for draft.
|
|
22
|
+
/** O principal de um Session Token válido. */
|
|
23
|
+
export interface PoppyPrincipal extends PoppyPrincipalBase {
|
|
24
|
+
/** O que `personalAgents.poppy.toActor` devolveu, quando configurado. */
|
|
25
|
+
actor?: unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
29
|
+
*/
|
|
30
|
+
export type PoppyAuthResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
principal: PoppyPrincipal;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
status: 401 | 403;
|
|
36
|
+
error: PoppyAuthError;
|
|
37
|
+
/** Scopes exigidos (`insufficient_scope`). */
|
|
38
|
+
scope?: string;
|
|
39
|
+
/** Valor pronto do header `WWW-Authenticate`. */
|
|
40
|
+
wwwAuthenticate: string;
|
|
41
|
+
/** Texto para logs/corpo — nunca contém o token. */
|
|
42
|
+
description: string;
|
|
43
|
+
/** Headers extras a devolver (`DPoP-Nonce`). */
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
/** Request no formato do slot (sem `HttpContext`). */
|
|
47
|
+
export interface PoppySlotRequest {
|
|
48
|
+
method: string;
|
|
49
|
+
/** URL pública completa da request (a query é ignorada). */
|
|
50
|
+
url: string;
|
|
51
|
+
headers: Record<string, string | string[] | undefined>;
|
|
52
|
+
}
|
|
53
|
+
export type PoppyAuthenticate = (request: PoppySlotRequest, options?: PoppyVerifyOptions) => Promise<PoppyAuthResult>;
|
|
54
|
+
/** URL pública da request: atrás de proxy TLS, o esquema segue o do issuer. */
|
|
55
|
+
export declare function publicRequestUrl(ctx: HttpContext, poppy: PoppyService): string;
|
|
56
|
+
/**
|
|
57
|
+
* Verifica o Session Token de uma request (DPoP; Bearer só no MCP para o qual
|
|
58
|
+
* foi emitido). Aceita o `HttpContext` ou `{ method, url, headers }`.
|
|
59
|
+
*
|
|
60
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
61
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
62
|
+
*/
|
|
63
|
+
export declare function verifyPoppyRequest(input: HttpContext | PoppySlotRequest, options?: PoppyVerifyOptions): Promise<PoppyAuthResult>;
|
|
64
|
+
/** Escreve a resposta de erro de um {@link PoppyAuthResult} recusado. */
|
|
65
|
+
export declare function writePoppyAuthError(ctx: HttpContext, result: Extract<PoppyAuthResult, {
|
|
66
|
+
ok: false;
|
|
67
|
+
}>): void;
|
|
68
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. *
|
|
69
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. * @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
70
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. * de forma incompatível fora de majors enquanto ela for draft.
|
|
71
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. */
|
|
72
|
+
export declare function poppyOf(ctx: HttpContext): PoppyPrincipal | null;
|
|
73
|
+
/**
|
|
74
|
+
* Middleware das APIs/rotas que aceitam Session Tokens Poppy.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* router.post('/orders/:id/exchanges', [Orders, 'exchange'])
|
|
78
|
+
* .use(poppyAuth({ scopes: ['poppy:write'] }))
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* Isente essas rotas do CSRF (são chamadas por agentes, sem navegador).
|
|
82
|
+
*
|
|
83
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
84
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
85
|
+
*/
|
|
86
|
+
export declare function poppyAuth(options?: PoppyVerifyOptions): (ctx: HttpContext, next: () => Promise<void>) => Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Publica o slot global `Symbol.for('@adonis-agora/poppy:authenticate')` (e
|
|
89
|
+
* `Symbol.for('@adonis-agora/poppy:issuer')`). Chamado no boot do provider
|
|
90
|
+
* quando `personalAgents.poppy` está no config.
|
|
91
|
+
*
|
|
92
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
93
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
94
|
+
*/
|
|
95
|
+
export declare function installPoppySlots(issuer: string): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getBootedApp } from '../../../services/booted_app.js';
|
|
2
|
+
import { buildPersonalAgentsRuntime, personalAgentsFor } from '../runtime.js';
|
|
3
|
+
/** Símbolo do slot global de autenticação Poppy. */
|
|
4
|
+
export const POPPY_AUTHENTICATE_SLOT = Symbol.for('@adonis-agora/poppy:authenticate');
|
|
5
|
+
/** URL pública da request: atrás de proxy TLS, o esquema segue o do issuer. */
|
|
6
|
+
export function publicRequestUrl(ctx, poppy) {
|
|
7
|
+
const url = new URL(ctx.request.completeUrl(false));
|
|
8
|
+
if (new URL(poppy.urls.origin).protocol === 'https:' && url.protocol === 'http:') {
|
|
9
|
+
url.protocol = 'https:';
|
|
10
|
+
}
|
|
11
|
+
return url.toString();
|
|
12
|
+
}
|
|
13
|
+
function requestFromCtx(ctx, poppy) {
|
|
14
|
+
return {
|
|
15
|
+
method: ctx.request.method(),
|
|
16
|
+
url: publicRequestUrl(ctx, poppy),
|
|
17
|
+
headers: ctx.request.headers(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const NOT_ENABLED = {
|
|
21
|
+
ok: false,
|
|
22
|
+
status: 401,
|
|
23
|
+
error: 'invalid_token',
|
|
24
|
+
description: 'Poppy is not enabled',
|
|
25
|
+
wwwAuthenticate: 'DPoP error="invalid_token"',
|
|
26
|
+
headers: {},
|
|
27
|
+
};
|
|
28
|
+
async function verifyWith(poppy, input, options) {
|
|
29
|
+
const result = await poppy.verifyAccess(input, options);
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
const { ok: _ok, ...rest } = result;
|
|
32
|
+
return { ok: false, ...rest };
|
|
33
|
+
}
|
|
34
|
+
const principal = { ...result.principal };
|
|
35
|
+
if (poppy.cfg.toActor)
|
|
36
|
+
principal.actor = await poppy.cfg.toActor(result.principal);
|
|
37
|
+
return { ok: true, principal };
|
|
38
|
+
}
|
|
39
|
+
async function poppyFromBootedApp() {
|
|
40
|
+
const app = getBootedApp();
|
|
41
|
+
const service = await app.container.make('authkit.server');
|
|
42
|
+
const runtime = await buildPersonalAgentsRuntime(service, () => app.container.make('lucid.db'), { make: (binding) => app.container.make(binding) });
|
|
43
|
+
return runtime?.poppy ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Verifica o Session Token de uma request (DPoP; Bearer só no MCP para o qual
|
|
47
|
+
* foi emitido). Aceita o `HttpContext` ou `{ method, url, headers }`.
|
|
48
|
+
*
|
|
49
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
50
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
51
|
+
*/
|
|
52
|
+
export async function verifyPoppyRequest(input, options = {}) {
|
|
53
|
+
if ('request' in input && typeof input.request?.method === 'function') {
|
|
54
|
+
const ctx = input;
|
|
55
|
+
const poppy = (await personalAgentsFor(ctx))?.poppy;
|
|
56
|
+
if (!poppy)
|
|
57
|
+
return NOT_ENABLED;
|
|
58
|
+
return verifyWith(poppy, requestFromCtx(ctx, poppy), options);
|
|
59
|
+
}
|
|
60
|
+
const poppy = await poppyFromBootedApp();
|
|
61
|
+
if (!poppy)
|
|
62
|
+
return NOT_ENABLED;
|
|
63
|
+
return verifyWith(poppy, input, options);
|
|
64
|
+
}
|
|
65
|
+
/** Escreve a resposta de erro de um {@link PoppyAuthResult} recusado. */
|
|
66
|
+
export function writePoppyAuthError(ctx, result) {
|
|
67
|
+
ctx.response.header('WWW-Authenticate', result.wwwAuthenticate);
|
|
68
|
+
for (const [k, v] of Object.entries(result.headers))
|
|
69
|
+
ctx.response.header(k, v);
|
|
70
|
+
ctx.response.header('Cache-Control', 'no-store');
|
|
71
|
+
return ctx.response.status(result.status).json({
|
|
72
|
+
error: result.error,
|
|
73
|
+
error_description: result.description,
|
|
74
|
+
...(result.scope ? { scope: result.scope } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Guardado fora do `HttpContext` (sem augmentation — ver `augmentation_isolation.spec.ts`). */
|
|
78
|
+
const principals = new WeakMap();
|
|
79
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. *
|
|
80
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. * @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
81
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. * de forma incompatível fora de majors enquanto ela for draft.
|
|
82
|
+
/** O principal Poppy da request — preenchido por {@link poppyAuth}. */
|
|
83
|
+
export function poppyOf(ctx) {
|
|
84
|
+
return principals.get(ctx) ?? null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Middleware das APIs/rotas que aceitam Session Tokens Poppy.
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* router.post('/orders/:id/exchanges', [Orders, 'exchange'])
|
|
91
|
+
* .use(poppyAuth({ scopes: ['poppy:write'] }))
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* Isente essas rotas do CSRF (são chamadas por agentes, sem navegador).
|
|
95
|
+
*
|
|
96
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
97
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
98
|
+
*/
|
|
99
|
+
export function poppyAuth(options = {}) {
|
|
100
|
+
return async (ctx, next) => {
|
|
101
|
+
const result = await verifyPoppyRequest(ctx, options);
|
|
102
|
+
if (!result.ok)
|
|
103
|
+
return writePoppyAuthError(ctx, result);
|
|
104
|
+
principals.set(ctx, result.principal);
|
|
105
|
+
return next();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Publica o slot global `Symbol.for('@adonis-agora/poppy:authenticate')` (e
|
|
110
|
+
* `Symbol.for('@adonis-agora/poppy:issuer')`). Chamado no boot do provider
|
|
111
|
+
* quando `personalAgents.poppy` está no config.
|
|
112
|
+
*
|
|
113
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
114
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
115
|
+
*/
|
|
116
|
+
export function installPoppySlots(issuer) {
|
|
117
|
+
const slot = globalThis;
|
|
118
|
+
const authenticate = (request, options) => verifyPoppyRequest({
|
|
119
|
+
method: request.method,
|
|
120
|
+
url: request.url,
|
|
121
|
+
headers: request.headers ?? {},
|
|
122
|
+
}, options);
|
|
123
|
+
slot[POPPY_AUTHENTICATE_SLOT] = authenticate;
|
|
124
|
+
slot[Symbol.for('@adonis-agora/poppy:issuer')] = issuer;
|
|
125
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch` de documentos JSON de terceiros à prova de SSRF.
|
|
3
|
+
*
|
|
4
|
+
* O `client_id` de um agente Poppy é uma URL que QUALQUER um escolhe — buscá-la
|
|
5
|
+
* sem cuidado faria deste app um proxy para a rede interna. Regras:
|
|
6
|
+
* - só `https:`; sem credenciais na URL; porta qualquer;
|
|
7
|
+
* - o IP é conferido NA CONEXÃO (o `lookup` do socket), não num DNS separado —
|
|
8
|
+
* sem janela para DNS rebinding; loopback, privados, link-local, CGNAT,
|
|
9
|
+
* multicast, reservados e os equivalentes IPv6 são recusados;
|
|
10
|
+
* - IP literal na URL passa pela mesma regra;
|
|
11
|
+
* - sem redirects (o `client_id` tem de ser exatamente a URL buscada);
|
|
12
|
+
* - teto de tempo e de bytes.
|
|
13
|
+
*/
|
|
14
|
+
import { type LookupAddress } from 'node:dns';
|
|
15
|
+
export declare class SafeFetchError extends Error {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
export interface FetchedJson {
|
|
19
|
+
status: number;
|
|
20
|
+
body: unknown;
|
|
21
|
+
/** `max-age` do `Cache-Control`, em segundos, quando há. */
|
|
22
|
+
maxAge: number | null;
|
|
23
|
+
}
|
|
24
|
+
/** Busca um JSON (GET). Lança {@link SafeFetchError} em qualquer recusa. */
|
|
25
|
+
export type JsonFetcher = (url: string) => Promise<FetchedJson>;
|
|
26
|
+
export type LookupFn = (hostname: string, options: {
|
|
27
|
+
all: true;
|
|
28
|
+
}, callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void) => void;
|
|
29
|
+
export interface SafeFetchOptions {
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
/** Resolução de nomes — injetável nos testes. Default: `dns.lookup`. */
|
|
33
|
+
lookup?: LookupFn;
|
|
34
|
+
}
|
|
35
|
+
/** `true` quando o IP não pode ser alvo de uma busca de saída. */
|
|
36
|
+
export declare function isBlockedAddress(ip: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Busca `url` e devolve o JSON. Só HTTPS, sem redirect, IP público conferido na
|
|
39
|
+
* conexão, `timeoutMs` (default 5 s) e `maxBytes` (default 64 KiB).
|
|
40
|
+
*/
|
|
41
|
+
export declare function safeFetchJson(url: string, options?: SafeFetchOptions): Promise<FetchedJson>;
|
|
42
|
+
/**
|
|
43
|
+
* SÓ DEV/TESTE (`personalAgents.poppy.allowInsecureClientIds`): busca um JSON por
|
|
44
|
+
* `http:` ou `https:` SEM a guarda de SSRF — loopback e rede privada liberados,
|
|
45
|
+
* para um agente local (`http://localhost:7777/agent.json`). Mantém sem redirect,
|
|
46
|
+
* teto de tempo e de bytes. Nunca use em produção (a config recusa).
|
|
47
|
+
*/
|
|
48
|
+
export declare function insecureDevFetchJson(url: string, options?: Omit<SafeFetchOptions, 'lookup'>): Promise<FetchedJson>;
|