diff --git a/README.md b/README.md index 4caec73..8c39fc6 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Validated via Joi (`src/config/env.js`). | `MICROSOFT_AUTH_MODE` | `auto` | OAuth flow selection: `auto`, `legacy`, or `modern` | | `MICROSOFT_OAUTH_REDIRECT_URI` | — | Exact HTTPS Entra Web redirect URI for `/auth/browser/callback` | | `MICROSOFT_OAUTH_FRONTEND_REDIRECT_URI` | — | Optional fixed HTTPS frontend target that receives only a short-lived result code | -| `MICROSOFT_OAUTH_CLIENT_SECRET` | — | Entra Web application secret used only by the server-side browser code exchange | +| `MICROSOFT_OAUTH_CLIENT_SECRET` | — | Entra Web application secret used by the server-side browser code exchange and modern refresh grant | | `MICROSOFT_OAUTH_TTL_MS` | `300000` | Lifetime of one-time OAuth states and browser result codes (60-900 seconds) | | `HTTP_TIMEOUT_MS` | `15000` | Timeout for outgoing HTTP calls (ms) | | `LOG_LEVEL` | `info` | General log level | @@ -155,11 +155,13 @@ Configure the Entra application as follows: 1. Under **Supported account types**, select an option that includes personal Microsoft accounts. For Xbox-only sign-in, **Personal Microsoft accounts only** is the narrowest choice. 2. Under **Authentication**, add `MICROSOFT_OAUTH_REDIRECT_URI` as an exact redirect URI on the **Web** platform. Production callback URLs must use HTTPS and must match path and case exactly. -3. Create a client secret under **Certificates & secrets** and store only its value in the server environment as `MICROSOFT_OAUTH_CLIENT_SECRET`. This server-side Web flow requires the secret. It is never sent to the browser. PKCE S256 is used in addition to the secret. +3. Create a client secret under **Certificates & secrets** and store only its value in the server environment as `MICROSOFT_OAUTH_CLIENT_SECRET`. The server-side Web code exchange and its later refresh grants require this secret. It is never sent to the browser. PKCE S256 is used in addition to the secret. 4. Optionally set `MICROSOFT_OAUTH_FRONTEND_REDIRECT_URI` to one fixed HTTPS frontend callback. The service redirects there with only a short-lived one-time `code`; the frontend redeems it once at `POST /auth/browser/token`. No Microsoft, Xbox, XSTS, PlayFab, Minecraft or refresh token is placed in a URL. The OAuth `state`, PKCE verifier and optional frontend result are held in process memory. They are single-use, capacity-bounded and expire after `MICROSOFT_OAUTH_TTL_MS`; deployments with multiple service instances need sticky routing or a shared transient store before enabling browser login. +Authentication responses include `microsoftAuthFlow` (`browser` or `device`). Clients must persist it and return it with `msRefreshToken` to `POST /auth/refresh`, so confidential browser refreshes use the client secret while device-code refreshes remain public-client requests. For backwards compatibility, a missing value is treated as `browser`. + Client handoff sessions created by `POST /auth/browser/session` use a private, one-time polling token. First-time clients may create a session without a JWT. During re-authentication, clients should include their valid XLink bearer JWT; the Microsoft account completing the browser flow must then have the same XUID, and mismatched logins are discarded. API JWTs contain non-reversible fingerprints of Xbox, redeem and PlayFab credentials issued with them. Protected routes reject those supplied upstream credentials when they are not bound to the current API JWT. Minecraft tokens are refreshed independently, so their issuing endpoint is protected through the bound PlayFab session ticket instead of pinning a short-lived Minecraft token into the JWT. Existing JWTs issued before this contract change must be replaced by completing or refreshing the Microsoft authentication flow. @@ -369,7 +371,7 @@ curl -X POST http://localhost:3000/debug/decode-token -H "Authorization: Beare | POST | `/messaging/session/start` | Alias of inbox start | `x-mc-token` | | POST | `/messaging/inbox/event` | Mark seen/delete message events | `x-mc-token` | | POST | `/minecraft/token` | Create Minecraft multiplayer token from SessionTicket | — | -| POST | `/minecraft/token/refresh` | Refresh PlayFab SessionTicket + Minecraft token | — | +| POST | `/minecraft/token/refresh` | Rotate SessionTicket + Minecraft token + bound API JWT | — | ### PlayFab | Method | Endpoint | Description | diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 9b4b7be..b94bb4c 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -6,6 +6,7 @@ import { buildBrowserAuthorizationUrl, exchangeAuthorizationCode, getMicrosoftOAuthConfig, + getRefreshClientSecretForFlow, getTokenFromDeviceCode, isModernMicrosoftClientId, refreshMsToken, @@ -120,11 +121,12 @@ router.post("/callback", authLimiter, asyncHandler(async (req, res) => { if (error) throw badRequest(error.message); const tokenData = await getTokenFromDeviceCode(env.CLIENT_ID, value.device_code); - res.json(await exchangeMicrosoftTokenBundle( + const result = await exchangeMicrosoftTokenBundle( tokenData, env.CLIENT_ID, env.PLAYFAB_TITLE_ID || "20ca2" - )); + ); + res.json({...result, microsoftAuthFlow: "device"}); })); /** @@ -148,6 +150,11 @@ router.post("/callback", authLimiter, asyncHandler(async (req, res) => { * msRefreshToken: * type: string * description: Microsoft OAuth refresh_token from a previous /auth/callback + * microsoftAuthFlow: + * type: string + * enum: [browser, device] + * default: browser + * description: Originating OAuth client flow; device refreshes are public-client requests * responses: * 200: * description: Tokens successfully refreshed @@ -156,24 +163,41 @@ router.post("/callback", authLimiter, asyncHandler(async (req, res) => { * schema: * $ref: '#/components/schemas/AuthCallbackResponse' * 400: - * description: Invalid refresh token + * description: Malformed request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Microsoft refresh token is expired, revoked, or otherwise invalid + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 502: + * description: Microsoft confidential-client authentication is misconfigured or rejected * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ router.post("/refresh", authLimiter, asyncHandler(async (req, res) => { - const schema = Joi.object({msRefreshToken: Joi.string().required()}); + const schema = Joi.object({ + msRefreshToken: Joi.string().required(), + microsoftAuthFlow: Joi.string().valid("browser", "device").default("browser") + }); const {value, error} = schema.validate(req.body); if (error) throw badRequest(error.message); - const tokenData = await refreshMsToken(env.CLIENT_ID, value.msRefreshToken); + const clientSecret = getRefreshClientSecretForFlow(value.microsoftAuthFlow); + const tokenData = await refreshMsToken(env.CLIENT_ID, value.msRefreshToken, undefined, clientSecret); tokenData.refresh_token = tokenData.refresh_token || value.msRefreshToken; - res.json(await exchangeMicrosoftTokenBundle( + const result = await exchangeMicrosoftTokenBundle( tokenData, env.CLIENT_ID, env.PLAYFAB_TITLE_ID || "20ca2" - )); + ); + res.json({...result, microsoftAuthFlow: value.microsoftAuthFlow}); })); router.post("/browser/session", authLimiter, asyncHandler(async (req, res) => { @@ -229,11 +253,14 @@ router.get("/browser/callback", authLimiter, asyncHandler(async (req, res) => { codeVerifier, clientSecret: config.clientSecret }); - const result = await exchangeMicrosoftTokenBundle( - tokenData, - config.clientId, - env.PLAYFAB_TITLE_ID || "20ca2" - ); + const result = { + ...(await exchangeMicrosoftTokenBundle( + tokenData, + config.clientId, + env.PLAYFAB_TITLE_ID || "20ca2" + )), + microsoftAuthFlow: "browser" + }; if (context.source === "client") { const handoff = oauthSessionStore.completeHandoff(context.handoffSessionId, result); if (handoff.successPath && env.MICROSOFT_OAUTH_FRONTEND_REDIRECT_URI) { diff --git a/src/routes/health.routes.js b/src/routes/health.routes.js index 8e9e9f2..5d5a41e 100644 --- a/src/routes/health.routes.js +++ b/src/routes/health.routes.js @@ -1,7 +1,19 @@ import express from "express"; +import {env} from "../config/env.js"; +import {getMicrosoftOAuthConfig, isModernMicrosoftClientId} from "../services/microsoft.service.js"; const router = express.Router(); +export function getReadinessStatus(config = env) { + const browserFlowEnabled = Boolean(config.MICROSOFT_OAUTH_REDIRECT_URI) && + isModernMicrosoftClientId(config.CLIENT_ID) && + getMicrosoftOAuthConfig(config.CLIENT_ID, config.MICROSOFT_AUTH_MODE).type === "modern"; + if (browserFlowEnabled && !config.MICROSOFT_OAUTH_CLIENT_SECRET) { + return {ready: false, reason: "microsoft_oauth_client_secret_missing"}; + } + return {ready: true}; +} + /** * @swagger * /healthz: @@ -25,14 +37,19 @@ router.get("/healthz", (_req, res) => res.json({ok: true})); * summary: Readiness / startup probe * description: > * Readiness check used by orchestrators to decide whether traffic can be routed to this instance. - * Currently a simple `{ ready: true }` response without deep dependency checks. + * Validates local configuration required for the configured browser OAuth flow. * No authentication required. * tags: [Health] * security: [] # overrides global BearerAuth * responses: * 200: * description: Service is ready to receive traffic + * 503: + * description: Required browser OAuth configuration is missing */ -router.get("/readyz", (_req, res) => res.json({ready: true})); +router.get("/readyz", (_req, res) => { + const status = getReadinessStatus(); + res.status(status.ready ? 200 : 503).json(status); +}); export default router; diff --git a/src/routes/minecraft.routes.js b/src/routes/minecraft.routes.js index 72a6f51..32dc9d1 100644 --- a/src/routes/minecraft.routes.js +++ b/src/routes/minecraft.routes.js @@ -1,13 +1,20 @@ import express from "express"; import Joi from "joi"; -import {jwtMiddleware} from "../utils/jwt.js"; +import {jwtMiddleware, signJwt} from "../utils/jwt.js"; import {asyncHandler} from "../utils/async.js"; import {getMCToken} from "../services/minecraft.service.js"; import {loginWithXbox} from "../services/playfab.service.js"; -import {badRequest} from "../utils/httpError.js"; +import {badGateway, badRequest} from "../utils/httpError.js"; +import {mergeTokenBindings} from "../utils/tokenBinding.js"; +import {env} from "../config/env.js"; const router = express.Router(); +function preventTokenCaching(res) { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Pragma", "no-cache"); +} + /** * @swagger * /minecraft/token: @@ -40,6 +47,7 @@ router.post("/token", jwtMiddleware, asyncHandler(async (req, res) => { const {value, error} = schema.validate(req.body); if (error) throw badRequest(error.message); const mcToken = await getMCToken(value.sessionTicket); + preventTokenCaching(res); res.json({mcToken}); })); @@ -51,8 +59,8 @@ router.post("/token", jwtMiddleware, asyncHandler(async (req, res) => { * description: > * Uses an existing PlayFab XSTS token (playfabToken, XBL3.0 …) to obtain a fresh * PlayFab SessionTicket and a new Minecraft multiplayer token (MCToken …). - * This is useful when the previous SessionTicket has expired but the Xbox / PlayFab - * login is still valid. + * It also returns a replacement API JWT bound to the rotated SessionTicket. Clients + * must persist the JWT, SessionTicket and Minecraft token together. * tags: [Minecraft] * security: * - BearerAuth: [] @@ -71,7 +79,26 @@ router.post("/token", jwtMiddleware, asyncHandler(async (req, res) => { * returned by /auth/callback. * responses: * 200: - * description: New PlayFab SessionTicket and Minecraft multiplayer token + * description: New PlayFab SessionTicket, Minecraft token, and replacement API JWT + * content: + * application/json: + * schema: + * type: object + * required: [jwt, expiresIn, sessionTicket, playFabId, mcToken] + * properties: + * jwt: + * type: string + * description: Replacement API JWT bound to the returned SessionTicket and Minecraft token + * expiresIn: + * type: string + * sessionTicket: + * type: string + * playFabId: + * type: string + * mcToken: + * type: string + * 502: + * description: PlayFab or Minecraft returned an invalid success response */ router.post("/token/refresh", jwtMiddleware, asyncHandler(async (req, res) => { const schema = Joi.object({ @@ -81,12 +108,30 @@ router.post("/token/refresh", jwtMiddleware, asyncHandler(async (req, res) => { const {value, error} = schema.validate(req.body); if (error) throw badRequest(error.message); - const {SessionTicket, PlayFabId} = await loginWithXbox(value.playfabToken); + const {SessionTicket, PlayFabId} = (await loginWithXbox(value.playfabToken)) || {}; + if (typeof SessionTicket !== "string" || !SessionTicket.trim() + || typeof PlayFabId !== "string" || !PlayFabId.trim()) { + throw badGateway("PlayFab returned an invalid login response"); + } const mcToken = await getMCToken(SessionTicket); + if (typeof mcToken !== "string" || !mcToken.trim()) { + throw badGateway("Minecraft returned an invalid token response"); + } + const {xuid, gamertag, uhs} = req.user; + const tokenBindings = mergeTokenBindings(req.user.tokenBindings, { + sessionTicket: SessionTicket, + minecraft: mcToken + }); + const jwt = signJwt({xuid, gamertag, uhs, tokenBindings}); + preventTokenCaching(res); res.json({ - sessionTicket: SessionTicket, playFabId: PlayFabId, mcToken + jwt, + expiresIn: env.JWT_EXPIRES_IN || "1h", + sessionTicket: SessionTicket, + playFabId: PlayFabId, + mcToken }); })); -export default router; \ No newline at end of file +export default router; diff --git a/src/services/microsoft.service.js b/src/services/microsoft.service.js index fa28d87..b229344 100644 --- a/src/services/microsoft.service.js +++ b/src/services/microsoft.service.js @@ -1,7 +1,8 @@ import crypto from "node:crypto"; import {env} from "../config/env.js"; -import {badRequest, internal, unauthorized} from "../utils/httpError.js"; +import {badGateway, badRequest, internal, unauthorized} from "../utils/httpError.js"; import {createHttp} from "../utils/http.js"; +import {log} from "../utils/logger.js"; const http = createHttp(env.HTTP_TIMEOUT_MS); @@ -60,16 +61,31 @@ export function buildDeviceTokenRequest(clientId, deviceCode) { }; } -export function buildRefreshTokenRequest(clientId, refreshToken) { +export function getRefreshClientSecretForFlow( + microsoftAuthFlow, + configuredSecret = env.MICROSOFT_OAUTH_CLIENT_SECRET +) { + return microsoftAuthFlow === "device" ? null : configuredSecret; +} + +export function buildRefreshTokenRequest( + clientId, + refreshToken, + clientSecret = env.MICROSOFT_OAUTH_CLIENT_SECRET +) { const config = getMicrosoftOAuthConfig(clientId); + const body = new URLSearchParams({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: config.scope + }); + if (config.type === "modern" && clientSecret) { + body.set("client_secret", clientSecret); + } return { url: config.tokenUrl, - body: new URLSearchParams({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: refreshToken, - scope: config.scope - }) + body }; } @@ -116,7 +132,10 @@ export function buildAuthorizationCodeTokenRequest(clientId, code, redirectUri, } function formOptions() { - return {headers: {"content-type": "application/x-www-form-urlencoded"}}; + return { + headers: {"content-type": "application/x-www-form-urlencoded"}, + maxRedirects: 0 + }; } export async function requestDeviceCode(clientId, httpClient = http) { @@ -146,20 +165,37 @@ export async function getTokenFromDeviceCode(clientId, deviceCode, httpClient = } } -export async function refreshMsToken(clientId, refreshToken, httpClient = http) { +export async function refreshMsToken( + clientId, + refreshToken, + httpClient = http, + clientSecret = env.MICROSOFT_OAUTH_CLIENT_SECRET +) { if (!refreshToken) throw badRequest("refresh_token is required"); try { - const request = buildRefreshTokenRequest(clientId, refreshToken); + const request = buildRefreshTokenRequest(clientId, refreshToken, clientSecret); const {data} = await httpClient.post(request.url, request.body.toString(), formOptions()); return data; } catch (err) { - const upstreamCode = err.response?.data?.error; + const upstream = err.response?.data; + const upstreamCode = upstream?.error; + log.warn("Microsoft token refresh failed", { + error: upstreamCode || "transport_error", + errorCodes: Array.isArray(upstream?.error_codes) ? upstream.error_codes : undefined, + traceId: upstream?.trace_id, + correlationId: upstream?.correlation_id + }); if (upstreamCode === "invalid_grant" || upstreamCode === "invalid_token") { const failure = unauthorized("Microsoft refresh token is invalid or expired"); failure.code = "MICROSOFT_REFRESH_TOKEN_INVALID"; throw failure; } - throw internal("Failed to refresh ms token", err.response?.data || err.message); + if (upstreamCode === "invalid_client" || upstreamCode === "unauthorized_client") { + const failure = badGateway("Microsoft OAuth client authentication failed"); + failure.code = "MICROSOFT_CLIENT_AUTH_FAILED"; + throw failure; + } + throw internal("Failed to refresh ms token", upstream || err.message); } } diff --git a/src/services/minecraft.service.js b/src/services/minecraft.service.js index 8fe428d..8c64210 100644 --- a/src/services/minecraft.service.js +++ b/src/services/minecraft.service.js @@ -2,8 +2,8 @@ import {randomUUID} from "crypto"; import jwtLib from "jsonwebtoken"; import {env} from "../config/env.js"; -import {HttpError, badRequest, conflict, forbidden, internal, unauthorized} from "../utils/httpError.js"; -import {createHttp} from "../utils/http.js"; +import {HttpError, badGateway, badRequest, conflict, forbidden, internal, unauthorized} from "../utils/httpError.js"; +import {createHttp, UPSTREAM_INVALID_SUCCESS} from "../utils/http.js"; const AUTH_BASE = "https://authorization.franchise.minecraft-services.net/api/v1.0/session/start"; const ENTITLEMENTS_BASE = "https://entitlements.mktpl.minecraft-services.net/api/v1.0"; @@ -103,18 +103,22 @@ export async function getMCToken(sessionTicket) { "user-agent": "MCPE/UWP" } }); - if (res.data?.result?.authorizationHeader) { - return res.data.result.authorizationHeader; + const authorizationHeader = res.data?.result?.authorizationHeader; + if (typeof authorizationHeader === "string" && authorizationHeader.trim()) { + return authorizationHeader; } const isString = typeof res.data === "string"; - throw internal("Failed to get Minecraft token", { - status: 500, + throw badGateway("Failed to get Minecraft token", { + status: 502, json: !isString ? res.data : undefined, htmlSnippet: isString ? res.data.slice(0, 800) : undefined }); } catch (err) { if (err instanceof HttpError) throw err; + if (err.code === UPSTREAM_INVALID_SUCCESS) { + throw badGateway("Failed to get Minecraft token", err.message); + } const status = err.response?.status; const detail = err.details || err.response?.data || err.message || err; if (status === 401) { diff --git a/src/services/playfab.service.js b/src/services/playfab.service.js index 90d68eb..03240c5 100644 --- a/src/services/playfab.service.js +++ b/src/services/playfab.service.js @@ -1,6 +1,6 @@ import {env} from "../config/env.js"; -import {badRequest, forbidden, internal, unauthorized} from "../utils/httpError.js"; -import {createHttp} from "../utils/http.js"; +import {badGateway, badRequest, forbidden, internal, unauthorized} from "../utils/httpError.js"; +import {createHttp, UPSTREAM_INVALID_SUCCESS} from "../utils/http.js"; const http = createHttp(env.HTTP_TIMEOUT_MS); @@ -21,6 +21,9 @@ export async function loginWithXbox(xstsToken, titleId = env.PLAYFAB_TITLE_ID) { }, {headers: {"Content-Type": "application/json", Accept: "application/json"}}); return data.data; } catch (err) { + if (err.code === UPSTREAM_INVALID_SUCCESS) { + throw badGateway("PlayFab returned an invalid login response", err.message); + } const status = err.response?.status; const detail = err.response?.data || err.message; if (status === 401) { diff --git a/src/utils/http.js b/src/utils/http.js index bc5c9ac..ebb654a 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -4,26 +4,33 @@ import https from "node:https"; const httpAgent = new http.Agent({keepAlive: true, maxSockets: 100}); const httpsAgent = new https.Agent({keepAlive: true, maxSockets: 100}); +export const UPSTREAM_INVALID_SUCCESS = "UPSTREAM_INVALID_SUCCESS"; + +function invalidUpstreamSuccess(message) { + const error = new Error(message); + error.code = UPSTREAM_INVALID_SUCCESS; + return error; +} export function validateUpstreamSuccess(response) { if (response.status === 204) return response; const contentType = String(response.headers?.["content-type"] || "").toLowerCase(); if (contentType && !contentType.includes("json")) { - throw new Error(`Upstream returned unexpected content type ${contentType}`); + throw invalidUpstreamSuccess(`Upstream returned unexpected content type ${contentType}`); } const data = response.data; if (data == null || typeof data === "string") { - throw new Error("Upstream returned an empty or non-JSON success body"); + throw invalidUpstreamSuccess("Upstream returned an empty or non-JSON success body"); } if (!Array.isArray(data) && typeof data !== "object") { - throw new Error("Upstream returned an invalid success body"); + throw invalidUpstreamSuccess("Upstream returned an invalid success body"); } const apparentError = !Array.isArray(data) && ( data.error != null || data.errorMessage != null || (typeof data.status === "string" && /^error$/i.test(data.status)) || (typeof data.code === "number" && data.code >= 400) ); - if (apparentError) throw new Error("Upstream returned an error envelope with a successful HTTP status"); + if (apparentError) throw invalidUpstreamSuccess("Upstream returned an error envelope with a successful HTTP status"); return response; } diff --git a/src/utils/swagger.js b/src/utils/swagger.js index 97813e3..3dc4e38 100644 --- a/src/utils/swagger.js +++ b/src/utils/swagger.js @@ -76,6 +76,7 @@ const options = { uhs: {type: "string"}, msAccessToken: {type: "string"}, msRefreshToken: {type: "string"}, + microsoftAuthFlow: {type: "string", enum: ["browser", "device"]}, msExpiresIn: {type: "integer"}, xblToken: {type: "string"}, xsts: { diff --git a/src/utils/tokenBinding.js b/src/utils/tokenBinding.js index f6297c8..1169281 100644 --- a/src/utils/tokenBinding.js +++ b/src/utils/tokenBinding.js @@ -12,6 +12,13 @@ export function buildTokenBindings(tokens = {}) { .filter(([, fingerprint]) => fingerprint)); } +export function mergeTokenBindings(existing = {}, rotatedTokens = {}) { + return { + ...(existing && typeof existing === "object" ? existing : {}), + ...buildTokenBindings(rotatedTokens) + }; +} + export function assertTokenBinding(user, kind, value) { if (!value) return; const expected = user?.tokenBindings?.[kind]; diff --git a/tests/healthReadiness.test.js b/tests/healthReadiness.test.js new file mode 100644 index 0000000..f542cc9 --- /dev/null +++ b/tests/healthReadiness.test.js @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import {getReadinessStatus} from "../src/routes/health.routes.js"; + +test("browser OAuth configuration is not ready without its confidential client secret", () => { + assert.deepEqual(getReadinessStatus({ + CLIENT_ID: "11111111-2222-3333-4444-555555555555", + MICROSOFT_AUTH_MODE: "modern", + MICROSOFT_OAUTH_REDIRECT_URI: "https://example.test/auth/callback" + }), { + ready: false, + reason: "microsoft_oauth_client_secret_missing" + }); +}); + +test("device-only or fully configured browser OAuth remains ready", () => { + assert.deepEqual(getReadinessStatus({}), {ready: true}); + assert.deepEqual(getReadinessStatus({ + CLIENT_ID: "legacy-client-id", + MICROSOFT_AUTH_MODE: "auto", + MICROSOFT_OAUTH_REDIRECT_URI: "https://example.test/auth/callback" + }), {ready: true}); + assert.deepEqual(getReadinessStatus({ + CLIENT_ID: "11111111-2222-3333-4444-555555555555", + MICROSOFT_AUTH_MODE: "modern", + MICROSOFT_OAUTH_REDIRECT_URI: "https://example.test/auth/callback", + MICROSOFT_OAUTH_CLIENT_SECRET: "configured-outside-source-control" + }), {ready: true}); +}); diff --git a/tests/microsoftRefresh.test.js b/tests/microsoftRefresh.test.js new file mode 100644 index 0000000..c589d59 --- /dev/null +++ b/tests/microsoftRefresh.test.js @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildRefreshTokenRequest, + getRefreshClientSecretForFlow, + refreshMsToken +} from "../src/services/microsoft.service.js"; +import {env} from "../src/config/env.js"; +import {log} from "../src/utils/logger.js"; + +const MODERN_CLIENT_ID = "11111111-2222-3333-4444-555555555555"; + +test("modern confidential refresh authenticates the Microsoft client", () => { + const request = buildRefreshTokenRequest( + MODERN_CLIENT_ID, + "refresh-token", + "oauth-client-secret" + ); + + assert.equal(request.body.get("client_id"), MODERN_CLIENT_ID); + assert.equal(request.body.get("grant_type"), "refresh_token"); + assert.equal(request.body.get("refresh_token"), "refresh-token"); + assert.equal(request.body.get("client_secret"), "oauth-client-secret"); +}); + +test("public and legacy refresh requests do not invent a client secret", () => { + const publicModern = buildRefreshTokenRequest(MODERN_CLIENT_ID, "refresh-token", null); + const legacy = buildRefreshTokenRequest("legacy-client-id", "refresh-token", "must-not-leak"); + + assert.equal(publicModern.body.has("client_secret"), false); + assert.equal(legacy.body.has("client_secret"), false); +}); + +test("refresh flow provenance keeps device grants public and browser grants confidential", () => { + assert.equal(getRefreshClientSecretForFlow("device", "configured-secret"), null); + assert.equal(getRefreshClientSecretForFlow("browser", "configured-secret"), "configured-secret"); +}); + +test("refreshMsToken sends the configured secret in the form body", async () => { + let captured; + const httpClient = { + async post(url, body, options) { + captured = {url, body: new URLSearchParams(body), options}; + return {data: {access_token: "fresh-access-token"}}; + } + }; + + const result = await refreshMsToken( + MODERN_CLIENT_ID, + "refresh-token", + httpClient, + "oauth-client-secret" + ); + + assert.equal(result.access_token, "fresh-access-token"); + assert.equal(captured.body.get("client_secret"), "oauth-client-secret"); + assert.equal(captured.options.headers["content-type"], "application/x-www-form-urlencoded"); + assert.equal(captured.options.maxRedirects, 0); +}); + +test("refreshMsToken uses the server environment secret when the route omits an override", async (t) => { + const originalSecret = env.MICROSOFT_OAUTH_CLIENT_SECRET; + t.after(() => { + env.MICROSOFT_OAUTH_CLIENT_SECRET = originalSecret; + }); + env.MICROSOFT_OAUTH_CLIENT_SECRET = "environment-client-secret"; + + let form; + const httpClient = { + async post(_url, body) { + form = new URLSearchParams(body); + return {data: {access_token: "fresh-access-token"}}; + } + }; + + await refreshMsToken(MODERN_CLIENT_ID, "refresh-token", httpClient); + assert.equal(form.get("client_secret"), "environment-client-secret"); +}); + +test("invalid_client is reported as server configuration without logging credentials", async (t) => { + const originalWarn = log.warn; + const warnings = []; + t.after(() => { + log.warn = originalWarn; + }); + log.warn = (...values) => warnings.push(values); + + const httpClient = { + async post() { + const error = new Error("rejected"); + error.response = {data: { + error: "invalid_client", + error_description: "must-not-log wrong-secret refresh-token" + }}; + throw error; + } + }; + + await assert.rejects( + refreshMsToken(MODERN_CLIENT_ID, "refresh-token", httpClient, "wrong-secret"), + error => error.status === 502 && error.code === "MICROSOFT_CLIENT_AUTH_FAILED" + ); + const logged = JSON.stringify(warnings); + assert.match(logged, /invalid_client/); + assert.doesNotMatch(logged, /wrong-secret|refresh-token|error_description/); +}); + +test("invalid_grant remains an expired or revoked user credential", async (t) => { + const originalWarn = log.warn; + t.after(() => { + log.warn = originalWarn; + }); + log.warn = () => {}; + const httpClient = { + async post() { + const error = new Error("rejected"); + error.response = {data: {error: "invalid_grant"}}; + throw error; + } + }; + + await assert.rejects( + refreshMsToken(MODERN_CLIENT_ID, "refresh-token", httpClient, "oauth-client-secret"), + error => error.status === 401 && error.code === "MICROSOFT_REFRESH_TOKEN_INVALID" + ); +}); diff --git a/tests/minecraftTokenRefresh.test.js b/tests/minecraftTokenRefresh.test.js new file mode 100644 index 0000000..83dfde1 --- /dev/null +++ b/tests/minecraftTokenRefresh.test.js @@ -0,0 +1,189 @@ +import test, {after} from "node:test"; +import assert from "node:assert/strict"; +import axios from "axios"; + +process.env.NODE_ENV = "test"; +process.env.JWT_SECRET = "minecraft-route-test-secret"; +process.env.JWT_EXPIRES_IN = "1h"; +process.env.JWT_ISSUER = "xlink-route-test"; +process.env.JWT_AUDIENCE = "xlink-route-test-api"; +process.env.CLIENT_ID = "minecraft-route-test-client"; +process.env.PLAYFAB_TITLE_ID = "20ca2"; + +const PLAYFAB_URL = "https://20ca2.playfabapi.com/Client/LoginWithXbox"; +const MINECRAFT_URL = "https://authorization.franchise.minecraft-services.net/api/v1.0/session/start"; +const PLAYFAB_TOKEN = "XBL3.0 x=test-uhs;playfab-token"; +const NEW_SESSION_TICKET = "new-session-ticket"; +const NEW_MC_TOKEN = "MCToken new-minecraft-token"; + +const originalAdapter = axios.defaults.adapter; +const upstreamCalls = []; +let playFabLogin = null; +let playFabEnvelope = null; +let minecraftEnvelope = null; + +function axiosResponse(config, data) { + return { + status: 200, + statusText: "OK", + headers: {"content-type": "application/json"}, + config, + request: {}, + data + }; +} + +axios.defaults.adapter = async config => { + const url = String(config.url); + upstreamCalls.push(url); + if (url === PLAYFAB_URL) { + return axiosResponse(config, playFabEnvelope); + } + if (url === MINECRAFT_URL) { + return axiosResponse(config, minecraftEnvelope); + } + throw new Error(`Unexpected upstream request: ${url}`); +}; + +const [ + {default: express}, + {default: minecraftRoutes}, + {errorHandler}, + {signJwt, verifyJwt}, + {buildTokenBindings, tokenFingerprint} +] = await Promise.all([ + import("express"), + import("../src/routes/minecraft.routes.js"), + import("../src/middleware/error.js"), + import("../src/utils/jwt.js"), + import("../src/utils/tokenBinding.js") +]); + +const originalBindings = buildTokenBindings({ + xboxlive: "original-xbox-token", + playfab: PLAYFAB_TOKEN, + redeem: "original-redeem-token", + sessionTicket: "old-session-ticket", + minecraft: "MCToken old-minecraft-token" +}); +const currentJwt = signJwt({ + xuid: "test-xuid", + gamertag: "TestPlayer", + uhs: "test-uhs", + tokenBindings: originalBindings +}); + +const app = express(); +app.use(express.json()); +app.use("/minecraft", minecraftRoutes); +app.use(errorHandler); + +const server = app.listen(0, "127.0.0.1"); +await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); +}); +const address = server.address(); +const baseUrl = `http://127.0.0.1:${address.port}`; + +after(async () => { + axios.defaults.adapter = originalAdapter; + if (server.listening) { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections?.(); + }); + } +}); + +function configureUpstreams({ + playFab = {SessionTicket: NEW_SESSION_TICKET, PlayFabId: "test-playfab-id"}, + playFabResponse = {data: playFab}, + minecraft = {result: {authorizationHeader: NEW_MC_TOKEN}} +} = {}) { + playFabLogin = playFab; + playFabEnvelope = playFabResponse; + minecraftEnvelope = minecraft; + upstreamCalls.length = 0; +} + +async function refreshMinecraftTokens() { + const response = await fetch(`${baseUrl}/minecraft/token/refresh`, { + method: "POST", + headers: { + Authorization: `Bearer ${currentJwt}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({playfabToken: PLAYFAB_TOKEN}) + }); + return {response, body: await response.json()}; +} + +test("minecraft token refresh returns a replacement JWT with rotated bindings and no-store headers", async () => { + configureUpstreams(); + + const {response, body} = await refreshMinecraftTokens(); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(response.headers.get("pragma"), "no-cache"); + assert.equal(body.sessionTicket, NEW_SESSION_TICKET); + assert.equal(body.playFabId, "test-playfab-id"); + assert.equal(body.mcToken, NEW_MC_TOKEN); + assert.equal(body.expiresIn, "1h"); + assert.equal(typeof body.jwt, "string"); + + const decoded = verifyJwt(body.jwt); + assert.ok(decoded); + assert.equal(decoded.xuid, "test-xuid"); + assert.equal(decoded.gamertag, "TestPlayer"); + assert.equal(decoded.uhs, "test-uhs"); + assert.equal(decoded.tokenBindings.xboxlive, originalBindings.xboxlive); + assert.equal(decoded.tokenBindings.playfab, originalBindings.playfab); + assert.equal(decoded.tokenBindings.redeem, originalBindings.redeem); + assert.equal(decoded.tokenBindings.sessionTicket, tokenFingerprint(NEW_SESSION_TICKET)); + assert.equal(decoded.tokenBindings.minecraft, tokenFingerprint(NEW_MC_TOKEN)); + assert.notEqual(decoded.tokenBindings.sessionTicket, originalBindings.sessionTicket); + assert.notEqual(decoded.tokenBindings.minecraft, originalBindings.minecraft); + assert.deepEqual(upstreamCalls, [PLAYFAB_URL, MINECRAFT_URL]); +}); + +test("minecraft token refresh rejects a malformed PlayFab success envelope without issuing a JWT", async () => { + configureUpstreams({playFab: null}); + + const {response, body} = await refreshMinecraftTokens(); + + assert.equal(response.status, 502); + assert.equal(body.error?.code, "BAD_GATEWAY"); + assert.equal(body.error?.message, "PlayFab returned an invalid login response"); + assert.equal(Object.hasOwn(body, "jwt"), false); + assert.deepEqual(upstreamCalls, [PLAYFAB_URL]); +}); + +test("minecraft token refresh rejects a malformed Minecraft success envelope without issuing a JWT", async () => { + configureUpstreams({minecraft: {result: {authorizationHeader: {token: NEW_MC_TOKEN}}}}); + + const {response, body} = await refreshMinecraftTokens(); + + assert.equal(response.status, 502); + assert.equal(body.error?.code, "BAD_GATEWAY"); + assert.equal(body.error?.message, "Failed to get Minecraft token"); + assert.equal(Object.hasOwn(body, "jwt"), false); + assert.deepEqual(upstreamCalls, [PLAYFAB_URL, MINECRAFT_URL]); +}); + +test("minecraft token refresh maps top-level empty upstream successes to bad gateway", async () => { + configureUpstreams({playFabResponse: null}); + let result = await refreshMinecraftTokens(); + assert.equal(result.response.status, 502); + assert.equal(result.body.error?.code, "BAD_GATEWAY"); + assert.equal(Object.hasOwn(result.body, "jwt"), false); + assert.deepEqual(upstreamCalls, [PLAYFAB_URL]); + + configureUpstreams({minecraft: null}); + result = await refreshMinecraftTokens(); + assert.equal(result.response.status, 502); + assert.equal(result.body.error?.code, "BAD_GATEWAY"); + assert.equal(Object.hasOwn(result.body, "jwt"), false); + assert.deepEqual(upstreamCalls, [PLAYFAB_URL, MINECRAFT_URL]); +}); diff --git a/tests/tokenBinding.test.js b/tests/tokenBinding.test.js new file mode 100644 index 0000000..8215322 --- /dev/null +++ b/tests/tokenBinding.test.js @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildTokenBindings, + mergeTokenBindings, + tokenFingerprint +} from "../src/utils/tokenBinding.js"; + +test("rotating a PlayFab session preserves unrelated bindings and replaces session credentials", () => { + const original = buildTokenBindings({ + xboxlive: "xbox-token", + playfab: "playfab-token", + sessionTicket: "old-session", + minecraft: "old-minecraft-token" + }); + + const rotated = mergeTokenBindings(original, { + sessionTicket: "new-session", + minecraft: "new-minecraft-token" + }); + + assert.equal(rotated.xboxlive, original.xboxlive); + assert.equal(rotated.playfab, original.playfab); + assert.equal(rotated.sessionTicket, tokenFingerprint("new-session")); + assert.equal(rotated.minecraft, tokenFingerprint("new-minecraft-token")); + assert.notEqual(rotated.sessionTicket, original.sessionTicket); +});