openvibers/openvibe.games Commits
POST /auth/fedcm on both hosts swaps a FedCM assertion for a session (jwt-bearer grant, nonce pre-check); silent login skips the Network when the ovg_sso cookie already resolves
Changes
@@@ -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;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++{"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;AAsBhE,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;AA6CD,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,CAshBR"}\\ No newline at end of file@@@ -44,6 +44,46 @@ 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;++/**++ * Decodes a JWT's payload WITHOUT checking its signature. Only for reading++ * claims we cross-check locally (the FedCM nonce); never for trusting an++ * identity.++ */++export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */++export declare function fedcmNonceMatches(token: string, nonce: string): boolean;++export interface FedcmBody {++ token: string;++ nonce: string;++}++/** Max bytes a /auth/fedcm body may carry: an assertion JWT is a few KB at most. */++export declare const MAX_FEDCM_BODY_BYTES: number;++/**++ * Parses and validates the JSON body of POST /auth/fedcm. Returns an error++ * code (for a 400) instead of the body when it is malformed, incomplete or++ * its nonce does not match the assertion's.++ */++export declare function parseFedcmBody(raw: string): {++ body: FedcmBody;++} | {++ error: string;++};++/**++ * Remembers, briefly, which session tokens openvibe.network last confirmed so++ * the silent-login shortcut (`/auth/login?silent=1` with an ovg_sso cookie++ * that already resolves) does not cost a Network lookup on every page. Only++ * positive answers are kept: a miss just falls through to the normal++ * prompt=none round trip, which is the right answer for it anyway.++ */++export declare class SessionCheckCache {++ private readonly ttlMs;++ private readonly maxEntries;++ private readonly hits;++ constructor(ttlMs?: number, maxEntries?: number);++ has(token: string, now?: number): boolean;++ remember(token: string, now?: number): void;++ forget(token: string): void;++}/*** The page the callback answers with: it mirrors the session into* localStorage (the game client reads it from there) before moving on, so a@@@ -1 +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++{"version":3,"file":"sso.d.ts","sourceRoot":"","sources":["../../src/net/sso.ts"],"names":[],"mappings":"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"}\\ No newline at end of file@@@ -7,13 +7,16 @@ import type { ServerMetrics } from '../observability/metrics.js'import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'import {LOGIN_STATE_COOKIE,++ MAX_FEDCM_BODY_BYTES,SESSION_COOKIE,++ SessionCheckCache,clearLoginStateCookie,clearSessionCookie,decodeLoginState,isSilentDenial,loginStateCookie,parseCookies,++ parseFedcmBody,safeNext,sessionCookie,sessionHandoffHtml,@@@ -80,6 +83,35 @@ async function editorAuthorized(auth: EditorAuth, token: string | undefined): Prreturn auth.key !== null && token === auth.key}++/** Reads a small request body as utf8, or null when it exceeds `limit`. */++function readBody(req: IncomingMessage, limit: number): Promise<string | null> {++ return new Promise((resolvePromise) => {++ const chunks: Buffer[] = []++ let size = 0++ let tooBig = false++ req.on('data', (c: Buffer) => {++ size += c.length++ if (size > limit) {++ tooBig = true++ req.destroy()++ } else chunks.push(c)++ })++ req.on('end', () => resolvePromise(tooBig ? null : Buffer.concat(chunks).toString('utf8')))++ req.on('close', () => resolvePromise(null))++ req.on('error', () => resolvePromise(null))++ })++}++++/** The token endpoint's answer, for both the code and the jwt-bearer grant. */++interface TokenResponse {++ access_token?: string++ refresh_token?: string++ error_description?: string++ error?: string++ user?: { username?: string; [key: string]: unknown }++ preferences?: unknown++}++export function createHttpServer(staticDir: string | null,metrics: ServerMetrics,@@@ -94,6 +126,9 @@ export function createHttpServer(oauth?: OAuthConfig | null,): Server {const root = staticDir ? resolve(staticDir) : null++ // Sessions the Network confirmed recently: lets a silent login on a signed-in++ // browser answer without the Network round trip. See /auth/login.++ const knownSessions = new SessionCheckCache()return createServer((req: IncomingMessage, res: ServerResponse) => {const url = (req.url ?? '/').split('?')[0] ?? '/'// Host-based routing: play.openvibe.games serves the game at its root@@@ -295,27 +330,59 @@ export function createHttpServer(}// The game IS the root on the play host; on the apex it lives at /play.const home = playHost ? '/' : '/play'++ // Where a session token is resolved to an account (see networkAuth.ts).++ const networkMeUrl =++ editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)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',++ const startLogin = () => {++ // Both callbacks are registered with the provider; using the host the++ // player came from means the callback lands back on that same host.++ 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()++ }++ const existing = silent ? cookies[SESSION_COOKIE] : undefined++ if (!existing) {++ startLogin()++ return++ }++ // Silent login on a browser that is already signed in here: the++ // Network would only hand back the session we hold, so go straight++ // to `next`. Confirmed either from the short-lived cache or by one++ // /api/auth/me lookup (the same check /auth/me itself makes).++ const shortcut = () => {++ res.writeHead(302, {++ location: next ?? home,++ 'set-cookie': clearLoginStateCookie(),++ 'cache-control': 'no-store',++ })++ res.end()++ }++ if (knownSessions.has(existing)) {++ shortcut()++ return++ }++ void fetchNetworkAccount(networkMeUrl, existing).then((user) => {++ if (user) {++ knownSessions.remember(existing)++ shortcut()++ } else startLogin()})-- res.end()return}if (url === '/auth/callback' && oauth && redirectBase) {@@@ -373,12 +440,7 @@ export function createHttpServer(redirect_uri: `${redirectBase}/auth/callback`,}),})-- const data = (await resp.json()) as {-- access_token?: string-- error_description?: string-- error?: string-- user?: { username?: string }-- }++ const data = (await resp.json()) as TokenResponseif (!data.access_token) {res.writeHead(400, {'content-type': 'text/plain',@@@ -387,6 +449,7 @@ export function createHttpServer(res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)return}++ knownSessions.remember(data.access_token)// 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@@@ -408,10 +471,86 @@ export function createHttpServer(})()return}++ if (url === '/auth/fedcm' && oauth) {++ // Browser-native FedCM sign-in: the shared navbar posts the assertion++ // JWT openvibe.network issued through the browser's account chooser,++ // plus the nonce it put in the FedCM request. Same-origin JSON only.++ // Both hosts serve it, like /auth/login: the play host's navbar++ // posts to its own origin.++ const json = (status: number, body: unknown, setCookie?: string[]) => {++ res.writeHead(status, {++ 'content-type': 'application/json',++ 'cache-control': 'no-store',++ ...(setCookie ? { 'set-cookie': setCookie } : {}),++ })++ res.end(JSON.stringify(body))++ }++ if (req.method !== 'POST') {++ res.writeHead(405, { allow: 'POST', 'content-type': 'application/json' })++ res.end('{"error":"method_not_allowed"}')++ return++ }++ const contentType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()++ if (contentType !== 'application/json') {++ json(400, { error: 'invalid_request', error_description: 'Expected application/json' })++ return++ }++ void readBody(req, MAX_FEDCM_BODY_BYTES).then(async (raw) => {++ if (raw === null) {++ json(400, { error: 'invalid_request', error_description: 'Body missing or too large' })++ return++ }++ const parsed = parseFedcmBody(raw)++ if ('error' in parsed) {++ json(400, { error: 'invalid_request', error_description: parsed.error })++ return++ }++ try {++ // Same endpoint and answer shape as the code exchange; the++ // Network verifies the assertion's signature and audience.++ const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify({++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',++ assertion: parsed.body.token,++ client_id: oauth.clientId,++ client_secret: oauth.clientSecret,++ }),++ })++ const data = (await resp.json().catch(() => ({}))) as TokenResponse++ if (!data.access_token) {++ log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })++ json(401, {++ error: data.error ?? 'invalid_grant',++ error_description: data.error_description ?? 'openvibe.network rejected the assertion',++ })++ return++ }++ knownSessions.remember(data.access_token)++ const user =++ data.user ?? (await fetchNetworkAccount(networkMeUrl, data.access_token)) ?? null++ // Exactly the session the callback sets up: the ovg_sso cookie++ // plus the year-long, JS-readable ov_sso_hint=account.++ json(200, { ok: true, user }, [++ sessionCookie(data.access_token, secure),++ ssoHintCookie('account', secure),++ ])++ } catch (err) {++ log.warn('fedcm exchange failed', { error: String(err) })++ json(502, {++ error: 'server_error',++ error_description: '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++ if (cookies[SESSION_COOKIE]) knownSessions.forget(cookies[SESSION_COOKIE])res.writeHead(200, {'content-type': 'text/html; charset=utf-8','cache-control': 'no-store',@@@ -429,9 +568,7 @@ export function createHttpServer(// 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) => {++ void fetchNetworkAccount(networkMeUrl, token).then((user) => {res.writeHead(200, {'content-type': 'application/json','cache-control': 'private, no-store',@@@ -1,14 +1,24 @@import { describe, expect, it } from 'vitest'import {++ SessionCheckCache,++ decodeJwtPayload,decodeLoginState,encodeLoginState,++ fedcmNonceMatches,isSilentDenial,parseCookies,++ parseFedcmBody,safeNext,sessionHandoffHtml,withSsoNone,} from './sso.js'++/** An unsigned-but-well-formed JWT carrying `claims` (the Network checks signatures, not us). */++function fakeJwt(claims: unknown): string {++ const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url')++ return `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}.c2ln`++}++const origins = {network: 'https://openvibe.network',self: ['https://openvibe.games', 'https://play.openvibe.games'],@@@ -59,6 +69,65 @@ describe('login state cookie', () => {})})++describe('fedcm assertion', () => {++ it('decodes a payload without trusting it', () => {++ expect(decodeJwtPayload(fakeJwt({ sub: 7, nonce: 'n1' }))).toEqual({ sub: 7, nonce: 'n1' })++ expect(decodeJwtPayload('not-a-jwt')).toBeNull()++ expect(decodeJwtPayload('a.b.c')).toBeNull()++ expect(decodeJwtPayload(fakeJwt([1, 2]))).toBeNull()++ })++ it('accepts only the nonce the assertion was minted for', () => {++ const token = fakeJwt({ sub: 7, nonce: 'expected' })++ expect(fedcmNonceMatches(token, 'expected')).toBe(true)++ expect(fedcmNonceMatches(token, 'other')).toBe(false)++ expect(fedcmNonceMatches(token, 'expecte')).toBe(false)++ expect(fedcmNonceMatches(token, '')).toBe(false)++ expect(fedcmNonceMatches(fakeJwt({ sub: 7 }), 'expected')).toBe(false)++ expect(fedcmNonceMatches(fakeJwt({ nonce: 7 }), '7')).toBe(false)++ })++ it('parses the posted body and rejects mismatches before anything reaches the Network', () => {++ const token = fakeJwt({ sub: 7, nonce: 'n1' })++ expect(parseFedcmBody(JSON.stringify({ token, nonce: 'n1' }))).toEqual({++ body: { token, nonce: 'n1' },++ })++ expect(parseFedcmBody(JSON.stringify({ token, nonce: 'n2' }))).toEqual({++ error: 'nonce_mismatch',++ })++ expect(parseFedcmBody(JSON.stringify({ nonce: 'n1' }))).toEqual({++ error: 'missing_token_or_nonce',++ })++ expect(parseFedcmBody(JSON.stringify({ token, nonce: 5 }))).toEqual({++ error: 'missing_token_or_nonce',++ })++ expect(parseFedcmBody('{nope')).toEqual({ error: 'malformed_json' })++ expect(parseFedcmBody('"str"')).toEqual({ error: 'malformed_json' })++ expect(parseFedcmBody('null')).toEqual({ error: 'malformed_json' })++ })++})++++describe('session check cache (silent-login shortcut)', () => {++ it('remembers a confirmed session only until its ttl', () => {++ const cache = new SessionCheckCache(1000)++ expect(cache.has('tok', 0)).toBe(false)++ cache.remember('tok', 0)++ expect(cache.has('tok', 999)).toBe(true)++ expect(cache.has('tok', 1000)).toBe(false)++ expect(cache.has('tok', 500)).toBe(false) // an expired entry is gone for good++ })++ it('forgets on logout and never grows past its cap', () => {++ const cache = new SessionCheckCache(1000, 2)++ cache.remember('a', 0)++ cache.forget('a')++ expect(cache.has('a', 1)).toBe(false)++ cache.remember('a', 0)++ cache.remember('b', 0)++ cache.remember('c', 0)++ expect(cache.has('a', 1)).toBe(false) // oldest evicted++ expect(cache.has('b', 1)).toBe(true)++ expect(cache.has('c', 1)).toBe(true)++ })++})++describe('cookies + handoff', () => {it('parses a cookie header', () => {expect(parseCookies('a=1; ovg_sso=tok%20en; b')).toEqual({ a: '1', ovg_sso: 'tok en' })@@@ -7,6 +7,8 @@* Kept free of http.Server types so the rules are unit-testable on their own.*/++import { timingSafeEqual } from 'node:crypto'++/** Name of the cookie holding the openvibe.network access token. */export const SESSION_COOKIE = 'ovg_sso'/**@@@ -148,6 +150,112 @@ export function clearLoginStateCookie(): string {return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`}++// ── FedCM ──────────────────────────────────────────────────────────────++// The shared navbar gets an assertion JWT from openvibe.network through the++// browser's own account chooser and POSTs it to /auth/fedcm with the nonce++// it put in the FedCM request. We only pre-check that nonce here; the++// Network verifies the signature when it swaps the assertion for tokens.++++/**++ * Decodes a JWT's payload WITHOUT checking its signature. Only for reading++ * claims we cross-check locally (the FedCM nonce); never for trusting an++ * identity.++ */++export function decodeJwtPayload(token: string): Record<string, unknown> | null {++ const parts = token.split('.')++ if (parts.length !== 3) return null++ try {++ const claims: unknown = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf8'))++ return claims && typeof claims === 'object' && !Array.isArray(claims)++ ? (claims as Record<string, unknown>)++ : null++ } catch {++ return null++ }++}++++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */++export function fedcmNonceMatches(token: string, nonce: string): boolean {++ if (!nonce || nonce.length > 256) return false++ const claims = decodeJwtPayload(token)++ if (!claims || typeof claims.nonce !== 'string') return false++ const a = Buffer.from(claims.nonce)++ const b = Buffer.from(nonce)++ return a.length === b.length && timingSafeEqual(a, b)++}++++export interface FedcmBody {++ token: string++ nonce: string++}++++/** Max bytes a /auth/fedcm body may carry: an assertion JWT is a few KB at most. */++export const MAX_FEDCM_BODY_BYTES = 16 * 1024++++/**++ * Parses and validates the JSON body of POST /auth/fedcm. Returns an error++ * code (for a 400) instead of the body when it is malformed, incomplete or++ * its nonce does not match the assertion's.++ */++export function parseFedcmBody(raw: string): { body: FedcmBody } | { error: string } {++ let parsed: unknown++ try {++ parsed = JSON.parse(raw)++ } catch {++ return { error: 'malformed_json' }++ }++ if (!parsed || typeof parsed !== 'object') return { error: 'malformed_json' }++ const { token, nonce } = parsed as { token?: unknown; nonce?: unknown }++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {++ return { error: 'missing_token_or_nonce' }++ }++ if (!fedcmNonceMatches(token, nonce)) return { error: 'nonce_mismatch' }++ return { body: { token, nonce } }++}++++// ── Session check cache ─────────────────────────────────────────────────++++/**++ * Remembers, briefly, which session tokens openvibe.network last confirmed so++ * the silent-login shortcut (`/auth/login?silent=1` with an ovg_sso cookie++ * that already resolves) does not cost a Network lookup on every page. Only++ * positive answers are kept: a miss just falls through to the normal++ * prompt=none round trip, which is the right answer for it anyway.++ */++export class SessionCheckCache {++ private readonly hits = new Map<string, number>()++++ constructor(++ private readonly ttlMs = 60_000,++ private readonly maxEntries = 5000,++ ) {}++++ has(token: string, now = Date.now()): boolean {++ const until = this.hits.get(token)++ if (until === undefined) return false++ if (until <= now) {++ this.hits.delete(token)++ return false++ }++ return true++ }++++ remember(token: string, now = Date.now()): void {++ if (this.hits.size >= this.maxEntries) {++ for (const [k, until] of this.hits) if (until <= now) this.hits.delete(k)++ if (this.hits.size >= this.maxEntries) {++ const oldest = this.hits.keys().next().value++ if (oldest !== undefined) this.hits.delete(oldest)++ }++ }++ this.hits.set(token, now + this.ttlMs)++ }++++ forget(token: string): void {++ this.hits.delete(token)++ }++}++/** A JS string literal that cannot close the <script> it sits in. */function jsString(value: string): string {return JSON.stringify(value).replace(/</g, '\\u003c')
@@ -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;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
+{"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;AAsBhE,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;AA6CD,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,CAshBR"}
\ No newline at end of file
\ No newline at end of file
@@ -44,6 +44,46 @@ export declare function clearSessionCookie(): string;
@@ -44,6 +44,46 @@ export declare function clearSessionCookie(): string;
export declare function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string;
export declare function ssoHintCookie(value: 'account' | 'guest', secure: boolean): string;
export declare function loginStateCookie(s: LoginState, secure: boolean): string;
export declare function loginStateCookie(s: LoginState, secure: boolean): string;
export declare function clearLoginStateCookie(): string;
export declare function clearLoginStateCookie(): string;
+/**
+ * Decodes a JWT's payload WITHOUT checking its signature. Only for reading
+ * claims we cross-check locally (the FedCM nonce); never for trusting an
+ * identity.
+ */
+export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
+/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
+export declare function fedcmNonceMatches(token: string, nonce: string): boolean;
+export interface FedcmBody {
+ token: string;
+ nonce: string;
+}
+/** Max bytes a /auth/fedcm body may carry: an assertion JWT is a few KB at most. */
+export declare const MAX_FEDCM_BODY_BYTES: number;
+/**
+ * Parses and validates the JSON body of POST /auth/fedcm. Returns an error
+ * code (for a 400) instead of the body when it is malformed, incomplete or
+ * its nonce does not match the assertion's.
+ */
+export declare function parseFedcmBody(raw: string): {
+ body: FedcmBody;
+} | {
+ error: string;
+};
+/**
+ * Remembers, briefly, which session tokens openvibe.network last confirmed so
+ * the silent-login shortcut (`/auth/login?silent=1` with an ovg_sso cookie
+ * that already resolves) does not cost a Network lookup on every page. Only
+ * positive answers are kept: a miss just falls through to the normal
+ * prompt=none round trip, which is the right answer for it anyway.
+ */
+export declare class SessionCheckCache {
+ private readonly ttlMs;
+ private readonly maxEntries;
+ private readonly hits;
+ constructor(ttlMs?: number, maxEntries?: number);
+ has(token: string, now?: number): boolean;
+ remember(token: string, now?: number): void;
+ forget(token: string): void;
+}
/**
/**
* The page the callback answers with: it mirrors the session into
* The page the callback answers with: it mirrors the session into
* localStorage (the game client reads it from there) before moving on, so a
* localStorage (the game client reads it from there) before moving on, so a
@@ -1 +1 @@
@@ -1 +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
+{"version":3,"file":"sso.d.ts","sourceRoot":"","sources":["../../src/net/sso.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
\ No newline at end of file
@@ -7,13 +7,16 @@ import type { ServerMetrics } from '../observability/metrics.js'
@@ -7,13 +7,16 @@ import type { ServerMetrics } from '../observability/metrics.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import {
import {
LOGIN_STATE_COOKIE,
LOGIN_STATE_COOKIE,
+ MAX_FEDCM_BODY_BYTES,
SESSION_COOKIE,
SESSION_COOKIE,
+ SessionCheckCache,
clearLoginStateCookie,
clearLoginStateCookie,
clearSessionCookie,
clearSessionCookie,
decodeLoginState,
decodeLoginState,
isSilentDenial,
isSilentDenial,
loginStateCookie,
loginStateCookie,
parseCookies,
parseCookies,
+ parseFedcmBody,
safeNext,
safeNext,
sessionCookie,
sessionCookie,
sessionHandoffHtml,
sessionHandoffHtml,
@@ -80,6 +83,35 @@ async function editorAuthorized(auth: EditorAuth, token: string | undefined): Pr
@@ -80,6 +83,35 @@ async function editorAuthorized(auth: EditorAuth, token: string | undefined): Pr
return auth.key !== null && token === auth.key
return auth.key !== null && token === auth.key
}
}
+/** Reads a small request body as utf8, or null when it exceeds `limit`. */
+function readBody(req: IncomingMessage, limit: number): Promise<string | null> {
+ return new Promise((resolvePromise) => {
+ const chunks: Buffer[] = []
+ let size = 0
+ let tooBig = false
+ req.on('data', (c: Buffer) => {
+ size += c.length
+ if (size > limit) {
+ tooBig = true
+ req.destroy()
+ } else chunks.push(c)
+ })
+ req.on('end', () => resolvePromise(tooBig ? null : Buffer.concat(chunks).toString('utf8')))
+ req.on('close', () => resolvePromise(null))
+ req.on('error', () => resolvePromise(null))
+ })
+}
+
+/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
+interface TokenResponse {
+ access_token?: string
+ refresh_token?: string
+ error_description?: string
+ error?: string
+ user?: { username?: string; [key: string]: unknown }
+ preferences?: unknown
+}
+
export function createHttpServer(
export function createHttpServer(
staticDir: string | null,
staticDir: string | null,
metrics: ServerMetrics,
metrics: ServerMetrics,
@@ -94,6 +126,9 @@ export function createHttpServer(
@@ -94,6 +126,9 @@ export function createHttpServer(
oauth?: OAuthConfig | null,
oauth?: OAuthConfig | null,
): Server {
): Server {
const root = staticDir ? resolve(staticDir) : null
const root = staticDir ? resolve(staticDir) : null
+ // Sessions the Network confirmed recently: lets a silent login on a signed-in
+ // browser answer without the Network round trip. See /auth/login.
+ const knownSessions = new SessionCheckCache()
return createServer((req: IncomingMessage, res: ServerResponse) => {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
// Host-based routing: play.openvibe.games serves the game at its root
@@ -295,27 +330,59 @@ export function createHttpServer(
@@ -295,27 +330,59 @@ export function createHttpServer(
}
}
// The game IS the root on the play host; on the apex it lives at /play.
// The game IS the root on the play host; on the apex it lives at /play.
const home = playHost ? '/' : '/play'
const home = playHost ? '/' : '/play'
+ // Where a session token is resolved to an account (see networkAuth.ts).
+ const networkMeUrl =
+ editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)
if (url === '/auth/login' && oauth && redirectBase) {
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 silent = query.get('silent') === '1'
const next = safeNext(query.get('next'), origins)
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',
+ const startLogin = () => {
+ // Both callbacks are registered with the provider; using the host the
+ // player came from means the callback lands back on that same host.
+ 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()
+ }
+ const existing = silent ? cookies[SESSION_COOKIE] : undefined
+ if (!existing) {
+ startLogin()
+ return
+ }
+ // Silent login on a browser that is already signed in here: the
+ // Network would only hand back the session we hold, so go straight
+ // to `next`. Confirmed either from the short-lived cache or by one
+ // /api/auth/me lookup (the same check /auth/me itself makes).
+ const shortcut = () => {
+ res.writeHead(302, {
+ location: next ?? home,
+ 'set-cookie': clearLoginStateCookie(),
+ 'cache-control': 'no-store',
+ })
+ res.end()
+ }
+ if (knownSessions.has(existing)) {
+ shortcut()
+ return
+ }
+ void fetchNetworkAccount(networkMeUrl, existing).then((user) => {
+ if (user) {
+ knownSessions.remember(existing)
+ shortcut()
+ } else startLogin()
})
})
- res.end()
return
return
}
}
if (url === '/auth/callback' && oauth && redirectBase) {
if (url === '/auth/callback' && oauth && redirectBase) {
@@ -373,12 +440,7 @@ export function createHttpServer(
@@ -373,12 +440,7 @@ export function createHttpServer(
redirect_uri: `${redirectBase}/auth/callback`,
redirect_uri: `${redirectBase}/auth/callback`,
}),
}),
})
})
- const data = (await resp.json()) as {
- access_token?: string
- error_description?: string
- error?: string
- user?: { username?: string }
- }
+ const data = (await resp.json()) as TokenResponse
if (!data.access_token) {
if (!data.access_token) {
res.writeHead(400, {
res.writeHead(400, {
'content-type': 'text/plain',
'content-type': 'text/plain',
@@ -387,6 +449,7 @@ export function createHttpServer(
@@ -387,6 +449,7 @@ export function createHttpServer(
res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)
res.end(`sign-in failed: ${data.error_description ?? data.error ?? 'no token'}`)
return
return
}
}
+ knownSessions.remember(data.access_token)
// Land where the login asked to (a same-site page or the next hop
// 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.
// of the Network's sign-in-everywhere chain), else at the game.
const dest = next ?? home
const dest = next ?? home
@@ -408,10 +471,86 @@ export function createHttpServer(
@@ -408,10 +471,86 @@ export function createHttpServer(
})()
})()
return
return
}
}
+ if (url === '/auth/fedcm' && oauth) {
+ // Browser-native FedCM sign-in: the shared navbar posts the assertion
+ // JWT openvibe.network issued through the browser's account chooser,
+ // plus the nonce it put in the FedCM request. Same-origin JSON only.
+ // Both hosts serve it, like /auth/login: the play host's navbar
+ // posts to its own origin.
+ const json = (status: number, body: unknown, setCookie?: string[]) => {
+ res.writeHead(status, {
+ 'content-type': 'application/json',
+ 'cache-control': 'no-store',
+ ...(setCookie ? { 'set-cookie': setCookie } : {}),
+ })
+ res.end(JSON.stringify(body))
+ }
+ if (req.method !== 'POST') {
+ res.writeHead(405, { allow: 'POST', 'content-type': 'application/json' })
+ res.end('{"error":"method_not_allowed"}')
+ return
+ }
+ const contentType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
+ if (contentType !== 'application/json') {
+ json(400, { error: 'invalid_request', error_description: 'Expected application/json' })
+ return
+ }
+ void readBody(req, MAX_FEDCM_BODY_BYTES).then(async (raw) => {
+ if (raw === null) {
+ json(400, { error: 'invalid_request', error_description: 'Body missing or too large' })
+ return
+ }
+ const parsed = parseFedcmBody(raw)
+ if ('error' in parsed) {
+ json(400, { error: 'invalid_request', error_description: parsed.error })
+ return
+ }
+ try {
+ // Same endpoint and answer shape as the code exchange; the
+ // Network verifies the assertion's signature and audience.
+ const resp = await fetch(`${oauth.baseUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: parsed.body.token,
+ client_id: oauth.clientId,
+ client_secret: oauth.clientSecret,
+ }),
+ })
+ const data = (await resp.json().catch(() => ({}))) as TokenResponse
+ if (!data.access_token) {
+ log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })
+ json(401, {
+ error: data.error ?? 'invalid_grant',
+ error_description: data.error_description ?? 'openvibe.network rejected the assertion',
+ })
+ return
+ }
+ knownSessions.remember(data.access_token)
+ const user =
+ data.user ?? (await fetchNetworkAccount(networkMeUrl, data.access_token)) ?? null
+ // Exactly the session the callback sets up: the ovg_sso cookie
+ // plus the year-long, JS-readable ov_sso_hint=account.
+ json(200, { ok: true, user }, [
+ sessionCookie(data.access_token, secure),
+ ssoHintCookie('account', secure),
+ ])
+ } catch (err) {
+ log.warn('fedcm exchange failed', { error: String(err) })
+ json(502, {
+ error: 'server_error',
+ error_description: 'openvibe.network is unreachable — try again shortly',
+ })
+ }
+ })
+ return
+ }
if (url === '/auth/logout') {
if (url === '/auth/logout') {
// Drops this site's session and marks the browser a guest for the
// Drops this site's session and marks the browser a guest for the
// whole network; `next` lets the sign-out-everywhere chain continue.
// whole network; `next` lets the sign-out-everywhere chain continue.
const dest = safeNext(query.get('next'), origins) ?? home
const dest = safeNext(query.get('next'), origins) ?? home
+ if (cookies[SESSION_COOKIE]) knownSessions.forget(cookies[SESSION_COOKIE])
res.writeHead(200, {
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'cache-control': 'no-store',
@@ -429,9 +568,7 @@ export function createHttpServer(
@@ -429,9 +568,7 @@ export function createHttpServer(
// ovg_sso cookie (or a bearer token), { user: null } otherwise.
// ovg_sso cookie (or a bearer token), { user: null } otherwise.
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
const token = cookies[SESSION_COOKIE] || bearer
const token = cookies[SESSION_COOKIE] || bearer
- const authUrl =
- editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)
- void fetchNetworkAccount(authUrl, token).then((user) => {
+ void fetchNetworkAccount(networkMeUrl, token).then((user) => {
res.writeHead(200, {
res.writeHead(200, {
'content-type': 'application/json',
'content-type': 'application/json',
'cache-control': 'private, no-store',
'cache-control': 'private, no-store',
@@ -1,14 +1,24 @@
@@ -1,14 +1,24 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
import {
+ SessionCheckCache,
+ decodeJwtPayload,
decodeLoginState,
decodeLoginState,
encodeLoginState,
encodeLoginState,
+ fedcmNonceMatches,
isSilentDenial,
isSilentDenial,
parseCookies,
parseCookies,
+ parseFedcmBody,
safeNext,
safeNext,
sessionHandoffHtml,
sessionHandoffHtml,
withSsoNone,
withSsoNone,
} from './sso.js'
} from './sso.js'
+/** An unsigned-but-well-formed JWT carrying `claims` (the Network checks signatures, not us). */
+function fakeJwt(claims: unknown): string {
+ const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url')
+ return `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}.c2ln`
+}
+
const origins = {
const origins = {
network: 'https://openvibe.network',
network: 'https://openvibe.network',
self: ['https://openvibe.games', 'https://play.openvibe.games'],
self: ['https://openvibe.games', 'https://play.openvibe.games'],
@@ -59,6 +69,65 @@ describe('login state cookie', () => {
@@ -59,6 +69,65 @@ describe('login state cookie', () => {
})
})
})
})
+describe('fedcm assertion', () => {
+ it('decodes a payload without trusting it', () => {
+ expect(decodeJwtPayload(fakeJwt({ sub: 7, nonce: 'n1' }))).toEqual({ sub: 7, nonce: 'n1' })
+ expect(decodeJwtPayload('not-a-jwt')).toBeNull()
+ expect(decodeJwtPayload('a.b.c')).toBeNull()
+ expect(decodeJwtPayload(fakeJwt([1, 2]))).toBeNull()
+ })
+ it('accepts only the nonce the assertion was minted for', () => {
+ const token = fakeJwt({ sub: 7, nonce: 'expected' })
+ expect(fedcmNonceMatches(token, 'expected')).toBe(true)
+ expect(fedcmNonceMatches(token, 'other')).toBe(false)
+ expect(fedcmNonceMatches(token, 'expecte')).toBe(false)
+ expect(fedcmNonceMatches(token, '')).toBe(false)
+ expect(fedcmNonceMatches(fakeJwt({ sub: 7 }), 'expected')).toBe(false)
+ expect(fedcmNonceMatches(fakeJwt({ nonce: 7 }), '7')).toBe(false)
+ })
+ it('parses the posted body and rejects mismatches before anything reaches the Network', () => {
+ const token = fakeJwt({ sub: 7, nonce: 'n1' })
+ expect(parseFedcmBody(JSON.stringify({ token, nonce: 'n1' }))).toEqual({
+ body: { token, nonce: 'n1' },
+ })
+ expect(parseFedcmBody(JSON.stringify({ token, nonce: 'n2' }))).toEqual({
+ error: 'nonce_mismatch',
+ })
+ expect(parseFedcmBody(JSON.stringify({ nonce: 'n1' }))).toEqual({
+ error: 'missing_token_or_nonce',
+ })
+ expect(parseFedcmBody(JSON.stringify({ token, nonce: 5 }))).toEqual({
+ error: 'missing_token_or_nonce',
+ })
+ expect(parseFedcmBody('{nope')).toEqual({ error: 'malformed_json' })
+ expect(parseFedcmBody('"str"')).toEqual({ error: 'malformed_json' })
+ expect(parseFedcmBody('null')).toEqual({ error: 'malformed_json' })
+ })
+})
+
+describe('session check cache (silent-login shortcut)', () => {
+ it('remembers a confirmed session only until its ttl', () => {
+ const cache = new SessionCheckCache(1000)
+ expect(cache.has('tok', 0)).toBe(false)
+ cache.remember('tok', 0)
+ expect(cache.has('tok', 999)).toBe(true)
+ expect(cache.has('tok', 1000)).toBe(false)
+ expect(cache.has('tok', 500)).toBe(false) // an expired entry is gone for good
+ })
+ it('forgets on logout and never grows past its cap', () => {
+ const cache = new SessionCheckCache(1000, 2)
+ cache.remember('a', 0)
+ cache.forget('a')
+ expect(cache.has('a', 1)).toBe(false)
+ cache.remember('a', 0)
+ cache.remember('b', 0)
+ cache.remember('c', 0)
+ expect(cache.has('a', 1)).toBe(false) // oldest evicted
+ expect(cache.has('b', 1)).toBe(true)
+ expect(cache.has('c', 1)).toBe(true)
+ })
+})
+
describe('cookies + handoff', () => {
describe('cookies + handoff', () => {
it('parses a cookie header', () => {
it('parses a cookie header', () => {
expect(parseCookies('a=1; ovg_sso=tok%20en; b')).toEqual({ a: '1', ovg_sso: 'tok en' })
expect(parseCookies('a=1; ovg_sso=tok%20en; b')).toEqual({ a: '1', ovg_sso: 'tok en' })
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* Kept free of http.Server types so the rules are unit-testable on their own.
* Kept free of http.Server types so the rules are unit-testable on their own.
*/
*/
+import { timingSafeEqual } from 'node:crypto'
+
/** Name of the cookie holding the openvibe.network access token. */
/** Name of the cookie holding the openvibe.network access token. */
export const SESSION_COOKIE = 'ovg_sso'
export const SESSION_COOKIE = 'ovg_sso'
/**
/**
@@ -148,6 +150,112 @@ export function clearLoginStateCookie(): string {
@@ -148,6 +150,112 @@ export function clearLoginStateCookie(): string {
return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`
return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`
}
}
+// ── FedCM ──────────────────────────────────────────────────────────────
+// The shared navbar gets an assertion JWT from openvibe.network through the
+// browser's own account chooser and POSTs it to /auth/fedcm with the nonce
+// it put in the FedCM request. We only pre-check that nonce here; the
+// Network verifies the signature when it swaps the assertion for tokens.
+
+/**
+ * Decodes a JWT's payload WITHOUT checking its signature. Only for reading
+ * claims we cross-check locally (the FedCM nonce); never for trusting an
+ * identity.
+ */
+export function decodeJwtPayload(token: string): Record<string, unknown> | null {
+ const parts = token.split('.')
+ if (parts.length !== 3) return null
+ try {
+ const claims: unknown = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf8'))
+ return claims && typeof claims === 'object' && !Array.isArray(claims)
+ ? (claims as Record<string, unknown>)
+ : null
+ } catch {
+ return null
+ }
+}
+
+/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
+export function fedcmNonceMatches(token: string, nonce: string): boolean {
+ if (!nonce || nonce.length > 256) return false
+ const claims = decodeJwtPayload(token)
+ if (!claims || typeof claims.nonce !== 'string') return false
+ const a = Buffer.from(claims.nonce)
+ const b = Buffer.from(nonce)
+ return a.length === b.length && timingSafeEqual(a, b)
+}
+
+export interface FedcmBody {
+ token: string
+ nonce: string
+}
+
+/** Max bytes a /auth/fedcm body may carry: an assertion JWT is a few KB at most. */
+export const MAX_FEDCM_BODY_BYTES = 16 * 1024
+
+/**
+ * Parses and validates the JSON body of POST /auth/fedcm. Returns an error
+ * code (for a 400) instead of the body when it is malformed, incomplete or
+ * its nonce does not match the assertion's.
+ */
+export function parseFedcmBody(raw: string): { body: FedcmBody } | { error: string } {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return { error: 'malformed_json' }
+ }
+ if (!parsed || typeof parsed !== 'object') return { error: 'malformed_json' }
+ const { token, nonce } = parsed as { token?: unknown; nonce?: unknown }
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
+ return { error: 'missing_token_or_nonce' }
+ }
+ if (!fedcmNonceMatches(token, nonce)) return { error: 'nonce_mismatch' }
+ return { body: { token, nonce } }
+}
+
+// ── Session check cache ─────────────────────────────────────────────────
+
+/**
+ * Remembers, briefly, which session tokens openvibe.network last confirmed so
+ * the silent-login shortcut (`/auth/login?silent=1` with an ovg_sso cookie
+ * that already resolves) does not cost a Network lookup on every page. Only
+ * positive answers are kept: a miss just falls through to the normal
+ * prompt=none round trip, which is the right answer for it anyway.
+ */
+export class SessionCheckCache {
+ private readonly hits = new Map<string, number>()
+
+ constructor(
+ private readonly ttlMs = 60_000,
+ private readonly maxEntries = 5000,
+ ) {}
+
+ has(token: string, now = Date.now()): boolean {
+ const until = this.hits.get(token)
+ if (until === undefined) return false
+ if (until <= now) {
+ this.hits.delete(token)
+ return false
+ }
+ return true
+ }
+
+ remember(token: string, now = Date.now()): void {
+ if (this.hits.size >= this.maxEntries) {
+ for (const [k, until] of this.hits) if (until <= now) this.hits.delete(k)
+ if (this.hits.size >= this.maxEntries) {
+ const oldest = this.hits.keys().next().value
+ if (oldest !== undefined) this.hits.delete(oldest)
+ }
+ }
+ this.hits.set(token, now + this.ttlMs)
+ }
+
+ forget(token: string): void {
+ this.hits.delete(token)
+ }
+}
+
/** A JS string literal that cannot close the <script> it sits in. */
/** A JS string literal that cannot close the <script> it sits in. */
function jsString(value: string): string {
function jsString(value: string): string {
return JSON.stringify(value).replace(/</g, '\\u003c')
return JSON.stringify(value).replace(/</g, '\\u003c')