"use strict";
/**
 * Silent OAuth token refresh — lightweight utility that exchanges an expired
 * access token for a new one using a stored refresh token, without opening
 * the browser or importing heavy dependencies.
 *
 * Returns null on any failure so the caller can fall back to full browser auth.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.EXPIRY_BUFFER_MS = void 0;
exports.tryRefreshToken = tryRefreshToken;
const constants_js_1 = require("../lib/constants.js");
const debug_js_1 = require("./debug.js");
const index_js_1 = require("../lib/observability/index.js");
/**
 * Five minutes in milliseconds — refresh proactively before actual expiry.
 * Exported so other refresh paths (notably the polling refresh inside
 * `DataIngestionCheckScreen`) apply the same skew tolerance and don't 401
 * on users whose laptop clock drifted by a few seconds.
 */
exports.EXPIRY_BUFFER_MS = 5 * 60 * 1000;
/**
 * Bound on the OAuth refresh-token HTTP exchange. The wizard blocks on this
 * call before any other auth path runs, so an unbounded fetch on a hung
 * connection would freeze the entire startup. 10s is generous for what is
 * normally a sub-200ms request and matches the gateway-liveness probe.
 */
const REFRESH_REQUEST_TIMEOUT_MS = 10_000;
/**
 * Attempts a silent OAuth token refresh if the access token is expired or
 * expiring within 5 minutes. Returns new token data on success, or null if
 * refresh is not needed, not possible, or fails.
 */
async function tryRefreshToken(storedEntry, zone = constants_js_1.DEFAULT_AMPLITUDE_ZONE) {
    // 1. Check whether the token actually needs refreshing
    const now = Date.now();
    if (storedEntry.expiresAt - now > exports.EXPIRY_BUFFER_MS) {
        // Token is still valid — no refresh needed
        return null;
    }
    // 2. A refresh token is required
    if (!storedEntry.refreshToken) {
        (0, debug_js_1.logToFile)('[token-refresh] access token expired but no refresh token stored');
        (0, index_js_1.addBreadcrumb)('auth', 'Silent refresh skipped — no refresh token');
        return null;
    }
    (0, debug_js_1.logToFile)('[token-refresh] access token expired or expiring soon, attempting silent refresh', {
        expiresAt: new Date(storedEntry.expiresAt).toISOString(),
        zone,
    });
    (0, index_js_1.addBreadcrumb)('auth', 'Silent refresh starting', { zone });
    return (0, index_js_1.withWizardSpan)('auth.token_refresh', 'auth.token_refresh', { zone }, async () => tryRefreshTokenInner(storedEntry.accessToken, storedEntry.refreshToken, zone, now));
}
async function tryRefreshTokenInner(oldAccessToken, refreshToken, zone, now) {
    // Exchange the refresh token for a new access token
    try {
        const { oAuthHost, oAuthClientId } = constants_js_1.AMPLITUDE_ZONE_SETTINGS[zone];
        const body = new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: refreshToken,
            client_id: oAuthClientId,
        });
        // Bound the fetch with an AbortController + clearTimeout pair so a
        // network stall doesn't block the wizard's startup path indefinitely.
        // The timer is cleared on the win path inside `finally`.
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), REFRESH_REQUEST_TIMEOUT_MS);
        let response;
        try {
            response = await fetch(`${oAuthHost}/oauth2/token`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: body.toString(),
                signal: controller.signal,
            });
        }
        finally {
            clearTimeout(timeoutId);
        }
        if (!response.ok) {
            (0, debug_js_1.logToFile)('[token-refresh] refresh request failed', {
                status: response.status,
                statusText: response.statusText,
            });
            (0, index_js_1.addBreadcrumb)('auth', 'Silent refresh failed (non-2xx)', {
                status: response.status,
                statusText: response.statusText,
            });
            return null;
        }
        const data = (await response.json());
        const accessToken = data.access_token;
        const expiresIn = data.expires_in;
        if (typeof accessToken !== 'string' || typeof expiresIn !== 'number') {
            (0, debug_js_1.logToFile)('[token-refresh] unexpected response shape', {
                hasAccessToken: typeof accessToken,
                hasExpiresIn: typeof expiresIn,
            });
            return null;
        }
        // Pull the rotated id_token. Ory returns one on every refresh when the
        // original auth included `openid` scope (we always do). The id_token's
        // `exp` is shorter than the access token's, so without persisting it,
        // long-lived sessions break: the refreshed access token would be valid,
        // but the still-stale id_token would 401 every API call that uses it.
        const newIdToken = typeof data.id_token === 'string' ? data.id_token : undefined;
        // `expiresAt` controls when `tryRefreshToken` next fires. Source it
        // from the rotated id_token's `exp` claim so the trigger aligns to
        // the binding constraint (id_token TTL), not access_token TTL. Falls
        // back to `expires_in` if the JWT isn't decodable. See
        // `src/utils/jwt-exp.ts`.
        const { resolveStoredExpiryMs } = await import('./jwt-exp.js');
        const expiresAt = resolveStoredExpiryMs({
            idToken: newIdToken,
            expiresInSeconds: expiresIn,
            now,
        });
        // Persist rotated refresh token if the server issued a new one
        const newRefreshToken = typeof data.refresh_token === 'string' ? data.refresh_token : undefined;
        (0, debug_js_1.logToFile)('[token-refresh] silent refresh succeeded', {
            expiresAt: new Date(expiresAt).toISOString(),
            rotatedRefreshToken: !!newRefreshToken,
            rotatedIdToken: !!newIdToken,
        });
        (0, index_js_1.addBreadcrumb)('auth', 'Silent refresh succeeded', {
            rotated_refresh_token: !!newRefreshToken,
            rotated_id_token: !!newIdToken,
        });
        // Drop any cached MCP sessions still keyed on the old access token —
        // future `callAmplitudeMcp` calls would otherwise reuse a session
        // whose `Authorization: Bearer ...` header is about to be rejected
        // by the gateway. Importing lazily keeps this utility free of the
        // MCP module on the hot path of token-still-valid runs.
        try {
            const { invalidateMcpSessionCache } = await import('../lib/mcp-with-fallback.js');
            invalidateMcpSessionCache(oldAccessToken);
        }
        catch (importErr) {
            (0, debug_js_1.logToFile)('[token-refresh] could not invalidate MCP cache after refresh', importErr instanceof Error ? importErr.message : 'unknown error');
        }
        return {
            accessToken,
            expiresAt,
            refreshToken: newRefreshToken,
            idToken: newIdToken,
        };
    }
    catch (err) {
        // Distinguish abort/timeout (controller aborted the fetch) from other
        // failures so it's easy to triage stuck startups in the log file.
        const isAbort = err instanceof Error &&
            (err.name === 'AbortError' ||
                err.code === 'ABORT_ERR');
        (0, debug_js_1.logToFile)(isAbort
            ? `[token-refresh] refresh timed out after ${REFRESH_REQUEST_TIMEOUT_MS}ms`
            : '[token-refresh] refresh failed', err instanceof Error ? err.message : 'unknown error');
        (0, index_js_1.addBreadcrumb)('auth', isAbort ? 'Silent refresh timed out' : 'Silent refresh threw', {
            error: err instanceof Error ? err.message : String(err),
        });
        return null;
    }
}
