From f7b8a9b507ba94b2e8a51eb62a5c11ec2f4d1e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 22:36:38 +0000 Subject: [PATCH] fix(skills): serialize OAuth credential refresh --- skills-manifest.json | 4 +- skills/media-use/audio/scripts/lib/heygen.mjs | 109 +++++++++++++++--- .../audio/scripts/lib/heygen.test.mjs | 92 ++++++++++++++- 3 files changed, 189 insertions(+), 16 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index a76f456884..d4079537c2 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 144 }, "faceless-explainer": { - "hash": "3ec4145b533d112c", + "hash": "988b7b689da9f33f", "files": 19 }, "figma": { @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "c7262e0f652322fd", + "hash": "b96e861d2f5dd746", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/lib/heygen.mjs b/skills/media-use/audio/scripts/lib/heygen.mjs index bcd91996ea..b32a0881f4 100644 --- a/skills/media-use/audio/scripts/lib/heygen.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.mjs @@ -4,7 +4,18 @@ // credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR // overrides the dir). Vendored so the skill ships standalone. Pure node. -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -13,6 +24,9 @@ const HEYGEN_OAUTH_TOKEN_URL = process.env.HYPERFRAMES_OAUTH_TOKEN_URL || "https://api2.heygen.com/v1/oauth/token"; const HEYGEN_OAUTH_CLIENT_ID = process.env.HYPERFRAMES_OAUTH_CLIENT_ID || "q2A2QRSke2LrFTPJhoDbHtXh"; +const REFRESH_LOCK_RETRY_MS = 25; +const REFRESH_LOCK_TIMEOUT_MS = 10_000; +const REFRESH_LOCK_STALE_MS = 30_000; export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" }; // Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so // the backend can isolate media-use consumption from other free TTS / avatar video. @@ -72,9 +86,9 @@ export function heygenCredential() { if (oauth?.access_token) { const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now(); if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } }; - if (oauth.refresh_token) return { expired: true, refreshable: true, file, credentials: cred }; - if (!cred.api_key) return { expired: true }; + if (!oauth.refresh_token && !cred.api_key) return { expired: true }; } + if (oauth?.refresh_token) return { expired: true, refreshable: true, file, credentials: cred }; if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } }; return null; } @@ -86,6 +100,7 @@ export function heygenCredential() { // with nothing to tag. export function heygenAuthMethod() { const cred = heygenCredential(); + if (cred?.refreshable) return "oauth"; if (!cred?.headers) return null; return "Authorization" in cred.headers ? "oauth" : "api_key"; } @@ -115,10 +130,59 @@ export function heygenAuthHeaders() { // when the persisted credential includes a refresh token. Access tokens remain // short-lived; the refresh token provides the no-prompt UX without weakening // OAuth's revocation and expiry guarantees. -export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { - const cred = heygenCredential(); - if (!cred?.refreshable) return heygenAuthHeaders(); +function oauthHeaders(accessToken) { + return { + Authorization: `Bearer ${accessToken}`, + ...HEYGEN_CLI_SOURCE_HEADERS, + ...HEYGEN_CLIENT_SOURCE_HEADERS, + }; +} +function writeCredentialsAtomically(file, credentials) { + const temporary = `${file}.tmp-${process.pid}-${Date.now()}`; + try { + writeFileSync(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + renameSync(temporary, file); + chmodSync(file, 0o600); + } finally { + rmSync(temporary, { force: true }); + } +} + +async function acquireRefreshLock(credentialsFile) { + const lockFile = `${credentialsFile}.refresh.lock`; + const deadline = Date.now() + REFRESH_LOCK_TIMEOUT_MS; + + while (true) { + try { + const descriptor = openSync(lockFile, "wx", 0o600); + return () => { + closeSync(descriptor); + rmSync(lockFile, { force: true }); + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + try { + if (Date.now() - statSync(lockFile).mtimeMs > REFRESH_LOCK_STALE_MS) { + rmSync(lockFile, { force: true }); + continue; + } + } catch (statError) { + if (statError?.code !== "ENOENT") throw statError; + continue; + } + if (Date.now() >= deadline) { + throw new Error("timed out waiting for another HeyGen OAuth refresh to finish"); + } + await new Promise((resolve) => setTimeout(resolve, REFRESH_LOCK_RETRY_MS)); + } + } +} + +async function refreshCredential(cred, fetchImpl) { const oauth = cred.credentials.oauth; const body = new URLSearchParams({ grant_type: "refresh_token", @@ -140,7 +204,13 @@ export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { } const payload = await res.json().catch(() => null); const accessToken = payload?.access_token; - if (typeof accessToken !== "string" || !accessToken || /[\r\n\0]/.test(accessToken)) { + const invalidAccessToken = + typeof accessToken !== "string" || + !accessToken || + accessToken.includes("\r") || + accessToken.includes("\n") || + accessToken.includes(String.fromCharCode(0)); + if (invalidAccessToken) { throw new Error( "HeyGen OAuth refresh returned an invalid access token — run `npx hyperframes auth login`", ); @@ -162,12 +232,25 @@ export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { delete renewed.expires_at; } const saved = { ...cred.credentials, oauth: renewed }; - writeFileSync(cred.file, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 }); - return { - Authorization: `Bearer ${accessToken}`, - ...HEYGEN_CLI_SOURCE_HEADERS, - ...HEYGEN_CLIENT_SOURCE_HEADERS, - }; + writeCredentialsAtomically(cred.file, saved); + return oauthHeaders(accessToken); +} + +export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { + const cred = heygenCredential(); + if (!cred?.refreshable) return heygenAuthHeaders(); + + const release = await acquireRefreshLock(cred.file); + try { + const current = heygenCredential(); + if (current?.headers?.Authorization) { + return oauthHeaders(current.headers.Authorization.slice("Bearer ".length)); + } + if (!current?.refreshable) return heygenAuthHeaders(); + return await refreshCredential(current, fetchImpl); + } finally { + release(); + } } // Authed JSON request against the v3 API; throws on a non-OK status. diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index 8ed6024663..894c977d8f 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { heygenAuthHeaders, heygenAuthHeadersWithRefresh, heygenAuthMethod } from "./heygen.mjs"; @@ -109,6 +109,27 @@ test("heygenAuthMethod returns oauth for a live OAuth credential", () => { }); }); +test("heygenAuthMethod returns oauth for a refreshable expired credential", () => { + withCleanHeygenEnv(() => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { + refresh_token: "current-refresh", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + assert.equal(heygenAuthMethod(), "oauth"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + test("heygenAuthMethod returns null with no credential at all", () => { withCleanHeygenEnv(() => { const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); @@ -172,6 +193,75 @@ test("heygenAuthHeadersWithRefresh silently renews an expired OAuth credential", assert.equal(saved.oauth.refresh_token, "rotated-refresh"); assert.equal(saved.oauth.scope, "openid profile"); assert.ok(new Date(saved.oauth.expires_at).getTime() > Date.now()); + assert.equal(statSync(credentialsPath).mode & 0o777, 0o600); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +test("heygenAuthHeadersWithRefresh renews a credential with only a refresh token", async () => { + await withCleanHeygenEnvAsync(async () => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ oauth: { refresh_token: "current-refresh" } }), + ); + + const headers = await heygenAuthHeadersWithRefresh( + async () => + new Response(JSON.stringify({ access_token: "renewed-access", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + assert.equal(headers.Authorization, "Bearer renewed-access"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +test("concurrent OAuth refreshes share the credential rotation", async () => { + await withCleanHeygenEnvAsync(async () => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { + access_token: "expired-access", + refresh_token: "current-refresh", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + + let requests = 0; + const fetchImpl = async () => { + requests += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return new Response( + JSON.stringify({ + access_token: "renewed-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const [first, second] = await Promise.all([ + heygenAuthHeadersWithRefresh(fetchImpl), + heygenAuthHeadersWithRefresh(fetchImpl), + ]); + + assert.equal(requests, 1); + assert.deepEqual(second, first); } finally { rmSync(dir, { recursive: true, force: true }); }