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)}`; }