117 lines
10 KiB
JavaScript
117 lines
10 KiB
JavaScript
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/);
|
|
});
|