Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
51 changes: 39 additions & 12 deletions src/routes/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildBrowserAuthorizationUrl,
exchangeAuthorizationCode,
getMicrosoftOAuthConfig,
getRefreshClientSecretForFlow,
getTokenFromDeviceCode,
isModernMicrosoftClientId,
refreshMsToken,
Expand Down Expand Up @@ -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"});
}));

/**
Expand All @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 19 additions & 2 deletions src/routes/health.routes.js
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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;
61 changes: 53 additions & 8 deletions src/routes/minecraft.routes.js
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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});
}));

Expand All @@ -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: []
Expand All @@ -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({
Expand All @@ -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;
export default router;
62 changes: 49 additions & 13 deletions src/services/microsoft.service.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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
};
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading