Skip to content

Commit ae93bf2

Browse files
usingtechnologyRyanBirtch-aot
authored andcommitted
fix:ccp-4138
enforce checks on apikey and form pairs, update docs.
1 parent b046ea7 commit ae93bf2

13 files changed

Lines changed: 271 additions & 25 deletions

File tree

app/frontend/public/embed/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,20 @@ These files are **not in source control** and are created by the build process:
6969

7070
### Authentication
7171

72+
#### Obtaining a gateway token (server-side only)
73+
74+
Your backend must fetch the JWT before passing it to the embed. Call this endpoint from your server — never expose the form API key to the browser.
75+
76+
- **Endpoint**: `POST {basePath}/gateway/v1/auth/token/forms/{formId}`
77+
- **Authorization**: `Basic base64(formId:apiKey)` (required)
78+
- **Response**: `201 { "token": "<jwt>" }`
79+
80+
The `formId` in the URL must match the `formId` in the Basic auth credentials.
81+
82+
#### Using the token in the webcomponent
83+
7284
The webcomponent uses the `X-Chefs-Gateway-Token` header for authentication when making requests to CHEFS backend endpoints. This custom header allows host applications to use the `Authorization: Bearer` header for their own authentication needs.
7385

7486
- **Header name**: `X-Chefs-Gateway-Token`
75-
- **Value**: JWT token obtained from the gateway token endpoint
87+
- **Value**: JWT token obtained from the gateway token endpoint above
7688
- **Usage**: Automatically added to all requests to CHEFS backend URLs (URLs matching the component's `baseUrl`)

app/frontend/public/embed/chefs-form-viewer-embed.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@
172172
* **Required:**
173173
* - `form-id`: CHEFS form UUID
174174
* - `auth-token`: JWT authentication token (preferred)
175-
* - The auth-token should be fetched by your backend server using the protected api-key and form-id via POST /app/gateway/v1/auth/token/forms/<form-id>.
175+
* - The auth-token should be fetched by your backend server via POST /app/gateway/v1/auth/token/forms/<form-id> with required Authorization: Basic base64(formId:apiKey).
176176
* - The backend should return the short-lived, refreshable token to the frontend for embedding and authenticating form access.
177177
* - `api-key`: API access key (fallback, only if auth-token is not available)
178178

app/frontend/public/embed/chefs-form-viewer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@
499499
* Configuration via attributes
500500
* - form-id (string): required. The CHEFS/Form.io form identifier.
501501
* - auth-token (string): JWT authentication token (preferred).
502-
* - The auth-token should be fetched by your backend server using the protected api-key and form-id via POST /app/gateway/v1/auth/token/forms/<form-id>.
502+
* - The auth-token should be fetched by your backend server via POST /app/gateway/v1/auth/token/forms/<form-id> with required Authorization: Basic base64(formId:apiKey).
503503
* - The backend should return the short-lived, refreshable token to the frontend for embedding and authenticating form access.
504504
* - Enables automatic token refresh based on expiry time. Refreshed 60 seconds before expiry.
505505
* - api-key (string): API access key (fallback, only if auth-token is not available).

app/src/gateway/v1/auth/controller.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ module.exports = {
66
*/
77
issueFormToken: async (req, res, next) => {
88
try {
9-
// You may want to validate input here
10-
const payload = { ...req.body, formId: req.params.formId };
9+
const payload = { formId: req.params.formId };
1110
const token = await tokenService.createToken(payload);
1211
res.status(201).json({ token });
1312
} catch (error) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const Problem = require('api-problem');
2+
const { validate: uuidValidate } = require('uuid');
3+
4+
const formService = require('../../../../forms/form/service');
5+
const { parseBasicPair } = require('../../../../runtime-auth/security/auth/utils/basicAuth');
6+
const { validateApiKey } = require('../../../../runtime-auth/security/auth/utils/apiKeyValidation');
7+
8+
const HTTP_401_DETAIL = 'Invalid authorization credentials.';
9+
10+
module.exports = async (req, res, next) => {
11+
try {
12+
const authz = req.headers?.authorization || '';
13+
if (!/^Basic\s+/i.test(authz)) {
14+
throw new Problem(401, { detail: HTTP_401_DETAIL });
15+
}
16+
17+
const formIdParam = req.params.formId;
18+
if (!uuidValidate(formIdParam)) {
19+
throw new Problem(400, { detail: `Bad formId "${formIdParam}".` });
20+
}
21+
22+
const pair = parseBasicPair(authz);
23+
if (!pair) {
24+
throw new Problem(401, { detail: HTTP_401_DETAIL });
25+
}
26+
27+
const { formId, apiKey } = pair;
28+
if (formId !== formIdParam) {
29+
throw new Problem(401, { detail: HTTP_401_DETAIL });
30+
}
31+
32+
const apiKeyRecord = await formService.readApiKey(formId);
33+
validateApiKey(apiKey, apiKeyRecord);
34+
35+
req.apiUser = true;
36+
next();
37+
} catch (error) {
38+
if (error.status === 400) {
39+
next(error);
40+
return;
41+
}
42+
43+
next(new Problem(401, { detail: HTTP_401_DETAIL }));
44+
}
45+
};

app/src/gateway/v1/auth/routes.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
const express = require('express');
22
const router = express.Router();
33

4-
// just reusing this for now...
5-
const apiAccess = require('../../../forms/auth/middleware/apiAccess');
4+
const requireApiKeyBasic = require('./middleware/requireApiKeyBasic');
65

76
const controller = require('./controller');
87

98
// Issue a new token for a specific form
10-
router.post('/token/forms/:formId', apiAccess, controller.issueFormToken);
9+
router.post('/token/forms/:formId', requireApiKeyBasic, controller.issueFormToken);
1110

1211
// Refresh an existing token
1312
router.post('/refresh', controller.refreshToken);

app/src/runtime-auth/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ Different authentication strategies use different HTTP headers:
161161
- **gatewayBearer**: Uses `X-Chefs-Gateway-Token: <token>` (for webcomponent routes only)
162162
- This custom header allows host applications to use `Authorization: Bearer` for their own authentication
163163

164+
### Gateway token issuance
165+
166+
Webcomponent embeds obtain a short-lived JWT from the gateway auth endpoint before making API calls:
167+
168+
- **Endpoint**: `POST /gateway/v1/auth/token/forms/:formId`
169+
- **Authentication**: `apiKeyBasic`-style `Authorization: Basic base64(formId:apiKey)` (required; the URL `formId` must match the Basic auth `formId`)
170+
- **Response**: `201 { "token": "<jwt>" }` with a server-signed payload containing `formId` only
171+
- **Consumption**: The returned token is sent on webcomponent API requests via `X-Chefs-Gateway-Token`, validated by the `gatewayBearer` strategy
172+
164173
## Resource Specs
165174

166175
Resources tell the middleware what entity the request is operating on. The middleware uses this to fetch permissions from RBAC.

app/src/runtime-auth/security/auth/strategies/apiKeyBasic.js

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
const ERRORS = require('../../errorMessages');
7+
const { parseBasicPair } = require('../utils/basicAuth');
78
const { validateApiKey } = require('../utils/apiKeyValidation');
89
const { createApiKeyAuthResult } = require('../utils/authResult');
910

@@ -12,18 +13,6 @@ function canHandle(req) {
1213
return /^Basic\s+/i.test(h);
1314
}
1415

15-
function parseBasicPair(header) {
16-
try {
17-
const base64 = header.replace(/^Basic\s+/i, '');
18-
const decoded = Buffer.from(base64, 'base64').toString('utf8');
19-
const idx = decoded.indexOf(':');
20-
if (idx === -1) return null;
21-
return { formId: decoded.slice(0, idx), apiKey: decoded.slice(idx + 1) };
22-
} catch {
23-
return null;
24-
}
25-
}
26-
2716
module.exports = function apiKeyBasicFactory({ deps }) {
2817
const { formService, authService } = deps.services || {};
2918

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Shared Basic auth parsing for formId:apiKey credentials.
3+
*/
4+
5+
function parseBasicPair(header) {
6+
try {
7+
const base64 = header.replace(/^Basic\s+/i, '');
8+
const decoded = Buffer.from(base64, 'base64').toString('utf8');
9+
const idx = decoded.indexOf(':');
10+
if (idx === -1) return null;
11+
return { formId: decoded.slice(0, idx), apiKey: decoded.slice(idx + 1) };
12+
} catch {
13+
return null;
14+
}
15+
}
16+
17+
module.exports = {
18+
parseBasicPair,
19+
};

app/tests/unit/gateway/auth/controller.spec.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,18 @@ describe('gateway/v1/auth/controller', () => {
2727
req.body = { userId: 'u1' };
2828
req.params.formId = 'abc';
2929
await controller.issueFormToken(req, res, next);
30-
expect(tokenService.createToken).toHaveBeenCalledWith({ userId: 'u1', formId: 'abc' });
30+
expect(tokenService.createToken).toHaveBeenCalledWith({ formId: 'abc' });
3131
expect(res.status).toHaveBeenCalledWith(201);
3232
expect(res.json).toHaveBeenCalledWith({ token: 'mocked-token' });
3333
});
3434

35+
test('issueFormToken does not forward request body fields into token payload', async () => {
36+
req.body = { userId: 'u1', role: 'admin', formId: 'other' };
37+
req.params.formId = 'abc';
38+
await controller.issueFormToken(req, res, next);
39+
expect(tokenService.createToken).toHaveBeenCalledWith({ formId: 'abc' });
40+
});
41+
3542
test('issueFormToken calls next on error', async () => {
3643
tokenService.createToken.mockImplementationOnce(() => {
3744
throw new Error('fail');

0 commit comments

Comments
 (0)