Squash of Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2
6f8cee147713c53652b58d47c5efd15ed47da1efbef6a126464857269b0b7787 (18 paths).
Sage's gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh
429 pass 0 fail; mutants 22 of 23 killed, #23 (second origin check) declared
equivalent by Launchpad (sage-1407). New dependency oauth4webapi 3.8.8: the
canonical checkout and any stack copy need npm ci. No live change; the live
re-pin is a window with Mos (mos-1099 item 3).
76 lines
4.7 KiB
JavaScript
76 lines
4.7 KiB
JavaScript
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
|
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
|
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
|
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
|
// never quotes a value, so a refusal can't print a client id or a subject.
|
|
|
|
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
|
// and no none, so a client secret can never be a signing key.
|
|
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
|
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
|
const SLOT = /^[a-z0-9-]{1,64}$/;
|
|
const SUB = /^[\x21-\x7e]{1,255}$/;
|
|
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
|
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
|
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
|
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
|
const RESERVED_NAMES = ['tester', 'human'];
|
|
|
|
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
function refuse(where, why) {
|
|
throw new Error(`${where} ${why}`);
|
|
}
|
|
function keys(value, where, allowed, required = allowed) {
|
|
if (!object(value)) refuse(where, 'must be a JSON object');
|
|
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
|
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
|
}
|
|
function httpsURL(value, where) {
|
|
let url;
|
|
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
|
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
|
refuse(where, 'must be https with no credentials, query or fragment');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
|
export function checkConsole(value, where = 'console') {
|
|
keys(value, where, ['sso']);
|
|
const sso = value.sso, at = `${where}.sso`;
|
|
keys(sso, at, ['publicOrigin', 'providers']);
|
|
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
|
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
|
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
|
const ids = new Set();
|
|
sso.providers.forEach((p, i) => {
|
|
const pat = `${at}.providers[${i}]`;
|
|
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
|
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
|
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
|
ids.add(p.id);
|
|
httpsURL(p.issuer, `${pat}.issuer`);
|
|
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
|
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
|
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
|
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
|
p.algs.forEach((alg, j) => {
|
|
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
|
});
|
|
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
|
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
|
const subs = new Set();
|
|
p.subjects.forEach((s, j) => {
|
|
const sat = `${pat}.subjects[${j}]`;
|
|
keys(s, sat, ['sub', 'name']);
|
|
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
|
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
|
subs.add(s.sub);
|
|
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
|
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
|
});
|
|
});
|
|
return value;
|
|
}
|