diff --git a/packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js b/packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js index b74ede737..b255459ad 100644 --- a/packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js +++ b/packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js @@ -20,6 +20,39 @@ import { loadPublicKey, validateToken } from './utils/token.js'; export { ISSUER } from './utils/token.js'; +/** + * Reserved scope names produced ONLY from dedicated boolean / tenant claims (is_admin, + * is_read_only_admin, the per-tenant fan-out below). These names must NEVER be sourced + * from a payload-side `scopes[]` entry — otherwise an accidental future mint could + * synthesise a privileged scope from a non-privileged token. + * + * Single source of truth for both the minter (spacecat-auth-service) and the consumer + * (this handler). See SITES-46454 and + * mysticat-architecture/platform/decisions/cross-product-sites-listing-via-client-id-scope.md. + */ +export const RESERVED_SCOPE_NAMES = Object.freeze(['admin', 'read_only_admin', 'user']); + +/** + * Allow-listed namespaces / exact names for payload-side `scopes[]` fan-out. Strings + * outside this set are ignored at warn-level. The list grows by ADR amendment only — + * adding a name here is a security-relevant change. + */ +export const ALLOWED_PAYLOAD_SCOPE_NAMES = Object.freeze([ + 'sites:list:cross_product', +]); + +/** + * Returns true when `name` is an acceptable payload-side scope: present in the + * allow-list AND not a reserved name. Defensive — the reserved-name check is redundant + * if the allow-list is curated, but it guarantees correctness even if a maintainer + * mistakenly adds a reserved name to ALLOWED_PAYLOAD_SCOPE_NAMES. + */ +function isAllowedPayloadScopeName(name) { + return typeof name === 'string' + && ALLOWED_PAYLOAD_SCOPE_NAMES.includes(name) + && !RESERVED_SCOPE_NAMES.includes(name); +} + export default class JwtHandler extends AbstractHandler { constructor(log) { super('jwt', log); @@ -59,6 +92,29 @@ export default class JwtHandler extends AbstractHandler { }), )); + /* + * Fan out payload-side `scopes[]` strings into first-class AuthInfo scopes + * (SITES-46454). The JWT signature is already verified above via validateToken, + * so any entry here was minted by spacecat-auth-service — the consumer cannot + * forge scopes. The discipline below is shape hygiene: + * + * - Only names in ALLOWED_PAYLOAD_SCOPE_NAMES are mapped. + * - RESERVED_SCOPE_NAMES (admin / read_only_admin / user) are never sourced + * from payload.scopes[]; those scopes come exclusively from the dedicated + * boolean / tenant claims handled above. + * - Unknown / disallowed entries are dropped and logged at warn so an + * accidental future mint is visible, not silent. + */ + if (Array.isArray(payload.scopes)) { + for (const scopeName of payload.scopes) { + if (isAllowedPayloadScopeName(scopeName)) { + scopes.push({ name: scopeName }); + } else { + this.log(`[jwt] ignoring unknown payload scope: ${JSON.stringify(scopeName)}`, 'warn'); + } + } + } + if (context.s2sConsumer) { this.log(`[jwt] S2S consumer ${context.s2sConsumer?.getClientId()} token used on route ${context.pathInfo?.method} ${context.pathInfo?.suffix}`, 'info'); } diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js index 6d58e804b..b929c725f 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js @@ -331,5 +331,111 @@ describe('SpacecatJWTHandler', () => { expect(result.hasScope('read_only_admin')).to.be.true; expect(result.hasOrganization(`${orgId}@AdobeId`)).to.be.true; }); + + describe('payload scopes[] fan-out (SITES-46454)', () => { + it('maps an allow-listed scope into AuthInfo so hasScope returns true', async () => { + const token = await createToken(createTokenPayload({ + user_id: 'cross-product-user', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + scopes: ['sites:list:cross_product'], + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + expect(result.hasScope('sites:list:cross_product')).to.be.true; + }); + + it('ignores an unknown scope and warns', async () => { + const token = await createToken(createTokenPayload({ + user_id: 'unknown-scope-user', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + scopes: ['something:else'], + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + expect(result.hasScope('something:else')).to.be.false; + expect(logStub.warn.calledWithMatch(/ignoring unknown payload scope/)).to.be.true; + }); + + it('refuses to source reserved names from payload scopes[] (admin)', async () => { + // is_admin is false; the only way admin could appear on AuthInfo would be via + // an illegal payload scopes[] entry. The handler must NOT produce it. + const token = await createToken(createTokenPayload({ + user_id: 'evil-token', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + scopes: ['admin'], + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + expect(result.isAdmin()).to.be.false; // unchanged: is_admin=false + const adminScopes = result.getScopes().filter((s) => s.name === 'admin'); + expect(adminScopes).to.have.lengthOf(0); + expect(logStub.warn.calledWithMatch(/ignoring unknown payload scope/)).to.be.true; + }); + + it('refuses to source reserved names from payload scopes[] (read_only_admin, user)', async () => { + const token = await createToken(createTokenPayload({ + user_id: 'evil-token-2', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + scopes: ['read_only_admin', 'user'], + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + // is_read_only_admin is unset, so read_only_admin must not appear via scopes[] + expect(result.hasScope('read_only_admin')).to.be.false; + // The 'user' scope still appears, but only from the tenant fan-out (with its + // domains/subScopes shape) — not from the bare name in payload.scopes[]. + const userScopes = result.getScopes().filter((s) => s.name === 'user'); + expect(userScopes).to.have.lengthOf(1); + expect(userScopes[0]).to.have.property('domains'); + }); + + it('handles missing/empty scopes claim without error', async () => { + const token = await createToken(createTokenPayload({ + user_id: 'no-scopes-user', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + // no scopes claim + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + expect(result.hasScope('sites:list:cross_product')).to.be.false; + }); + + it('drops non-string entries with a warn (defensive)', async () => { + const token = await createToken(createTokenPayload({ + user_id: 'non-string-user', + is_admin: false, + tenants: [{ id: 'org-1', subServices: [] }], + scopes: [null, 42, 'sites:list:cross_product'], + })); + context.pathInfo = { headers: { authorization: `Bearer ${token}` } }; + + const result = await handler.checkAuth({}, context); + + expect(result).to.be.instanceof(AuthInfo); + // Only the valid entry survives. + expect(result.hasScope('sites:list:cross_product')).to.be.true; + expect(logStub.warn.callCount).to.be.at.least(2); + }); + }); }); });