MineJSv0.5.1
client/auth.tstypescript
import http from "node:http";
import crypto from "node:crypto";
import { exec } from "node:child_process";
import { AuthDenied, OAuthError, AuthTokenExpired, CantGetMsAccessToken, ProfileError, ProfileNotFound, XboxError, AuthClientError, MinecraftError } from "../base/error";
import { minecraftSha1 } from "../base/math";
import { getJson } from "../base/http";

interface Loopback {
    /**
     * This method will provide you a url to click on and do the authentication.
     * 
     * This will require an opening port in order to receive the response from Microsoft. If you cant, considering using `device_code` method.
     */
    method: "loopback",

    /**
     * The opening port for listening the response
     */
    port: number

    /**
     * Automatedly open browser or print a URL to click on.
     */
    openBrowser: boolean,
}

interface DeviceCode {
    /**
     * This method will provide you a device code, then you have to type this code in the given URL.
     */
    method: "device_code",
}

export type AuthOption = (Loopback | DeviceCode) & {
    /**
     * The Client ID
     * 
     * Get it by creating Microsoft Azure application
     */
    client_id: string,

    /**
     * Custom logger, default to `console.log`
     */
    log?: (message: string) => void;
}
// From assets dir
const ErrorHTML = `
<!DOCTYPE html>
<html>
    <head>
        <title>MineJS</title>
        <style>
            p { margin: 0; }
        </style>
    </head>
    <body>
        <div style="display: flex; height: 100vh; width: 100vw;">
            <div style="margin: auto; display: flex; flex-direction: column; text-align: center">
                <p style="font-size: 30px">An error is occurred while MineJS trying to get the token</p>
                <p style="font-size: 35px; color: crimson">{error}</p>
                <p style="font-size: 28px">{error_description}</p>
            </div>
        </div>
    </body>
</html>
`;
const SuccessHTML = `
<!DOCTYPE html>
<html>
    <head>
        <title>MineJS</title>
        <style>
            p { margin: 0; }
        </style>
    </head>
    <body>
        <div style="display: flex; height: 100vh; width: 100vw;">
            <div style="margin: auto; display: flex; flex-direction: column; text-align: center">
                <p style="font-size: 40px; font-weight: bold; color: darkcyan;">Done!</p>
                <p style="font-size: 30px;">You can close this tab</p>
            </div>
        </div>
    </body>
</html>
`;
const InvalidHTML = `
<!DOCTYPE html>
<html>
    <head>
        <title>MineJS</title>
        <style>
            p { margin: 0; }
        </style>
    </head>
    <body>
        <div style="display: flex; height: 100vh; width: 100vw;">
            <div style="margin: auto; display: flex; flex-direction: column; text-align: center">
                <p style="font-size: 35px; color: crimson">Invalid request >:&lpar;</p>
                <p style="font-size: 30px; color: crimson">{reason}</p>
            </div>
        </div>
    </body>
</html>
`;

function open(url: string, log: (message: string) => any) {
    // From https://stackoverflow.com/a/49013356/17106809
    switch (process.platform) {
        case "linux": exec(`xdg-open "${url}"`); break;
        case "win32": exec(`start "${url}"`); break;
        case "darwin": exec(`open "${url}"`); break;
        default:
            log(`${process.platform} is not supported`);
            log(`please open this url in your browser: ${url}`);
            break;
    }
}

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

/**
 * Auth client, for authenticating Microsoft account
 */
export class AuthClient {
    private readonly codeVerifier: string;
    private readonly codeChallenge: string;
    // private refreshToken?: string;
    private accessToken: string = "";
    private uuid: string = "";

    constructor(private option: AuthOption) {
        const codeVerifier = crypto.randomBytes(32).toString("hex");
        const hashed = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
        this.option.log ||= console.log;

        this.codeVerifier = codeVerifier;
        this.codeChallenge = hashed;
    }

    /**
     * Send a auth notify to Minecraft server, after receive Encryption Request
     * 
     * @see https://minecraft.wiki/w/Java_Edition_protocol/Encryption#Authentication
     */
    public async sendAuth(
        // Sever things
        serverId: string,
        sharedSecret: Buffer,
        publicKey: Buffer,
    ) {
        const hash = minecraftSha1(serverId, sharedSecret, publicKey);
        const response = await fetch(
            "https://sessionserver.mojang.com/session/minecraft/join",
            {
                method: "POST",
                headers: {
                    "Content-Type": "application/json"
                },
                body: JSON.stringify({
                    accessToken: this.accessToken,
                    selectedProfile: this.uuid,
                    serverId: hash,
                })
            }
        );
        console.log({
            accessToken: this.accessToken,
            selectedProfile: this.uuid,
            serverId: hash,
        });
        if (response.status === 204) return;
        const json = await getJson(response, "send auth request") as Record<string, string>;
        throw new AuthClientError(json["error"]! + ("errorMessage" in json ? `: ${json["errorMessage"]}` : ""));
    }

    /**
     * Get player key pair for signing stuff
     * 
     * @see https://minecraft.wiki/w/Mojang_API#Get_keypair_for_signature
     */
    public async getKeyPair() {
        const response = await fetch(
            "https://api.minecraftservices.com/player/certificates",
            {
                headers: {
                    Authorization: `Bearer ${this.accessToken}`
                }
            }
        );
        if (response.status !== 200) throw new AuthClientError("can't get the auth key", await response.text());
        const json = await getJson(response, "get key pair") as any;
        console.log(json);
        return {
            publicKey: String(json["keyPair"]["publicKey"]),
            signature: String(json["publicKeySignatureV2"]),
            expiresAt: Number(json["expiresAt"]),
        };
    }


    /**
     * Get player UUID
     */
    public async getUUID() {
        if (this.option.method === "loopback") return this.loopback();
        else return this.deviceCode();
    }

    private async loopback() {
        const option = this.option as Loopback;
        const expectState = crypto.randomBytes(8).toString("hex");
        const authUrl = new URL("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize");
        authUrl.searchParams.append("client_id", this.option.client_id);
        authUrl.searchParams.append("response_type", "code");
        authUrl.searchParams.append("redirect_uri", `http://localhost:${option.port}`);
        authUrl.searchParams.append("scope", "XboxLive.signin offline_access");
        authUrl.searchParams.append("code_challenge", this.codeChallenge);
        authUrl.searchParams.append("code_challenge_method", "S256");
        authUrl.searchParams.append("state", expectState);

        if (option.openBrowser === true)
            open(authUrl.toString(), this.option.log!);
        else
            this.option.log!(`please open this url in your browser: ${authUrl.toString()}`);

        const msAuthToken = await new Promise<string | null>((resolve) => {
            const server = http.createServer((req, res) => {
                const end = (html: string, result: any = null) => {
                    if (res.writable)
                        res.end(html);
                    if (server.listening) {
                        server.close();
                        return resolve(result);
                    }
                };

                const url = new URL(req.url || "", `http://localhost:${option.port}`);
                const error = url.searchParams.get("error");
                if (error) {
                    const description = url.searchParams.get("error_description")!;
                    return end(ErrorHTML.replace("{error}", error).replace("{error_description}", description));
                }

                const code = url.searchParams.get("code");
                if (!code)
                    return end(InvalidHTML.replace("{reason}", "Missing <code>code</code> parameter"));

                const state = url.searchParams.get("state");
                if (!state)
                    return end(InvalidHTML.replace("{reason}", "Missing <code>state</code> parameter"));
                if (state !== expectState)
                    return end(InvalidHTML.replace("{reason}", "Mismatch state"));

                return end(SuccessHTML, code);
            });

            server.listen(option.port);
        });

        if (!msAuthToken)
            throw new CantGetMsAccessToken();

        const msTokenUrl = new URL("https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
        const msTokenBody = new URLSearchParams();
        msTokenBody.append("client_id", this.option.client_id);
        msTokenBody.append("scope", "XboxLive.signin offline_access");
        msTokenBody.append("code", msAuthToken);
        msTokenBody.append("redirect_uri", `http://localhost:${option.port}`);
        msTokenBody.append("grant_type", "authorization_code");
        msTokenBody.append("code_verifier", this.codeVerifier);
        const msTokenRequest = await fetch(msTokenUrl, {
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            method: "POST",
            body: msTokenBody.toString()
        });
        const msTokenResponse = await getJson(msTokenRequest, "microsoft token request") as Record<string, any>;
        if ("error" in msTokenResponse)
            throw new OAuthError(msTokenResponse["error"]);
        const msToken = msTokenResponse["access_token"];

        return this.getMcToken(msToken);
    }

    private async deviceCode() {
        const codeUrl = new URL("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode");
        const codeBody = new URLSearchParams();
        codeBody.set("client_id", this.option.client_id);
        codeBody.append("scope", "XboxLive.signin offline_access");

        const codeRequest = await fetch(codeUrl, {
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            method: "POST",
            body: codeBody.toString()
        });
        const codeResponse = await codeRequest.json() as Record<string, any>;
        const deviceCode = codeResponse["device_code"];
        const message = codeResponse["message"];
        let interval = Number(codeResponse["interval"]);

        this.option.log!(message);

        let msToken;
        const msTokenUrl = new URL("https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
        const msTokenBody = new URLSearchParams();
        msTokenBody.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
        msTokenBody.set("client_id", this.option.client_id);
        msTokenBody.set("device_code", deviceCode);
        while (true) {
            await sleep(interval * 1000);
            const msTokenRequest = await fetch(msTokenUrl, {
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                method: "POST",
                body: msTokenBody.toString()
            });
            const msTokenResponse = await msTokenRequest.json() as Record<string, any>;
            if ("error" in msTokenResponse)
                switch (msTokenResponse["error"]) {
                    case "authorization_pending": continue;
                    case "slow_down": interval += 5; continue;
                    case "auth_denied": throw new AuthDenied();
                    case "expired_token": throw new AuthTokenExpired();
                    default: throw new OAuthError(msTokenResponse["error"]);
                }
            else {
                msToken = msTokenResponse["access_token"];
                break;
            }
        }

        if (!msToken)
            throw new CantGetMsAccessToken();

        return this.getMcToken(msToken);
    }

    private async getMcToken(msToken: string): Promise<{
        uuid: string,
        name: string
    }> {
        // Get Xbox Live token
        const xboxRequest = await fetch("https://user.auth.xboxlive.com/user/authenticate", {
            headers: {
                "Content-Type": "application/json",
                Accept: "application/json"
            },
            body: JSON.stringify({
                "Properties": {
                    "AuthMethod": "RPS",
                    "SiteName": "user.auth.xboxlive.com",
                    "RpsTicket": `d=${msToken}`
                },
                "RelyingParty": "http://auth.xboxlive.com",
                "TokenType": "JWT"
            }),
            method: "POST"
        });
        const xboxReponse = await getJson(xboxRequest, "xbox request") as Record<string, any>;
        const xboxToken = xboxReponse["Token"];
        const userHash = xboxReponse["DisplayClaims"]["xui"][0]["uhs"];

        const xboxSecurityRequest = await fetch("https://xsts.auth.xboxlive.com/xsts/authorize", {
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            body: JSON.stringify({
                "Properties": {
                    "SandboxId": "RETAIL",
                    "UserTokens": [xboxToken]
                },
                "RelyingParty": "rp://api.minecraftservices.com/",
                "TokenType": "JWT"
            }),
            method: "POST"
        });
        const xboxSecurityReponse = await getJson(xboxSecurityRequest, "xbox security request") as Record<string, any>;
        if ("XErr" in xboxSecurityReponse)
            throw new XboxError(xboxSecurityReponse["XErr"]);
        const xboxSecurityToken = xboxSecurityReponse["Token"];

        const minecraftRequest = await fetch("https://api.minecraftservices.com/authentication/login_with_xbox", {
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json",
                'User-Agent': 'MinecraftLauncher/2.2.10675'
            },
            body: JSON.stringify({
                "identityToken": `XBL3.0 x=${userHash};${xboxSecurityToken}`,
            }),
            method: "POST"
        });
        const minecraftResponse = await getJson(minecraftRequest, "minecraft request") as Record<string, any>;
        if ("errorMessage" in minecraftResponse)
            throw new MinecraftError(minecraftResponse["errorMessage"]);
        const minecraftToken = minecraftResponse["access_token"];

        const mcProfileRequest = await fetch("https://api.minecraftservices.com/minecraft/profile", {
            headers: {
                "Authorization": `Bearer ${minecraftToken}`
            },
            method: "GET"
        });
        const mcProfileResponse = await getJson(mcProfileRequest, "minecraft profile request") as Record<string, any>;
        if ("error" in mcProfileResponse) {
            if (mcProfileResponse["error"] === "NOT_FOUND")
                throw new ProfileNotFound();
            else throw new ProfileError(mcProfileResponse["error"], mcProfileResponse["errorMessage"]);
        }

        this.accessToken = minecraftToken;
        this.uuid = mcProfileResponse["id"];
        return {
            uuid: mcProfileResponse["id"],
            name: mcProfileResponse["name"]
        };
    }
}