# Microsoft account sign-in and online-mode play Horizon signs players in with their Microsoft account in the launcher and joins online-mode (authenticated) servers like vanilla 26.3 does. Offline profiles and offline/LAN play stay exactly as they are. This note is the design, the status of each part and what Ludovic has to register. ## What Ludovic registers (needs his accounts) Nothing below can be done by an agent: the app registrations belong to Ludovic. Until the client ID is set, the launcher's sign-in shows "Coming soon" ("Microsoft sign-in is coming soon") and everything else works as before. 1. **Azure app registration** (https://portal.azure.com → Microsoft Entra ID → App registrations → New registration): - Name: `Horizon` (shown to players on the Microsoft consent page). - Supported account types: **Personal Microsoft accounts only** (Minecraft accounts are consumer accounts; the launcher uses the `consumers` tenant). - Redirect URI: none needed for the device-code flow. If the form insists, add a "Mobile and desktop applications" redirect `https://login.microsoftonline.com/common/oauth2/nativeclient`. - After creating it: Authentication → Advanced settings → **Allow public client flows: Yes** (this enables the device-code flow; there is no client secret, Horizon is a public client). - API permissions: none to add. The launcher requests the scopes `XboxLive.signin offline_access` at sign-in time; `offline_access` gives the refresh token. - Copy the **Application (client) ID** (a GUID). 2. **Minecraft API access** (Mojang): new Azure apps are refused by `api.minecraftservices.com/authentication/login_with_xbox` (HTTP 403, "Invalid app registration") until Mojang approves them. Apply with the form linked from https://aka.ms/mce-reviewappid (Minecraft Java "App ID review"), giving the client ID above, the app name, the project URL (https://github.com/ludovic111/horizon) and a short description ("open-source Minecraft Java Edition client written in Rust; signs players in to join online-mode servers"). Approval takes days to weeks; sign-in fails at the Minecraft step with "This app is not approved by Mojang yet" until then. 3. **Where the ID goes**: - Release builds: the GitHub repository variable (not a secret: client IDs are public) `HORIZON_MSA_CLIENT_ID`; the launcher's build reads it with `option_env!` at compile time. - Any build, at run time: the environment variable `HORIZON_MSA_CLIENT_ID` overrides the built-in value (empty or unset = not configured). Never use another application's client ID (the official launcher's or another launcher's). ## Flow (launcher) All requests use HTTPS and JSON except the two OAuth calls (form-encoded): 1. **Device code**: `POST https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode` (`client_id`, `scope=XboxLive.signin offline_access`). The launcher shows the `user_code` and `verification_uri` (https://www.microsoft.com/link) with a copy button and opens the browser. 2. **Poll** `POST …/consumers/oauth2/v2.0/token` (`grant_type=urn:ietf:params:oauth:grant-type:device_code`) every `interval` seconds; `authorization_pending` waits, `slow_down` adds 5 s, `authorization_declined`, `expired_token` and `bad_verification_code` end the sign-in with a message. Result: the Microsoft access token and refresh token. 3. **Xbox Live**: `POST https://user.auth.xboxlive.com/user/authenticate` with `AuthMethod=RPS, SiteName=user.auth.xboxlive.com, RpsTicket=d=`, `RelyingParty=http://auth.xboxlive.com`. Result: the XBL token and the user hash (`uhs`). 4. **XSTS**: `POST https://xsts.auth.xboxlive.com/xsts/authorize` with the XBL token, `SandboxId=RETAIL`, `RelyingParty=rp://api.minecraftservices.com/`. The `XErr` codes become messages: 2148916233 no Xbox profile yet, 2148916235 Xbox Live not available in the country, 2148916236/2148916237 adult verification (South Korea), 2148916238 child account (needs a family). 5. **Minecraft**: `POST https://api.minecraftservices.com/authentication/login_with_xbox` (`identityToken = "XBL3.0 x=;"`). Result: the Minecraft access token (24 h). 6. **Entitlements**: `GET https://api.minecraftservices.com/entitlements/mcstore`: the account must own `game_minecraft` or `product_minecraft` (Game Pass included), else "This account doesn't own Minecraft Java Edition". 7. **Profile**: `GET https://api.minecraftservices.com/minecraft/profile`: UUID, name, skins, capes. 404 = owns the game but has no profile yet ("Create your Minecraft profile on minecraft.net first"). Refresh: the Minecraft token is renewed from the Microsoft refresh token (steps 2-5 with `grant_type=refresh_token`) when it has less than an hour left, before every launch. A refused refresh token (`invalid_grant`) asks the player to sign in again. Several accounts can be signed in; one is selected. "Sign out" deletes the account's secrets from the keychain. ## Token storage - Secrets (the Microsoft refresh token, the Minecraft access token and its expiry) go to the OS keychain, one item per account: Keychain on macOS, Credential Manager on Windows, the Secret Service (GNOME Keyring, KWallet) on Linux. Service name `dev.playhorizon.launcher`, account = the Minecraft UUID. - `accounts.json` in the launcher's data folder keeps only public data: UUID, name, skin URL, which account is selected. Never a token. - No keychain available (a Linux box without a Secret Service, a locked keychain): sign-in still works for the session but nothing is saved, and the launcher says "No keychain available: you will have to sign in again next time" (it never falls back to a plain file). - Tokens never appear in logs, errors, `horizon-launcher` CLI output, MCP results or crash reports. Requests are logged by endpoint name only. ## Handing the session to the game Vanilla's launcher passes `--accessToken ` on the command line, where every user of the machine can read it in the process list. Horizon keeps it out of argv: - The launcher writes `session-.json` (mode 0600 on Unix, created new, in the game home's `sessions/` folder) with `{"name", "uuid", "access_token", "user_type": "msa", "xuid"}` and starts the game with `--session-file PATH`. - The game reads the file at start and deletes it at once; the token lives only in the game's memory. A file older than two minutes is refused (and deleted), so a stale file can't be replayed. - Without a session file the game is offline as today (`--name`, offline UUID). - `HORIZON_SESSION_FILE` is the same as `--session-file` (development and harnesses). ## Online-mode login (game) Vanilla's `ClientHandshakePacketListenerImpl.handleHello`, `Crypt` and authlib's `MinecraftServicesSessionService.joinServer`: 1. `ClientboundHelloPacket` (server id, X.509 RSA public key, challenge, should_authenticate). 2. Generate a random 16-byte AES key (the shared secret). 3. Server hash: SHA-1 over `serverId` (ISO-8859-1), the shared secret, the encoded public key; `new BigInteger(digest).toString(16)` (signed two's complement hex, e.g. "Notch" → `4ed1f46bbe04bc756bcb17c0c7ce3e4632f06a48`, "jeb_" → `-7c9d5b0044c130109a5d7b5fb5c317c02b4e28c1`). 4. If `should_authenticate`: `POST` the session service's `join` endpoint `{"accessToken", "selectedProfile" (undashed UUID), "serverId": hash}`, the URL taken from the services discovery document (`https://discovery.minecraftservices.com/minecraft/client`, fetched once per game). Errors map like authlib's `MinecraftClientHttpException`: `ForbiddenOperationException` or 401 → "Failed to log in: Invalid session (Try restarting your game and the launcher)", `InsufficientPrivilegesException` → "Multiplayer is disabled…", `multiplayer.access.banned` / `FORCED_USERNAME_CHANGE` → "You are banned from playing online", 5xx or no network → "The authentication servers are currently not reachable…". Without a session the game says "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it." (Vanilla goes on after a failed join on LAN-discovered servers, which then refuse the player anyway; Horizon stops at once.) 5. Send `ServerboundKeyPacket` (the secret and the challenge, each RSA PKCS#1 v1.5 encrypted with the server key), then AES/CFB8 (key = IV = the secret) both ways. The connecting screen follows vanilla's states: "Connecting to the server..." until the encryption request, "Logging in..." while joining the session service, "Encrypting...", then "Joining world...". Code: `horizon-protocol` (`rsa.rs`, `crypto::server_hash`), `horizon-client` (`auth.rs`, `net.rs`). Horizon's own dedicated and LAN servers stay offline-mode (they don't verify players with the session service); that is separate work. `HORIZON_API_DISCOVERY` overrides the discovery URL, the counterpart of authlib's `-Dminecraft.api.discovery.host`; it is how the harness points both the vanilla server and Horizon at a local mock. ## Skins and capes (part b) Profile properties (`textures`, base64 JSON) arrive in `ClientboundPlayerInfoUpdatePacket` for every player and in the login profile for ourselves. Like authlib's `unpackTextures`, only URLs under the discovery document's `profiles/getTexture` `validUris` (textures.minecraft.net) are fetched; skins (classic/slim from the `metadata.model`), capes and elytra textures are downloaded over HTTPS on a worker thread, cached on disk by texture hash (`assets/skins/xx/hash`, like vanilla's `SkinManager`), converted from legacy 64×32 the way `SkinTextureDownloader` does and given to the player renderer. Until a skin arrives the default skin for the UUID shows (as today). ## Chat signing (part d) 26.3's dedicated servers default to `enforce-secure-profile=true`. A client without a chat session can join and play, but every chat message and signed command argument is refused with "Chat disabled due to missing profile public key. Please try reconnecting." (`SignedMessageChain.Decoder.unsigned`). To chat on such servers the game will: - fetch the profile key pair from the services `player/getCertificates` endpoint (Minecraft token); - send `ServerboundChatSessionUpdatePacket` (random session UUID, key expiry, public key, Mojang's key signature) after joining; - sign each chat message and signable command argument with SHA256withRSA over vanilla's `SignedMessageBody` layout and keep the last-seen message tracker and acknowledgements. Until part d lands, Horizon joins online servers but chat works only where `enforce-secure-profile=false` (or the server can't validate keys). ## Harnesses `tools/test_online_mode_parity.py` (dev only, needs no account): a local mock of the Minecraft services (discovery document, `join`, `hasJoined`/`verify`, no public keys) and a vanilla 26.3 dedicated server in online mode started with `-Dminecraft.api.discovery.host=` (authlib 10.0.77's only override: it replaces the whole discovery URL; the old `minecraft.api.session.host` style properties are gone). Horizon's headless client (`HORIZON_NET_REPORT`) joins with a session file holding a mock token: the server verifies the join through the mock with the hash it computed itself (so the RSA wrapping, the hash and AES/CFB8 are all checked against vanilla's code), the player spawns, chats and runs `/list`. Negative cases: a token the mock rejects (the client must show vanilla's "Invalid session" message) and a client that skips the join (the server must say "Failed to verify username!"). Rust unit tests: server hash vectors (Notch, jeb_, simon), RSA wrapping round trip with a test key, X.509 key parsing, AES/CFB8 vectors (already in `horizon-protocol`), the session file (permissions, age, deletion), the error mapping. Launcher: the whole token chain against a local mock of the Microsoft, Xbox and Minecraft endpoints, and the keychain abstraction with an in-memory backend. ## PR plan - (a) Game: online-mode handshake, encryption, session join, session file, harness (15/15 checks against vanilla 26.3 on macOS). - (c) Launcher: device-code sign-in, token chain, keychain, accounts UI, CLI/MCP (`account list`, `account sign-in`, `account sign-out`, never tokens), session file handoff. - (b) Game: skins and capes over HTTPS, the real names and skins in game. - (d) Game: chat signing (profile keys, chat session, signed messages and commands). Each part ships on its own: online mode needs a session, which only (c) creates.