utils/oauthShared.ts
Rama main · réplica de lectura del repositorio original.
/**
* Provider-agnostic OAuth primitives shared by the Codex and Grok chains.
* Pure stdlib (Buffer) so both the action runtime and the Next.js server can
* import it via pullfrog/internal.
*
* Both chains mint JWT access tokens whose `exp` we read as a freshness hint,
* and both rotate their refresh token on every use — so both need the same
* "the provider rejected this permanently" signal.
*/
/**
* Thrown when an OAuth provider rejects a refresh token (4xx).
*
* `chainIsDead` is decided by the CALLER, because the two providers we talk to
* disagree about how to say it and only the caller knows its own dialect, and
* so does the list of rejections that are NOT dead. xAI answers RFC 6749
* (`{"error":"invalid_grant"}`), so anything else — an unknown client id, an
* unsupported grant, a CDN error page — is spared. OpenAI answers a dead chain
* with a `401` whose body varies and is often empty, so there the `401` itself
* is the signal and only `invalid_client` is spared. Latching wrongly retires a
* customer's working credential until they re-mint it.
*/
export class OAuthInvalidGrantError extends Error {
public readonly status: number;
/** whether the provider said this refresh chain is permanently unusable, so
* the rotation core may latch it out of use until the user re-mints. */
public readonly chainIsDead: boolean;
constructor(provider: string, status: number, body: string, chainIsDead: boolean) {
super(`${provider} token refresh failed: ${status} ${body}`);
this.name = "OAuthInvalidGrantError";
this.status = status;
this.chainIsDead = chainIsDead;
}
}
/** parse a token-endpoint error body, or null when it is not a JSON object.
* Each provider's refresh reads its own discriminator out of the result. */
export function parseOAuthErrorBody(body: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(body);
if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
} catch {
// non-JSON body: unclassifiable, so the caller treats it as retryable.
}
return null;
}
/** decode a JWT payload's `exp` claim and return it in ms since epoch.
* returns null if the token isn't a parseable JWT or has no `exp` claim —
* caller falls back to "treat as expired".
*
* We don't verify the signature (we'd need the issuer's JWKS); we're only
* using the claim as a freshness hint. The real auth check happens at the
* provider when the token is used, so trusting a forged JWT here would just
* delay the inevitable 401. No security boundary at this decode step. */
export function decodeJwtExpMs(token: string): number | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
let payload: { exp?: unknown };
try {
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
} catch {
return null;
}
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null;
return payload.exp * 1000;
}