import crypto from "node:crypto";
import { DeferredPromise } from "./deferred-promise.js";
import { HTTPServer } from "./http.js";
export class OAuthError extends Error {
    error;
    constructor({ error, error_description, ...rest }) {
        super(error_description);
        this.error = error;
        this.name = "OAuthError";
        Object.assign(this, rest);
    }
}
/**
 * Generic OAuth 2.0 client.
 *
 * Currently only handles the subset required for Cloudflare, but can be modified easily:
 * - authorization and refresh code flow
 * - S256 code challenge
 * - no client secret
 */
export class OAuthClient {
    options;
    constructor(options) {
        this.options = options;
    }
    /**
     * Generate an authorization URL, state, and verifier for the given scopes.
     */
    authorize(scopes) {
        const state = generateState();
        const pkce = generatePKCE();
        const url = new URL(this.options.endpoints.authorize);
        url.searchParams.set("client_id", this.options.clientId);
        url.searchParams.set("redirect_uri", this.options.redirectUri);
        url.searchParams.set("response_type", "code");
        url.searchParams.set("scope", scopes.join(" "));
        url.searchParams.set("state", state);
        url.searchParams.set("code_challenge", pkce.challenge);
        url.searchParams.set("code_challenge_method", "S256");
        return { url: url.toString(), state, verifier: pkce.verifier };
    }
    /**
     * Exchange an authorization code for credentials.
     */
    async exchange(code, verifier) {
        const res = await this.request(this.options.endpoints.token, {
            grant_type: "authorization_code",
            code,
            code_verifier: verifier,
            client_id: this.options.clientId,
            redirect_uri: this.options.redirectUri,
        });
        return await extractCredentialsFromResponse(res);
    }
    /**
     * Refresh OAuth 2.0 credentials.
     */
    async refresh(credentials) {
        const res = await this.request(this.options.endpoints.token, {
            grant_type: "refresh_token",
            refresh_token: credentials.refresh,
            client_id: this.options.clientId,
            redirect_uri: this.options.redirectUri,
        });
        return await extractCredentialsFromResponse(res);
    }
    /**
     * Revoke OAuth 2.0 credentials.
     */
    async revoke(credentials) {
        await this.request(this.options.endpoints.revoke, {
            refresh_token: credentials.refresh,
            client_id: this.options.clientId,
            redirect_uri: this.options.redirectUri,
        });
    }
    /**
     * Make a POST request to the OAuth provider with the given urlencoded body.
     */
    async request(url, body) {
        const res = await fetch(url, {
            method: "POST",
            headers: {
                Accept: "application/json",
                "Content-Type": "application/x-www-form-urlencoded",
            },
            body: new URLSearchParams(body).toString(),
        });
        if (!res.ok) {
            const json = await res.json();
            throw new OAuthError(json);
        }
        return res;
    }
    /**
     * Listens for the callback from the provider by starting a local HTTP server and exchanges the authorization code for credentials.
     * Should be called with the authorization object returned by {@link authorize}.
     * Listens for the callback on the client's redirect URI.
     * Throws if the request is invalid, the code exchange fails, or if no callback is received within 5 minutes.
     */
    async callback(authorization) {
        const { pathname, port } = new URL(this.options.redirectUri);
        const promise = new DeferredPromise();
        const handle = async (request) => {
            const url = new URL(request.url);
            if (url.pathname !== pathname) {
                throw new OAuthError({
                    error: "invalid_request",
                    error_description: "Invalid redirect URI",
                });
            }
            const error = url.searchParams.get("error");
            const errorDescription = url.searchParams.get("error_description");
            if (error) {
                throw new OAuthError({
                    error,
                    error_description: errorDescription ?? "An unknown error occurred.",
                });
            }
            const code = url.searchParams.get("code");
            const state = url.searchParams.get("state");
            if (!code || !state) {
                throw new OAuthError({
                    error: "invalid_request",
                    error_description: "Missing code or state",
                });
            }
            if (state !== authorization.state) {
                throw new OAuthError({
                    error: "invalid_request",
                    error_description: "Invalid state",
                });
            }
            return await this.exchange(code, authorization.verifier);
        };
        const server = new HTTPServer({
            fetch: async (req) => {
                try {
                    const result = await handle(req);
                    promise.resolve(result);
                    return Response.redirect("http://alchemy.run/auth/success");
                }
                catch (error) {
                    promise.reject(error);
                    return Response.redirect("http://alchemy.run/auth/error");
                }
            },
        });
        await server.listen(Number(port));
        const timeout = setTimeout(() => {
            promise.reject(new OAuthError({
                error: "timeout",
                error_description: "The authorization process timed out.",
            }));
        }, 1000 * 60 * 5);
        try {
            const result = await promise.value;
            clearTimeout(timeout);
            return result;
        }
        finally {
            // Not awaited because the server can take a few seconds to close, don't want to block the login process from completing
            void server.close();
        }
    }
}
async function extractCredentialsFromResponse(response) {
    const json = (await response.json());
    return {
        type: "oauth",
        access: json.access_token,
        refresh: json.refresh_token,
        expires: Date.now() + json.expires_in * 1000,
        scopes: json.scope.split(" "),
    };
}
function generateState(length = 32) {
    return crypto.randomBytes(length).toString("base64url");
}
function generatePKCE(length = 96) {
    const verifier = crypto.randomBytes(length).toString("base64url");
    const challenge = crypto
        .createHash("sha256")
        .update(verifier)
        .digest("base64url");
    return { verifier, challenge };
}
//# sourceMappingURL=oauth-client.js.map