Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
}
Expand Down
106 changes: 106 additions & 0 deletions packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
Loading