fix(cli): M3 re-check: provider denials answer 403; A2 test owner carries rootOwner (#1873)

The rehearsal provider matches routes on the parsed pathname, so a
query string no longer turns authorize or token into a 404. Authorize
and token answer 403 to any method before the GET check, and only
discovery and the JWKS answer 405 to another method. R20 now sends a
POST to token and a GET to authorize with a query, and expects 403 for
both.

The upstream A2 agent-levels write test stubbed an owner session
without rootOwner. M3 sets the hook's owner flag from rootOwner, as
sso.mjs grants it, so the stub now carries rootOwner: true, as
system-types-http does. No check changes.
This commit was merged in pull request #1918.
This commit is contained in:
jason.woltje committed 2026-11-10 21:38:17 -06:00
1 parent 9acc378555
commit d0cfebe286
4 files changed
+17 -10

No files matched your search

+2 -1
View File
@@ -827,7 +827,8 @@ Before that, a stamped run starts a rehearsal provider and prints
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
each live provider id it serves discovery and a JWKS whose public key it made
in memory for the first algorithm it can make from that provider's list. Its
authorize and token routes answer 403: it issues no code and no token, and
authorize and token routes answer 403 to any method and query: it issues no
code and no token, and
it reads no live secret. It logs each request to `provider/requests.log`,
deletes its TLS key file once it has read it, and exits on SIGTERM or when
the rehearsal's process is gone. `provider-start` fails when it exits early,
+7 -3
View File
@@ -2148,7 +2148,8 @@ function rHost(){
// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the
// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults:
// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider
// ready without asking any; console-asks-token asks provider a's token endpoint once.
// ready without asking any; console-asks-token POSTs to provider a's token endpoint, then asks its
// authorize endpoint with a query, once each.
async function rConsole(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
@@ -2168,7 +2169,10 @@ async function rConsole(){
let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)}
health=()=>sso.health();
}
if(fault('console-asks-token'))await fetch(`${ps[0].issuer}token`).catch(()=>{});
if(fault('console-asks-token')){
await fetch(`${ps[0].issuer}token`,{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'grant_type=authorization_code&code=x'}).catch(()=>{});
await fetch(`${ps[0].issuer}authorize?client_id=rehearsal&response_type=code`).catch(()=>{});
}
}
const s=http.createServer((q,r)=>{
const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'});
@@ -2728,7 +2732,7 @@ test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no
// passes and provider-discovery fails.
for(const [f,why] of [
['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'],
['console-asks-token','a request for GET /a/token 403'],
['console-asks-token','a request for POST /a/token 403; a request for GET /a/authorize?client_id=rehearsal&response_type=code 403'],
]){
const e=await smk(t);rfault(e,f);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
@@ -23,7 +23,8 @@ async function fixture(t, { send = true, granted = false } = {}) {
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
const trail = [];
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason' };
// M3 (#1873): the hook fence's owner flag is the session's root authority (rootOwner), as sso.mjs sets it.
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason', rootOwner: true };
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
+6 -5
View File
@@ -2104,8 +2104,8 @@ process.stdout.write(JSON.stringify(c,null,2)+"\n");'
# read into memory and its file removed before the server listens; cert.pem stays for the scratch
# Console's NODE_EXTRA_CA_CERTS. Per configured provider, at /<id>/, it serves discovery and a JWKS
# holding one public key made in memory for the first of the provider's algorithms it can make.
# Authorization, token and every other route answer 403 or 404: it issues no code, no token and no
# sign-in. Each request is a line in requests.log. It writes origin when listening and exits when
# Authorization and token answer 403 to any method and query; another method on discovery or the
# JWKS is 405 and any other route 404: it issues no code, no token and no sign-in. Each request is a line in requests.log. It writes origin when listening and exits when
# PARENT (the rehearsal) is gone, on SIGTERM, or on an error.
R_PROVIDER_JS='
const fs=require("node:fs"),path=require("node:path"),https=require("node:https"),crypto=require("node:crypto"),{spawnSync}=require("node:child_process");
@@ -2136,13 +2136,14 @@ const log=(q,status)=>fs.appendFileSync(path.join(dir,"requests.log"),`${q.metho
let origin;
const server=https.createServer({key,cert,requestTimeout:5000,headersTimeout:5000},(q,res)=>{
const send=(status,body)=>{log(q,status);res.writeHead(status,{"content-type":"application/json","cache-control":"no-store"});res.end(JSON.stringify(body))};
if(q.method!=="GET")return send(405,{error:"GET only"});
const m=/^\/([^/?#]+)\/(\.well-known\/openid-configuration|jwks|authorize|token)$/.exec(q.url);
let at;try{at=new URL(q.url,origin).pathname}catch{at=""}
const m=/^\/([^/]+)\/(\.well-known\/openid-configuration|jwks|authorize|token)$/.exec(at);
const jwk=m?providers.get(m[1]):undefined;
if(!jwk)return send(404,{error:"not found"});
if(m[2]==="authorize"||m[2]==="token")return send(403,{error:"the rehearsal provider issues no code and no token"});
if(q.method!=="GET")return send(405,{error:"GET only"});
const issuer=`${origin}/${m[1]}/`;
if(m[2]==="jwks")return send(200,{keys:[jwk]});
if(m[2]!==".well-known/openid-configuration")return send(403,{error:"the rehearsal provider issues no code and no token"});
send(200,{issuer,authorization_endpoint:`${issuer}authorize`,token_endpoint:`${issuer}token`,jwks_uri:`${issuer}jwks`,response_types_supported:["code"],subject_types_supported:["public"],id_token_signing_alg_values_supported:[jwk.alg],code_challenge_methods_supported:["S256"]});
});
server.on("error",(e)=>die(e.message));