Skip to content

Commit 8f411b8

Browse files
committed
Introduce extensible secure form submissions pattern
Introduce APIM support
1 parent 1e86a4b commit 8f411b8

16 files changed

Lines changed: 349 additions & 70 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Microsoft Authentication Layer (MSAL) APIM Authentication Support
2+
3+
The `MsalAuthorizer` service supports MSAL authentication to communicate securely with APIM-hosted API services.
4+
5+
## Setup
6+
7+
To use MSAL Authentication you will need to configure the following environment variables:
8+
9+
| Variable name | Definition | Example |
10+
| --------------- | ---------------------------------------------- | ---------------------------------- |
11+
| `APIM_BASE_URL` | The base URL of the APIM hosted service | `https://apim.example.com` |
12+
| `TENANT_ID` | Azure AD tenant ID for MSAL authentication | `tenant-id` |
13+
| `CLIENT_ID` | Azure AD client ID for MSAL authentication | `client-id` |
14+
| `CLIENT_SECRET` | Azure AD client secret for MSAL authentication | `client-secret` |
15+
| `SCOPES` | MSAL scopes required to access the service | `["https://example.com/.default"]` |
16+
17+
## Multi-tenant support
18+
19+
Multi-tenancy is supported for each form IoC registration. Each form registers its own APIM hosted service dependencies with their respective `MsalAuthorizerConfig`, which includes its own MSAL credentials. Through this approach there is no shared state between forms or service instances on a form, so forms on the same deployed server can authenticate against different tenants independently.
20+
21+
## Token lifecycle
22+
23+
Authentication is handled by `MsalAuthorizer`, which uses the MSAL package's `acquireTokenByClientCredential`. MSAL caches tokens internally and reuses them until expiry (or at least near expiry), so there is no network call to the identity provider on every invocation. Token refresh is also handled automatically by MSAL, so no additional retry or refresh logic is needed.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Secure Form Submissions
2+
3+
This feature allows the user to achieve secure form submissions through the addition of security headers
4+
5+
## Form Configuration
6+
7+
The configuration inherits the `MsalAuthorizerConfig`
8+
Refer to the [`MSAL Authentication documentation`](/docs/runner/msal-apim-authentication.md) for information about `tenantId`, `clientId`, `clientSecret` and `scopes`
9+
10+
```json
11+
"secureFormSubmissionConfig": {
12+
"tenantId": "${env_form_submission_tenant_id}",
13+
"clientId": "${env_form_submission_client_id}",
14+
"clientSecret": "${env_form_submission_client_secret}",
15+
"scopes": ["${env_form_submission_scope1}", "${env_form_submission_scope2}"],
16+
"useAwsWafUserAgentWorkaround": boolean
17+
}
18+
```
19+
20+
> **Note: useAwsWafUserAgentWorkaround**
21+
> AWS WAF requires User-Agent to be present as part of auth, setting this option to tru will make the functioality provide one to prevent 403
22+
23+
## How to Update env variables
24+
25+
Environment variables can be added or updated by following the guide at <https://ukhsa.atlassian.net/wiki/spaces/IDT/pages/294420993/XGO-007+Adding+New+Environment+Variables>
26+
27+
## How it works
28+
29+
### The following is a top-down view
30+
31+
The [`FormSecurityService`](/runner/src/server/services/formSecurityService.ts) supports our different security mechanisms.
32+
33+
- For the [`webhookHmacSharedKey`](/runner/src/server/services/formSecurityService.ts#L31) approach it generates Hmac and sets HMAC specific headers.
34+
35+
- For the [`APIM`](/runner/src/server/services/formSecurityService.ts#L49) approach it uses server Dependency Injection to register named instances of [`SecureFormSubmissionService`](/runner/src/server/services/secureFormSubmissionService.ts#L137) which exposes a `getAuthHeader` method that returns an object in the form of `{ Authorization: "Bearer <token>"}`.
36+
Registration into DI is done on [`server initialization`](/runner/src/server/index.ts#L191) for forms declaring the `secureFormSubmissionConfig`
37+
38+
The `StatusService` [`invokes`](/runner/src/server/services/statusService.ts#L137) `FormSecurityService.getSecurityHeaders` method which returns headers based on what approach the form use. It then passes those headers to the `WebhookService`
39+
40+
The `WebhookService` accepts [`additionalHeaders?: Record<string, string>`](/runner/src/server/services/webhookService.ts#L32) as part of it's postRequest method, which it then includes when it makes the request to the form configured webhook output.
41+
42+
### Implementation Details: DI service registration
43+
44+
Per form SecureFormSubmissionService registration
45+
46+
```typescript
47+
for (const form of enginePlugin.options.configs) {
48+
const formId = form.id;
49+
50+
if (form.configuration.secureFormSubmissionConfig) {
51+
const instanceName = getSecureFormSubmissionServiceInstance(formId);
52+
53+
const namedService = new SecureFormSubmissionService(
54+
form.configuration.secureFormSubmissionConfig
55+
);
56+
57+
await server.registerService(
58+
Schmervice.withName(instanceName, namedService)
59+
);
60+
}
61+
}
62+
```
63+
64+
### Implementation Details: DI service resolution
65+
66+
Once registered, the service can be resolved from the DI using the below.
67+
Notice we have to do a dynamic resolution - since services are dynamically registered based on what forms are served, there is no knowledge of what services exists and in turn there's no way to statically type the access.
68+
69+
```typescript
70+
const instanceName = getSecureFormSubmissionServiceInstance(formId);
71+
if (instanceName) {
72+
const services = request.services([])
73+
74+
const serviceInstance = services[instanceName] as SecureFormSubmissionService;
75+
if (serviceInstance) {
76+
const headers = await serviceInstance.getAuthHeader();
77+
...
78+
}
79+
....
80+
}
81+
```

model/src/data-model/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ export type Analytics = {
196196
matomoUrl: string;
197197
};
198198

199+
export interface MsalAuthorizerConfig {
200+
tenantId: string;
201+
clientId: string;
202+
clientSecret: string;
203+
scopes: string[];
204+
}
205+
206+
export interface SecureFormSubmissionConfig extends MsalAuthorizerConfig {
207+
/* Empty for now */
208+
useAwsWafUserAgentWorkaround?: boolean;
209+
}
210+
199211
/**
200212
* `FormDefinition` is a typescript representation of `Schema`
201213
*/
@@ -232,4 +244,5 @@ export type FormDefinition = {
232244
serviceName?: string | undefined;
233245
confirmationSessionTimeout: number | undefined;
234246
returnTo?: boolean | undefined;
247+
secureFormSubmissionConfig: SecureFormSubmissionConfig;
235248
};

model/src/schema/schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,19 @@ const exitSchema = joi.object().keys({
322322
format: joi.string().allow("STATE", "WEBHOOK"),
323323
});
324324

325+
const msalAuthorizeConfigSchema = joi.object().keys({
326+
tenantId: joi.string().required(),
327+
clientId: joi.string().required(),
328+
clientSecret: joi.string().required(),
329+
scopes: joi.array().items(joi.string()).min(1).required(),
330+
});
331+
332+
const secureFormSubmissionConfig = msalAuthorizeConfigSchema.concat(
333+
joi.object().keys({
334+
useAwsWafUserAgentWorkaround: joi.bool().optional(),
335+
})
336+
);
337+
325338
export const Schema = joi
326339
.object()
327340
.required()
@@ -365,6 +378,7 @@ export const Schema = joi
365378
serviceName: joi.string().optional(),
366379
confirmationSessionTimeout: joi.number().optional(),
367380
returnTo: joi.boolean().optional(),
381+
secureFormSubmissionConfig: secureFormSubmissionConfig.optional(),
368382
});
369383

370384
/**

runner/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
},
3535
"license": "SEE LICENSE IN LICENSE",
3636
"dependencies": {
37+
"@azure/msal-node": "^5.2.1",
3738
"@babel/runtime": "^7.23.3",
3839
"@hapi/bell": "^13.0.1",
3940
"@hapi/boom": "^10.0.1",

runner/src/server/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import {
3434
MockUploadService,
3535
WebhookService,
3636
ExitService,
37+
FormSecurityService,
38+
SecureFormSubmissionService,
39+
getSecureFormSubmissionServiceInstance,
3740
} from "./services";
3841
import { HapiRequest, HapiResponseToolkit, RouteConfig } from "./types";
3942
import getRequestInfo from "./utils/getRequestInfo";
@@ -116,6 +119,7 @@ async function createServer(routeConfig: RouteConfig) {
116119
WebhookService,
117120
AddressService,
118121
ExitService,
122+
FormSecurityService,
119123
]);
120124
if (!config.documentUploadApiUrl) {
121125
server.registerService([
@@ -173,6 +177,26 @@ async function createServer(routeConfig: RouteConfig) {
173177
return h.continue;
174178
});
175179

180+
const enginePlugin = configureEnginePlugin(
181+
formFileName,
182+
formFilePath,
183+
options
184+
);
185+
186+
for (const form of enginePlugin.options.configs) {
187+
const formId = form.id;
188+
189+
if (form.configuration.secureFormSubmissionConfig) {
190+
const instanceName = getSecureFormSubmissionServiceInstance(formId);
191+
const namedService = new SecureFormSubmissionService(
192+
form.configuration.secureFormSubmissionConfig
193+
);
194+
await server.registerService(
195+
Schmervice.withName(instanceName, namedService)
196+
);
197+
}
198+
}
199+
176200
await server.register(pluginLocale);
177201
await server.register(pluginViews);
178202
await server.register(

runner/src/server/plugins/engine/configureEnginePlugin.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,14 @@ import {
77
} from "./services/configurationService";
88
import { idFromFilename } from "./helpers";
99
import config from "../../config";
10-
11-
type ConfigureEnginePlugin = (
12-
formFileName?: string,
13-
formFilePath?: string
14-
) => {
15-
plugin: any;
16-
options: {
17-
modelOptions: {
18-
relativeTo: string;
19-
previewMode: any;
20-
};
21-
configs: {
22-
configuration: any;
23-
id: string;
24-
}[];
25-
previewMode: boolean;
26-
};
27-
};
10+
import { FormDefinition } from "@xgovformbuilder/model";
2811

2912
const relativeTo = __dirname;
3013

3114
type EngineOptions = {
3215
previewMode?: boolean;
3316
};
34-
export const configureEnginePlugin: ConfigureEnginePlugin = (
17+
export const configureEnginePlugin = (
3518
formFileName,
3619
formFilePath,
3720
options?: EngineOptions
@@ -41,7 +24,10 @@ export const configureEnginePlugin: ConfigureEnginePlugin = (
4124
if (formFileName && formFilePath) {
4225
configs = [
4326
{
44-
configuration: require(path.join(formFilePath, formFileName)),
27+
configuration: require(path.join(
28+
formFilePath,
29+
formFileName
30+
)) as FormDefinition,
4531
id: idFromFilename(formFileName),
4632
},
4733
];

runner/src/server/plugins/engine/services/configurationService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import fs from "fs";
22
import path from "path";
33

44
import { idFromFilename } from "../helpers";
5+
import { FormDefinition } from "@xgovformbuilder/model";
56

67
const FORMS_FOLDER = path.join(__dirname, "..", "..", "..", "forms");
78

89
export type FormConfiguration = {
9-
configuration: any; // TODO
10+
configuration: FormDefinition;
1011
id: string;
1112
};
1213

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { HapiRequest, HapiServer } from "../types";
2+
import { createHmacRaw } from "../utils/hmac";
3+
import {
4+
getSecureFormSubmissionServiceInstance,
5+
SecureFormSubmissionService,
6+
} from "./secureFormSubmissionService";
7+
8+
export class FormSecurityService {
9+
/**
10+
* Service responsible for securing form submissions. This service has been registered by {@link #createServer}
11+
*/
12+
13+
logger: HapiServer["logger"];
14+
constructor(server) {
15+
this.logger = server.logger;
16+
}
17+
18+
async getSecurityHeaders(request: HapiRequest) {
19+
const formId = request.params?.id as string;
20+
if (!formId) {
21+
return;
22+
}
23+
24+
const form = request.server?.app?.forms?.[formId];
25+
if (!form) {
26+
return;
27+
}
28+
29+
let customSecurityHeaders: Record<string, string> = {};
30+
31+
if (form.def.webhookHmacSharedKey) {
32+
/**
33+
* If the config contains the OPTIONAL webhookHmacSharedKey, then we send HMAC Auth headers
34+
* This is used to confirm ONLY X-Gov's backend is sending data to our API
35+
* Everyone else will be Rejected
36+
* (Used by KLS)
37+
*/
38+
const hmacKey = form.def.webhookHmacSharedKey;
39+
40+
const [hmacSignature, requestTime, hmacExpiryTime] = await createHmacRaw(
41+
request.yar.id,
42+
hmacKey
43+
);
44+
customSecurityHeaders = {
45+
"X-Request-ID": request.yar.id.toString(),
46+
"X-HMAC-Signature": hmacSignature.toString(),
47+
"X-HMAC-Time": requestTime.toString(),
48+
};
49+
} else if (form.def.secureFormSubmissionConfig) {
50+
/** Secure form submissions facilitated through a form specific instance of the secureFormSubmissionService
51+
* We resolve the named service instance for the form
52+
* If it exists, it's implementation will return a headers object \{ Authorization: "Bearer <Token>"\}
53+
* For more information @see secure-form-submissions.md
54+
*/
55+
56+
const instanceName = getSecureFormSubmissionServiceInstance(formId);
57+
if (instanceName) {
58+
const serviceInstance = request.services([])[
59+
instanceName
60+
] as SecureFormSubmissionService;
61+
62+
if (serviceInstance) {
63+
const headers = await serviceInstance.getAuthHeader();
64+
if (headers) {
65+
const {
66+
useAwsWafUserAgentWorkaround,
67+
} = form.def.secureFormSubmissionConfig;
68+
69+
if (useAwsWafUserAgentWorkaround == true) {
70+
/* AWS WAF forces User-Agent as part of auth, provide one to prevent 403 */
71+
headers["User-Agent"] = "X-GOV Forms/v1.0";
72+
}
73+
74+
customSecurityHeaders = headers;
75+
}
76+
}
77+
}
78+
}
79+
80+
return customSecurityHeaders;
81+
}
82+
}

runner/src/server/services/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ export { WebhookService } from "./webhookService";
88
export { StatusService } from "./statusService";
99
export { AddressService } from "./addressService";
1010
export { ExitService } from "./ExitService";
11+
export { FormSecurityService } from "./formSecurityService";
12+
export { MsalAuthorizer } from "./msalAuthorizerService";
13+
export {
14+
SecureFormSubmissionService,
15+
getSecureFormSubmissionServiceInstance,
16+
} from "./secureFormSubmissionService";

0 commit comments

Comments
 (0)