@adonis-agora/authkit-server 0.77.0 → 0.79.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/host/views/agents/consent.edge +13 -2
- package/build/host/views/agents/done.edge +3 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +8 -0
- package/build/providers/authkit_server_provider.js +15 -1
- package/build/src/agents/config.d.ts +22 -3
- package/build/src/agents/config.js +22 -4
- package/build/src/agents/delegation_service.js +18 -8
- package/build/src/agents/delegation_store.d.ts +16 -0
- package/build/src/agents/delegation_store.js +44 -0
- 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 +365 -0
- package/build/src/agents/poppy/config.js +208 -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 +286 -0
- package/build/src/agents/poppy/service.js +1464 -0
- package/build/src/agents/poppy/store.d.ts +111 -0
- package/build/src/agents/poppy/store.js +269 -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/registry_store.d.ts +41 -0
- package/build/src/agents/registry_store.js +138 -0
- package/build/src/agents/runtime.d.ts +30 -1
- package/build/src/agents/runtime.js +221 -14
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -0
- package/build/src/define_config.js +6 -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_api/dto.d.ts +1 -1
- package/build/src/host/admin_console/admin_shell_controller.js +7 -0
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +83 -0
- package/build/src/host/admin_console/console_personal_agents_controller.js +337 -0
- 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/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/i18n.d.ts +4 -0
- package/build/src/host/i18n.js +4 -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 +76 -0
- package/build/src/host/ui-dist/assets/index-LrScaR9C.js +155 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/oidc_service.d.ts +5 -0
- package/build/src/provider/oidc_service.js +9 -2
- package/build/src/schema/ensure.js +152 -0
- package/package.json +2 -2
- package/build/src/host/ui-dist/assets/index-CIqtoNcO.js +0 -155
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/** Scopes amplos definidos pelo protocolo (§4.4). */
|
|
2
|
+
export const POPPY_READ = 'poppy:read';
|
|
3
|
+
export const POPPY_WRITE = 'poppy:write';
|
|
4
|
+
/** Descrições default dos scopes do protocolo — o que a tela de consentimento mostra. */
|
|
5
|
+
export const POPPY_SCOPE_DESCRIPTIONS = {
|
|
6
|
+
[POPPY_READ]: 'View your account information, such as orders and bookings',
|
|
7
|
+
[POPPY_WRITE]: 'Make changes on your account, such as exchanging an item or updating a booking',
|
|
8
|
+
};
|
|
9
|
+
const SCOPE_ID = /^[\x21\x23-\x5B\x5D-\x7E]+$/; // RFC 6749 §3.3 scope-token
|
|
10
|
+
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
11
|
+
function fail(message) {
|
|
12
|
+
throw new Error(`authkit: personalAgents.poppy.${message}`);
|
|
13
|
+
}
|
|
14
|
+
function positive(value, fallback, what) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return fallback;
|
|
17
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
18
|
+
fail(`${what} precisa ser > 0.`);
|
|
19
|
+
return Math.floor(value);
|
|
20
|
+
}
|
|
21
|
+
function absoluteUrl(value, what) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
fail(`${what} precisa ser uma URL absoluta (recebeu "${value}").`);
|
|
28
|
+
}
|
|
29
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
30
|
+
fail(`${what} precisa ser http(s) (recebeu "${value}").`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** `www.example.com` → `example.com`, minúsculo. */
|
|
34
|
+
export function stripWww(host) {
|
|
35
|
+
const lower = host.toLowerCase().replace(/\.$/, '');
|
|
36
|
+
return lower.startsWith('www.') ? lower.slice(4) : lower;
|
|
37
|
+
}
|
|
38
|
+
export function normalizePoppyPrefix(prefix) {
|
|
39
|
+
const trimmed = (prefix ?? '/poppy').trim().replace(/\/+$/, '');
|
|
40
|
+
if (!trimmed)
|
|
41
|
+
return '/poppy';
|
|
42
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
43
|
+
}
|
|
44
|
+
function membership(value) {
|
|
45
|
+
if (typeof value === 'function')
|
|
46
|
+
return async (id) => (await value(id)) === true;
|
|
47
|
+
const set = new Set(value ?? []);
|
|
48
|
+
return async (id) => set.has(id);
|
|
49
|
+
}
|
|
50
|
+
function listOf(value) {
|
|
51
|
+
if (typeof value === 'function')
|
|
52
|
+
return null;
|
|
53
|
+
return [...new Set(value ?? [])];
|
|
54
|
+
}
|
|
55
|
+
function typeScopes(input, all, what) {
|
|
56
|
+
if (input === false)
|
|
57
|
+
return null;
|
|
58
|
+
const scopes = typeof input === 'object' && input.scopes ? [...new Set(input.scopes)] : all;
|
|
59
|
+
for (const id of scopes) {
|
|
60
|
+
if (!all.includes(id))
|
|
61
|
+
fail(`signIn.${what}.scopes — scope desconhecido "${id}".`);
|
|
62
|
+
}
|
|
63
|
+
if (scopes.length === 0)
|
|
64
|
+
fail(`signIn.${what}.scopes precisa de pelo menos um scope.`);
|
|
65
|
+
return { scopes };
|
|
66
|
+
}
|
|
67
|
+
export function resolvePoppyConfig(input, fallbackScopes) {
|
|
68
|
+
if (!input)
|
|
69
|
+
return undefined;
|
|
70
|
+
const org = input.organization;
|
|
71
|
+
if (!org?.name?.trim())
|
|
72
|
+
fail('organization.name é obrigatório.');
|
|
73
|
+
const domains = [org.domain, ...(org.aliases ?? [])].map((d) => stripWww(d ?? ''));
|
|
74
|
+
for (const d of domains) {
|
|
75
|
+
if (!DOMAIN.test(d))
|
|
76
|
+
fail(`organization — domínio inválido "${d}" (só o host, sem esquema).`);
|
|
77
|
+
}
|
|
78
|
+
const customScopes = { ...(input.scopes ?? fallbackScopes ?? {}) };
|
|
79
|
+
for (const id of Object.keys(customScopes)) {
|
|
80
|
+
if (!SCOPE_ID.test(id))
|
|
81
|
+
fail(`scopes — id inválido "${id}" (sem espaços/aspas).`);
|
|
82
|
+
if (id.startsWith('poppy:')) {
|
|
83
|
+
fail(`scopes — "${id}": nomes com "poppy:" são reservados ao protocolo (§4.4).`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const broad = input.broadScopes !== false;
|
|
87
|
+
const scopes = {
|
|
88
|
+
...(broad
|
|
89
|
+
? {
|
|
90
|
+
[POPPY_READ]: input.broadScopeDescriptions?.[POPPY_READ] ?? POPPY_SCOPE_DESCRIPTIONS[POPPY_READ],
|
|
91
|
+
[POPPY_WRITE]: input.broadScopeDescriptions?.[POPPY_WRITE] ?? POPPY_SCOPE_DESCRIPTIONS[POPPY_WRITE],
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
94
|
+
...customScopes,
|
|
95
|
+
};
|
|
96
|
+
const all = Object.keys(scopes);
|
|
97
|
+
const signInInput = input.signIn ?? {};
|
|
98
|
+
let mediated = null;
|
|
99
|
+
if (signInInput.mediated) {
|
|
100
|
+
const m = signInInput.mediated;
|
|
101
|
+
if (!Array.isArray(m.fields) || m.fields.length === 0) {
|
|
102
|
+
fail('signIn.mediated.fields precisa de pelo menos um campo.');
|
|
103
|
+
}
|
|
104
|
+
const names = new Set();
|
|
105
|
+
for (const f of m.fields) {
|
|
106
|
+
if (!f?.name || !f.label || typeof f.secret !== 'boolean') {
|
|
107
|
+
fail('signIn.mediated.fields — cada campo precisa de name, label e secret.');
|
|
108
|
+
}
|
|
109
|
+
if (names.has(f.name))
|
|
110
|
+
fail(`signIn.mediated.fields — campo duplicado "${f.name}".`);
|
|
111
|
+
names.add(f.name);
|
|
112
|
+
}
|
|
113
|
+
if (typeof m.verify !== 'function')
|
|
114
|
+
fail('signIn.mediated.verify é obrigatório.');
|
|
115
|
+
const mediatedScopes = typeScopes({ scopes: m.scopes ?? (broad ? [POPPY_READ] : all) }, all, 'mediated').scopes;
|
|
116
|
+
mediated = {
|
|
117
|
+
fields: m.fields.map((f) => ({ name: f.name, label: f.label, secret: f.secret })),
|
|
118
|
+
scopes: mediatedScopes,
|
|
119
|
+
verify: m.verify,
|
|
120
|
+
verifyCode: m.verifyCode,
|
|
121
|
+
codeTtl: positive(m.codeTtl, 600, 'signIn.mediated.codeTtl'),
|
|
122
|
+
maxCodeAttempts: positive(m.maxCodeAttempts, 5, 'signIn.mediated.maxCodeAttempts'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (all.length === 0 && (signInInput.direct !== false || signInInput.device !== false)) {
|
|
126
|
+
fail('nenhum scope: com broadScopes: false, declare `scopes`.');
|
|
127
|
+
}
|
|
128
|
+
for (const entry of input.agent?.protocols ?? []) {
|
|
129
|
+
if (!entry.type)
|
|
130
|
+
fail('agent.protocols[].type é obrigatório.');
|
|
131
|
+
absoluteUrl(entry.endpoint, 'agent.protocols[].endpoint');
|
|
132
|
+
if (entry.resource)
|
|
133
|
+
absoluteUrl(entry.resource, 'agent.protocols[].resource');
|
|
134
|
+
}
|
|
135
|
+
for (const api of input.apis ?? []) {
|
|
136
|
+
if (!api.type)
|
|
137
|
+
fail('apis[].type é obrigatório.');
|
|
138
|
+
absoluteUrl(api.url, 'apis[].url');
|
|
139
|
+
if (api.resource)
|
|
140
|
+
absoluteUrl(api.resource, 'apis[].resource');
|
|
141
|
+
if (typeof api.description !== 'string')
|
|
142
|
+
fail('apis[].description é obrigatório.');
|
|
143
|
+
}
|
|
144
|
+
for (const name of Object.keys(input.extensions ?? {})) {
|
|
145
|
+
if (typeof input.extensions[name]?.version !== 'string') {
|
|
146
|
+
fail(`extensions.${name}.version é obrigatório (string).`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const dpopAlgs = input.dpop?.algorithms ?? ['ES256'];
|
|
150
|
+
if (dpopAlgs.length === 0 || dpopAlgs.some((a) => a === 'none' || a.startsWith('HS'))) {
|
|
151
|
+
fail('dpop.algorithms precisa de algoritmos assimétricos.');
|
|
152
|
+
}
|
|
153
|
+
const allowInsecureClientIds = input.allowInsecureClientIds === true;
|
|
154
|
+
if (allowInsecureClientIds && process.env.NODE_ENV === 'production') {
|
|
155
|
+
fail('allowInsecureClientIds é só para desenvolvimento/teste e não pode ser ligado com NODE_ENV=production.');
|
|
156
|
+
}
|
|
157
|
+
let rateLimit;
|
|
158
|
+
if (input.rateLimit === false)
|
|
159
|
+
rateLimit = false;
|
|
160
|
+
else if (typeof input.rateLimit === 'function')
|
|
161
|
+
rateLimit = input.rateLimit;
|
|
162
|
+
else {
|
|
163
|
+
rateLimit = {
|
|
164
|
+
sessionsPerMinute: positive(input.rateLimit?.sessionsPerMinute, 120, 'rateLimit.sessionsPerMinute'),
|
|
165
|
+
mediatedPerHour: positive(input.rateLimit?.mediatedPerHour, 10, 'rateLimit.mediatedPerHour'),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
organization: { name: org.name, domain: domains[0], aliases: domains.slice(1) },
|
|
170
|
+
prefix: normalizePoppyPrefix(input.prefix),
|
|
171
|
+
scopes,
|
|
172
|
+
customScopes,
|
|
173
|
+
signIn: {
|
|
174
|
+
direct: typeScopes(signInInput.direct, all, 'direct'),
|
|
175
|
+
device: typeScopes(signInInput.device, all, 'device'),
|
|
176
|
+
mediated,
|
|
177
|
+
},
|
|
178
|
+
agent: input.agent?.protocols?.length ? { protocols: [...input.agent.protocols] } : null,
|
|
179
|
+
apis: [...(input.apis ?? [])],
|
|
180
|
+
web: input.web === false
|
|
181
|
+
? null
|
|
182
|
+
: { browserSession: input.web?.browserSession !== false, login: input.web?.login },
|
|
183
|
+
extensions: { ...(input.extensions ?? {}) },
|
|
184
|
+
clients: {
|
|
185
|
+
requireRegistration: input.clients?.requireRegistration === true,
|
|
186
|
+
isRegistered: membership(input.clients?.registered),
|
|
187
|
+
isBlocked: membership(input.clients?.blocked),
|
|
188
|
+
registeredList: listOf(input.clients?.registered),
|
|
189
|
+
blockedList: listOf(input.clients?.blocked),
|
|
190
|
+
},
|
|
191
|
+
allowInsecureClientIds,
|
|
192
|
+
sessionTokenTtl: positive(input.sessionTokenTtl, 3600, 'sessionTokenTtl'),
|
|
193
|
+
accountTokenTtl: positive(input.accountTokenTtl, 30 * 24 * 3600, 'accountTokenTtl'),
|
|
194
|
+
sessionTtl: positive(input.sessionTtl, 30 * 24 * 3600, 'sessionTtl'),
|
|
195
|
+
authorizationCodeTtl: positive(input.authorizationCodeTtl, 60, 'authorizationCodeTtl'),
|
|
196
|
+
deviceCodeTtl: positive(input.deviceCodeTtl, 600, 'deviceCodeTtl'),
|
|
197
|
+
pollInterval: positive(input.pollInterval, 5, 'pollInterval'),
|
|
198
|
+
assertionMaxLifetime: positive(input.assertionMaxLifetime, 300, 'assertionMaxLifetime'),
|
|
199
|
+
dpop: {
|
|
200
|
+
requireNonce: input.dpop?.requireNonce === true,
|
|
201
|
+
maxAge: positive(input.dpop?.maxAge, 60, 'dpop.maxAge'),
|
|
202
|
+
algorithms: [...dpopAlgs],
|
|
203
|
+
},
|
|
204
|
+
replay: input.replay ?? 'database',
|
|
205
|
+
rateLimit,
|
|
206
|
+
toActor: input.toActor,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { PoppyReplayStore } from './config.js';
|
|
2
|
+
export type DpopFailure = {
|
|
3
|
+
ok: false;
|
|
4
|
+
error: 'invalid_dpop_proof' | 'use_dpop_nonce';
|
|
5
|
+
description: string;
|
|
6
|
+
};
|
|
7
|
+
export type DpopResult = {
|
|
8
|
+
ok: true;
|
|
9
|
+
jkt: string;
|
|
10
|
+
jti: string;
|
|
11
|
+
} | DpopFailure;
|
|
12
|
+
export interface DpopNonceSource {
|
|
13
|
+
/** Nonce novo para o header `DPoP-Nonce`. */
|
|
14
|
+
issue(): string;
|
|
15
|
+
/** O nonce ainda vale? */
|
|
16
|
+
valid(nonce: string): boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Nonces sem estado: `base64url(ts).base64url(hmac(ts))`, válidos por
|
|
20
|
+
* `ttlSeconds` (default 300). Qualquer instância com o mesmo segredo valida.
|
|
21
|
+
*
|
|
22
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — acompanha a spec em desenvolvimento e PODE MUDAR
|
|
23
|
+
* de forma incompatível fora de majors enquanto ela for draft.
|
|
24
|
+
*/
|
|
25
|
+
export declare function hmacNonceSource(secret: Buffer | string, options?: {
|
|
26
|
+
ttlSeconds?: number;
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}): DpopNonceSource;
|
|
29
|
+
/** `ath`: base64url(SHA-256(access token)). */
|
|
30
|
+
export declare function accessTokenHash(token: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* `htu` normalizado para comparação: esquema e host minúsculos, sem porta
|
|
33
|
+
* default, sem query nem fragmento. `null` se não for URL http(s).
|
|
34
|
+
*/
|
|
35
|
+
export declare function normalizeHtu(value: string): string | null;
|
|
36
|
+
export interface VerifyDpopInput {
|
|
37
|
+
/** Valor do(s) header(s) `DPoP`. Mais de um = inválido. */
|
|
38
|
+
proof: string | string[] | undefined | null;
|
|
39
|
+
method: string;
|
|
40
|
+
/** URL da request (a query é ignorada). */
|
|
41
|
+
url: string;
|
|
42
|
+
/** Access token apresentado junto (resource server) — exige `ath`. */
|
|
43
|
+
accessToken?: string;
|
|
44
|
+
/** Thumbprint (RFC 7638) a que o token está preso. */
|
|
45
|
+
expectedJkt?: string;
|
|
46
|
+
algorithms: string[];
|
|
47
|
+
/** Janela do `iat`, em segundos. */
|
|
48
|
+
maxAge: number;
|
|
49
|
+
/** Tolerância de relógio para `iat` no futuro, em segundos. Default: 5. */
|
|
50
|
+
clockSkew?: number;
|
|
51
|
+
replay: PoppyReplayStore;
|
|
52
|
+
nonce?: {
|
|
53
|
+
required: boolean;
|
|
54
|
+
source: DpopNonceSource;
|
|
55
|
+
};
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyDpopProof(input: VerifyDpopInput): Promise<DpopResult>;
|
|
@@ -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
|
+
}
|