mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
fix(skills): serialize OAuth credential refresh
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user