openvibers/openvibe.games Commits
Sign-in moves onto openvibe-sdk/sso (PKCE, constant-time state, /auth/health) (#285)
Changes
@@@ -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),@@@ -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 TokenResponseif (!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 TokenResponseif (!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@@@ -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', () => {@@@ -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: stringnext: string | nullsilent: boolean}@@@ -118,6 +120,7 @@ export function decodeLoginState(raw: string | undefined): LoginState | null {if (typeof parsed.state !== 'string') return nullreturn {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 noncennew file mode 100644@@@ -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' })++ })++})nnew file mode 100644@@@ -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.
@@ -54,6 +54,8 @@ export interface ServerConfig {
@@ -54,6 +54,8 @@ export interface ServerConfig {
selfUrl: string
selfUrl: string
/** Public base of the play host (Host-routed game vhost). */
/** Public base of the play host (Host-routed game vhost). */
playUrl: string
playUrl: string
+ /** Host-internal Network base the token grant tries first. */
+ internalUrl: string
} | null
} | null
/**
/**
* Platform integration (roadmap Wave 12): Games calls OpenVibe services with
* Platform integration (roadmap Wave 12): Games calls OpenVibe services with
@@ -253,6 +255,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -253,6 +255,9 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
baseUrl: env.OV_NETWORK_URL ?? 'https://openvibe.network',
baseUrl: env.OV_NETWORK_URL ?? 'https://openvibe.network',
selfUrl: env.SELF_URL ?? 'https://openvibe.games',
selfUrl: env.SELF_URL ?? 'https://openvibe.games',
playUrl: env.PLAY_URL ?? 'https://play.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,
: null,
platform: loadPlatformConfig(env),
platform: loadPlatformConfig(env),
@@ -1,4 +1,3 @@
@@ -1,4 +1,3 @@
-import { randomBytes } from 'node:crypto'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
@@ -6,19 +5,24 @@ import type { Logger } from '@openvibe/shared'
@@ -6,19 +5,24 @@ import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
+import { createSsoClient, failurePage } from 'openvibe-sdk/sso'
import {
import {
LOGIN_STATE_COOKIE,
LOGIN_STATE_COOKIE,
MAX_FEDCM_BODY_BYTES,
MAX_FEDCM_BODY_BYTES,
+ RETRY_COOKIE,
SESSION_COOKIE,
SESSION_COOKIE,
SessionCheckCache,
SessionCheckCache,
clearLoginStateCookie,
clearLoginStateCookie,
+ clearRetryCookie,
clearSessionCookie,
clearSessionCookie,
decodeLoginState,
decodeLoginState,
isSilentDenial,
isSilentDenial,
loginStateCookie,
loginStateCookie,
parseCookies,
parseCookies,
parseFedcmBody,
parseFedcmBody,
+ retryCookie,
safeNext,
safeNext,
+ sameString,
sessionCookie,
sessionCookie,
sessionHandoffHtml,
sessionHandoffHtml,
ssoHintCookie,
ssoHintCookie,
@@ -176,6 +180,8 @@ export interface OAuthConfig {
@@ -176,6 +180,8 @@ export interface OAuthConfig {
selfUrl: string
selfUrl: string
/** Public base of the play vhost, e.g. https://play.openvibe.games. */
/** Public base of the play vhost, e.g. https://play.openvibe.games. */
playUrl: string
playUrl: string
+ /** Host-internal Network base the token grant tries first (OV_NETWORK_INTERNAL_URL). */
+ internalUrl?: string
}
}
/**
/**
@@ -272,6 +278,33 @@ export function createHttpServer(
@@ -272,6 +278,33 @@ export function createHttpServer(
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// browser answer without the Network round trip. See /auth/login.
// browser answer without the Network round trip. See /auth/login.
const knownSessions = new SessionCheckCache()
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.
// The real entry points, offered on the 404 page of each host.
const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`
const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`
const apexLinks: NotFoundLink[] = [
const apexLinks: NotFoundLink[] = [
@@ -548,25 +581,24 @@ export function createHttpServer(
@@ -548,25 +581,24 @@ export function createHttpServer(
// Where a session token is resolved to an account (see networkAuth.ts).
// Where a session token is resolved to an account (see networkAuth.ts).
const networkMeUrl =
const networkMeUrl =
editorAuth?.networkAuthUrl ?? (oauth ? `${oauth.baseUrl}/api/auth/me` : null)
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 silent = query.get('silent') === '1'
const next = safeNext(query.get('next'), origins)
const next = safeNext(query.get('next'), origins)
const startLogin = () => {
const startLogin = () => {
// Both callbacks are registered with the provider; using the host the
// Both callbacks are registered with the provider; using the host the
// player came from means the callback lands back on that same host.
// 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, {
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',
'cache-control': 'no-store',
})
})
res.end()
res.end()
@@ -600,9 +632,51 @@ export function createHttpServer(
@@ -600,9 +632,51 @@ export function createHttpServer(
})
})
return
return
}
}
- if (url === '/auth/callback' && oauth && redirectBase) {
+ if (url === '/auth/callback' && oauth && redirectBase && sso) {
const login = decodeLoginState(cookies[LOGIN_STATE_COOKIE])
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')
const error = query.get('error')
if (error) {
if (error) {
// prompt=none found nobody signed in (or the silent attempt was
// prompt=none found nobody signed in (or the silent attempt was
@@ -610,58 +684,45 @@ export function createHttpServer(
@@ -610,58 +684,45 @@ export function createHttpServer(
if (login?.silent || isSilentDenial(error)) {
if (login?.silent || isSilentDenial(error)) {
res.writeHead(302, {
res.writeHead(302, {
location: withSsoNone(next ?? home),
location: withSsoNone(next ?? home),
- 'set-cookie': clearLoginStateCookie(),
+ 'set-cookie': [clearLoginStateCookie(), clearRetryCookie()],
'cache-control': 'no-store',
'cache-control': 'no-store',
})
})
res.end()
res.end()
return
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
return
}
}
const code = query.get('code')
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', {})
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
return
}
}
void (async () => {
void (async () => {
try {
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) {
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
return
}
}
knownSessions.remember(data.access_token)
knownSessions.remember(data.access_token)
@@ -675,18 +736,44 @@ export function createHttpServer(
@@ -675,18 +736,44 @@ export function createHttpServer(
sessionCookie(data.access_token, secure),
sessionCookie(data.access_token, secure),
ssoHintCookie('account', secure),
ssoHintCookie('account', secure),
clearLoginStateCookie(),
clearLoginStateCookie(),
+ clearRetryCookie(),
],
],
})
})
res.end(sessionHandoffHtml(data.access_token, 'account', dest))
res.end(sessionHandoffHtml(data.access_token, 'account', dest))
} catch (err) {
} 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) })
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
return
}
}
- if (url === '/auth/fedcm' && oauth) {
+ if (url === '/auth/fedcm' && oauth && sso) {
// Browser-native FedCM sign-in: the shared navbar posts the assertion
// Browser-native FedCM sign-in: the shared navbar posts the assertion
// JWT openvibe.network issued through the browser's account chooser,
// JWT openvibe.network issued through the browser's account chooser,
// plus the nonce it put in the FedCM request. Same-origin JSON only.
// plus the nonce it put in the FedCM request. Same-origin JSON only.
@@ -721,19 +808,12 @@ export function createHttpServer(
@@ -721,19 +808,12 @@ export function createHttpServer(
return
return
}
}
try {
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.
// 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) {
if (!data.access_token) {
log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })
log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })
json(401, {
json(401, {
@@ -753,6 +833,15 @@ export function createHttpServer(
@@ -753,6 +833,15 @@ export function createHttpServer(
ssoHintCookie('account', secure),
ssoHintCookie('account', secure),
])
])
} catch (err) {
} 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) })
log.warn('fedcm exchange failed', { error: String(err) })
json(502, {
json(502, {
error: 'server_error',
error: 'server_error',
@@ -793,6 +882,19 @@ export function createHttpServer(
@@ -793,6 +882,19 @@ export function createHttpServer(
})
})
return
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.writeHead(404, { 'content-type': 'application/json' })
res.end('{"error":"not_found"}')
res.end('{"error":"not_found"}')
return
return
@@ -60,7 +60,12 @@ describe('withSsoNone', () => {
@@ -60,7 +60,12 @@ describe('withSsoNone', () => {
describe('login state cookie', () => {
describe('login state cookie', () => {
it('round-trips', () => {
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)
expect(decodeLoginState(encodeLoginState(s))).toEqual(s)
})
})
it('tolerates garbage', () => {
it('tolerates garbage', () => {
@@ -103,6 +103,8 @@ export function parseCookies(header: string | undefined): Record<string, string>
@@ -103,6 +103,8 @@ export function parseCookies(header: string | undefined): Record<string, string>
export interface LoginState {
export interface LoginState {
state: string
state: string
+ /** The PKCE verifier for this flow (openvibe-sdk/sso's authorizeUrl); never leaves this cookie. */
+ verifier: string
next: string | null
next: string | null
silent: boolean
silent: boolean
}
}
@@ -118,6 +120,7 @@ export function decodeLoginState(raw: string | undefined): LoginState | null {
@@ -118,6 +120,7 @@ export function decodeLoginState(raw: string | undefined): LoginState | null {
if (typeof parsed.state !== 'string') return null
if (typeof parsed.state !== 'string') return null
return {
return {
state: parsed.state,
state: parsed.state,
+ verifier: typeof parsed.verifier === 'string' ? parsed.verifier : '',
next: typeof parsed.next === 'string' ? parsed.next : null,
next: typeof parsed.next === 'string' ? parsed.next : null,
silent: parsed.silent === true,
silent: parsed.silent === true,
}
}
@@ -150,6 +153,30 @@ export function clearLoginStateCookie(): string {
@@ -150,6 +153,30 @@ 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`
}
}
+/**
+ * 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 ──────────────────────────────────────────────────────────────
// ── FedCM ──────────────────────────────────────────────────────────────
// The shared navbar gets an assertion JWT from openvibe.network through the
// 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
// browser's own account chooser and POSTs it to /auth/fedcm with the nonce
new file mode 100644
new file mode 100644
@@ -0,0 +1,350 @@
@@ -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' })
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.