openvibers/openvibe.games Commits

Sign-in moves onto openvibe-sdk/sso (PKCE, constant-time state, /auth/health) (#285)

OP OpenVibers committed 7 days ago · 58798c386d · 6 files, +560 −68

The /auth routes built their own authorize URL and token request. They now
use openvibe-sdk/sso's createSsoClient for the authorize URL (state + PKCE
S256), the code and jwt-bearer grants, the failure page and the client
health check, one client per public origin (apex and play), so both
registered callback URIs are unchanged.

What stays exactly as it was: the ovg_sso session cookie and the
ov_sso_hint hint, the ovg_login flow cookie's path and lifetime, the
localStorage hand-off page the game client reads its token from, the
silent (prompt=none) sign-in and its quiet sso=none landing, FedCM's
nonce pre-check, and every editor permission check.

The callback now requires the flow cookie and compares its state in
constant time, and sends the pinned PKCE verifier with the exchange. A
lost or expired flow starts the sign-in once more by itself (two-minute
ov_oauth_retry cookie) and then shows a page with "Try again" instead of
a bare line of text. next is sanitized at login and again at the
callback. GET /auth/health answers 503 only when the Network refuses
this site's OAuth client.

Checked: typecheck, eslint, node test/run.js (29 entries, incl. the new
apps/server/src/net/ssoFlow.test.ts which drives the real routes against
a stand-in Network: PKCE challenge/verifier, hand-off cookies, retry,
silent landing, FedCM, health 200/503).

Changes

ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 97764ce..081045f 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -54,6 +54,8 @@ export interface ServerConfig {
selfUrl: string
/** Public base of the play host (Host-routed game vhost). */
playUrl: string
++ /** Host-internal Network base the token grant tries first. */
++ internalUrl: string
} | null
/**
* Platform integration (roadmap Wave 12): Games calls OpenVibe services with
@@@ -253,6 +255,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
baseUrl: env.OV_NETWORK_URL ?? 'https://openvibe.network',
selfUrl: env.SELF_URL ?? 'https://openvibe.games',
playUrl: env.PLAY_URL ?? 'https://play.openvibe.games',
++ internalUrl: trimSlash(
++ env.OV_NETWORK_INTERNAL_URL ?? env.OV_NETWORK_URL ?? 'https://openvibe.network',
++ ),
}
: null,
platform: loadPlatformConfig(env),
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex ea4eeb9..d2a6ca8 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -1,4 +1,3 @@
--import { randomBytes } from 'node:crypto'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
@@@ -6,19 +5,24 @@ import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
++import { createSsoClient, failurePage } from 'openvibe-sdk/sso'
import {
LOGIN_STATE_COOKIE,
MAX_FEDCM_BODY_BYTES,
++ RETRY_COOKIE,
SESSION_COOKIE,
SessionCheckCache,
clearLoginStateCookie,
++ clearRetryCookie,
clearSessionCookie,
decodeLoginState,
isSilentDenial,
loginStateCookie,
parseCookies,
parseFedcmBody,
++ retryCookie,
safeNext,
++ sameString,
sessionCookie,
sessionHandoffHtml,
ssoHintCookie,
@@@ -176,6 +180,8 @@ export interface OAuthConfig {
selfUrl: string
/** Public base of the play vhost, e.g. https://play.openvibe.games. */
playUrl: string
++ /** Host-internal Network base the token grant tries first (OV_NETWORK_INTERNAL_URL). */
++ internalUrl?: string
}
/**
@@@ -272,6 +278,33 @@ export function createHttpServer(
// 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()
++ // openvibe-sdk/sso's client per public origin: the authorize URL, PKCE, the code exchange and the
++ // /auth/health verdict are the SDK's, not a hand copy. Both callback URIs stay registered.
++ const ssoClients = new Map<string, ReturnType<typeof createSsoClient>>()
++ function ssoFor(base: string, cfg: OAuthConfig) {
++ let client = ssoClients.get(base)
++ if (!client) {
++ client = createSsoClient({
++ site: 'games',
++ siteName: 'OpenVibe.Games',
++ baseUrl: base,
++ clientId: cfg.clientId,
++ clientSecret: cfg.clientSecret,
++ networkUrl: cfg.baseUrl,
++ networkInternalUrl: cfg.internalUrl ?? cfg.baseUrl,
++ redirectUri: `${base}/auth/callback`,
++ scope: 'profile theme',
++ secureCookies: base.startsWith('https'),
++ log: {
++ log: (msg: string) => log.info(msg),
++ warn: (msg: string) => log.warn(msg),
++ error: (msg: string) => log.error(msg),
++ },
++ })
++ ssoClients.set(base, client)
++ }
++ return client
++ }
// The real entry points, offered on the 404 page of each host.
const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`
const apexLinks: NotFoundLink[] = [
@@@ -548,25 +581,24 @@ export function createHttpServer(
// Where a session token is resolved to an account (see networkAuth.ts).
const networkMeUrl =
editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)
++ // The SDK client for the host the browser is on (see ssoFor above).
++ const sso = oauth && redirectBase ? ssoFor(redirectBase, oauth) : null
-- if (url === '/auth/login' && oauth && redirectBase) {
++ if (url === '/auth/login' && oauth && redirectBase && sso) {
const silent = query.get('silent') === '1'
const next = safeNext(query.get('next'), origins)
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')
++ // openvibe-sdk/sso builds the URL with state and PKCE (S256); the
++ // verifier rides in the login state cookie and never reaches a script.
++ const started = sso.authorizeUrl({ silent })
res.writeHead(302, {
-- location: `${oauth.baseUrl}/oauth/authorize?${params.toString()}`,
-- 'set-cookie': loginStateCookie({ state, next, silent }, secure),
++ location: started.url,
++ 'set-cookie': loginStateCookie(
++ { state: started.state, verifier: started.verifier, next, silent },
++ secure,
++ ),
'cache-control': 'no-store',
})
res.end()
@@@ -600,9 +632,51 @@ export function createHttpServer(
})
return
}
-- if (url === '/auth/callback' && oauth && redirectBase) {
++ if (url === '/auth/callback' && oauth && redirectBase && sso) {
const login = decodeLoginState(cookies[LOGIN_STATE_COOKIE])
-- const next = login?.next ?? null
++ // Sanitized again here: the cookie is the browser's to lose or rewrite,
++ // and this is the value a redirect follows.
++ const next = safeNext(login?.next ?? null, origins)
++ const loginHref = `/auth/login${next ? `?next=${encodeURIComponent(next)}` : ''}`
++ /** The page a sign-in that cannot finish shows: plain words, "Try again", no bare line of text. */
++ const failure = (status: number, title: string, text: string, cookieList: string[]) => {
++ res.writeHead(status, {
++ 'content-type': 'text/html; charset=utf-8',
++ 'cache-control': 'no-store',
++ 'set-cookie': cookieList,
++ })
++ res.end(
++ failurePage({
++ siteName: 'OpenVibe.Games',
++ title,
++ text,
++ retryHref: loginHref,
++ backHref: next ?? home,
++ }),
++ )
++ }
++ /**
++ * A lost or expired flow (the cookie is gone, or the state does not
++ * match) starts the sign-in once more by itself; the second failure
++ * shows the page instead of looping. Neither ever sets a session.
++ */
++ const retryOrExplain = (cookieList: string[]) => {
++ if (cookies[RETRY_COOKIE] !== '1') {
++ res.writeHead(302, {
++ location: loginHref,
++ 'set-cookie': [...cookieList, retryCookie(secure)],
++ 'cache-control': 'no-store',
++ })
++ res.end()
++ return
++ }
++ failure(
++ 400,
++ "Sign-in didn't finish",
++ 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.',
++ [...cookieList, clearRetryCookie()],
++ )
++ }
const error = query.get('error')
if (error) {
// prompt=none found nobody signed in (or the silent attempt was
@@@ -610,58 +684,45 @@ export function createHttpServer(
if (login?.silent || isSilentDenial(error)) {
res.writeHead(302, {
location: withSsoNone(next ?? home),
-- 'set-cookie': clearLoginStateCookie(),
++ 'set-cookie': [clearLoginStateCookie(), clearRetryCookie()],
'cache-control': 'no-store',
})
res.end()
return
}
-- res.writeHead(400, {
-- 'content-type': 'text/plain',
-- 'set-cookie': clearLoginStateCookie(),
++ log.warn('oauth sign-in not completed', {
++ error: String(query.get('error_description') ?? error).slice(0, 120),
})
-- res.end(`sign-in failed: ${query.get('error_description') ?? error}`)
++ failure(
++ 400,
++ "Sign-in didn't finish",
++ `OpenVibe.Network did not complete the sign-in (${String(error).slice(0, 60)}). Try again when you are ready; your account is fine.`,
++ [clearLoginStateCookie(), clearRetryCookie()],
++ )
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')) {
++ // The cookie is required, not only compared when present: without it
++ // a crafted link would sign a visitor into someone else's account. The
++ // state is compared in constant time and so is the PKCE verifier's flow.
++ if (!code || !login || !login.verifier || !sameString(query.get('state'), login.state)) {
log.warn('oauth callback state mismatch', {})
-- res.writeHead(400, {
-- 'content-type': 'text/plain',
-- 'set-cookie': clearLoginStateCookie(),
-- })
-- res.end('sign-in failed: state mismatch — start again')
++ retryOrExplain([clearLoginStateCookie()])
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 TokenResponse
++ // The exchange (and its PKCE verifier) is openvibe-sdk/sso's. It
++ // must repeat the redirect_uri the authorize step used; the
++ // callback arrives on that same host, so Host decides.
++ const data = (await sso.tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: `${redirectBase}/auth/callback`,
++ code: String(code),
++ code_verifier: login.verifier,
++ })) as TokenResponse
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'}`)
++ retryOrExplain([clearLoginStateCookie()])
return
}
knownSessions.remember(data.access_token)
@@@ -675,18 +736,44 @@ export function createHttpServer(
sessionCookie(data.access_token, secure),
ssoHintCookie('account', secure),
clearLoginStateCookie(),
++ clearRetryCookie(),
],
})
res.end(sessionHandoffHtml(data.access_token, 'account', dest))
} catch (err) {
++ const failureInfo = err as { status?: number; error?: string }
++ if (
++ failureInfo.error === 'invalid_client' ||
++ failureInfo.error === 'unauthorized_client' ||
++ failureInfo.status === 401
++ ) {
++ log.error("the Network refuses this site's OAuth client", {
++ error: failureInfo.error ?? String(failureInfo.status),
++ })
++ failure(
++ 500,
++ "Sign-in isn't working on OpenVibe.Games right now",
++ 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',
++ [clearLoginStateCookie(), clearRetryCookie()],
++ )
++ return
++ }
log.warn('oauth callback failed', { error: String(err) })
-- res.writeHead(502, { 'content-type': 'text/plain' })
-- res.end('openvibe.network is unreachable — try again shortly')
++ if (failureInfo.status && failureInfo.status < 500) {
++ retryOrExplain([clearLoginStateCookie()])
++ return
++ }
++ failure(
++ 502,
++ "OpenVibe.Network didn't answer",
++ 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.',
++ [clearLoginStateCookie(), clearRetryCookie()],
++ )
}
})()
return
}
-- if (url === '/auth/fedcm' && oauth) {
++ if (url === '/auth/fedcm' && oauth && sso) {
// 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.
@@@ -721,19 +808,12 @@ export function createHttpServer(
return
}
try {
-- // Same endpoint and answer shape as the code exchange; the
++ // Same endpoint and answer shape as the code exchange (the SDK's grant); 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
++ const data = (await sso.tokenGrant({
++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
++ assertion: parsed.body.token,
++ })) as TokenResponse
if (!data.access_token) {
log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })
json(401, {
@@@ -753,6 +833,15 @@ export function createHttpServer(
ssoHintCookie('account', secure),
])
} catch (err) {
++ const info = err as { status?: number; error?: string; message?: string }
++ if (info.status && info.status < 500) {
++ log.warn('fedcm exchange rejected', { error: info.error ?? info.status })
++ json(401, {
++ error: info.error ?? 'invalid_grant',
++ error_description: info.message ?? 'openvibe.network rejected the assertion',
++ })
++ return
++ }
log.warn('fedcm exchange failed', { error: String(err) })
json(502, {
error: 'server_error',
@@@ -793,6 +882,19 @@ export function createHttpServer(
})
return
}
++ if (url === '/auth/health' && sso) {
++ // Does the Network accept this site's OAuth client? openvibe-sdk/sso's checkClient
++ // (a token request with a code no sign-in ever had), cached 10 minutes; 503 only
++ // when the Network refuses the client, never for a Network that cannot answer.
++ void sso.checkClient().then((client) => {
++ res.writeHead(client === 'invalid' ? 503 : 200, {
++ 'content-type': 'application/json',
++ 'cache-control': 'no-store',
++ })
++ res.end(JSON.stringify({ ok: client !== 'invalid', client }))
++ })
++ return
++ }
res.writeHead(404, { 'content-type': 'application/json' })
res.end('{"error":"not_found"}')
return
ddiff --git a/apps/server/src/net/sso.test.ts b/apps/server/src/net/sso.test.ts
iindex 32d0bff..2c6ef7d 100644
---- a/apps/server/src/net/sso.test.ts
++++ b/apps/server/src/net/sso.test.ts
@@@ -60,7 +60,12 @@ describe('withSsoNone', () => {
describe('login state cookie', () => {
it('round-trips', () => {
-- const s = { state: 'abc', next: 'https://openvibe.network/x', silent: true }
++ const s = {
++ state: 'abc',
++ verifier: 'verifier-1',
++ next: 'https://openvibe.network/x',
++ silent: true,
++ }
expect(decodeLoginState(encodeLoginState(s))).toEqual(s)
})
it('tolerates garbage', () => {
ddiff --git a/apps/server/src/net/sso.ts b/apps/server/src/net/sso.ts
iindex b50f5cc..b6e3c9a 100644
---- a/apps/server/src/net/sso.ts
++++ b/apps/server/src/net/sso.ts
@@@ -103,6 +103,8 @@ export function parseCookies(header: string | undefined): Record<string, string>
export interface LoginState {
state: string
++ /** The PKCE verifier for this flow (openvibe-sdk/sso's authorizeUrl); never leaves this cookie. */
++ verifier: string
next: string | null
silent: boolean
}
@@@ -118,6 +120,7 @@ export function decodeLoginState(raw: string | undefined): LoginState | null {
if (typeof parsed.state !== 'string') return null
return {
state: parsed.state,
++ verifier: typeof parsed.verifier === 'string' ? parsed.verifier : '',
next: typeof parsed.next === 'string' ? parsed.next : null,
silent: parsed.silent === true,
}
@@@ -150,6 +153,30 @@ export function clearLoginStateCookie(): string {
return `${LOGIN_STATE_COOKIE}=; Path=/auth; Max-Age=0; HttpOnly; SameSite=Lax`
}
++/**
++ * Comparison in constant time: the OAuth `state` coming back must match the
++ * one the login state cookie pinned before the code is exchanged.
++ */
++export function sameString(a: unknown, b: unknown): boolean {
++ const x = Buffer.from(String(a ?? ''))
++ const y = Buffer.from(String(b ?? ''))
++ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y)
++}
++
++/**
++ * Set once when a sign-in cannot finish, so the callback starts it again by
++ * itself; the second failure shows the page instead of looping.
++ */
++export const RETRY_COOKIE = 'ov_oauth_retry'
++
++export function retryCookie(secure: boolean): string {
++ return `${RETRY_COOKIE}=1; Path=/auth; Max-Age=120; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`
++}
++
++export function clearRetryCookie(): string {
++ return `${RETRY_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
ddiff --git a/apps/server/src/net/ssoFlow.test.ts b/apps/server/src/net/ssoFlow.test.ts
nnew file mode 100644
iindex 0000000..82c2fd0
---- /dev/null
++++ b/apps/server/src/net/ssoFlow.test.ts
@@@ -0,0 +1,350 @@
++import { createHash } from 'node:crypto'
++import { createServer, request, type IncomingHttpHeaders, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { createConsoleLogger } from '@openvibe/shared'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { ServerMetrics } from '../observability/metrics.js'
++import { createHttpServer, type OAuthConfig } from './httpServer.js'
++
++/**
++ * The /auth routes against a stand-in openvibe.network: the authorize URL
++ * (state + PKCE S256), the code exchange that repeats the verifier, the
++ * cookies the game client still reads, and GET /auth/health.
++ */
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const APEX = 'openvibe.games'
++
++interface Reply {
++ status: number
++ headers: IncomingHttpHeaders
++ body: string
++}
++
++function listen(s: Server): Promise<number> {
++ return new Promise((resolve) =>
++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
++ )
++}
++
++function send(
++ port: number,
++ path: string,
++ opts: { host?: string; cookie?: string; method?: string; json?: unknown } = {},
++): Promise<Reply> {
++ const payload = opts.json === undefined ? null : Buffer.from(JSON.stringify(opts.json))
++ return new Promise((resolve, reject) => {
++ const req = request(
++ {
++ host: '127.0.0.1',
++ port,
++ path,
++ method: opts.method ?? (payload ? 'POST' : 'GET'),
++ headers: {
++ host: opts.host ?? APEX,
++ ...(opts.cookie ? { cookie: opts.cookie } : {}),
++ ...(payload
++ ? { 'content-type': 'application/json', 'content-length': payload.length }
++ : {}),
++ },
++ },
++ (res) => {
++ const chunks: Buffer[] = []
++ res.on('data', (c: Buffer) => chunks.push(c))
++ res.on('end', () =>
++ resolve({
++ status: res.statusCode ?? 0,
++ headers: res.headers,
++ body: Buffer.concat(chunks).toString('utf8'),
++ }),
++ )
++ },
++ )
++ req.on('error', reject)
++ if (payload) req.write(payload)
++ req.end()
++ })
++}
++
++function setCookies(r: Reply): string[] {
++ const raw = r.headers['set-cookie']
++ return Array.isArray(raw) ? raw : raw ? [raw] : []
++}
++
++function cookieNamed(r: Reply, name: string): string | undefined {
++ return setCookies(r)
++ .map((c) => c.split(';')[0] ?? '')
++ .find((c) => c.startsWith(`${name}=`))
++}
++
++/** The login state cookie's JSON payload. */
++function pinned(r: Reply): {
++ state: string
++ verifier: string
++ next: string | null
++ silent: boolean
++} {
++ const raw = (cookieNamed(r, 'ovg_login') ?? '').split('=')[1] ?? ''
++ return JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
++}
++
++/** An unsigned-but-well-formed JWT carrying `claims` (the Network checks signatures, not us). */
++function fedcmAssertion(claims: Record<string, unknown>): string {
++ const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url')
++ return `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}.c2ln`
++}
++
++const tokenCalls: Array<Record<string, unknown>> = []
++
++let network: Server
++let networkPort = 0
++let server: Server
++let port = 0
++let refusing: Server
++let refusingPort = 0
++let portInvalid = 0
++
++function oauthFor(base: string): OAuthConfig {
++ return {
++ clientId: 'games',
++ clientSecret: 'secret',
++ baseUrl: base,
++ selfUrl: 'https://openvibe.games',
++ playUrl: 'https://play.openvibe.games',
++ internalUrl: base,
++ }
++}
++
++beforeAll(async () => {
++ network = createServer((req, res) => {
++ const chunks: Buffer[] = []
++ req.on('data', (c: Buffer) => chunks.push(c))
++ req.on('end', () => {
++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as Record<
++ string,
++ unknown
++ >
++ tokenCalls.push(body)
++ res.setHeader('content-type', 'application/json')
++ // A code no sign-in ever had is the health check: invalid_grant means the client itself is fine.
++ if (body.code === 'ov_health_check_never_issued') {
++ res.statusCode = 400
++ res.end(JSON.stringify({ error: 'invalid_grant' }))
++ return
++ }
++ if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
++ const claims = JSON.parse(
++ Buffer.from(String(body.assertion).split('.')[1] ?? '', 'base64url').toString('utf8'),
++ ) as { nonce?: string }
++ if (claims.nonce !== 'n1') {
++ res.statusCode = 400
++ res.end(JSON.stringify({ error: 'invalid_grant' }))
++ return
++ }
++ res.end(JSON.stringify({ access_token: 'tok-fedcm', user: { username: 'ana' } }))
++ return
++ }
++ if (
++ body.code !== 'good-code' ||
++ body.client_id !== 'games' ||
++ body.client_secret !== 'secret'
++ ) {
++ res.statusCode = 400
++ res.end(JSON.stringify({ error: 'invalid_grant' }))
++ return
++ }
++ res.end(JSON.stringify({ access_token: 'tok-flow', refresh_token: 'rt-flow' }))
++ })
++ })
++ networkPort = await listen(network)
++ const base = `http://127.0.0.1:${networkPort}`
++
++ server = createHttpServer(
++ null,
++ new ServerMetrics(),
++ log,
++ undefined,
++ { networkAuthUrl: null },
++ undefined,
++ undefined,
++ oauthFor(base),
++ )
++ port = await listen(server)
++
++ // A Network that refuses this site's client: the one answer /auth/health must not hide.
++ refusing = createServer((_req, res) => {
++ res.statusCode = 401
++ res.setHeader('content-type', 'application/json')
++ res.end(JSON.stringify({ error: 'invalid_client' }))
++ })
++ refusingPort = await listen(refusing)
++ const invalid = createHttpServer(
++ null,
++ new ServerMetrics(),
++ log,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ oauthFor(`http://127.0.0.1:${refusingPort}`),
++ )
++ portInvalid = await listen(invalid)
++})
++
++afterAll(() => {
++ server.close()
++ network.close()
++ refusing.close()
++})
++
++describe('/auth/login', () => {
++ it('redirects with state and PKCE S256, pinning both in the login state cookie', async () => {
++ const r = await send(port, '/auth/login?next=%2Fplay%3Fgame%3Dsurf_lagoon')
++ expect(r.status).toBe(302)
++ const url = new URL(r.headers.location ?? '')
++ expect(url.pathname).toBe('/oauth/authorize')
++ expect(url.searchParams.get('client_id')).toBe('games')
++ expect(url.searchParams.get('redirect_uri')).toBe('https://openvibe.games/auth/callback')
++ expect(url.searchParams.get('response_type')).toBe('code')
++ expect(url.searchParams.get('scope')).toBe('profile theme')
++ expect(url.searchParams.get('code_challenge_method')).toBe('S256')
++ const state = url.searchParams.get('state') ?? ''
++ expect(state.length).toBeGreaterThan(20)
++ expect(url.searchParams.get('prompt')).toBeNull()
++
++ const pin = cookieNamed(r, 'ovg_login')
++ expect(pin).toBeTruthy()
++ const value = pinned(r)
++ expect(value.state).toBe(state)
++ expect(value.next).toBe('/play?game=surf_lagoon')
++ expect(value.silent).toBe(false)
++ // The verifier is in the cookie only; the Network got its hash.
++ expect(value.verifier).toMatch(/^[A-Za-z0-9_-]{40,}$/)
++ const challenge = createHash('sha256').update(value.verifier).digest('base64url')
++ expect(url.searchParams.get('code_challenge')).toBe(challenge)
++ })
++
++ it('silent=1 asks for prompt=none', async () => {
++ const r = await send(port, '/auth/login?silent=1')
++ const url = new URL(r.headers.location ?? '')
++ expect(url.searchParams.get('prompt')).toBe('none')
++ })
++
++ it('sanitizes next: an off-site target is not pinned', async () => {
++ const r = await send(port, '/auth/login?next=https%3A%2F%2Fevil.example%2Fx')
++ expect(pinned(r).next).toBeNull()
++ })
++})
++
++describe('/auth/callback', () => {
++ it('exchanges the code with the pinned verifier and keeps the ovg_sso hand-off', async () => {
++ const started = await send(port, '/auth/login?next=%2Fplay')
++ const pin = (cookieNamed(started, 'ovg_login') ?? '').split(';')[0] ?? ''
++ const value = pinned(started)
++
++ tokenCalls.length = 0
++ const r = await send(
++ port,
++ `/auth/callback?code=good-code&state=${encodeURIComponent(value.state)}`,
++ { cookie: pin },
++ )
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ // The game client reads the token from localStorage; the cookie and the hint still ride along.
++ expect(r.body).toContain('localStorage.setItem(\'ovg_sso\', "tok-flow")')
++ expect(r.body).toContain('location.replace("/play")')
++ expect(cookieNamed(r, 'ovg_sso')).toBe('ovg_sso=tok-flow')
++ expect(cookieNamed(r, 'ov_sso_hint')).toBe('ov_sso_hint=account')
++ expect(cookieNamed(r, 'ovg_login')).toBe('ovg_login=')
++ expect(tokenCalls.at(-1)).toMatchObject({
++ grant_type: 'authorization_code',
++ code: 'good-code',
++ code_verifier: value.verifier,
++ redirect_uri: 'https://openvibe.games/auth/callback',
++ client_id: 'games',
++ client_secret: 'secret',
++ })
++ })
++
++ it('recovers a lost flow once, then explains it — and never sets a session', async () => {
++ const first = await send(port, '/auth/callback?code=good-code&state=not-the-state')
++ expect(first.status).toBe(302)
++ expect(first.headers.location).toMatch(/^\/auth\/login/)
++ const retry = cookieNamed(first, 'ov_oauth_retry')
++ expect(retry).toBe('ov_oauth_retry=1')
++ expect(cookieNamed(first, 'ovg_sso')).toBeUndefined()
++
++ const second = await send(port, '/auth/callback?code=good-code&state=not-the-state', {
++ cookie: retry ?? '',
++ })
++ expect(second.status).toBe(400)
++ expect(second.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(second.body).toContain('Try again')
++ expect(cookieNamed(second, 'ovg_sso')).toBeUndefined()
++ })
++
++ it('sanitizes next again at the callback', async () => {
++ const pin = `ovg_login=${Buffer.from(
++ JSON.stringify({
++ state: 's1',
++ verifier: 'v1',
++ next: 'https://evil.example/x',
++ silent: false,
++ }),
++ 'utf8',
++ ).toString('base64url')}`
++ const r = await send(port, '/auth/callback?code=good-code&state=s1', { cookie: pin })
++ expect(r.status).toBe(200)
++ expect(r.body).toContain('location.replace("/play")')
++ expect(r.body).not.toContain('evil.example')
++ })
++
++ it('refuses a callback with no login state cookie at all', async () => {
++ const r = await send(port, '/auth/callback?code=good-code&state=anything')
++ expect(r.status).toBe(302)
++ expect(cookieNamed(r, 'ovg_sso')).toBeUndefined()
++ })
++
++ it('lands a silent sign-in quietly with sso=none', async () => {
++ const started = await send(port, '/auth/login?silent=1&next=%2Fplay')
++ const pin = (cookieNamed(started, 'ovg_login') ?? '').split(';')[0] ?? ''
++ const r = await send(port, '/auth/callback?error=login_required', { cookie: pin })
++ expect(r.status).toBe(302)
++ expect(r.headers.location).toBe('/play?sso=none')
++ })
++})
++
++describe('/auth/fedcm', () => {
++ it('sets the same session cookies from an assertion', async () => {
++ const r = await send(port, '/auth/fedcm', {
++ json: { token: fedcmAssertion({ sub: 7, nonce: 'n1' }), nonce: 'n1' },
++ })
++ expect(r.status).toBe(200)
++ expect(JSON.parse(r.body)).toMatchObject({ ok: true, user: { username: 'ana' } })
++ expect(cookieNamed(r, 'ovg_sso')).toBe('ovg_sso=tok-fedcm')
++ expect(cookieNamed(r, 'ov_sso_hint')).toBe('ov_sso_hint=account')
++ })
++
++ it('refuses an assertion whose nonce does not match', async () => {
++ const r = await send(port, '/auth/fedcm', {
++ json: { token: fedcmAssertion({ sub: 7, nonce: 'other' }), nonce: 'n1' },
++ })
++ expect(r.status).toBe(400)
++ expect(cookieNamed(r, 'ovg_sso')).toBeUndefined()
++ })
++})
++
++describe('/auth/health', () => {
++ it('is 200 when the Network accepts the client (invalid_grant, not invalid_client)', async () => {
++ const r = await send(port, '/auth/health')
++ expect(r.status).toBe(200)
++ expect(r.headers['cache-control']).toBe('no-store')
++ expect(JSON.parse(r.body)).toEqual({ ok: true, client: 'ok' })
++ })
++
++ it('is 503 only when the Network refuses the client', async () => {
++ const r = await send(portInvalid, '/auth/health')
++ expect(r.status).toBe(503)
++ expect(JSON.parse(r.body)).toEqual({ ok: false, client: 'invalid' })
++ })
++})
ddiff --git a/test/apps__server__src__net__ssoFlow.test.js b/test/apps__server__src__net__ssoFlow.test.js
nnew file mode 100644
iindex 0000000..1deb825
---- /dev/null
++++ b/test/apps__server__src__net__ssoFlow.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/ssoFlow.test.ts'
++// covers: 'apps/server/src/net'
++// Generated by node test/run.js --index; do not edit.