Skip to content

Commit 771fe8e

Browse files
committed
feat: new service to service auth wrapper
1 parent dc39ba0 commit 771fe8e

File tree

7 files changed

+550
-35
lines changed

7 files changed

+550
-35
lines changed

packages/spacecat-shared-http-utils/src/auth/auth-info.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export default class AuthInfo {
8888

8989
isS2SAdmin() { return this.profile?.is_s2s_admin; }
9090

91+
isS2SConsumer() { return this.profile?.is_s2s_consumer; }
92+
9193
hasOrganization(orgId) {
9294
const [id] = orgId.split('@');
9395
return this.profile?.tenants?.some(

packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,54 +11,25 @@
1111
*/
1212

1313
import { hasText } from '@adobe/spacecat-shared-utils';
14-
import { importSPKI, jwtVerify } from 'jose';
1514

1615
import AbstractHandler from './abstract.js';
1716
import AuthInfo from '../auth-info.js';
1817
import { getBearerToken } from './utils/bearer.js';
1918
import { getCookieValue } from './utils/cookie.js';
19+
import { loadPublicKey, validateToken } from './utils/token.js';
2020

21-
const ALGORITHM_ES256 = 'ES256';
22-
export const ISSUER = 'https://spacecat.experiencecloud.live';
21+
export { ISSUER } from './utils/token.js';
2322

2423
export default class JwtHandler extends AbstractHandler {
2524
constructor(log) {
2625
super('jwt', log);
2726
}
2827

29-
async #setup(context) {
30-
const authPublicKeyB64 = context.env?.AUTH_PUBLIC_KEY_B64;
31-
32-
if (!hasText(authPublicKeyB64)) {
33-
throw new Error('No public key provided');
34-
}
35-
36-
const authPublicKey = Buffer.from(authPublicKeyB64, 'base64').toString('utf-8');
37-
38-
this.authPublicKey = await importSPKI(authPublicKey, ALGORITHM_ES256);
39-
}
40-
41-
async #validateToken(token) {
42-
const verifiedToken = await jwtVerify(
43-
token,
44-
this.authPublicKey,
45-
{
46-
algorithms: [ALGORITHM_ES256], // force expected algorithm
47-
clockTolerance: 5, // number of seconds to tolerate when checking the nbf and exp claims
48-
complete: false, // only return the payload and not headers etc.
49-
ignoreExpiration: false, // validate expiration
50-
issuer: ISSUER, // validate issuer
51-
},
52-
);
53-
54-
verifiedToken.payload.tenants = verifiedToken.payload.tenants || [];
55-
56-
return verifiedToken.payload;
57-
}
58-
5928
async checkAuth(request, context) {
6029
try {
61-
await this.#setup(context);
30+
if (!this.authPublicKey) {
31+
this.authPublicKey = await loadPublicKey(context);
32+
}
6233

6334
const token = getBearerToken(context) ?? getCookieValue(context, 'sessionToken');
6435

@@ -67,7 +38,8 @@ export default class JwtHandler extends AbstractHandler {
6738
return null;
6839
}
6940

70-
const payload = await this.#validateToken(token);
41+
const payload = await validateToken(token, this.authPublicKey);
42+
payload.tenants = payload.tenants || [];
7143

7244
const scopes = payload.is_admin ? [{ name: 'admin' }] : [];
7345

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { hasText } from '@adobe/spacecat-shared-utils';
14+
import { importSPKI, jwtVerify } from 'jose';
15+
16+
export const ALGORITHM_ES256 = 'ES256';
17+
export const ISSUER = 'https://spacecat.experiencecloud.live';
18+
19+
/**
20+
* Loads the ES256 public key from the context environment variable AUTH_PUBLIC_KEY_B64.
21+
* @param {Object} context - The universal context.
22+
* @returns {Promise<CryptoKey>} The imported public key.
23+
*/
24+
export async function loadPublicKey(context) {
25+
const authPublicKeyB64 = context.env?.AUTH_PUBLIC_KEY_B64;
26+
if (!hasText(authPublicKeyB64)) {
27+
throw new Error('No public key provided');
28+
}
29+
const pem = Buffer.from(authPublicKeyB64, 'base64').toString('utf-8');
30+
return importSPKI(pem, ALGORITHM_ES256);
31+
}
32+
33+
/**
34+
* Validates a JWT token against the given public key using ES256.
35+
* @param {string} token - The raw JWT string.
36+
* @param {CryptoKey} publicKey - The public key to verify against.
37+
* @returns {Promise<Object>} The verified token payload.
38+
*/
39+
export async function validateToken(token, publicKey) {
40+
const { payload } = await jwtVerify(token, publicKey, {
41+
algorithms: [ALGORITHM_ES256],
42+
clockTolerance: 5,
43+
complete: false,
44+
ignoreExpiration: false,
45+
issuer: ISSUER,
46+
});
47+
return payload;
48+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { Response } from '@adobe/fetch';
14+
import { hasText, isNonEmptyArray, isObject } from '@adobe/spacecat-shared-utils';
15+
16+
import { getBearerToken } from './handlers/utils/bearer.js';
17+
import { loadPublicKey, validateToken } from './handlers/utils/token.js';
18+
19+
/**
20+
* Matches pre-split request segments against a route pattern with :param segments.
21+
* e.g. ['sites', 'abc-123', 'audits'] matches 'GET /sites/:siteId/audits'
22+
*/
23+
function matchRoute(method, requestSegments, routeKey) {
24+
const spaceIdx = routeKey.indexOf(' ');
25+
if (spaceIdx === -1) return false;
26+
27+
const routeMethod = routeKey.slice(0, spaceIdx);
28+
if (routeMethod !== method) return false;
29+
30+
const routeSegments = routeKey.slice(spaceIdx + 1).split('/').filter(Boolean);
31+
if (routeSegments.length !== requestSegments.length) return false;
32+
33+
return routeSegments.every(
34+
(seg, i) => seg.charCodeAt(0) === 58 /* ':' */ || seg === requestSegments[i],
35+
);
36+
}
37+
38+
/**
39+
* Looks up the required capability for the current request from the
40+
* routeCapabilities map using the method and path from context.pathInfo.
41+
*/
42+
function resolveCapability(context, routeCapabilities) {
43+
const method = context.pathInfo?.method?.toUpperCase();
44+
const path = context.pathInfo?.suffix;
45+
if (!method || !path) return null;
46+
47+
const exactKey = `${method} ${path}`;
48+
if (routeCapabilities[exactKey]) return routeCapabilities[exactKey];
49+
50+
const requestSegments = path.split('/').filter(Boolean);
51+
const matchedKey = Object.keys(routeCapabilities)
52+
.find((key) => matchRoute(method, requestSegments, key));
53+
return matchedKey ? routeCapabilities[matchedKey] : null;
54+
}
55+
56+
/**
57+
* S2S consumer auth wrapper for the helix-shared-wrap `.with()` chain.
58+
* Validates a JWT bearer token and, when the token carries the
59+
* {@code is_s2s_consumer} claim, resolves the required capability for the
60+
* current route from the provided {@code routeCapabilities} map and verifies
61+
* the caller holds that capability in {@code tenants[].capabilities}.
62+
*
63+
* Non-S2S tokens (end-user) pass through untouched.
64+
*
65+
* @param {Function} fn - The handler to wrap.
66+
* @param {{ routeCapabilities: Object<string, string> }} opts - Map of route
67+
* patterns (e.g. 'GET /sites/:siteId') to capability strings (e.g. 'site:read').
68+
* @returns {Function} A wrapped handler.
69+
*/
70+
export function s2sAuthWrapper(fn, { routeCapabilities } = {}) {
71+
let publicKey;
72+
73+
return async (request, context) => {
74+
const { log } = context;
75+
76+
try {
77+
if (!publicKey) {
78+
publicKey = await loadPublicKey(context);
79+
}
80+
81+
const token = getBearerToken(context);
82+
if (!hasText(token)) {
83+
log.debug('[s2s] No bearer token provided');
84+
return new Response('Unauthorized', { status: 401 });
85+
}
86+
87+
const payload = await validateToken(token, publicKey);
88+
89+
if (!payload.is_s2s_consumer) {
90+
log.debug('[s2s] Token is not an S2S consumer token, passing through');
91+
return fn(request, context);
92+
}
93+
94+
const tenants = payload.tenants || [];
95+
const capabilities = tenants.flatMap((tenant) => tenant.capabilities || []);
96+
97+
if (!isNonEmptyArray(capabilities)) {
98+
log.debug('[s2s] S2S consumer token has no capabilities');
99+
return new Response('Forbidden', { status: 403 });
100+
}
101+
102+
if (isObject(routeCapabilities)) {
103+
const requiredCapability = resolveCapability(context, routeCapabilities);
104+
if (!hasText(requiredCapability)) {
105+
log.error(`[s2s] Route ${context.pathInfo?.method} ${context.pathInfo?.suffix} is not allowed for S2S consumers`);
106+
return new Response('Unauthorized', { status: 401 });
107+
}
108+
if (!capabilities.includes(requiredCapability)) {
109+
log.error(`[s2s] Token is missing required capability: ${requiredCapability}`);
110+
return new Response('Forbidden', { status: 403 });
111+
}
112+
}
113+
} catch (e) {
114+
log.error(`[s2s] Authentication failed: ${e.message}`);
115+
return new Response('Unauthorized', { status: 401 });
116+
}
117+
118+
return fn(request, context);
119+
};
120+
}

packages/spacecat-shared-http-utils/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export function internalServerError(message = 'internal server error', headers =
162162
}
163163

164164
export { authWrapper } from './auth/auth-wrapper.js';
165+
export { s2sAuthWrapper } from './auth/s2s-wrapper.js';
165166
export { enrichPathInfo } from './enrich-path-info-wrapper.js';
166167
export { hashWithSHA256 } from './auth/generate-hash.js';
167168

packages/spacecat-shared-http-utils/test/auth/auth-info.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,26 @@ describe('AuthInfo', () => {
5959
expect(authInfo.isS2SAdmin()).to.be.false;
6060
});
6161
});
62+
63+
describe('isS2SConsumer', () => {
64+
it('should return undefined if profile is not set', () => {
65+
const authInfo = new AuthInfo();
66+
expect(authInfo.isS2SConsumer()).to.be.undefined;
67+
});
68+
69+
it('should return undefined if is_s2s_consumer is not in profile', () => {
70+
const authInfo = new AuthInfo().withProfile({});
71+
expect(authInfo.isS2SConsumer()).to.be.undefined;
72+
});
73+
74+
it('should return true if is_s2s_consumer is true', () => {
75+
const authInfo = new AuthInfo().withProfile({ is_s2s_consumer: true });
76+
expect(authInfo.isS2SConsumer()).to.be.true;
77+
});
78+
79+
it('should return false if is_s2s_consumer is false', () => {
80+
const authInfo = new AuthInfo().withProfile({ is_s2s_consumer: false });
81+
expect(authInfo.isS2SConsumer()).to.be.false;
82+
});
83+
});
6284
});

0 commit comments

Comments
 (0)