@adonis-agora/authkit-server 0.75.0 → 0.77.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 +148 -0
- package/build/host/views/account/apps.edge +30 -0
- package/build/host/views/agents/consent.edge +60 -0
- package/build/host/views/agents/done.edge +24 -0
- package/build/host/views/consent.edge +9 -1
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +18 -1
- package/build/index.js +9 -1
- package/build/providers/authkit_server_provider.js +75 -50
- 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/agent_identity.d.ts +33 -0
- package/build/src/agents/agent_identity.js +156 -0
- package/build/src/agents/config.d.ts +99 -0
- package/build/src/agents/config.js +101 -0
- package/build/src/agents/delegation_service.d.ts +154 -0
- package/build/src/agents/delegation_service.js +394 -0
- package/build/src/agents/delegation_store.d.ts +93 -0
- package/build/src/agents/delegation_store.js +222 -0
- package/build/src/agents/middleware.d.ts +73 -0
- package/build/src/agents/middleware.js +113 -0
- package/build/src/agents/protocol.d.ts +62 -0
- package/build/src/agents/protocol.js +51 -0
- package/build/src/agents/runtime.d.ts +36 -0
- package/build/src/agents/runtime.js +65 -0
- package/build/src/agents/signer.d.ts +34 -0
- package/build/src/agents/signer.js +70 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +5 -0
- package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
- package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
- package/build/src/define_config.d.ts +50 -3
- package/build/src/define_config.js +42 -1
- package/build/src/host/account_api/account_api_controller.d.ts +12 -0
- package/build/src/host/account_api/account_api_controller.js +40 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_sessions_service.d.ts +2 -0
- package/build/src/host/admin_sessions_service.js +18 -0
- package/build/src/host/auth_host_config.d.ts +4 -0
- package/build/src/host/client_names.d.ts +11 -0
- package/build/src/host/client_names.js +12 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
- package/build/src/host/controllers/account_apps_controller.js +39 -3
- package/build/src/host/controllers/account_orgs_controller.js +2 -1
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
- package/build/src/host/controllers/agent_consent_controller.js +138 -0
- package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
- package/build/src/host/controllers/agent_oauth_controller.js +110 -0
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +380 -24
- package/build/src/host/csrf.d.ts +8 -18
- package/build/src/host/csrf.js +28 -2
- 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 +58 -0
- package/build/src/host/i18n.js +64 -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/redirect_exact.d.ts +12 -0
- package/build/src/host/redirect_exact.js +17 -0
- package/build/src/host/register_auth_host.js +60 -7
- package/build/src/host/request_url.d.ts +10 -0
- package/build/src/host/request_url.js +17 -0
- package/build/src/host/sudo/methods/magic_link.js +2 -1
- package/build/src/host/sudo/runtime.js +3 -2
- package/build/src/host/sudo_mode.js +4 -4
- 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/mcp/mcp_oauth.d.ts +85 -0
- package/build/src/mcp/mcp_oauth.js +154 -0
- package/build/src/provider/build_provider.js +15 -1
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +16 -2
- package/build/src/schema/ensure.js +92 -0
- package/build/stubs/ui/react/pages/consent.tsx +17 -1
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- package/stubs/ui/react/pages/consent.tsx +17 -1
- package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { PersonalAgentIdentity } from './agent_identity.js';
|
|
2
|
+
import { type ResolvedPersonalAgentDelegationConfig } from './config.js';
|
|
3
|
+
import type { DelegationStore } from './delegation_store.js';
|
|
4
|
+
import type { AgentSigner } from './signer.js';
|
|
5
|
+
export declare const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
6
|
+
export declare const REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
|
|
7
|
+
/** Erro OAuth (RFC 6749 §5.2 / RFC 8628 §3.5) — vira `{ error, error_description }`. */
|
|
8
|
+
export declare class AgentOAuthError extends Error {
|
|
9
|
+
readonly code: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'invalid_scope' | 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' | 'unsupported_grant_type';
|
|
10
|
+
readonly description: string;
|
|
11
|
+
readonly status: number;
|
|
12
|
+
constructor(code: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'invalid_scope' | 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' | 'unsupported_grant_type', description: string, status?: number);
|
|
13
|
+
}
|
|
14
|
+
export interface DeviceAuthorizationResponse {
|
|
15
|
+
device_code: string;
|
|
16
|
+
user_code: string;
|
|
17
|
+
verification_uri: string;
|
|
18
|
+
verification_uri_complete: string;
|
|
19
|
+
expires_in: number;
|
|
20
|
+
interval: number;
|
|
21
|
+
}
|
|
22
|
+
export interface DelegationTokenResponse {
|
|
23
|
+
token_type: 'Bearer';
|
|
24
|
+
access_token: string;
|
|
25
|
+
refresh_token: string;
|
|
26
|
+
expires_in: number;
|
|
27
|
+
scope: string;
|
|
28
|
+
}
|
|
29
|
+
/** Um pedido aguardando o usuário na tela de consentimento. */
|
|
30
|
+
export interface PendingDelegationRequest {
|
|
31
|
+
userCode: string;
|
|
32
|
+
/** `iss` do agente que pediu. */
|
|
33
|
+
clientId: string;
|
|
34
|
+
scopes: {
|
|
35
|
+
id: string;
|
|
36
|
+
description: string;
|
|
37
|
+
}[];
|
|
38
|
+
expiresAt: Date;
|
|
39
|
+
}
|
|
40
|
+
/** O que um token de delegação válido autoriza (PACT §5.5). */
|
|
41
|
+
export interface DelegationContext {
|
|
42
|
+
/** Conta NESTE app em nome da qual o agente age (`sub` do token). */
|
|
43
|
+
accountId: string;
|
|
44
|
+
scopes: string[];
|
|
45
|
+
grantId: string;
|
|
46
|
+
/** `iss` do agente. */
|
|
47
|
+
clientId: string;
|
|
48
|
+
}
|
|
49
|
+
export interface DelegationGrantSummary {
|
|
50
|
+
id: string;
|
|
51
|
+
clientId: string;
|
|
52
|
+
scopes: {
|
|
53
|
+
id: string;
|
|
54
|
+
description: string;
|
|
55
|
+
}[];
|
|
56
|
+
createdAt: Date;
|
|
57
|
+
updatedAt: Date;
|
|
58
|
+
expiresAt: Date;
|
|
59
|
+
}
|
|
60
|
+
/** Recibo de uma ação feita sob delegação (PACT §5.6). */
|
|
61
|
+
export interface DelegationReceipt {
|
|
62
|
+
jws: string;
|
|
63
|
+
claims: {
|
|
64
|
+
grantId: string;
|
|
65
|
+
user: string;
|
|
66
|
+
pa: string;
|
|
67
|
+
brand: string;
|
|
68
|
+
scopesUsed: string[];
|
|
69
|
+
actions: {
|
|
70
|
+
tool: string;
|
|
71
|
+
argsHash?: string;
|
|
72
|
+
}[];
|
|
73
|
+
ts: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export interface PersonalAgentDelegationDeps {
|
|
77
|
+
cfg: ResolvedPersonalAgentDelegationConfig;
|
|
78
|
+
store: DelegationStore;
|
|
79
|
+
signer: AgentSigner;
|
|
80
|
+
/** `issuer` do authorization server de delegação e URL da tela de consentimento. */
|
|
81
|
+
urls: {
|
|
82
|
+
issuer: string;
|
|
83
|
+
consent: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* A conta ainda pode agir? (existe e não está desabilitada). Checado em cada
|
|
87
|
+
* uso do grant: apagar ou suspender a conta corta a delegação na hora.
|
|
88
|
+
* Default: sempre ativa.
|
|
89
|
+
*/
|
|
90
|
+
isAccountActive?: (accountId: string) => Promise<boolean>;
|
|
91
|
+
now?: () => Date;
|
|
92
|
+
}
|
|
93
|
+
/** Aceita o código como o usuário digitar: minúsculas, sem hífen, com espaços. */
|
|
94
|
+
export declare function normalizeUserCode(input: unknown): string | null;
|
|
95
|
+
/**
|
|
96
|
+
* Delegação de personal agents: RFC 8628 (device code) em que o cliente OAuth é
|
|
97
|
+
* o agente — autenticado pelo JWT dele, não por client secret — e o login é o
|
|
98
|
+
* login deste app. O usuário aprova scopes definidos pelo app; o agente recebe
|
|
99
|
+
* um token assinado pelo keystore do IdP, com vida curta e refresh rotativo,
|
|
100
|
+
* revogável a qualquer momento no console de conta.
|
|
101
|
+
*
|
|
102
|
+
* Um grant por (conta, agente, usuário do agente): aprovar de novo — o step-up
|
|
103
|
+
* de um scope que faltava — SOMA scopes ao grant existente em vez de criar outro.
|
|
104
|
+
*/
|
|
105
|
+
export declare class PersonalAgentDelegation {
|
|
106
|
+
#private;
|
|
107
|
+
constructor(deps: PersonalAgentDelegationDeps);
|
|
108
|
+
get scopes(): Record<string, string>;
|
|
109
|
+
get interfaceUrl(): string;
|
|
110
|
+
/** `POST device_authorization` (PACT §5.3). */
|
|
111
|
+
requestDevice(agent: PersonalAgentIdentity, scopeParam: unknown): Promise<DeviceAuthorizationResponse>;
|
|
112
|
+
/** `POST token` com `grant_type=device_code` (RFC 8628 §3.4/§3.5). */
|
|
113
|
+
exchangeDeviceCode(agent: PersonalAgentIdentity, deviceCode: unknown): Promise<DelegationTokenResponse>;
|
|
114
|
+
/** `POST token` com `grant_type=refresh_token`. Rotativo: o refresh usado morre. */
|
|
115
|
+
refresh(agent: PersonalAgentIdentity, refreshToken: unknown): Promise<DelegationTokenResponse>;
|
|
116
|
+
/** O pedido por trás de um `user_code`, se ainda está aguardando. */
|
|
117
|
+
pendingRequest(userCodeInput: unknown): Promise<PendingDelegationRequest | null>;
|
|
118
|
+
/**
|
|
119
|
+
* O usuário aprovou `scopes` (só o que estava no pedido conta). `null` = nada
|
|
120
|
+
* foi aprovado: nenhum scope do pedido marcado, ou o pedido não está mais
|
|
121
|
+
* pendente — o chamador decide se nega.
|
|
122
|
+
*/
|
|
123
|
+
approve(input: {
|
|
124
|
+
userCode: string;
|
|
125
|
+
accountId: string;
|
|
126
|
+
scopes: string[];
|
|
127
|
+
}): Promise<{
|
|
128
|
+
grantId: string;
|
|
129
|
+
scopes: string[];
|
|
130
|
+
} | null>;
|
|
131
|
+
deny(input: {
|
|
132
|
+
userCode: string;
|
|
133
|
+
accountId: string;
|
|
134
|
+
}): Promise<boolean>;
|
|
135
|
+
listGrants(accountId: string): Promise<DelegationGrantSummary[]>;
|
|
136
|
+
/** Revoga na hora: tokens já emitidos param de valer na próxima request. */
|
|
137
|
+
revokeGrant(accountId: string, grantId: string): Promise<boolean>;
|
|
138
|
+
/** Revoga TODAS as delegações da conta. Devolve quantas estavam vivas. */
|
|
139
|
+
revokeAllGrants(accountId: string): Promise<number>;
|
|
140
|
+
/**
|
|
141
|
+
* Valida o header `X-A2A-User-Delegation: Bearer <token>` de uma request que
|
|
142
|
+
* JÁ passou pela verificação do JWT do agente (PACT §5.5). `null` = inválido
|
|
143
|
+
* (o chamador responde 401 `invalid_token`).
|
|
144
|
+
*/
|
|
145
|
+
verify(agent: PersonalAgentIdentity, header: string | null | undefined): Promise<DelegationContext | null>;
|
|
146
|
+
/** Recibo assinado de uma resposta servida sob delegação (PACT §5.6). */
|
|
147
|
+
receipt(delegation: DelegationContext, input: {
|
|
148
|
+
scopesUsed: string[];
|
|
149
|
+
actions?: {
|
|
150
|
+
tool: string;
|
|
151
|
+
argsHash?: string;
|
|
152
|
+
}[];
|
|
153
|
+
}): Promise<DelegationReceipt>;
|
|
154
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
import { jwtVerify } from 'jose';
|
|
3
|
+
import { bearerToken } from './agent_identity.js';
|
|
4
|
+
import { formatScope, parseScope } from './config.js';
|
|
5
|
+
export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token';
|
|
7
|
+
/** Sem vogais: o código não forma palavras (RFC 8628 §6.1). */
|
|
8
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
9
|
+
/** Erro OAuth (RFC 6749 §5.2 / RFC 8628 §3.5) — vira `{ error, error_description }`. */
|
|
10
|
+
export class AgentOAuthError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
description;
|
|
13
|
+
status;
|
|
14
|
+
constructor(code, description, status = 400) {
|
|
15
|
+
super(description);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.description = description;
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sha256(value) {
|
|
22
|
+
return createHash('sha256').update(value).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
function randomToken(prefix) {
|
|
25
|
+
return `${prefix}${randomBytes(32).toString('base64url')}`;
|
|
26
|
+
}
|
|
27
|
+
function newUserCode() {
|
|
28
|
+
let code = '';
|
|
29
|
+
for (let i = 0; i < 8; i++)
|
|
30
|
+
code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
|
|
31
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
32
|
+
}
|
|
33
|
+
/** Aceita o código como o usuário digitar: minúsculas, sem hífen, com espaços. */
|
|
34
|
+
export function normalizeUserCode(input) {
|
|
35
|
+
if (typeof input !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
const letters = input.toUpperCase().replace(/[^A-Z]/g, '');
|
|
38
|
+
if (letters.length !== 8)
|
|
39
|
+
return null;
|
|
40
|
+
return `${letters.slice(0, 4)}-${letters.slice(4)}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Delegação de personal agents: RFC 8628 (device code) em que o cliente OAuth é
|
|
44
|
+
* o agente — autenticado pelo JWT dele, não por client secret — e o login é o
|
|
45
|
+
* login deste app. O usuário aprova scopes definidos pelo app; o agente recebe
|
|
46
|
+
* um token assinado pelo keystore do IdP, com vida curta e refresh rotativo,
|
|
47
|
+
* revogável a qualquer momento no console de conta.
|
|
48
|
+
*
|
|
49
|
+
* Um grant por (conta, agente, usuário do agente): aprovar de novo — o step-up
|
|
50
|
+
* de um scope que faltava — SOMA scopes ao grant existente em vez de criar outro.
|
|
51
|
+
*/
|
|
52
|
+
export class PersonalAgentDelegation {
|
|
53
|
+
#cfg;
|
|
54
|
+
#store;
|
|
55
|
+
#signer;
|
|
56
|
+
#urls;
|
|
57
|
+
#isAccountActive;
|
|
58
|
+
#now;
|
|
59
|
+
constructor(deps) {
|
|
60
|
+
this.#cfg = deps.cfg;
|
|
61
|
+
this.#store = deps.store;
|
|
62
|
+
this.#signer = deps.signer;
|
|
63
|
+
this.#urls = deps.urls;
|
|
64
|
+
this.#isAccountActive = deps.isAccountActive ?? (async () => true);
|
|
65
|
+
this.#now = deps.now ?? (() => new Date());
|
|
66
|
+
}
|
|
67
|
+
get scopes() {
|
|
68
|
+
return this.#cfg.scopes;
|
|
69
|
+
}
|
|
70
|
+
get interfaceUrl() {
|
|
71
|
+
return this.#cfg.interfaceUrl;
|
|
72
|
+
}
|
|
73
|
+
// ─── endpoints do agente ──────────────────────────────────────────────────
|
|
74
|
+
/** `POST device_authorization` (PACT §5.3). */
|
|
75
|
+
async requestDevice(agent, scopeParam) {
|
|
76
|
+
const scopes = typeof scopeParam === 'string' ? parseScope(scopeParam) : [];
|
|
77
|
+
if (scopes.length === 0 || scopes.some((id) => !(id in this.#cfg.scopes))) {
|
|
78
|
+
throw new AgentOAuthError('invalid_scope', 'Request scope ids listed on the Agent Card');
|
|
79
|
+
}
|
|
80
|
+
const now = this.#now();
|
|
81
|
+
await this.#store.deleteDevicesExpiredBefore(now);
|
|
82
|
+
const deviceCode = randomToken('dc_');
|
|
83
|
+
const expiresAt = new Date(now.getTime() + this.#cfg.deviceCodeTtl * 1000);
|
|
84
|
+
let userCode = newUserCode();
|
|
85
|
+
for (let attempt = 0;; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
await this.#store.insertDevice({
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
deviceCodeHash: sha256(deviceCode),
|
|
90
|
+
userCode,
|
|
91
|
+
clientId: agent.issuer,
|
|
92
|
+
agentSub: agent.sub,
|
|
93
|
+
requestedScope: formatScope(scopes),
|
|
94
|
+
status: 'pending',
|
|
95
|
+
intervalSeconds: this.#cfg.pollInterval,
|
|
96
|
+
expiresAt,
|
|
97
|
+
createdAt: now,
|
|
98
|
+
});
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// Colisão de `user_code` (índice único) — 20^8 combinações, raríssimo.
|
|
103
|
+
if (attempt >= 2)
|
|
104
|
+
throw error;
|
|
105
|
+
userCode = newUserCode();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const complete = new URL(this.#urls.consent);
|
|
109
|
+
complete.searchParams.set('user_code', userCode);
|
|
110
|
+
return {
|
|
111
|
+
device_code: deviceCode,
|
|
112
|
+
user_code: userCode,
|
|
113
|
+
verification_uri: this.#urls.consent,
|
|
114
|
+
verification_uri_complete: complete.toString(),
|
|
115
|
+
expires_in: this.#cfg.deviceCodeTtl,
|
|
116
|
+
interval: this.#cfg.pollInterval,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** `POST token` com `grant_type=device_code` (RFC 8628 §3.4/§3.5). */
|
|
120
|
+
async exchangeDeviceCode(agent, deviceCode) {
|
|
121
|
+
if (typeof deviceCode !== 'string' || !deviceCode) {
|
|
122
|
+
throw new AgentOAuthError('invalid_request', 'device_code is required');
|
|
123
|
+
}
|
|
124
|
+
const row = await this.#store.findDeviceByCodeHash(sha256(deviceCode));
|
|
125
|
+
// De outro agente (ou de outro usuário do agente) = desconhecido.
|
|
126
|
+
if (!row || row.clientId !== agent.issuer || row.agentSub !== agent.sub) {
|
|
127
|
+
throw new AgentOAuthError('invalid_grant', 'Unknown device_code');
|
|
128
|
+
}
|
|
129
|
+
const now = this.#now();
|
|
130
|
+
if (row.status === 'denied')
|
|
131
|
+
throw new AgentOAuthError('access_denied', 'The user denied access');
|
|
132
|
+
if (row.status === 'consumed')
|
|
133
|
+
throw new AgentOAuthError('invalid_grant', 'device_code was used');
|
|
134
|
+
if (row.expiresAt <= now)
|
|
135
|
+
throw new AgentOAuthError('expired_token', 'The device code expired');
|
|
136
|
+
// `approved` sem grant = a aprovação ainda está gravando o grant.
|
|
137
|
+
if (row.status === 'pending' || !row.grantId) {
|
|
138
|
+
const tooSoon = row.lastPolledAt !== null &&
|
|
139
|
+
now.getTime() - row.lastPolledAt.getTime() < row.intervalSeconds * 1000;
|
|
140
|
+
await this.#store.markPolled(row.id, now);
|
|
141
|
+
throw tooSoon
|
|
142
|
+
? new AgentOAuthError('slow_down', 'Poll less often')
|
|
143
|
+
: new AgentOAuthError('authorization_pending', 'Waiting for the user');
|
|
144
|
+
}
|
|
145
|
+
const grant = await this.#store.findGrant(row.grantId);
|
|
146
|
+
if (!grant || !(await this.#grantUsable(grant, agent, now))) {
|
|
147
|
+
throw new AgentOAuthError('invalid_grant', 'The grant is no longer valid');
|
|
148
|
+
}
|
|
149
|
+
// Assina ANTES de consumir: uma falha de assinatura não pode queimar o código.
|
|
150
|
+
const accessToken = await this.#signAccessToken(grant, now);
|
|
151
|
+
if (!(await this.#store.transitionDevice(row.id, 'approved', { status: 'consumed' }))) {
|
|
152
|
+
throw new AgentOAuthError('invalid_grant', 'device_code was used');
|
|
153
|
+
}
|
|
154
|
+
return this.#tokenResponse(grant, accessToken, now);
|
|
155
|
+
}
|
|
156
|
+
/** `POST token` com `grant_type=refresh_token`. Rotativo: o refresh usado morre. */
|
|
157
|
+
async refresh(agent, refreshToken) {
|
|
158
|
+
if (typeof refreshToken !== 'string' || !refreshToken) {
|
|
159
|
+
throw new AgentOAuthError('invalid_request', 'refresh_token is required');
|
|
160
|
+
}
|
|
161
|
+
const invalid = new AgentOAuthError('invalid_grant', 'Unknown or expired refresh_token');
|
|
162
|
+
const now = this.#now();
|
|
163
|
+
const hash = sha256(refreshToken);
|
|
164
|
+
const row = await this.#store.findRefreshToken(hash);
|
|
165
|
+
if (!row || row.expiresAt <= now)
|
|
166
|
+
throw invalid;
|
|
167
|
+
const grant = await this.#store.findGrant(row.grantId);
|
|
168
|
+
// Valida o chamador ANTES de gastar o token: um terceiro não queima o refresh alheio.
|
|
169
|
+
if (!grant || !(await this.#grantUsable(grant, agent, now)))
|
|
170
|
+
throw invalid;
|
|
171
|
+
// Reuso de um refresh já gasto = vazou. Revoga o grant inteiro (RFC 9700 §4.14).
|
|
172
|
+
if (row.usedAt !== null || !(await this.#store.useRefreshToken(hash, now))) {
|
|
173
|
+
await this.#store.revokeGrantById(grant.id, now);
|
|
174
|
+
throw invalid;
|
|
175
|
+
}
|
|
176
|
+
const accessToken = await this.#signAccessToken(grant, now);
|
|
177
|
+
return this.#tokenResponse(grant, accessToken, now);
|
|
178
|
+
}
|
|
179
|
+
// ─── tela de consentimento ────────────────────────────────────────────────
|
|
180
|
+
/** O pedido por trás de um `user_code`, se ainda está aguardando. */
|
|
181
|
+
async pendingRequest(userCodeInput) {
|
|
182
|
+
const userCode = normalizeUserCode(userCodeInput);
|
|
183
|
+
if (!userCode)
|
|
184
|
+
return null;
|
|
185
|
+
const row = await this.#store.findDeviceByUserCode(userCode);
|
|
186
|
+
if (!row || row.status !== 'pending' || row.expiresAt <= this.#now())
|
|
187
|
+
return null;
|
|
188
|
+
return {
|
|
189
|
+
userCode: row.userCode,
|
|
190
|
+
clientId: row.clientId,
|
|
191
|
+
scopes: this.#describe(parseScope(row.requestedScope)),
|
|
192
|
+
expiresAt: row.expiresAt,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* O usuário aprovou `scopes` (só o que estava no pedido conta). `null` = nada
|
|
197
|
+
* foi aprovado: nenhum scope do pedido marcado, ou o pedido não está mais
|
|
198
|
+
* pendente — o chamador decide se nega.
|
|
199
|
+
*/
|
|
200
|
+
async approve(input) {
|
|
201
|
+
const userCode = normalizeUserCode(input.userCode);
|
|
202
|
+
const row = userCode ? await this.#store.findDeviceByUserCode(userCode) : null;
|
|
203
|
+
const now = this.#now();
|
|
204
|
+
if (!row || row.status !== 'pending' || row.expiresAt <= now)
|
|
205
|
+
return null;
|
|
206
|
+
const requested = parseScope(row.requestedScope);
|
|
207
|
+
const granted = requested.filter((id) => input.scopes.includes(id));
|
|
208
|
+
if (granted.length === 0)
|
|
209
|
+
return null;
|
|
210
|
+
// Reivindica o pedido ANTES de mexer em grant: de duas aprovações
|
|
211
|
+
// concorrentes do mesmo código, só uma passa daqui.
|
|
212
|
+
const claimed = await this.#store.transitionDevice(row.id, 'pending', {
|
|
213
|
+
status: 'approved',
|
|
214
|
+
accountId: input.accountId,
|
|
215
|
+
});
|
|
216
|
+
if (!claimed)
|
|
217
|
+
return null;
|
|
218
|
+
const expiresAt = new Date(now.getTime() + this.#cfg.grantTtl * 1000);
|
|
219
|
+
const existing = (await this.#store.findGrantsFor(input.accountId, row.clientId, row.agentSub))
|
|
220
|
+
.filter((g) => g.expiresAt > now)
|
|
221
|
+
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0];
|
|
222
|
+
let result = null;
|
|
223
|
+
if (existing) {
|
|
224
|
+
const scope = formatScope(new Set([...parseScope(existing.scope), ...granted]));
|
|
225
|
+
// Só soma num grant que continua vivo — revogado no meio do caminho, cria outro.
|
|
226
|
+
if (await this.#store.updateGrant(existing.id, { scope, expiresAt, updatedAt: now })) {
|
|
227
|
+
result = { grantId: existing.id, scopes: parseScope(scope) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!result) {
|
|
231
|
+
const grantId = `agrant_${randomUUID()}`;
|
|
232
|
+
await this.#store.insertGrant({
|
|
233
|
+
id: grantId,
|
|
234
|
+
accountId: input.accountId,
|
|
235
|
+
clientId: row.clientId,
|
|
236
|
+
agentSub: row.agentSub,
|
|
237
|
+
scope: formatScope(granted),
|
|
238
|
+
expiresAt,
|
|
239
|
+
createdAt: now,
|
|
240
|
+
updatedAt: now,
|
|
241
|
+
});
|
|
242
|
+
result = await this.#converge(input.accountId, row.clientId, row.agentSub, now);
|
|
243
|
+
}
|
|
244
|
+
// Só agora o polling do agente enxerga a aprovação (antes disso: pending).
|
|
245
|
+
await this.#store.setDeviceGrant(row.id, result.grantId);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
async deny(input) {
|
|
249
|
+
const userCode = normalizeUserCode(input.userCode);
|
|
250
|
+
const row = userCode ? await this.#store.findDeviceByUserCode(userCode) : null;
|
|
251
|
+
if (!row || row.status !== 'pending')
|
|
252
|
+
return false;
|
|
253
|
+
return this.#store.transitionDevice(row.id, 'pending', {
|
|
254
|
+
status: 'denied',
|
|
255
|
+
accountId: input.accountId,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// ─── console de conta ─────────────────────────────────────────────────────
|
|
259
|
+
async listGrants(accountId) {
|
|
260
|
+
const now = this.#now();
|
|
261
|
+
return (await this.#store.listGrants(accountId))
|
|
262
|
+
.filter((g) => g.expiresAt > now)
|
|
263
|
+
.map((g) => ({
|
|
264
|
+
id: g.id,
|
|
265
|
+
clientId: g.clientId,
|
|
266
|
+
scopes: this.#describe(parseScope(g.scope)),
|
|
267
|
+
createdAt: g.createdAt,
|
|
268
|
+
updatedAt: g.updatedAt,
|
|
269
|
+
expiresAt: g.expiresAt,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
/** Revoga na hora: tokens já emitidos param de valer na próxima request. */
|
|
273
|
+
revokeGrant(accountId, grantId) {
|
|
274
|
+
return this.#store.revokeGrant(accountId, grantId, this.#now());
|
|
275
|
+
}
|
|
276
|
+
/** Revoga TODAS as delegações da conta. Devolve quantas estavam vivas. */
|
|
277
|
+
revokeAllGrants(accountId) {
|
|
278
|
+
return this.#store.revokeAllGrants(accountId, this.#now());
|
|
279
|
+
}
|
|
280
|
+
// ─── resource server ──────────────────────────────────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* Valida o header `X-A2A-User-Delegation: Bearer <token>` de uma request que
|
|
283
|
+
* JÁ passou pela verificação do JWT do agente (PACT §5.5). `null` = inválido
|
|
284
|
+
* (o chamador responde 401 `invalid_token`).
|
|
285
|
+
*/
|
|
286
|
+
async verify(agent, header) {
|
|
287
|
+
const token = bearerToken(header);
|
|
288
|
+
if (!token)
|
|
289
|
+
return null;
|
|
290
|
+
const now = this.#now();
|
|
291
|
+
let payload;
|
|
292
|
+
try {
|
|
293
|
+
({ payload } = await jwtVerify(token, this.#signer.keySet(), {
|
|
294
|
+
algorithms: this.#signer.algorithms,
|
|
295
|
+
issuer: this.#urls.issuer,
|
|
296
|
+
audience: this.#cfg.interfaceUrl,
|
|
297
|
+
currentDate: now,
|
|
298
|
+
requiredClaims: ['sub', 'exp', 'client_id', 'scope', 'grant_id'],
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
if (payload.client_id !== agent.issuer || typeof payload.grant_id !== 'string')
|
|
305
|
+
return null;
|
|
306
|
+
// Revogação: o grant precisa continuar vivo, e ser DESTE usuário do agente —
|
|
307
|
+
// o token não pode ser reaproveitado na conversa de outro usuário dele.
|
|
308
|
+
const grant = await this.#store.findGrant(payload.grant_id);
|
|
309
|
+
if (!grant ||
|
|
310
|
+
grant.accountId !== payload.sub ||
|
|
311
|
+
!(await this.#grantUsable(grant, agent, now))) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
accountId: grant.accountId,
|
|
316
|
+
scopes: parseScope(payload.scope),
|
|
317
|
+
grantId: grant.id,
|
|
318
|
+
clientId: agent.issuer,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** Recibo assinado de uma resposta servida sob delegação (PACT §5.6). */
|
|
322
|
+
async receipt(delegation, input) {
|
|
323
|
+
const claims = {
|
|
324
|
+
grantId: delegation.grantId,
|
|
325
|
+
user: delegation.accountId,
|
|
326
|
+
pa: delegation.clientId,
|
|
327
|
+
brand: this.#cfg.interfaceUrl,
|
|
328
|
+
scopesUsed: input.scopesUsed,
|
|
329
|
+
actions: input.actions ?? [],
|
|
330
|
+
ts: this.#now().toISOString(),
|
|
331
|
+
};
|
|
332
|
+
return { jws: await this.#signer.signJson(claims), claims };
|
|
333
|
+
}
|
|
334
|
+
// ─── internos ─────────────────────────────────────────────────────────────
|
|
335
|
+
/**
|
|
336
|
+
* Duas aprovações simultâneas (dois códigos, mesma conta/agente/usuário) podem
|
|
337
|
+
* ambas não achar grant e ambas inserir. Depois do insert, todo mundo elege o
|
|
338
|
+
* MESMO sobrevivente — o mais antigo, desempate pelo id — soma os scopes nele
|
|
339
|
+
* e revoga o resto. As duas chamadas convergem para o mesmo grant.
|
|
340
|
+
*/
|
|
341
|
+
async #converge(accountId, clientId, agentSub, now) {
|
|
342
|
+
const live = (await this.#store.findGrantsFor(accountId, clientId, agentSub))
|
|
343
|
+
.filter((g) => g.expiresAt > now)
|
|
344
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
|
|
345
|
+
const [survivor, ...extras] = live;
|
|
346
|
+
if (!survivor)
|
|
347
|
+
throw new AgentOAuthError('invalid_grant', 'The grant vanished');
|
|
348
|
+
if (extras.length === 0)
|
|
349
|
+
return { grantId: survivor.id, scopes: parseScope(survivor.scope) };
|
|
350
|
+
const scope = formatScope(new Set(live.flatMap((g) => parseScope(g.scope))));
|
|
351
|
+
const expiresAt = new Date(Math.max(...live.map((g) => g.expiresAt.getTime())));
|
|
352
|
+
await this.#store.updateGrant(survivor.id, { scope, expiresAt, updatedAt: now });
|
|
353
|
+
for (const extra of extras)
|
|
354
|
+
await this.#store.revokeGrantById(extra.id, now);
|
|
355
|
+
return { grantId: survivor.id, scopes: parseScope(scope) };
|
|
356
|
+
}
|
|
357
|
+
async #grantUsable(grant, agent, now) {
|
|
358
|
+
return (grant.revokedAt === null &&
|
|
359
|
+
grant.expiresAt > now &&
|
|
360
|
+
grant.clientId === agent.issuer &&
|
|
361
|
+
grant.agentSub === agent.sub &&
|
|
362
|
+
(await this.#isAccountActive(grant.accountId)));
|
|
363
|
+
}
|
|
364
|
+
#signAccessToken(grant, now) {
|
|
365
|
+
return this.#signer.signJwt({
|
|
366
|
+
iss: this.#urls.issuer,
|
|
367
|
+
aud: this.#cfg.interfaceUrl,
|
|
368
|
+
sub: grant.accountId,
|
|
369
|
+
client_id: grant.clientId,
|
|
370
|
+
scope: grant.scope,
|
|
371
|
+
grant_id: grant.id,
|
|
372
|
+
jti: randomUUID(),
|
|
373
|
+
}, { issuedAt: now, expiresIn: this.#cfg.accessTokenTtl });
|
|
374
|
+
}
|
|
375
|
+
async #tokenResponse(grant, accessToken, now) {
|
|
376
|
+
const refreshToken = randomToken('rt_');
|
|
377
|
+
await this.#store.insertRefreshToken({
|
|
378
|
+
tokenHash: sha256(refreshToken),
|
|
379
|
+
grantId: grant.id,
|
|
380
|
+
expiresAt: grant.expiresAt,
|
|
381
|
+
createdAt: now,
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
token_type: 'Bearer',
|
|
385
|
+
access_token: accessToken,
|
|
386
|
+
refresh_token: refreshToken,
|
|
387
|
+
expires_in: this.#cfg.accessTokenTtl,
|
|
388
|
+
scope: grant.scope,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
#describe(ids) {
|
|
392
|
+
return ids.map((id) => ({ id, description: this.#cfg.scopes[id] ?? id }));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistência da delegação de personal agents — três tabelas LIB-OWNED (ver
|
|
3
|
+
* `schema/ensure.ts`): pedidos de device flow, grants e refresh tokens.
|
|
4
|
+
*
|
|
5
|
+
* Query builder puro (sem model Lucid): as tabelas são da lib, o host não as
|
|
6
|
+
* estende. Comparações de data acontecem em JS depois de buscar a linha pela
|
|
7
|
+
* chave — timestamp em SQL compara diferente em sqlite/pg/mysql; status e
|
|
8
|
+
* chaves únicas, não.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEVICE_TABLE = "auth_agent_device_codes";
|
|
11
|
+
export declare const GRANT_TABLE = "auth_agent_grants";
|
|
12
|
+
export declare const REFRESH_TABLE = "auth_agent_refresh_tokens";
|
|
13
|
+
export type DeviceStatus = 'pending' | 'approved' | 'denied' | 'consumed';
|
|
14
|
+
export interface DeviceCodeRow {
|
|
15
|
+
id: string;
|
|
16
|
+
deviceCodeHash: string;
|
|
17
|
+
userCode: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
agentSub: string;
|
|
20
|
+
requestedScope: string;
|
|
21
|
+
status: DeviceStatus;
|
|
22
|
+
accountId: string | null;
|
|
23
|
+
grantId: string | null;
|
|
24
|
+
intervalSeconds: number;
|
|
25
|
+
lastPolledAt: Date | null;
|
|
26
|
+
expiresAt: Date;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
}
|
|
29
|
+
export interface GrantRow {
|
|
30
|
+
id: string;
|
|
31
|
+
accountId: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
agentSub: string;
|
|
34
|
+
scope: string;
|
|
35
|
+
expiresAt: Date;
|
|
36
|
+
revokedAt: Date | null;
|
|
37
|
+
createdAt: Date;
|
|
38
|
+
updatedAt: Date;
|
|
39
|
+
}
|
|
40
|
+
export declare class DelegationStore {
|
|
41
|
+
private conn;
|
|
42
|
+
/** `conn` = uma conexão Lucid (`db.connection(name?)`) — ou uma transação. */
|
|
43
|
+
constructor(conn: () => any);
|
|
44
|
+
insertDevice(row: Omit<DeviceCodeRow, 'accountId' | 'grantId' | 'lastPolledAt'>): Promise<void>;
|
|
45
|
+
findDeviceByCodeHash(hash: string): Promise<DeviceCodeRow | null>;
|
|
46
|
+
findDeviceByUserCode(userCode: string): Promise<DeviceCodeRow | null>;
|
|
47
|
+
/** Liga o pedido aprovado ao grant — a partir daqui o polling do agente recebe o token. */
|
|
48
|
+
setDeviceGrant(id: string, grantId: string): Promise<void>;
|
|
49
|
+
markPolled(id: string, at: Date): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Transição de estado ATÔMICA: só aplica se o pedido ainda está em `from`.
|
|
52
|
+
* `false` = outra request chegou antes (aprovação dupla, poll concorrente).
|
|
53
|
+
*/
|
|
54
|
+
transitionDevice(id: string, from: DeviceStatus, patch: {
|
|
55
|
+
status: DeviceStatus;
|
|
56
|
+
accountId?: string;
|
|
57
|
+
grantId?: string;
|
|
58
|
+
}): Promise<boolean>;
|
|
59
|
+
/**
|
|
60
|
+
* Housekeeping: apaga pedidos que expiraram antes de `before`. O parâmetro
|
|
61
|
+
* vai pelo mesmo binding do INSERT, então a comparação é coerente no dialeto.
|
|
62
|
+
*/
|
|
63
|
+
deleteDevicesExpiredBefore(before: Date): Promise<void>;
|
|
64
|
+
insertGrant(row: Omit<GrantRow, 'revokedAt'>): Promise<void>;
|
|
65
|
+
findGrant(id: string): Promise<GrantRow | null>;
|
|
66
|
+
/** Grants NÃO revogados da tripla (conta, agente, usuário do agente). */
|
|
67
|
+
findGrantsFor(accountId: string, clientId: string, agentSub: string): Promise<GrantRow[]>;
|
|
68
|
+
/** Atualiza um grant NÃO revogado. `false` = revogado (ou sumiu) nesse meio-tempo. */
|
|
69
|
+
updateGrant(id: string, patch: {
|
|
70
|
+
scope: string;
|
|
71
|
+
expiresAt: Date;
|
|
72
|
+
updatedAt: Date;
|
|
73
|
+
}): Promise<boolean>;
|
|
74
|
+
listGrants(accountId: string): Promise<GrantRow[]>;
|
|
75
|
+
/** Revoga um grant DA CONTA. `false` = não existe, é de outra conta ou já revogado. */
|
|
76
|
+
revokeGrant(accountId: string, id: string, at: Date): Promise<boolean>;
|
|
77
|
+
revokeAllGrants(accountId: string, at: Date): Promise<number>;
|
|
78
|
+
/** Revoga um grant sem checar a conta — reação a reuso de refresh token. */
|
|
79
|
+
revokeGrantById(id: string, at: Date): Promise<void>;
|
|
80
|
+
insertRefreshToken(row: {
|
|
81
|
+
tokenHash: string;
|
|
82
|
+
grantId: string;
|
|
83
|
+
expiresAt: Date;
|
|
84
|
+
createdAt: Date;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
findRefreshToken(tokenHash: string): Promise<{
|
|
87
|
+
grantId: string;
|
|
88
|
+
expiresAt: Date;
|
|
89
|
+
usedAt: Date | null;
|
|
90
|
+
} | null>;
|
|
91
|
+
/** Marca o refresh como usado (uso único, atômico). `false` = já tinha sido usado. */
|
|
92
|
+
useRefreshToken(tokenHash: string, at: Date): Promise<boolean>;
|
|
93
|
+
}
|