openvibers/openvibe.games Commits

SSO: silent sign-in, next-hop chains, ov_sso_hint, /auth/logout and /auth/me

OP OpenVibers committed 18 Sept 2026 · d04fcf4e9d · 12 files, +534 −83

/auth/login?silent=1 adds prompt=none; ?next= may be a same-site path or an
https://openvibe.network/... hop (the Network's sign-in-everywhere chain).
The login pins its OAuth state + next in a short HttpOnly cookie, so the
callback can verify state and land where it was asked to. A prompt=none
denial (login_required & co) redirects to next + sso=none quietly.

Success sets ov_sso_hint=account (1y, JS-readable) beside ovg_sso;
/auth/logout clears the session, sets the hint to guest and follows a
validated next (sign-out-everywhere). /auth/me answers { user } for the
ovg_sso cookie so the shared navbar can resolve the session same-origin.
The character screen's Log out goes through /auth/logout now.

Changes

ddiff --git a/apps/client/src/ui/characterSelect.ts b/apps/client/src/ui/characterSelect.ts
iindex 505d160..2dd42a8 100644
---- a/apps/client/src/ui/characterSelect.ts
++++ b/apps/client/src/ui/characterSelect.ts
@@@ -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)
ddiff --git a/apps/server/dist-types/net/httpServer.d.ts.map b/apps/server/dist-types/net/httpServer.d.ts.map
iindex f5c4fdb..a1d3b9c 100644
---- a/apps/server/dist-types/net/httpServer.d.ts.map
++++ b/apps/server/dist-types/net/httpServer.d.ts.map
@@@ -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
ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts b/apps/server/dist-types/net/networkAuth.d.ts
iindex 20c52d0..7f92e17 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts
++++ b/apps/server/dist-types/net/networkAuth.d.ts
@@@ -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
ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts.map b/apps/server/dist-types/net/networkAuth.d.ts.map
iindex 14ce55a..3ec260a 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts.map
++++ b/apps/server/dist-types/net/networkAuth.d.ts.map
@@@ -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 file
ddiff --git a/apps/server/dist-types/net/sso.d.ts b/apps/server/dist-types/net/sso.d.ts
nnew file mode 100644
iindex 0000000..79935c3
---- /dev/null
++++ b/apps/server/dist-types/net/sso.d.ts
@@@ -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
ddiff --git a/apps/server/dist-types/net/sso.d.ts.map b/apps/server/dist-types/net/sso.d.ts.map
nnew file mode 100644
iindex 0000000..836ee22
---- /dev/null
++++ b/apps/server/dist-types/net/sso.d.ts.map
@@@ -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
ddiff --git a/apps/server/dist-types/net/sso.test.d.ts b/apps/server/dist-types/net/sso.test.d.ts
nnew file mode 100644
iindex 0000000..b1baae2
---- /dev/null
++++ b/apps/server/dist-types/net/sso.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=sso.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/sso.test.d.ts.map b/apps/server/dist-types/net/sso.test.d.ts.map
nnew file mode 100644
iindex 0000000..81b9cd5
---- /dev/null
++++ b/apps/server/dist-types/net/sso.test.d.ts.map
@@@ -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
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex a88cff9..59ffd73 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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') {
ddiff --git a/apps/server/src/net/networkAuth.ts b/apps/server/src/net/networkAuth.ts
iindex e23fe0d..8fe4540 100644
---- a/apps/server/src/net/networkAuth.ts
++++ b/apps/server/src/net/networkAuth.ts
@@@ -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 null
try {
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 } & NetworkAccount
const u = body.user ?? body
const id = u.id ?? u.username
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 {
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'
}
ddiff --git a/apps/server/src/net/sso.test.ts b/apps/server/src/net/sso.test.ts
nnew file mode 100644
iindex 0000000..8b59795
---- /dev/null
++++ b/apps/server/src/net/sso.test.ts
@@@ -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('&quot;')
++ })
++})
ddiff --git a/apps/server/src/net/sso.ts b/apps/server/src/net/sso.ts
nnew file mode 100644
iindex 0000000..b2654ff
---- /dev/null
++++ b/apps/server/src/net/sso.ts
@@@ -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, '&quot;')}">Continue</a></noscript>`
++}