Ctrl K
ComunidadGuías…

lib/xero-token-refresh.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomUUID } from "node:crypto";
import {
  decryptCredential,
  encryptCredential,
  OAuthRefreshError,
  refreshOAuthAccessTokenSingleFlight,
} from "@/lib/credentials";
import { createAdminClient } from "@/lib/neon/server";
import { tokenLifetime } from "@/lib/runtime/token-lifecycle";

const XERO_TOKEN_URL = "https://identity.xero.com/connect/token";
const REFRESH_AHEAD_MS = 10 * 60 * 1000;
const LEASE_MS = 60 * 1000;
const MAX_BATCH_SIZE = 50;

type XeroCredential = {
  id: string;
  user_id: string;
  server_id: string | null;
  provider: string | null;
  client_id: string | null;
  client_secret_ciphertext: string | null;
  token_auth_method: "client_secret_post" | "client_secret_basic" | null;
  refresh_token_ciphertext: string | null;
  token_url: string | null;
  scopes: unknown;
  refresh_generation: number | null;
};

export type XeroRefreshSummary = {
  inspected: number;
  refreshed: number;
  skipped: number;
  reauthRequired: number;
  failed: number;
};

function stringScopes(value: unknown) {
  return Array.isArray(value) ? value.filter((scope): scope is string => typeof scope === "string") : [];
}

export async function refreshExpiringXeroTokens(limit = MAX_BATCH_SIZE): Promise<XeroRefreshSummary> {
  const admin = createAdminClient();
  const batchSize = Math.max(1, Math.min(MAX_BATCH_SIZE, Math.trunc(limit)));
  const cutoff = new Date(Date.now() + REFRESH_AHEAD_MS).toISOString();
  const { data, error } = await admin.from("api_credentials")
    .select("id,user_id,server_id,provider,client_id,client_secret_ciphertext,token_auth_method,refresh_token_ciphertext,token_url,scopes,refresh_generation")
    .eq("auth_scheme", "oauth2")
    .ilike("provider", "xero")
    .eq("connect_status", "active")
    .is("revocation_status", null)
    .not("refresh_token_ciphertext", "is", null)
    .lte("expires_at", cutoff)
    .order("expires_at", { ascending: true })
    .limit(batchSize);
  if (error) throw new Error(`Could not load expiring Xero credentials: ${error.message}`);

  const credentials = (data ?? []) as XeroCredential[];
  const summary: XeroRefreshSummary = { inspected: credentials.length, refreshed: 0, skipped: 0, reauthRequired: 0, failed: 0 };
  for (const credential of credentials) {
    if (!credential.client_id || !credential.client_secret_ciphertext || !credential.refresh_token_ciphertext
      || credential.token_url !== XERO_TOKEN_URL || !stringScopes(credential.scopes).includes("offline_access")) {
      summary.reauthRequired += 1;
      await admin.from("api_credentials").update({
        connect_status: "reauth_required",
        last_refresh_status: "reauth_required",
        last_refresh_error: "xero_refresh_configuration_invalid",
        updated_at: new Date().toISOString(),
      }).eq("id", credential.id).eq("user_id", credential.user_id);
      continue;
    }

    const leaseId = randomUUID();
    const generation = Number(credential.refresh_generation ?? 0);
    const { data: leaseData, error: leaseError } = await admin.rpc("claim_oauth_refresh", {
      p_credential_id: credential.id,
      p_user_id: credential.user_id,
      p_expected_generation: generation,
      p_lease_id: leaseId,
      p_lease_until: new Date(Date.now() + LEASE_MS).toISOString(),
    });
    const lease = Array.isArray(leaseData) ? leaseData[0] : leaseData;
    if (leaseError || !lease?.claimed) {
      summary.skipped += 1;
      continue;
    }

    try {
      const refreshed = await refreshOAuthAccessTokenSingleFlight(credential.id, {
        provider: "xero",
        tokenUrl: XERO_TOKEN_URL,
        clientId: credential.client_id,
        clientSecret: decryptCredential(credential.client_secret_ciphertext),
        tokenAuthMethod: "client_secret_basic",
        refreshToken: decryptCredential(credential.refresh_token_ciphertext),
        scopes: stringScopes(credential.scopes),
      });
      const refreshedAt = new Date();
      const lifetime = tokenLifetime(refreshed.expiresAt ?? null, refreshedAt);
      const { data: committed, error: commitError } = await admin.rpc("commit_oauth_refresh", {
        p_credential_id: credential.id,
        p_user_id: credential.user_id,
        p_lease_id: leaseId,
        p_expected_generation: generation,
        p_access_token_ciphertext: encryptCredential(refreshed.accessToken),
        p_refresh_token_ciphertext: refreshed.refreshToken ? encryptCredential(refreshed.refreshToken) : null,
        p_expires_at: refreshed.expiresAt,
        p_issued_at: lifetime.issuedAt,
        p_original_ttl_seconds: lifetime.ttlSeconds,
        p_scopes: refreshed.scopes,
      });
      if (commitError || committed !== true) throw new Error("Xero token rotation lost its database lease before commit.");
      summary.refreshed += 1;
      await admin.from("oauth_lifecycle_events").insert({
        user_id: credential.user_id,
        server_id: credential.server_id,
        credential_id: credential.id,
        provider: "xero",
        event_type: "scheduled_refresh",
        generation: generation + 1,
        outcome: "succeeded",
        detail: { ttl_seconds: lifetime.ttlSeconds },
      });
    } catch (refreshError) {
      const permanent = refreshError instanceof OAuthRefreshError && refreshError.permanent;
      summary[permanent ? "reauthRequired" : "failed"] += 1;
      await admin.from("api_credentials").update({
        ...(permanent ? { connect_status: "reauth_required" } : {}),
        refresh_lease_id: null,
        refresh_lease_until: null,
        last_refresh_status: permanent ? "reauth_required" : "transient_failure",
        last_refresh_at: new Date().toISOString(),
        last_refresh_error: permanent ? (refreshError.oauthErrorCode ?? "xero_refresh_rejected") : "transient_refresh_failure",
        updated_at: new Date().toISOString(),
      }).eq("id", credential.id).eq("user_id", credential.user_id).eq("refresh_lease_id", leaseId);
    }
  }
  return summary;
}