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

OP OpenVibers committed 18 Sept 2026 · 9be6a8e495 · 6 files, +381 −27


Changes

ddiff --git a/apps/server/dist-types/net/httpServer.d.ts.map b/apps/server/dist-types/net/httpServer.d.ts.map
iindex a1d3b9c..53a577d 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;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
ddiff --git a/apps/server/dist-types/net/sso.d.ts b/apps/server/dist-types/net/sso.d.ts
iindex 79935c3..37a7141 100644
---- a/apps/server/dist-types/net/sso.d.ts
++++ b/apps/server/dist-types/net/sso.d.ts
@@@ -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
ddiff --git a/apps/server/dist-types/net/sso.d.ts.map b/apps/server/dist-types/net/sso.d.ts.map
iindex 836ee22..532bca4 100644
---- a/apps/server/dist-types/net/sso.d.ts.map
++++ b/apps/server/dist-types/net/sso.d.ts.map
@@@ -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
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 59ffd73..986eaac 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -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): Pr
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(
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 TokenResponse
if (!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',
ddiff --git a/apps/server/src/net/sso.test.ts b/apps/server/src/net/sso.test.ts
iindex 8b59795..32d0bff 100644
---- a/apps/server/src/net/sso.test.ts
++++ b/apps/server/src/net/sso.test.ts
@@@ -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' })
ddiff --git a/apps/server/src/net/sso.ts b/apps/server/src/net/sso.ts
iindex b2654ff..b50f5cc 100644
---- a/apps/server/src/net/sso.ts
++++ b/apps/server/src/net/sso.ts
@@@ -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')