Files
AgentCompany/src/main/services/auth-broker-service.ts
T

197 lines
7.4 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import { createHash } from "node:crypto";
import type { ProviderAuthType } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { SecretVault } from "./secret-vault";
export interface ProviderSecretVault {
encryptSecret(secret: string): Buffer;
decryptSecret(ciphertext: Buffer): string;
}
export interface CreateProviderAuthProfileInput {
orgId: string;
providerId: string;
providerKey: string;
authType: ProviderAuthType;
secret: string;
profileKind: string;
sourceType: string;
sourceRef: string | null;
capabilityTags: string[];
}
export interface ProviderAuthProfileRef {
authProfileId: string;
credentialId: string;
displayHint: string;
}
export interface RevokeProviderAuthProfileInput {
orgId: string;
providerId: string;
providerKey: string;
/** The credential being disconnected; a newer connection made meanwhile is left alone. */
credentialId: string;
}
export class AuthBrokerService {
private readonly audit: AuditService;
constructor(
private readonly db: DatabaseSync,
private readonly vault: ProviderSecretVault = new SecretVault(),
) {
this.audit = new AuditService(db);
}
createProviderProfile(input: CreateProviderAuthProfileInput): ProviderAuthProfileRef {
const secret = input.secret.trim();
if (!secret) throw new Error("Credential is required.");
const credentialId = createId("cred");
const authProfileId = createId("authp");
const displayHint = credentialDisplayHint(secret);
const now = new Date().toISOString();
const capabilityJson = JSON.stringify({
providerKey: input.providerKey,
authType: input.authType,
capabilityTags: input.capabilityTags,
});
this.db.prepare(`
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(credentialId, input.orgId, input.providerKey, input.authType, this.vault.encryptSecret(secret), displayHint, now);
const replaced = this.db.prepare("select credential_id from provider_auth_profiles where org_id = ? and provider_id = ? and active = 1")
.all(input.orgId, input.providerId) as Array<{ credential_id: string }>;
this.db.prepare(`
update provider_auth_profiles
set active = 0
where org_id = ? and provider_id = ? and active = 1
`).run(input.orgId, input.providerId);
for (const row of replaced) this.eraseCredential(row.credential_id);
this.db.prepare(`
insert into provider_auth_profiles(
id, org_id, provider_id, credential_id, provider_key, auth_type,
profile_kind, display_hint, source_type, source_ref, capability_json,
active, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(
authProfileId,
input.orgId,
input.providerId,
credentialId,
input.providerKey,
input.authType,
input.profileKind,
displayHint,
input.sourceType,
input.sourceRef,
capabilityJson,
now,
);
this.audit.record({
orgId: input.orgId,
entityType: "provider_auth_profile",
entityId: authProfileId,
actorType: "system",
actorId: null,
action: "created",
payload: {
providerId: input.providerId,
providerKey: input.providerKey,
authType: input.authType,
profileKind: input.profileKind,
},
});
return { authProfileId, credentialId, displayHint };
}
loadProviderSecret(input: {
orgId: string;
providerId: string;
providerKey: string;
credentialId: string;
}): string {
const row = this.db.prepare(`
select credentials.ciphertext
from provider_auth_profiles
join credentials on credentials.id = provider_auth_profiles.credential_id
where provider_auth_profiles.org_id = ?
and provider_auth_profiles.provider_id = ?
and provider_auth_profiles.provider_key = ?
and provider_auth_profiles.credential_id = ?
and provider_auth_profiles.active = 1
and credentials.org_id = provider_auth_profiles.org_id
and credentials.provider_key = provider_auth_profiles.provider_key
`).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { ciphertext: Uint8Array } | undefined;
if (!row) throw new Error("Provider credential was not found.");
return this.vault.decryptSecret(Buffer.from(row.ciphertext));
}
/**
* Re-encrypts a refreshed OAuth credential into its existing credential row,
* keeping the same credential id (and active auth profile) so request-time
* token refresh persists without re-binding the provider.
*/
updateProviderSecret(input: { orgId: string; providerKey: string; credentialId: string; secret: string }): void {
const secret = input.secret.trim();
if (!secret) throw new Error("Credential is required.");
const displayHint = credentialDisplayHint(secret);
// Only a credential still in use may be written: a renewal that lands after a disconnect or replacement must not revive it.
const result = this.db.prepare(`
update credentials
set ciphertext = ?, display_hint = ?
where id = ? and org_id = ? and provider_key = ?
and id in (select credential_id from provider_auth_profiles where active = 1)
`).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey);
if (result.changes === 0) throw new Error("The sign-in was disconnected or replaced while it was being renewed; the renewed token was discarded.");
this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?")
.run(displayHint, input.credentialId, input.orgId);
}
/** A retired credential keeps its row for history, never its secret. */
private eraseCredential(credentialId: string): void {
this.db.prepare("update credentials set ciphertext = x'' where id = ?").run(credentialId);
}
/** Retires one credential; false when a newer connection had already replaced it (it is erased either way). */
revokeProviderProfile(input: RevokeProviderAuthProfileInput): boolean {
this.eraseCredential(input.credentialId);
const row = this.db.prepare(`
select id, credential_id
from provider_auth_profiles
where org_id = ?
and provider_id = ?
and provider_key = ?
and credential_id = ?
and active = 1
`).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { id: string; credential_id: string } | undefined;
if (!row) return false;
this.db.prepare("update provider_auth_profiles set active = 0 where id = ? and org_id = ?")
.run(row.id, input.orgId);
this.audit.record({
orgId: input.orgId,
entityType: "provider_auth_profile",
entityId: row.id,
actorType: "system",
actorId: null,
action: "revoked",
payload: {
providerId: input.providerId,
providerKey: input.providerKey,
credentialId: row.credential_id,
},
});
return true;
}
}
function credentialDisplayHint(secret: string): string {
const clean = secret.trim();
if (clean.startsWith("{")) return `json:${createHash("sha256").update(clean).digest("hex").slice(0, 10)}`;
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
return `${clean.slice(0, 6)}...${clean.slice(-4)}`;
}