fix(skills): serialize OAuth credential refresh

This commit is contained in:
Miguel Ángel
2026-07-14 22:36:38 +00:00
parent 178d472aa3
commit f7b8a9b507
3 changed files with 189 additions and 16 deletions
+2 -2
View File
@@ -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": {
+96 -13
View File
@@ -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.
@@ -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 });
}