/* Galvanic Corrosion - Rec Room custom server for communities. Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { APIUtils, NoBody } from "../../apiutils.ts"; import express from "express"; import UnifiedProfile, { Profile } from "../../data/profiles.ts"; import { decode } from "@gz/jwt"; import { Config } from "../../config.ts"; import Logging from "@proxnet/undead-logging"; import { z } from "zod"; import { AuthType } from "../../data/users.ts"; import { Redis } from "../../db.ts"; import { validVersions } from "../api/versioncheck.ts"; const config = Config.getConfig(); const log = new Logging("AuthConnectRoute"); export const route = APIUtils.createRouter("/connect"); interface AuthBodyBase { grant_type: string; client_id: string; client_secret: string; platform: string; platform_id: string; device_id: string; device_class: string; time: string; ver: string; asid: string; platform_auth: string; } interface TokenRequest extends AuthBodyBase { account_id: string; grant_type: "cached_login" } interface RefreshRequest extends AuthBodyBase { refresh_token: string, grant_type: "refresh_token" } type TokenRequestBody = TokenRequest | RefreshRequest; const AuthBodyBaseSchema = z.object({ grant_type: z.string(), client_id: z.string(), client_secret: z.string(), platform: z.string(), platform_id: z.string(), device_id: z.string(), device_class: z.string(), time: z.string(), ver: z.string(), asid: z.string(), platform_auth: z.string(), }); const TokenRequestSchema = AuthBodyBaseSchema.extend({ grant_type: z.literal('cached_login'), account_id: z.string(), }); const RefreshRequestSchema = AuthBodyBaseSchema.extend({ grant_type: z.literal('refresh_token'), refresh_token: z.string(), }); const TokenRequestBodySchema = z.discriminatedUnion('grant_type', [ TokenRequestSchema, RefreshRequestSchema, ]); interface TokenResponseBody { error?: string; error_description?: string; access_token: string; refresh_token: string; } route.router.post("/token", APIUtils.startTimer, APIUtils.Authentication, APIUtils.AuthenticationType(AuthType.Web), express.urlencoded({ extended: true }), APIUtils.validateRequestBody(TokenRequestBodySchema), async ( rq: express.Request, rs: express.Response, nxt: express.NextFunction ) => { function requestFailed(msg: string = "invalid_request") { rs.json({ error: msg, access_token: "", refresh_token: "", }); } const conditionsMet = ![ rq.body.client_id === "recroom", rq.body.platform === "0", validVersions.includes(rq.body.ver), rq.body.device_class.length === 1, !isNaN(Number(rq.body.device_class)), !(rq.body.device_id.length > 96), !(rq.body.client_secret.length > 96), !(rq.body.platform_id.length > 32), !(rq.body.time.length > 32), !(rq.body.asid.length > 32), ].includes(false); if (!conditionsMet) { requestFailed(); return; } let targetAccount: number; if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id); else { const refreshToken = rq.body.refresh_token; if (typeof refreshToken == 'undefined') { requestFailed(); return; } let decodedToken; try { decodedToken = await decode(rq.body.refresh_token, config.auth.secret, { algorithm: "HS512" }); } catch (err) { log.w(`Refresh token decode failed: ${err}`); requestFailed(); return; } targetAccount = parseInt(decodedToken.sub ? decodedToken.sub : "NaN"); } if (isNaN(targetAccount)) { requestFailed(); return; } const accounts = await rs.locals.user.getAssociatedProfiles(); if (!accounts.has(targetAccount)) { requestFailed("access_denied"); return; } rs.locals.user.addAssociatedDeviceId(rq.body.device_id); rs.locals.user.addAssociatedPlatformId(rq.body.platform_id); Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.PlatformAssociations, rq.body.platform_id), targetAccount); const profile = UnifiedProfile.get(targetAccount); if (!(await Profile.exists(profile.getId()))) { requestFailed(); return; } const details = await profile.export(); log.i(`Player ${details?.username} "${details?.displayName}" (${profile.getId()}) logged in`); const token = await profile.getToken(); rs.json({ access_token: token, refresh_token: token, }); await profile.setKnownDeviceClass(Number(rq.body.device_class)); nxt(); }, APIUtils.stopTimer );