import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash, verify, createPublicKey } from 'node:crypto'; import { request } from 'node:https'; import { createServer } from 'node:http'; import { readFileSync, statSync, writeFileSync,renameSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { prepare,serve,SUBJECT } from './isolated-identity.mjs'; import { fixture,closeServer,closeChild } from './test-support.mjs'; import { readSlot } from '../../webui/src/sso.mjs'; function setup(t) { const f=fixture(t),runtime=join(f.root,'identity'); const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime,tlsPort:17443}); return {...f,runtime,output}; } function http(runtime,path,options={}) { return new Promise((resolve,reject)=>{ const req=request(`https://127.0.0.1:17443${path}`,{ca:readFileSync(join(runtime,'ca.pem')),...options},res=>{ let body='';res.on('data',c=>body+=c);res.on('end',()=>resolve({status:res.statusCode,headers:res.headers,body})); });req.on('error',reject);req.end(options.body); }); } test('prepare writes a valid SSO block, private CA/keys and a real slot, never rewrites config',t=>{ const f=setup(t),before=readFileSync(f.config,'utf8'); const p=f.output.console.sso.providers[0]; assert.deepEqual(p.subjects,[{sub:SUBJECT,name:'ui797-owner'}]); assert.ok(readSlot(join(f.root,'mosaic-ui797'),p,{repo:process.cwd()}).length>30); for(const n of ['ca.key','tls.key','signing.pem','identity.json','ca.pem']) assert.equal(statSync(join(f.runtime,n)).mode&0o777,0o600); assert.equal(readFileSync(f.config,'utf8'),before); assert.throws(()=>prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:f.runtime,tlsPort:17443})); }); test('live/default instances, wrong owner, unclaimed root and unsafe config paths refuse before creating runtime',t=>{ const f=fixture(t); const defaults={instance:'ui797',config:f.config,owner:'ui797-owner',runtime:join(f.root,'id'),tlsPort:17443}; for(const instance of ['dev','prod','production'])assert.throws(()=>prepare({...defaults,instance})); assert.throws(()=>prepare({...defaults,owner:'jason'})); assert.throws(()=>prepare({...defaults,config:join(f.root,'mosaic-dev/config.json')})); assert.throws(()=>prepare({...defaults,tlsPort:7332})); assert.throws(()=>prepare({...defaults,config:join(process.cwd(),'packages/cli/live/..test/mosaic-ui797/config.json')}),/outside source/); writeFileSync(join(f.dataRoot,'instance.json'),'{"instanceVersion":1,"name":"dev"}'); assert.throws(()=>prepare(defaults)); }); test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bounded RS256 token',async t=>{ const f=setup(t),server=await serve(f.runtime);f.after(()=>closeServer(server)); const discovery=await http(f.runtime,'/oidc/.well-known/openid-configuration');assert.equal(discovery.status,200); const verifier='a'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'test-state',nonce:'test-nonce',code_challenge:challenge,code_challenge_method:'S256'}); const auth=await http(f.runtime,'/oidc/authorize?'+q);assert.equal(auth.status,302); const callback=new URL(auth.headers.location);assert.equal(callback.searchParams.get('state'),'test-state'); const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim(); const tokenOptions={method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64'),'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({grant_type:'authorization_code',code:callback.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()}; const token=await http(f.runtime,'/oidc/token',tokenOptions);assert.equal(token.status,200); const [head,payload,sig]=JSON.parse(token.body).id_token.split('.'),claims=JSON.parse(Buffer.from(payload,'base64url')); assert.equal(claims.sub,SUBJECT);assert.equal(claims.nonce,'test-nonce');assert.equal(claims.exp-claims.iat,300); const jwks=JSON.parse((await http(f.runtime,'/oidc/jwks')).body); assert.ok(verify('sha256',Buffer.from(head+'.'+payload),createPublicKey({key:jwks.keys[0],format:'jwk'}),Buffer.from(sig,'base64url'))); assert.equal((await http(f.runtime,'/oidc/token',tokenOptions)).status,400); q.set('redirect_uri','https://mosaic.woltje.com/auth/callback');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400); q.set('redirect_uri','https://127.0.0.1:17443/auth/callback');q.set('login_hint','jason');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400);q.delete('login_hint'); const second=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location); const wrong={...tokenOptions,body:new URLSearchParams({grant_type:'authorization_code',code:second.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:'wrong'}).toString()}; assert.equal((await http(f.runtime,'/oidc/token',wrong)).status,400); assert.equal((await http(f.runtime,'/oidc/jwks',{servername:'127.0.0.1',headers:{host:'mosaic.woltje.com'}})).status,403); }); test('fixed TLS forwarder preserves normal Console Host, cookies and origin without injected identity',async t=>{ const f=setup(t);let headers; const upstream=createServer((req,res)=>{headers=req.headers;res.end('Console upstream');}); await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(upstream)); const server=await serve(f.runtime);f.after(()=>closeServer(server)); const r=await http(f.runtime,'/test',{headers:{origin:'https://127.0.0.1:17443',cookie:'fixture=value'}}); assert.equal(r.body,'Console upstream');assert.equal(headers.host,'127.0.0.1:17443');assert.equal(headers.origin,'https://127.0.0.1:17443');assert.equal(headers.cookie,'fixture=value');assert.equal(headers['x-auth-subject'],undefined); }); test('production Console CLI discovers this OP through NODE_EXTRA_CA_CERTS with ordinary TLS validation',async t=>{ const f=setup(t),config=JSON.parse(readFileSync(f.config));config.console=f.output.console;writeFileSync(f.config,JSON.stringify(config)); const server=await serve(f.runtime);f.after(()=>closeServer(server)); const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(f.runtime,'ca.pem')},stdio:['ignore','pipe','pipe']}); f.after(async()=>{await closeChild(child);}); let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Console startup: '+output)),15000);child.stdout.on('data',()=>{if(output.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error('Console refused: '+output));});}); const health=JSON.parse((await http(f.runtime,'/healthz')).body);assert.equal(health.sso['ui797-local'],'ready'); }); test('authorization codes expire, and tampering with the single subject refuses service',async t=>{ const f=setup(t);let now=Date.now(); const server=await serve(f.runtime,{clock:()=>now});f.after(()=>closeServer(server)); const verifier='v'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'state',nonce:'nonce',code_challenge:challenge,code_challenge_method:'S256'}); const code=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location).searchParams.get('code');now+=60001; const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim(); const response=await http(f.runtime,'/oidc/token',{method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64')},body:new URLSearchParams({grant_type:'authorization_code',code,redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()}); assert.equal(response.status,400); const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));state.console.sso.providers[0].subjects=[{sub:'jason',name:'jason'}];writeFileSync(stateFile,JSON.stringify(state)); await assert.rejects(serve(f.runtime),/metadata refused/); }); test('prepare and persisted serve metadata refuse an otherwise valid unrelated development instance',async t=>{ const f=setup(t),other=join(f.root,'mosaic-unrelated'); renameSync(join(f.root,'mosaic-ui797'),other); const config=join(other,'config.json'); writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); assert.throws(()=>prepare({instance:'unrelated',config,owner:'ui797-owner',runtime:join(f.root,'other-id'),tlsPort:17443}),/literal ui797/); const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); state.instance='unrelated';state.config=config;writeFileSync(stateFile,JSON.stringify(state)); await assert.rejects(serve(f.runtime),/literal ui797/); }); test('serve revalidates the config directory and current data marker before listening',async t=>{ const f=setup(t); writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); await assert.rejects(serve(f.runtime),/marker mismatch/); writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); const other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other); state.config=join(other,'config.json');writeFileSync(stateFile,JSON.stringify(state)); await assert.rejects(serve(f.runtime),/matching named test config/); });