Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/cli/src/auth/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type AuthErrorCode =
| "UNAUTHENTICATED"
| "OAUTH_NOT_CONFIGURED"
| "DEVICE_AUTH_FAILED"
| "REFRESH_FAILED";
| "REFRESH_FAILED"
| "LOGIN_EXPIRED"
| "LOGIN_CHANGED";

export class AuthError extends Error {
readonly code: AuthErrorCode;
Expand Down Expand Up @@ -62,6 +64,20 @@ export const ErrRefreshFailed = (detail?: string) =>
"Run `hyperframes auth login` to re-authenticate.",
);

export const ErrLoginExpired = () =>
new AuthError(
"LOGIN_EXPIRED",
"Your HeyGen login expired",
"Run `hyperframes auth login` to sign in again.",
);

export const ErrLoginChanged = () =>
new AuthError(
"LOGIN_CHANGED",
"Your HeyGen login changed while this command ran",
"Run the command again.",
);

export const ErrDeviceAuthFailed = (detail: string) =>
new AuthError(
"DEVICE_AUTH_FAILED",
Expand Down
33 changes: 31 additions & 2 deletions packages/cli/src/auth/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ describe("auth/oauth", () => {
describe("refreshTokens", () => {
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
await writeStore({ oauth: { access_token: "old_at", refresh_token: "old_rt" } });
let capturedBody: string | undefined;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
Expand Down Expand Up @@ -214,8 +215,36 @@ describe("auth/oauth", () => {
expect(credentials.oauth?.refresh_token).toBe("keep_me_rt");
});

it("leaves a login that replaced the refreshed one untouched", async () => {
const login = {
oauth: { access_token: "b_at", refresh_token: "b_rt" },
user: { email: "b@example.com" },
};
await writeStore(login);
const fetchImpl = tokenFetch({ access_token: "a_new_at", expires_in: 3600 });
await expect(refreshTokens("a_rt", { fetchImpl })).rejects.toSatisfy(
(err) => isAuthError(err) && err.code === "LOGIN_CHANGED",
);
const { credentials } = await readStore();
expect(credentials.oauth).toMatchObject(login.oauth);
expect(credentials.user?.email).toBe("b@example.com");
});

it("reports an unreadable credentials file on refresh instead of a changed login", async () => {
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(path, "{not json", { mode: 0o600 });
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await expect(refreshTokens("old_rt", { fetchImpl })).rejects.toSatisfy(
(err) => isAuthError(err) && err.code === "INVALID_STORE",
);
expect(await fs.readFile(path, "utf8")).toBe("{not json");
});

it("preserves an existing api_key when persisting refreshed oauth", async () => {
await writeStore({ api_key: "hg_keep" });
await writeStore({
api_key: "hg_keep",
oauth: { access_token: "old_at", refresh_token: "old_rt" },
});
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 });
await refreshTokens("old_rt", { fetchImpl });
const { credentials } = await readStore();
Expand All @@ -224,7 +253,7 @@ describe("auth/oauth", () => {
});

it("preserves an unknown key INSIDE the oauth sub-object across a refresh", async () => {
// The refresh path is `persistOAuth(preserveMissing: true)`, i.e.
// The refresh path is `persistOAuth({ refreshed })`, i.e.
// `{ ...existing.oauth, ...tokens }` — object spread carries the
// hidden Symbol-keyed unknown bag from `existing.oauth`, so a key
// another CLI wrote inside `oauth` (e.g. an `id_token`) must survive
Expand Down
41 changes: 20 additions & 21 deletions packages/cli/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { failCommand } from "../utils/commandResult.js";
import {
ErrApi,
ErrDeviceAuthFailed,
ErrLoginChanged,
ErrOAuthNotConfigured,
ErrRefreshFailed,
isAuthError,
Expand Down Expand Up @@ -205,7 +206,7 @@ export async function startAuthorizationCodeFlow(
});

// Fresh login → clean OAuth block (no inherited refresh_token).
await persistOAuth(tokens, { preserveMissing: false });
await persistOAuth(tokens);
return { tokens };
}

Expand Down Expand Up @@ -411,7 +412,7 @@ export async function refreshTokens(
const payload = await readJsonOrThrow(res);
const tokens = parseTokenResponse(payload);
// Refresh grant → preserve a refresh_token the server didn't rotate.
await persistOAuth(tokens, { preserveMissing: true });
await persistOAuth(tokens, { refreshed: refresh_token });
return tokens;
}

Expand Down Expand Up @@ -592,38 +593,36 @@ function numericField(obj: Record<string, unknown>, key: string): number | undef
}

/**
* Persist a new OAuth token set, always preserving a co-located
* `api_key`.
* Persist a new OAuth token set, always preserving a co-located `api_key`.
*
* `preserveMissing` controls how the new tokens combine with whatever
* OAuth block is already on disk:
* - `false` (fresh authorization-code login): overwrite the OAuth
* `refreshed` (the refresh_token that was exchanged) controls how the new
* tokens combine with whatever OAuth block is already on disk:
* - absent (fresh authorization-code login): overwrite the OAuth
* block entirely. A new interactive login is a clean session — it
* must NOT inherit the previous session's refresh_token, or a
* response that omits one would pair a new access token with a
* stale refresh token and break/misroute the next refresh.
* - `true` (refresh grant): keep the prior refresh_token / scope /
* - set (refresh grant): keep the prior refresh_token / scope /
* token_type when the response omits them. RFC 6749 §6 lets the
* token endpoint skip refresh_token on a no-rotation refresh, and
* dropping it would brick future refreshes.
* dropping it would brick future refreshes. Throws `LOGIN_CHANGED`
* without writing if the stored login is no longer the refreshed one.
*/
async function persistOAuth(
tokens: OAuthTokens,
opts: { preserveMissing: boolean },
): Promise<void> {
async function persistOAuth(tokens: OAuthTokens, opts: { refreshed?: string } = {}): Promise<void> {
let existing: Credentials = {};
try {
const { credentials } = await readStore();
existing = credentials;
} catch {
// Treat unreadable existing file as empty — we're about to
// overwrite the OAuth block anyway.
existing = {};
} catch (err) {
// A fresh login overwrites the OAuth block anyway; a refresh must not guess.
if (opts.refreshed !== undefined) throw err;
}

const oauth: OAuthTokens = opts.preserveMissing
? { ...existing.oauth, ...tokens }
: { ...tokens };
if (opts.refreshed !== undefined && existing.oauth?.refresh_token !== opts.refreshed) {
throw ErrLoginChanged();
}
const oauth: OAuthTokens =
opts.refreshed !== undefined ? { ...existing.oauth, ...tokens } : { ...tokens };
// Start from the existing record so co-located data survives: the
// api_key, the friendly-display `user` block, AND any unknown/foreign
// keys another CLI wrote (carried on a hidden symbol slot by spread).
Expand All @@ -633,7 +632,7 @@ async function persistOAuth(

/** Persist a verified fresh OAuth login while preserving cross-CLI fields. */
export async function persistFreshOAuth(tokens: OAuthTokens): Promise<void> {
await persistOAuth(tokens, { preserveMissing: false });
await persistOAuth(tokens);
}

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/auth/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ describe("auth/resolver", () => {
if (r.type === "api_key") expect(r.key).toBe("fallback");
});

it("reports an expired login with no refresh token as expired, not as never signed in", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({ oauth: { access_token: "stale-at", expires_at: past } });
await expect(tryResolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "LOGIN_EXPIRED";
});
});

it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
await expect(resolveCredential()).rejects.toSatisfy((err) => {
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
* Expiry policy: an OAuth access_token whose `expires_at` is in the
* past (60s skew) is considered expired. If a `refresh_token` is also
* present, callers can still use it via `refreshable: true`. Otherwise
* the api_key (if any) wins.
* the api_key (if any) wins, else `ErrLoginExpired`, not `ErrNotConfigured`.
*/

import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js";
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";

type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";

Expand Down Expand Up @@ -70,14 +70,12 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise<Reso

const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";

if (credentials.oauth) {
const oauth = pickOAuth(credentials.oauth, now, fileSource);
if (oauth) return oauth;
}
const oauth = credentials.oauth ? pickOAuth(credentials.oauth, now, fileSource) : null;
if (oauth) return oauth;
if (credentials.api_key) {
return { type: "api_key", key: credentials.api_key, source: fileSource };
}
throw ErrNotConfigured();
throw credentials.oauth ? ErrLoginExpired() : ErrNotConfigured();
}

/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
Expand All @@ -100,7 +98,7 @@ function pickOAuth(
source: CredentialSource,
): OAuthCredential | null {
const expiresAt = parseDate(tokens.expires_at);
const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
const expired = isTokenExpired(expiresAt, now);

if (expired && !tokens.refresh_token) return null;

Expand All @@ -116,6 +114,10 @@ function pickOAuth(
return out;
}

export function isTokenExpired(expiresAt: Date | undefined, now: Date): boolean {
return expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
}

function parseDate(s: string | undefined): Date | undefined {
if (!s) return undefined;
const d = new Date(s);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cloud/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function resolveCloudBaseUrl(): string {
}

// fallow-ignore-next-line complexity
async function refreshIfNeeded(credential: ResolvedCredential): Promise<ResolvedCredential> {
export async function refreshIfNeeded(credential: ResolvedCredential): Promise<ResolvedCredential> {
if (credential.type !== "oauth") return credential;
if (!credential.refreshable || !credential.refresh_token) return credential;
const fresh = await refreshTokens(credential.refresh_token);
Expand Down
Loading
Loading