openvibers/openvibe.games Commits
SSO: silent sign-in, next-hop chains, ov_sso_hint, /auth/logout and /auth/me
Changes
@@@ -51,9 +51,9 @@ export function characterSelect(out.className = 'char-logout'out.textContent = 'Log out'out.addEventListener('click', () => {-- localStorage.removeItem('ovg_sso')-- document.cookie = 'ovg_sso=; Path=/; Max-Age=0'-- location.reload()++ // Server-side sign-out: drops the session cookie, marks the browser a++ // guest for the network's silent sign-in, clears localStorage, reloads.++ location.href = `/auth/logout?next=${encodeURIComponent(location.pathname)}`})bar.appendChild(out)panel.appendChild(bar)@@@ -1 +1 @@--{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAIhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAuBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAgBD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,MAAM,CAgVR"}\\ No newline at end of file++{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAmBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAgBD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,MAAM,CA6aR"}\\ No newline at end of file@@@ -11,6 +11,20 @@ export interface NetworkUser {name: string;rank: OpenVibeRank;}++/** The account record as openvibe.network's /api/auth/me returns it. */++export interface NetworkAccount {++ id?: string | number;++ username?: string;++ role?: string;++ is_banned?: number;++ [key: string]: unknown;++}++/**++ * Fetches the raw account behind a bearer token from openvibe.network, or++ * null when the token is missing, rejected or the Network is unreachable.++ * The same-origin /auth/me endpoint hands this object to the shared navbar.++ */++export declare function fetchNetworkAccount(url: string | null, auth: string | undefined): Promise<NetworkAccount | null>;export declare function resolveNetworkUser(url: string | null, auth: string | undefined): Promise<NetworkUser | null>;export declare function canEditMap(rank: OpenVibeRank): boolean;//# sourceMappingURL=networkAuth.d.ts.map\\ No newline at end of file@@@ -1 +1 @@--{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAuB7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}\\ No newline at end of file++{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAU7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,53 @@++/**++ * openvibe.network SSO helpers for the /auth/* routes: where a sign-in may++ * send the browser afterwards, the cookies that carry the session and the++ * cross-site "is anyone signed in here?" hint, and the state cookie that ties++ * a callback to the login that started it.++ *++ * Kept free of http.Server types so the rules are unit-testable on their own.++ */++/** Name of the cookie holding the openvibe.network access token. */++export declare const SESSION_COOKIE = "ovg_sso";++/**++ * Cross-network hint read by the shared navbar: `account` means this browser++ * has signed in somewhere on OpenVibe (so a silent prompt=none sign-in is++ * worth attempting), `guest` means it signed out. JS-readable by design.++ */++export declare const SSO_HINT_COOKIE = "ov_sso_hint";++/** Short-lived, HttpOnly: pins the OAuth `state` and the post-login `next`. */++export declare const LOGIN_STATE_COOKIE = "ovg_login";++export declare function isSilentDenial(error: string | null | undefined): boolean;++export interface NextOrigins {++ /** openvibe.network — the sign-in-everywhere / sign-out-everywhere chains hop through it. */++ network: string;++ /** Our own public bases (apex + play host). */++ self: string[];++}++/**++ * Validates a `next` target. Accepts a same-site path (`/play`, never `//evil`)++ * or an absolute https URL on openvibe.network or one of our own hosts.++ * Anything else yields null so an open redirect is impossible.++ */++export declare function safeNext(raw: string | null | undefined, origins: NextOrigins): string | null;++/** Appends `sso=none` to a validated `next` so the landing page knows the silent attempt found nobody. */++export declare function withSsoNone(next: string): string;++export declare function parseCookies(header: string | undefined): Record<string, string>;++export interface LoginState {++ state: string;++ next: string | null;++ silent: boolean;++}++export declare function encodeLoginState(s: LoginState): string;++export declare function decodeLoginState(raw: string | undefined): LoginState | null;++export declare function sessionCookie(token: string, secure: boolean): string;++export declare function clearSessionCookie(): string;++export declare function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string;++export declare function loginStateCookie(s: LoginState, secure: boolean): string;++export declare function clearLoginStateCookie(): string;++/**++ * The page the callback answers with: it mirrors the session into++ * localStorage (the game client reads it from there) before moving on, so a++ * cross-origin `next` still leaves this origin signed in.++ */++export declare function sessionHandoffHtml(token: string | null, hint: 'account' | 'guest', dest: string): string;++//# sourceMappingURL=sso.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"sso.d.ts","sourceRoot":"","sources":["../../src/net/sso.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,oEAAoE;AACpE,eAAO,MAAM,cAAc,YAAY,CAAA;AACvC;;;;GAIG;AACH,eAAO,MAAM,eAAe,gBAAgB,CAAA;AAC5C,+EAA+E;AAC/E,eAAO,MAAM,kBAAkB,cAAc,CAAA;AAc7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAExE;AAED,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAA;IACf,+CAA+C;IAC/C,IAAI,EAAE,MAAM,EAAE,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0B5F;AAED,0GAA0G;AAC1G,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAe/E;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,IAAI,CAa3E;AAMD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEpE;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEjF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEvE;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAOD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,IAAI,EAAE,SAAS,GAAG,OAAO,EACzB,IAAI,EAAE,MAAM,GACX,MAAM,CASR"}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=sso.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"sso.test.d.ts","sourceRoot":"","sources":["../../src/net/sso.test.ts"],"names":[],"mappings":""}\\ No newline at end of file@@@ -4,7 +4,22 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }import { dirname, extname, join, normalize, resolve } from 'node:path'import type { Logger } from '@openvibe/shared'import type { ServerMetrics } from '../observability/metrics.js'--import { canEditMap, resolveNetworkUser } from './networkAuth.js'++import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'++import {++ LOGIN_STATE_COOKIE,++ SESSION_COOKIE,++ clearLoginStateCookie,++ clearSessionCookie,++ decodeLoginState,++ isSilentDenial,++ loginStateCookie,++ parseCookies,++ safeNext,++ sessionCookie,++ sessionHandoffHtml,++ ssoHintCookie,++ withSsoNone,++} from './sso.js'import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'import type { MapFileV2 } from '@openvibe/content'@@@ -14,6 +29,8 @@ import type { MapFileV2 } from '@openvibe/content'* built client bundle. Game traffic itself is WebSocket-only.*/++const DEFAULT_NETWORK_URL = 'https://openvibe.network'++const MIME: Record<string, string> = {'.html': 'text/html; charset=utf-8','.js': 'text/javascript',@@@ -263,75 +280,168 @@ export function createHttpServer(// /auth/login redirects to the openvibe.network account chooser; the// callback exchanges the code server-side (client secret never// reaches the browser), then hands the access token to the page.-- if (url === '/auth/login' && oauth) {-- // Both callbacks are registered with the provider; using the host the-- // player came from means the callback lands back on that same host.-- const redirectBase = playHost ? oauth.playUrl : oauth.selfUrl-- const params = new URLSearchParams({-- client_id: oauth.clientId,-- redirect_uri: `${redirectBase}/auth/callback`,-- response_type: 'code',-- scope: 'profile theme',-- state: randomBytes(16).toString('hex'),-- })-- res.writeHead(302, { location: `${oauth.baseUrl}/oauth/authorize?${params.toString()}` })-- res.end()-- return-- }-- if (url === '/auth/callback' && oauth) {-- const code = new URL(req.url ?? '/', 'http://x').searchParams.get('code')-- if (!code) {-- res.writeHead(400, { 'content-type': 'text/plain' })-- res.end('missing code')++ //++ // `?silent=1` adds prompt=none (the Network's sign-in-everywhere chain++ // and the shared navbar's one-shot silent attempt); `?next=` may be a++ // same-site path or an https://openvibe.network/... hop. See sso.ts.++ if (url.startsWith('/auth/')) {++ const query = new URL(req.url ?? '/', 'http://x').searchParams++ const cookies = parseCookies(req.headers.cookie)++ const redirectBase = oauth ? (playHost ? oauth.playUrl : oauth.selfUrl) : null++ const secure = redirectBase ? redirectBase.startsWith('https') : false++ const origins = {++ network: oauth?.baseUrl ?? DEFAULT_NETWORK_URL,++ self: oauth ? [oauth.selfUrl, oauth.playUrl] : [],++ }++ // The game IS the root on the play host; on the apex it lives at /play.++ const home = playHost ? '/' : '/play'++++ if (url === '/auth/login' && oauth && redirectBase) {++ // Both callbacks are registered with the provider; using the host the++ // player came from means the callback lands back on that same host.++ const silent = query.get('silent') === '1'++ const next = safeNext(query.get('next'), origins)++ const state = randomBytes(16).toString('hex')++ const params = new URLSearchParams({++ client_id: oauth.clientId,++ redirect_uri: `${redirectBase}/auth/callback`,++ response_type: 'code',++ scope: 'profile theme',++ state,++ })++ if (silent) params.set('prompt', 'none')++ res.writeHead(302, {++ location: `${oauth.baseUrl}/oauth/authorize?${params.toString()}`,++ 'set-cookie': loginStateCookie({ state, next, silent }, secure),++ 'cache-control': 'no-store',++ })++ res.end()return}-- void (async () => {-- try {-- // The exchange must repeat the redirect_uri the authorize step-- // used; the callback arrives on that same host, so Host decides.-- const redirectBase = playHost ? oauth.playUrl : oauth.selfUrl-- const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {-- method: 'POST',-- headers: { 'content-type': 'application/json' },-- body: JSON.stringify({-- grant_type: 'authorization_code',-- client_id: oauth.clientId,-- client_secret: oauth.clientSecret,-- code,-- redirect_uri: `${redirectBase}/auth/callback`,-- }),-- })-- const data = (await resp.json()) as {-- access_token?: string-- error_description?: string-- error?: string-- user?: { username?: string }-- }-- if (!data.access_token) {-- res.writeHead(400, { 'content-type': 'text/plain' })-- res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)++ if (url === '/auth/callback' && oauth && redirectBase) {++ const login = decodeLoginState(cookies[LOGIN_STATE_COOKIE])++ const next = login?.next ?? null++ const error = query.get('error')++ if (error) {++ // prompt=none found nobody signed in (or the silent attempt was++ // refused): land quietly where the caller asked, flagged sso=none.++ if (login?.silent || isSilentDenial(error)) {++ res.writeHead(302, {++ location: withSsoNone(next ?? home),++ 'set-cookie': clearLoginStateCookie(),++ 'cache-control': 'no-store',++ })++ res.end()return}-- const tok = JSON.stringify(data.access_token)-- // Secure cookies are dropped over plain http (local dev).-- const secure = redirectBase.startsWith('https') ? '; Secure' : ''-- // Land back where the player started: play-host logins go to the-- // game at its root, apex logins to /play.-- const dest = playHost ? '/' : '/play'-- res.writeHead(200, {-- 'content-type': 'text/html; charset=utf-8',-- 'set-cookie': `ovg_sso=${encodeURIComponent(data.access_token)}; Path=/; Max-Age=${7 * 86400}; SameSite=Lax${secure}`,++ res.writeHead(400, {++ 'content-type': 'text/plain',++ 'set-cookie': clearLoginStateCookie(),++ })++ res.end(`sign-in failed: ${query.get('error_description') ?? error}`)++ return++ }++ const code = query.get('code')++ if (!code) {++ res.writeHead(400, { 'content-type': 'text/plain' })++ res.end('missing code')++ return++ }++ // A login-state cookie is only ever missing when the browser refused++ // it; when present, the state it pinned must be the one coming back.++ if (login && login.state !== query.get('state')) {++ log.warn('oauth callback state mismatch', {})++ res.writeHead(400, {++ 'content-type': 'text/plain',++ 'set-cookie': clearLoginStateCookie(),})-- res.end(`<!doctype html><title>Signing in…</title><script>--localStorage.setItem('ovg_sso', ${tok});--location.href = ${JSON.stringify(dest)};--</script><noscript><a href="${dest}">Continue</a></noscript>`)-- } catch (err) {-- log.warn('oauth callback failed', { error: String(err) })-- res.writeHead(502, { 'content-type': 'text/plain' })-- res.end('openvibe.network is unreachable — try again shortly')++ res.end('sign-in failed: state mismatch — start again')++ return}-- })()++ void (async () => {++ try {++ // The exchange must repeat the redirect_uri the authorize step++ // used; the callback arrives on that same host, so Host decides.++ const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify({++ grant_type: 'authorization_code',++ client_id: oauth.clientId,++ client_secret: oauth.clientSecret,++ code,++ redirect_uri: `${redirectBase}/auth/callback`,++ }),++ })++ const data = (await resp.json()) as {++ access_token?: string++ error_description?: string++ error?: string++ user?: { username?: string }++ }++ if (!data.access_token) {++ res.writeHead(400, {++ 'content-type': 'text/plain',++ 'set-cookie': clearLoginStateCookie(),++ })++ res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)++ return++ }++ // Land where the login asked to (a same-site page or the next hop++ // of the Network's sign-in-everywhere chain), else at the game.++ const dest = next ?? home++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-store',++ 'set-cookie': [++ sessionCookie(data.access_token, secure),++ ssoHintCookie('account', secure),++ clearLoginStateCookie(),++ ],++ })++ res.end(sessionHandoffHtml(data.access_token, 'account', dest))++ } catch (err) {++ log.warn('oauth callback failed', { error: String(err) })++ res.writeHead(502, { 'content-type': 'text/plain' })++ res.end('openvibe.network is unreachable — try again shortly')++ }++ })()++ return++ }++ if (url === '/auth/logout') {++ // Drops this site's session and marks the browser a guest for the++ // whole network; `next` lets the sign-out-everywhere chain continue.++ const dest = safeNext(query.get('next'), origins) ?? home++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-store',++ 'set-cookie': [++ clearSessionCookie(),++ ssoHintCookie('guest', secure),++ clearLoginStateCookie(),++ ],++ })++ res.end(sessionHandoffHtml(null, 'guest', dest))++ return++ }++ if (url === '/auth/me') {++ // Same-origin session lookup for the shared navbar: { user } for the++ // ovg_sso cookie (or a bearer token), { user: null } otherwise.++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]++ const token = cookies[SESSION_COOKIE] || bearer++ const authUrl =++ editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)++ void fetchNetworkAccount(authUrl, token).then((user) => {++ res.writeHead(200, {++ 'content-type': 'application/json',++ 'cache-control': 'private, no-store',++ })++ res.end(JSON.stringify({ user }))++ })++ return++ }++ res.writeHead(404, { 'content-type': 'application/json' })++ res.end('{"error":"not_found"}')return}if (url === '/healthz') {@@@ -15,34 +15,53 @@ export interface NetworkUser {const OWNER_USERNAME = (process.env.OWNER_USERNAME ?? 'goosely').toLowerCase()--export async function resolveNetworkUser(++/** The account record as openvibe.network's /api/auth/me returns it. */++export interface NetworkAccount {++ id?: string | number++ username?: string++ role?: string++ is_banned?: number++ [key: string]: unknown++}++++/**++ * Fetches the raw account behind a bearer token from openvibe.network, or++ * null when the token is missing, rejected or the Network is unreachable.++ * The same-origin /auth/me endpoint hands this object to the shared navbar.++ */++export async function fetchNetworkAccount(url: string | null,auth: string | undefined,--): Promise<NetworkUser | null> {++): Promise<NetworkAccount | null> {if (!url || !auth) return nulltry {const resp = await fetch(url, { headers: { authorization: `Bearer ${auth}` } })if (!resp.ok) return null-- const body = (await resp.json()) as {-- user?: { id?: string | number; username?: string; role?: string; is_banned?: number }-- id?: string | number-- username?: string-- role?: string-- }++ const body = (await resp.json()) as { user?: NetworkAccount } & NetworkAccountconst u = body.user ?? bodyconst id = u.id ?? u.usernameif (id === undefined || id === null || String(id).length === 0) return null-- const username = String(u.username ?? id)-- let rank: OpenVibeRank = null-- if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'-- else if (u.role === 'admin') rank = 'admin'-- else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'-- return { id: String(id), name: username, rank }++ return u} catch {return null}}++export async function resolveNetworkUser(++ url: string | null,++ auth: string | undefined,++): Promise<NetworkUser | null> {++ const u = await fetchNetworkAccount(url, auth)++ if (!u) return null++ const id = u.id ?? u.username++ const username = String(u.username ?? id)++ let rank: OpenVibeRank = null++ if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'++ else if (u.role === 'admin') rank = 'admin'++ else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'++ return { id: String(id), name: username, rank }++}++export function canEditMap(rank: OpenVibeRank): boolean {return rank === 'owner' || rank === 'admin'}nnew file mode 100644@@@ -0,0 +1,77 @@++import { describe, expect, it } from 'vitest'++import {++ decodeLoginState,++ encodeLoginState,++ isSilentDenial,++ parseCookies,++ safeNext,++ sessionHandoffHtml,++ withSsoNone,++} from './sso.js'++++const origins = {++ network: 'https://openvibe.network',++ self: ['https://openvibe.games', 'https://play.openvibe.games'],++}++++describe('safeNext', () => {++ it('accepts same-site paths', () => {++ expect(safeNext('/play', origins)).toBe('/play')++ expect(safeNext('/editor?x=1#y', origins)).toBe('/editor?x=1#y')++ })++ it('rejects protocol-relative and junk', () => {++ expect(safeNext('//evil.example/x', origins)).toBeNull()++ expect(safeNext('/\\evil.example', origins)).toBeNull()++ expect(safeNext('javascript:alert(1)', origins)).toBeNull()++ expect(safeNext('', origins)).toBeNull()++ expect(safeNext(null, origins)).toBeNull()++ })++ it('accepts the Network and our own hosts, nothing else', () => {++ expect(safeNext('https://openvibe.network/sso/fanout?step=2', origins)).toBe(++ 'https://openvibe.network/sso/fanout?step=2',++ )++ expect(safeNext('https://play.openvibe.games/', origins)).toBe('https://play.openvibe.games/')++ expect(safeNext('https://openvibe.network.evil.example/', origins)).toBeNull()++ expect(safeNext('https://evil.example/?u=https://openvibe.network', origins)).toBeNull()++ })++})++++describe('withSsoNone', () => {++ it('appends to a path, keeping query and hash', () => {++ expect(withSsoNone('/play')).toBe('/play?sso=none')++ expect(withSsoNone('/play?a=1#top')).toBe('/play?a=1&sso=none#top')++ })++ it('appends to an absolute URL', () => {++ expect(withSsoNone('https://openvibe.network/sso/fanout?step=2')).toBe(++ 'https://openvibe.network/sso/fanout?step=2&sso=none',++ )++ })++})++++describe('login state cookie', () => {++ it('round-trips', () => {++ const s = { state: 'abc', next: 'https://openvibe.network/x', silent: true }++ expect(decodeLoginState(encodeLoginState(s))).toEqual(s)++ })++ it('tolerates garbage', () => {++ expect(decodeLoginState('not-base64-json')).toBeNull()++ expect(decodeLoginState(undefined)).toBeNull()++ })++})++++describe('cookies + handoff', () => {++ it('parses a cookie header', () => {++ expect(parseCookies('a=1; ovg_sso=tok%20en; b')).toEqual({ a: '1', ovg_sso: 'tok en' })++ })++ it('recognises prompt=none denials', () => {++ expect(isSilentDenial('login_required')).toBe(true)++ expect(isSilentDenial('access_denied')).toBe(false)++ })++ it('handoff page escapes the destination', () => {++ const html = sessionHandoffHtml('t"</script>', 'account', '/play"><x')++ // Exactly one </script>: the real closing tag, never one smuggled in a value.++ expect(html.split('</script>').length).toBe(2)++ expect(html).toContain('\\u003c/script>')++ expect(html).toContain('"')++ })++})nnew file mode 100644@@@ -0,0 +1,174 @@++/**++ * openvibe.network SSO helpers for the /auth/* routes: where a sign-in may++ * send the browser afterwards, the cookies that carry the session and the++ * cross-site "is anyone signed in here?" hint, and the state cookie that ties++ * a callback to the login that started it.++ *++ * Kept free of http.Server types so the rules are unit-testable on their own.++ */++++/** Name of the cookie holding the openvibe.network access token. */++export const SESSION_COOKIE = 'ovg_sso'++/**++ * Cross-network hint read by the shared navbar: `account` means this browser++ * has signed in somewhere on OpenVibe (so a silent prompt=none sign-in is++ * worth attempting), `guest` means it signed out. JS-readable by design.++ */++export const SSO_HINT_COOKIE = 'ov_sso_hint'++/** Short-lived, HttpOnly: pins the OAuth `state` and the post-login `next`. */++export const LOGIN_STATE_COOKIE = 'ovg_login'++++const YEAR_SECONDS = 365 * 86400++const SESSION_SECONDS = 7 * 86400++const LOGIN_STATE_SECONDS = 600++++/** OAuth error codes that mean "nobody is signed in" for a prompt=none request. */++const SILENT_DENIALS = new Set([++ 'login_required',++ 'interaction_required',++ 'consent_required',++ 'account_selection_required',++])++++export function isSilentDenial(error: string | null | undefined): boolean {++ return !!error && SILENT_DENIALS.has(error)++}++++export interface NextOrigins {++ /** openvibe.network — the sign-in-everywhere / sign-out-everywhere chains hop through it. */++ network: string++ /** Our own public bases (apex + play host). */++ self: string[]++}++++/**++ * Validates a `next` target. Accepts a same-site path (`/play`, never `//evil`)++ * or an absolute https URL on openvibe.network or one of our own hosts.++ * Anything else yields null so an open redirect is impossible.++ */++export function safeNext(raw: string | null | undefined, origins: NextOrigins): string | null {++ if (!raw) return null++ const value = raw.trim()++ if (value.length === 0 || value.length > 2048) return null++ if (value.startsWith('/')) {++ // Protocol-relative (`//host`) and backslash tricks are not paths.++ if (value.startsWith('//') || value.startsWith('/\\')) return null++ return value++ }++ let url: URL++ try {++ url = new URL(value)++ } catch {++ return null++ }++ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null++ const allowed = [origins.network, ...origins.self]++ .map((o) => {++ try {++ return new URL(o).origin++ } catch {++ return null++ }++ })++ .filter((o): o is string => o !== null)++ return allowed.includes(url.origin) ? url.toString() : null++}++++/** Appends `sso=none` to a validated `next` so the landing page knows the silent attempt found nobody. */++export function withSsoNone(next: string): string {++ const abs = /^https?:\/\//i.test(next)++ const url = new URL(next, 'http://ovg.invalid')++ url.searchParams.set('sso', 'none')++ return abs ? url.toString() : `${url.pathname}${url.search}${url.hash}`++}++++export function parseCookies(header: string | undefined): Record<string, string> {++ const out: Record<string, string> = {}++ if (!header) return out++ for (const part of header.split(';')) {++ const eq = part.indexOf('=')++ if (eq < 0) continue++ const k = part.slice(0, eq).trim()++ if (!k) continue++ try {++ out[k] = decodeURIComponent(part.slice(eq + 1).trim())++ } catch {++ out[k] = part.slice(eq + 1).trim()++ }++ }++ return out++}++++export interface LoginState {++ state: string++ next: string | null++ silent: boolean++}++++export function encodeLoginState(s: LoginState): string {++ return Buffer.from(JSON.stringify(s), 'utf8').toString('base64url')++}++++export function decodeLoginState(raw: string | undefined): LoginState | null {++ if (!raw) return null++ try {++ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Partial<LoginState>++ if (typeof parsed.state !== 'string') return null++ return {++ state: parsed.state,++ next: typeof parsed.next === 'string' ? parsed.next : null,++ silent: parsed.silent === true,++ }++ } catch {++ return null++ }++}++++// ── Set-Cookie builders ────────────────────────────────────────────────++// `secure` is false only for plain-http local development, where browsers++// drop Secure cookies outright.++++export function sessionCookie(token: string, secure: boolean): string {++ return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${SESSION_SECONDS}; SameSite=Lax${secure ? '; Secure' : ''}`++}++++export function clearSessionCookie(): string {++ return `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`++}++++export function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string {++ return `${SSO_HINT_COOKIE}=${value}; Path=/; Max-Age=${YEAR_SECONDS}; SameSite=Lax${secure ? '; Secure' : ''}`++}++++export function loginStateCookie(s: LoginState, secure: boolean): string {++ return `${LOGIN_STATE_COOKIE}=${encodeLoginState(s)}; Path=/auth; Max-Age=${LOGIN_STATE_SECONDS}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`++}++++export function clearLoginStateCookie(): string {++ return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`++}++++/** A JS string literal that cannot close the <script> it sits in. */++function jsString(value: string): string {++ return JSON.stringify(value).replace(/</g, '\\u003c')++}++++/**++ * The page the callback answers with: it mirrors the session into++ * localStorage (the game client reads it from there) before moving on, so a++ * cross-origin `next` still leaves this origin signed in.++ */++export function sessionHandoffHtml(++ token: string | null,++ hint: 'account' | 'guest',++ dest: string,++): string {++ const setToken = token++ ? `localStorage.setItem('${SESSION_COOKIE}', ${jsString(token)});`++ : `localStorage.removeItem('${SESSION_COOKIE}');`++ const title = token ? 'Signing in…' : 'Signing out…'++ return `<!doctype html><title>${title}</title><script>++try { ${setToken} localStorage.setItem('${SSO_HINT_COOKIE}', ${jsString(hint)}); } catch (e) {}++location.replace(${jsString(dest)});++</script><noscript><a href="${dest.replace(/"/g, '"')}">Continue</a></noscript>`++}
@@ -51,9 +51,9 @@ export function characterSelect(
@@ -51,9 +51,9 @@ export function characterSelect(
out.className = 'char-logout'
out.className = 'char-logout'
out.textContent = 'Log out'
out.textContent = 'Log out'
out.addEventListener('click', () => {
out.addEventListener('click', () => {
- localStorage.removeItem('ovg_sso')
- document.cookie = 'ovg_sso=; Path=/; Max-Age=0'
- location.reload()
+ // Server-side sign-out: drops the session cookie, marks the browser a
+ // guest for the network's silent sign-in, clears localStorage, reloads.
+ location.href = `/auth/logout?next=${encodeURIComponent(location.pathname)}`
})
})
bar.appendChild(out)
bar.appendChild(out)
panel.appendChild(bar)
panel.appendChild(bar)
@@ -1 +1 @@
@@ -1 +1 @@
-{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAIhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAuBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAgBD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,MAAM,CAgVR"}
\ No newline at end of file
\ No newline at end of file
+{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAmBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAgBD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,MAAM,CA6aR"}
\ No newline at end of file
\ No newline at end of file
@@ -11,6 +11,20 @@ export interface NetworkUser {
@@ -11,6 +11,20 @@ export interface NetworkUser {
name: string;
name: string;
rank: OpenVibeRank;
rank: OpenVibeRank;
}
}
+/** The account record as openvibe.network's /api/auth/me returns it. */
+export interface NetworkAccount {
+ id?: string | number;
+ username?: string;
+ role?: string;
+ is_banned?: number;
+ [key: string]: unknown;
+}
+/**
+ * Fetches the raw account behind a bearer token from openvibe.network, or
+ * null when the token is missing, rejected or the Network is unreachable.
+ * The same-origin /auth/me endpoint hands this object to the shared navbar.
+ */
+export declare function fetchNetworkAccount(url: string | null, auth: string | undefined): Promise<NetworkAccount | null>;
export declare function resolveNetworkUser(url: string | null, auth: string | undefined): Promise<NetworkUser | null>;
export declare function resolveNetworkUser(url: string | null, auth: string | undefined): Promise<NetworkUser | null>;
export declare function canEditMap(rank: OpenVibeRank): boolean;
export declare function canEditMap(rank: OpenVibeRank): boolean;
//# sourceMappingURL=networkAuth.d.ts.map
//# sourceMappingURL=networkAuth.d.ts.map
\ No newline at end of file
\ No newline at end of file
@@ -1 +1 @@
@@ -1 +1 @@
-{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAuB7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\ No newline at end of file
\ No newline at end of file
+{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAU7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+/**
+ * openvibe.network SSO helpers for the /auth/* routes: where a sign-in may
+ * send the browser afterwards, the cookies that carry the session and the
+ * cross-site "is anyone signed in here?" hint, and the state cookie that ties
+ * a callback to the login that started it.
+ *
+ * Kept free of http.Server types so the rules are unit-testable on their own.
+ */
+/** Name of the cookie holding the openvibe.network access token. */
+export declare const SESSION_COOKIE = "ovg_sso";
+/**
+ * Cross-network hint read by the shared navbar: `account` means this browser
+ * has signed in somewhere on OpenVibe (so a silent prompt=none sign-in is
+ * worth attempting), `guest` means it signed out. JS-readable by design.
+ */
+export declare const SSO_HINT_COOKIE = "ov_sso_hint";
+/** Short-lived, HttpOnly: pins the OAuth `state` and the post-login `next`. */
+export declare const LOGIN_STATE_COOKIE = "ovg_login";
+export declare function isSilentDenial(error: string | null | undefined): boolean;
+export interface NextOrigins {
+ /** openvibe.network — the sign-in-everywhere / sign-out-everywhere chains hop through it. */
+ network: string;
+ /** Our own public bases (apex + play host). */
+ self: string[];
+}
+/**
+ * Validates a `next` target. Accepts a same-site path (`/play`, never `//evil`)
+ * or an absolute https URL on openvibe.network or one of our own hosts.
+ * Anything else yields null so an open redirect is impossible.
+ */
+export declare function safeNext(raw: string | null | undefined, origins: NextOrigins): string | null;
+/** Appends `sso=none` to a validated `next` so the landing page knows the silent attempt found nobody. */
+export declare function withSsoNone(next: string): string;
+export declare function parseCookies(header: string | undefined): Record<string, string>;
+export interface LoginState {
+ state: string;
+ next: string | null;
+ silent: boolean;
+}
+export declare function encodeLoginState(s: LoginState): string;
+export declare function decodeLoginState(raw: string | undefined): LoginState | null;
+export declare function sessionCookie(token: string, secure: boolean): string;
+export declare function clearSessionCookie(): string;
+export declare function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string;
+export declare function loginStateCookie(s: LoginState, secure: boolean): string;
+export declare function clearLoginStateCookie(): string;
+/**
+ * The page the callback answers with: it mirrors the session into
+ * localStorage (the game client reads it from there) before moving on, so a
+ * cross-origin `next` still leaves this origin signed in.
+ */
+export declare function sessionHandoffHtml(token: string | null, hint: 'account' | 'guest', dest: string): string;
+//# sourceMappingURL=sso.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"sso.d.ts","sourceRoot":"","sources":["../../src/net/sso.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,oEAAoE;AACpE,eAAO,MAAM,cAAc,YAAY,CAAA;AACvC;;;;GAIG;AACH,eAAO,MAAM,eAAe,gBAAgB,CAAA;AAC5C,+EAA+E;AAC/E,eAAO,MAAM,kBAAkB,cAAc,CAAA;AAc7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAExE;AAED,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAA;IACf,+CAA+C;IAC/C,IAAI,EAAE,MAAM,EAAE,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0B5F;AAED,0GAA0G;AAC1G,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAe/E;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,IAAI,CAa3E;AAMD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEpE;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEjF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAEvE;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAOD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,IAAI,EAAE,SAAS,GAAG,OAAO,EACzB,IAAI,EAAE,MAAM,GACX,MAAM,CASR"}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=sso.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"sso.test.d.ts","sourceRoot":"","sources":["../../src/net/sso.test.ts"],"names":[],"mappings":""}
\ No newline at end of file
\ No newline at end of file
@@ -4,7 +4,22 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
@@ -4,7 +4,22 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, extname, join, normalize, resolve } from 'node:path'
import { dirname, extname, join, normalize, resolve } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
-import { canEditMap, resolveNetworkUser } from './networkAuth.js'
+import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
+import {
+ LOGIN_STATE_COOKIE,
+ SESSION_COOKIE,
+ clearLoginStateCookie,
+ clearSessionCookie,
+ decodeLoginState,
+ isSilentDenial,
+ loginStateCookie,
+ parseCookies,
+ safeNext,
+ sessionCookie,
+ sessionHandoffHtml,
+ ssoHintCookie,
+ withSsoNone,
+} from './sso.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
@@ -14,6 +29,8 @@ import type { MapFileV2 } from '@openvibe/content'
@@ -14,6 +29,8 @@ import type { MapFileV2 } from '@openvibe/content'
* built client bundle. Game traffic itself is WebSocket-only.
* built client bundle. Game traffic itself is WebSocket-only.
*/
*/
+const DEFAULT_NETWORK_URL = 'https://openvibe.network'
+
const MIME: Record<string, string> = {
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.js': 'text/javascript',
@@ -263,75 +280,168 @@ export function createHttpServer(
@@ -263,75 +280,168 @@ export function createHttpServer(
// /auth/login redirects to the openvibe.network account chooser; the
// /auth/login redirects to the openvibe.network account chooser; the
// callback exchanges the code server-side (client secret never
// callback exchanges the code server-side (client secret never
// reaches the browser), then hands the access token to the page.
// reaches the browser), then hands the access token to the page.
- if (url === '/auth/login' && oauth) {
- // Both callbacks are registered with the provider; using the host the
- // player came from means the callback lands back on that same host.
- const redirectBase = playHost ? oauth.playUrl : oauth.selfUrl
- const params = new URLSearchParams({
- client_id: oauth.clientId,
- redirect_uri: `${redirectBase}/auth/callback`,
- response_type: 'code',
- scope: 'profile theme',
- state: randomBytes(16).toString('hex'),
- })
- res.writeHead(302, { location: `${oauth.baseUrl}/oauth/authorize?${params.toString()}` })
- res.end()
- return
- }
- if (url === '/auth/callback' && oauth) {
- const code = new URL(req.url ?? '/', 'http://x').searchParams.get('code')
- if (!code) {
- res.writeHead(400, { 'content-type': 'text/plain' })
- res.end('missing code')
+ //
+ // `?silent=1` adds prompt=none (the Network's sign-in-everywhere chain
+ // and the shared navbar's one-shot silent attempt); `?next=` may be a
+ // same-site path or an https://openvibe.network/... hop. See sso.ts.
+ if (url.startsWith('/auth/')) {
+ const query = new URL(req.url ?? '/', 'http://x').searchParams
+ const cookies = parseCookies(req.headers.cookie)
+ const redirectBase = oauth ? (playHost ? oauth.playUrl : oauth.selfUrl) : null
+ const secure = redirectBase ? redirectBase.startsWith('https') : false
+ const origins = {
+ network: oauth?.baseUrl ?? DEFAULT_NETWORK_URL,
+ self: oauth ? [oauth.selfUrl, oauth.playUrl] : [],
+ }
+ // The game IS the root on the play host; on the apex it lives at /play.
+ const home = playHost ? '/' : '/play'
+
+ if (url === '/auth/login' && oauth && redirectBase) {
+ // Both callbacks are registered with the provider; using the host the
+ // player came from means the callback lands back on that same host.
+ const silent = query.get('silent') === '1'
+ const next = safeNext(query.get('next'), origins)
+ const state = randomBytes(16).toString('hex')
+ const params = new URLSearchParams({
+ client_id: oauth.clientId,
+ redirect_uri: `${redirectBase}/auth/callback`,
+ response_type: 'code',
+ scope: 'profile theme',
+ state,
+ })
+ if (silent) params.set('prompt', 'none')
+ res.writeHead(302, {
+ location: `${oauth.baseUrl}/oauth/authorize?${params.toString()}`,
+ 'set-cookie': loginStateCookie({ state, next, silent }, secure),
+ 'cache-control': 'no-store',
+ })
+ res.end()
return
return
}
}
- void (async () => {
- try {
- // The exchange must repeat the redirect_uri the authorize step
- // used; the callback arrives on that same host, so Host decides.
- const redirectBase = playHost ? oauth.playUrl : oauth.selfUrl
- const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- grant_type: 'authorization_code',
- client_id: oauth.clientId,
- client_secret: oauth.clientSecret,
- code,
- redirect_uri: `${redirectBase}/auth/callback`,
- }),
- })
- const data = (await resp.json()) as {
- access_token?: string
- error_description?: string
- error?: string
- user?: { username?: string }
- }
- if (!data.access_token) {
- res.writeHead(400, { 'content-type': 'text/plain' })
- res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)
+ if (url === '/auth/callback' && oauth && redirectBase) {
+ const login = decodeLoginState(cookies[LOGIN_STATE_COOKIE])
+ const next = login?.next ?? null
+ const error = query.get('error')
+ if (error) {
+ // prompt=none found nobody signed in (or the silent attempt was
+ // refused): land quietly where the caller asked, flagged sso=none.
+ if (login?.silent || isSilentDenial(error)) {
+ res.writeHead(302, {
+ location: withSsoNone(next ?? home),
+ 'set-cookie': clearLoginStateCookie(),
+ 'cache-control': 'no-store',
+ })
+ res.end()
return
return
}
}
- const tok = JSON.stringify(data.access_token)
- // Secure cookies are dropped over plain http (local dev).
- const secure = redirectBase.startsWith('https') ? '; Secure' : ''
- // Land back where the player started: play-host logins go to the
- // game at its root, apex logins to /play.
- const dest = playHost ? '/' : '/play'
- res.writeHead(200, {
- 'content-type': 'text/html; charset=utf-8',
- 'set-cookie': `ovg_sso=${encodeURIComponent(data.access_token)}; Path=/; Max-Age=${7 * 86400}; SameSite=Lax${secure}`,
+ res.writeHead(400, {
+ 'content-type': 'text/plain',
+ 'set-cookie': clearLoginStateCookie(),
+ })
+ res.end(`sign-in failed: ${query.get('error_description') ?? error}`)
+ return
+ }
+ const code = query.get('code')
+ if (!code) {
+ res.writeHead(400, { 'content-type': 'text/plain' })
+ res.end('missing code')
+ return
+ }
+ // A login-state cookie is only ever missing when the browser refused
+ // it; when present, the state it pinned must be the one coming back.
+ if (login && login.state !== query.get('state')) {
+ log.warn('oauth callback state mismatch', {})
+ res.writeHead(400, {
+ 'content-type': 'text/plain',
+ 'set-cookie': clearLoginStateCookie(),
})
})
- res.end(`<!doctype html><title>Signing in…</title><script>
-localStorage.setItem('ovg_sso', ${tok});
-location.href = ${JSON.stringify(dest)};
-</script><noscript><a href="${dest}">Continue</a></noscript>`)
- } catch (err) {
- log.warn('oauth callback failed', { error: String(err) })
- res.writeHead(502, { 'content-type': 'text/plain' })
- res.end('openvibe.network is unreachable — try again shortly')
+ res.end('sign-in failed: state mismatch — start again')
+ return
}
}
- })()
+ void (async () => {
+ try {
+ // The exchange must repeat the redirect_uri the authorize step
+ // used; the callback arrives on that same host, so Host decides.
+ const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ grant_type: 'authorization_code',
+ client_id: oauth.clientId,
+ client_secret: oauth.clientSecret,
+ code,
+ redirect_uri: `${redirectBase}/auth/callback`,
+ }),
+ })
+ const data = (await resp.json()) as {
+ access_token?: string
+ error_description?: string
+ error?: string
+ user?: { username?: string }
+ }
+ if (!data.access_token) {
+ res.writeHead(400, {
+ 'content-type': 'text/plain',
+ 'set-cookie': clearLoginStateCookie(),
+ })
+ res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)
+ return
+ }
+ // Land where the login asked to (a same-site page or the next hop
+ // of the Network's sign-in-everywhere chain), else at the game.
+ const dest = next ?? home
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store',
+ 'set-cookie': [
+ sessionCookie(data.access_token, secure),
+ ssoHintCookie('account', secure),
+ clearLoginStateCookie(),
+ ],
+ })
+ res.end(sessionHandoffHtml(data.access_token, 'account', dest))
+ } catch (err) {
+ log.warn('oauth callback failed', { error: String(err) })
+ res.writeHead(502, { 'content-type': 'text/plain' })
+ res.end('openvibe.network is unreachable — try again shortly')
+ }
+ })()
+ return
+ }
+ if (url === '/auth/logout') {
+ // Drops this site's session and marks the browser a guest for the
+ // whole network; `next` lets the sign-out-everywhere chain continue.
+ const dest = safeNext(query.get('next'), origins) ?? home
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store',
+ 'set-cookie': [
+ clearSessionCookie(),
+ ssoHintCookie('guest', secure),
+ clearLoginStateCookie(),
+ ],
+ })
+ res.end(sessionHandoffHtml(null, 'guest', dest))
+ return
+ }
+ if (url === '/auth/me') {
+ // Same-origin session lookup for the shared navbar: { user } for the
+ // ovg_sso cookie (or a bearer token), { user: null } otherwise.
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
+ const token = cookies[SESSION_COOKIE] || bearer
+ const authUrl =
+ editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)
+ void fetchNetworkAccount(authUrl, token).then((user) => {
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'cache-control': 'private, no-store',
+ })
+ res.end(JSON.stringify({ user }))
+ })
+ return
+ }
+ res.writeHead(404, { 'content-type': 'application/json' })
+ res.end('{"error":"not_found"}')
return
return
}
}
if (url === '/healthz') {
if (url === '/healthz') {
@@ -15,34 +15,53 @@ export interface NetworkUser {
@@ -15,34 +15,53 @@ export interface NetworkUser {
const OWNER_USERNAME = (process.env.OWNER_USERNAME ?? 'goosely').toLowerCase()
const OWNER_USERNAME = (process.env.OWNER_USERNAME ?? 'goosely').toLowerCase()
-export async function resolveNetworkUser(
+/** The account record as openvibe.network's /api/auth/me returns it. */
+export interface NetworkAccount {
+ id?: string | number
+ username?: string
+ role?: string
+ is_banned?: number
+ [key: string]: unknown
+}
+
+/**
+ * Fetches the raw account behind a bearer token from openvibe.network, or
+ * null when the token is missing, rejected or the Network is unreachable.
+ * The same-origin /auth/me endpoint hands this object to the shared navbar.
+ */
+export async function fetchNetworkAccount(
url: string | null,
url: string | null,
auth: string | undefined,
auth: string | undefined,
-): Promise<NetworkUser | null> {
+): Promise<NetworkAccount | null> {
if (!url || !auth) return null
if (!url || !auth) return null
try {
try {
const resp = await fetch(url, { headers: { authorization: `Bearer ${auth}` } })
const resp = await fetch(url, { headers: { authorization: `Bearer ${auth}` } })
if (!resp.ok) return null
if (!resp.ok) return null
- const body = (await resp.json()) as {
- user?: { id?: string | number; username?: string; role?: string; is_banned?: number }
- id?: string | number
- username?: string
- role?: string
- }
+ const body = (await resp.json()) as { user?: NetworkAccount } & NetworkAccount
const u = body.user ?? body
const u = body.user ?? body
const id = u.id ?? u.username
const id = u.id ?? u.username
if (id === undefined || id === null || String(id).length === 0) return null
if (id === undefined || id === null || String(id).length === 0) return null
- const username = String(u.username ?? id)
- let rank: OpenVibeRank = null
- if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'
- else if (u.role === 'admin') rank = 'admin'
- else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'
- return { id: String(id), name: username, rank }
+ return u
} catch {
} catch {
return null
return null
}
}
}
}
+export async function resolveNetworkUser(
+ url: string | null,
+ auth: string | undefined,
+): Promise<NetworkUser | null> {
+ const u = await fetchNetworkAccount(url, auth)
+ if (!u) return null
+ const id = u.id ?? u.username
+ const username = String(u.username ?? id)
+ let rank: OpenVibeRank = null
+ if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'
+ else if (u.role === 'admin') rank = 'admin'
+ else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'
+ return { id: String(id), name: username, rank }
+}
+
export function canEditMap(rank: OpenVibeRank): boolean {
export function canEditMap(rank: OpenVibeRank): boolean {
return rank === 'owner' || rank === 'admin'
return rank === 'owner' || rank === 'admin'
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import {
+ decodeLoginState,
+ encodeLoginState,
+ isSilentDenial,
+ parseCookies,
+ safeNext,
+ sessionHandoffHtml,
+ withSsoNone,
+} from './sso.js'
+
+const origins = {
+ network: 'https://openvibe.network',
+ self: ['https://openvibe.games', 'https://play.openvibe.games'],
+}
+
+describe('safeNext', () => {
+ it('accepts same-site paths', () => {
+ expect(safeNext('/play', origins)).toBe('/play')
+ expect(safeNext('/editor?x=1#y', origins)).toBe('/editor?x=1#y')
+ })
+ it('rejects protocol-relative and junk', () => {
+ expect(safeNext('//evil.example/x', origins)).toBeNull()
+ expect(safeNext('/\\evil.example', origins)).toBeNull()
+ expect(safeNext('javascript:alert(1)', origins)).toBeNull()
+ expect(safeNext('', origins)).toBeNull()
+ expect(safeNext(null, origins)).toBeNull()
+ })
+ it('accepts the Network and our own hosts, nothing else', () => {
+ expect(safeNext('https://openvibe.network/sso/fanout?step=2', origins)).toBe(
+ 'https://openvibe.network/sso/fanout?step=2',
+ )
+ expect(safeNext('https://play.openvibe.games/', origins)).toBe('https://play.openvibe.games/')
+ expect(safeNext('https://openvibe.network.evil.example/', origins)).toBeNull()
+ expect(safeNext('https://evil.example/?u=https://openvibe.network', origins)).toBeNull()
+ })
+})
+
+describe('withSsoNone', () => {
+ it('appends to a path, keeping query and hash', () => {
+ expect(withSsoNone('/play')).toBe('/play?sso=none')
+ expect(withSsoNone('/play?a=1#top')).toBe('/play?a=1&sso=none#top')
+ })
+ it('appends to an absolute URL', () => {
+ expect(withSsoNone('https://openvibe.network/sso/fanout?step=2')).toBe(
+ 'https://openvibe.network/sso/fanout?step=2&sso=none',
+ )
+ })
+})
+
+describe('login state cookie', () => {
+ it('round-trips', () => {
+ const s = { state: 'abc', next: 'https://openvibe.network/x', silent: true }
+ expect(decodeLoginState(encodeLoginState(s))).toEqual(s)
+ })
+ it('tolerates garbage', () => {
+ expect(decodeLoginState('not-base64-json')).toBeNull()
+ expect(decodeLoginState(undefined)).toBeNull()
+ })
+})
+
+describe('cookies + handoff', () => {
+ it('parses a cookie header', () => {
+ expect(parseCookies('a=1; ovg_sso=tok%20en; b')).toEqual({ a: '1', ovg_sso: 'tok en' })
+ })
+ it('recognises prompt=none denials', () => {
+ expect(isSilentDenial('login_required')).toBe(true)
+ expect(isSilentDenial('access_denied')).toBe(false)
+ })
+ it('handoff page escapes the destination', () => {
+ const html = sessionHandoffHtml('t"</script>', 'account', '/play"><x')
+ // Exactly one </script>: the real closing tag, never one smuggled in a value.
+ expect(html.split('</script>').length).toBe(2)
+ expect(html).toContain('\\u003c/script>')
+ expect(html).toContain('"')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+/**
+ * openvibe.network SSO helpers for the /auth/* routes: where a sign-in may
+ * send the browser afterwards, the cookies that carry the session and the
+ * cross-site "is anyone signed in here?" hint, and the state cookie that ties
+ * a callback to the login that started it.
+ *
+ * Kept free of http.Server types so the rules are unit-testable on their own.
+ */
+
+/** Name of the cookie holding the openvibe.network access token. */
+export const SESSION_COOKIE = 'ovg_sso'
+/**
+ * Cross-network hint read by the shared navbar: `account` means this browser
+ * has signed in somewhere on OpenVibe (so a silent prompt=none sign-in is
+ * worth attempting), `guest` means it signed out. JS-readable by design.
+ */
+export const SSO_HINT_COOKIE = 'ov_sso_hint'
+/** Short-lived, HttpOnly: pins the OAuth `state` and the post-login `next`. */
+export const LOGIN_STATE_COOKIE = 'ovg_login'
+
+const YEAR_SECONDS = 365 * 86400
+const SESSION_SECONDS = 7 * 86400
+const LOGIN_STATE_SECONDS = 600
+
+/** OAuth error codes that mean "nobody is signed in" for a prompt=none request. */
+const SILENT_DENIALS = new Set([
+ 'login_required',
+ 'interaction_required',
+ 'consent_required',
+ 'account_selection_required',
+])
+
+export function isSilentDenial(error: string | null | undefined): boolean {
+ return !!error && SILENT_DENIALS.has(error)
+}
+
+export interface NextOrigins {
+ /** openvibe.network — the sign-in-everywhere / sign-out-everywhere chains hop through it. */
+ network: string
+ /** Our own public bases (apex + play host). */
+ self: string[]
+}
+
+/**
+ * Validates a `next` target. Accepts a same-site path (`/play`, never `//evil`)
+ * or an absolute https URL on openvibe.network or one of our own hosts.
+ * Anything else yields null so an open redirect is impossible.
+ */
+export function safeNext(raw: string | null | undefined, origins: NextOrigins): string | null {
+ if (!raw) return null
+ const value = raw.trim()
+ if (value.length === 0 || value.length > 2048) return null
+ if (value.startsWith('/')) {
+ // Protocol-relative (`//host`) and backslash tricks are not paths.
+ if (value.startsWith('//') || value.startsWith('/\\')) return null
+ return value
+ }
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
+ const allowed = [origins.network, ...origins.self]
+ .map((o) => {
+ try {
+ return new URL(o).origin
+ } catch {
+ return null
+ }
+ })
+ .filter((o): o is string => o !== null)
+ return allowed.includes(url.origin) ? url.toString() : null
+}
+
+/** Appends `sso=none` to a validated `next` so the landing page knows the silent attempt found nobody. */
+export function withSsoNone(next: string): string {
+ const abs = /^https?:\/\//i.test(next)
+ const url = new URL(next, 'http://ovg.invalid')
+ url.searchParams.set('sso', 'none')
+ return abs ? url.toString() : `${url.pathname}${url.search}${url.hash}`
+}
+
+export function parseCookies(header: string | undefined): Record<string, string> {
+ const out: Record<string, string> = {}
+ if (!header) return out
+ for (const part of header.split(';')) {
+ const eq = part.indexOf('=')
+ if (eq < 0) continue
+ const k = part.slice(0, eq).trim()
+ if (!k) continue
+ try {
+ out[k] = decodeURIComponent(part.slice(eq + 1).trim())
+ } catch {
+ out[k] = part.slice(eq + 1).trim()
+ }
+ }
+ return out
+}
+
+export interface LoginState {
+ state: string
+ next: string | null
+ silent: boolean
+}
+
+export function encodeLoginState(s: LoginState): string {
+ return Buffer.from(JSON.stringify(s), 'utf8').toString('base64url')
+}
+
+export function decodeLoginState(raw: string | undefined): LoginState | null {
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Partial<LoginState>
+ if (typeof parsed.state !== 'string') return null
+ return {
+ state: parsed.state,
+ next: typeof parsed.next === 'string' ? parsed.next : null,
+ silent: parsed.silent === true,
+ }
+ } catch {
+ return null
+ }
+}
+
+// ── Set-Cookie builders ────────────────────────────────────────────────
+// `secure` is false only for plain-http local development, where browsers
+// drop Secure cookies outright.
+
+export function sessionCookie(token: string, secure: boolean): string {
+ return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${SESSION_SECONDS}; SameSite=Lax${secure ? '; Secure' : ''}`
+}
+
+export function clearSessionCookie(): string {
+ return `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
+}
+
+export function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string {
+ return `${SSO_HINT_COOKIE}=${value}; Path=/; Max-Age=${YEAR_SECONDS}; SameSite=Lax${secure ? '; Secure' : ''}`
+}
+
+export function loginStateCookie(s: LoginState, secure: boolean): string {
+ return `${LOGIN_STATE_COOKIE}=${encodeLoginState(s)}; Path=/auth; Max-Age=${LOGIN_STATE_SECONDS}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`
+}
+
+export function clearLoginStateCookie(): string {
+ return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`
+}
+
+/** A JS string literal that cannot close the <script> it sits in. */
+function jsString(value: string): string {
+ return JSON.stringify(value).replace(/</g, '\\u003c')
+}
+
+/**
+ * The page the callback answers with: it mirrors the session into
+ * localStorage (the game client reads it from there) before moving on, so a
+ * cross-origin `next` still leaves this origin signed in.
+ */
+export function sessionHandoffHtml(
+ token: string | null,
+ hint: 'account' | 'guest',
+ dest: string,
+): string {
+ const setToken = token
+ ? `localStorage.setItem('${SESSION_COOKIE}', ${jsString(token)});`
+ : `localStorage.removeItem('${SESSION_COOKIE}');`
+ const title = token ? 'Signing in…' : 'Signing out…'
+ return `<!doctype html><title>${title}</title><script>
+try { ${setToken} localStorage.setItem('${SSO_HINT_COOKIE}', ${jsString(hint)}); } catch (e) {}
+location.replace(${jsString(dest)});
+</script><noscript><a href="${dest.replace(/"/g, '"')}">Continue</a></noscript>`
+}