@gov-cy/govcy-express-services 1.9.4 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -24
- package/package.json +1 -1
- package/src/auth/cyLoginAuth.mjs +32 -4
- package/src/middleware/cyLoginAuth.mjs +48 -20
- package/src/utils/govcyExpressions.mjs +27 -23
package/README.md
CHANGED
|
@@ -156,16 +156,17 @@ The CY Login settings are configured in the `secrets/.env` file.
|
|
|
156
156
|
|
|
157
157
|
Each service can specify which types of authenticated CY Login profiles are allowed to access it using the `site.cyLoginPolicies` property in its site configuration.
|
|
158
158
|
|
|
159
|
-
```json
|
|
160
|
-
"cyLoginPolicies": ["naturalPerson", "legalPerson"]
|
|
161
|
-
```
|
|
159
|
+
```json
|
|
160
|
+
"cyLoginPolicies": ["naturalPerson", "legalPerson", "eidasNaturalPerson"]
|
|
161
|
+
```
|
|
162
162
|
|
|
163
163
|
##### Supported Policies
|
|
164
164
|
|
|
165
|
-
| Policy name | Description | Typical use |
|
|
166
|
-
| --------------- | ------------------------------------------------------------ | ---------------------------------------------------- |
|
|
167
|
-
| `naturalPerson` | Allows individual users (Cypriot citizens or foreign residents) who have a verified profile in the Civil Registry. Identified by `profile_type: "Individual"` and a 10-digit identifier starting with `00` (citizen) or `05` (foreigner). | Citizen-facing services, personal applications, etc. |
|
|
168
|
-
| `legalPerson` | Allows legal entities (companies, partnerships, organisations) with verified profiles in the Registrar of Companies. Identified by `profile_type: "Organisation"` and a `legal_unique_identifier`. | Business-facing services, company submissions, etc. |
|
|
165
|
+
| Policy name | Description | Typical use |
|
|
166
|
+
| --------------- | ------------------------------------------------------------ | ---------------------------------------------------- |
|
|
167
|
+
| `naturalPerson` | Allows individual users (Cypriot citizens or foreign residents) who have a verified profile in the Civil Registry. Identified by `profile_type: "Individual"` and a 10-digit identifier starting with `00` (citizen) or `05` (foreigner). | Citizen-facing services, personal applications, etc. |
|
|
168
|
+
| `legalPerson` | Allows legal entities (companies, partnerships, organisations) with verified profiles in the Registrar of Companies. Identified by `profile_type: "Organisation"` and a `legal_unique_identifier`. | Business-facing services, company submissions, etc. |
|
|
169
|
+
| `eidasNaturalPerson` | Allows eIDAS natural persons identified by `profile_type: "Individual"` and `unique_identifier` in the `CC/CC/<identifier>` format. | Cross-border eIDAS individual services. |
|
|
169
170
|
|
|
170
171
|
##### How it works
|
|
171
172
|
|
|
@@ -184,13 +185,21 @@ This maintains backward compatibility with existing services that only supported
|
|
|
184
185
|
|
|
185
186
|
##### Example
|
|
186
187
|
|
|
187
|
-
Allow both natural and legal persons:
|
|
188
|
-
|
|
189
|
-
```json
|
|
190
|
-
"site": {
|
|
191
|
-
"cyLoginPolicies": ["naturalPerson", "legalPerson"]
|
|
192
|
-
}
|
|
193
|
-
```
|
|
188
|
+
Allow both natural and legal persons:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
"site": {
|
|
192
|
+
"cyLoginPolicies": ["naturalPerson", "legalPerson"]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Allow Cypriot and eIDAS natural persons:
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
"site": {
|
|
200
|
+
"cyLoginPolicies": ["naturalPerson", "eidasNaturalPerson"]
|
|
201
|
+
}
|
|
202
|
+
```
|
|
194
203
|
|
|
195
204
|
Restrict access to natural persons only:
|
|
196
205
|
|
|
@@ -2055,14 +2064,16 @@ To use data layer values, use the special `dataLayer[]` array. For example `data
|
|
|
2055
2064
|
- `formData` is a reserved word for the form data (already inputed data by the user) for that page
|
|
2056
2065
|
- `showExtra`refers to a input component with that name
|
|
2057
2066
|
|
|
2058
|
-
The `dataLayer` typically contains keys such as:
|
|
2059
|
-
- `inputData`: **All data submitted by the user through forms**
|
|
2060
|
-
- `eligibilityResults`: **Cached results from service eligibility API checks**
|
|
2067
|
+
The `dataLayer` typically contains keys such as:
|
|
2068
|
+
- `inputData`: **All data submitted by the user through forms**
|
|
2069
|
+
- `eligibilityResults`: **Cached results from service eligibility API checks**
|
|
2070
|
+
- `user.profile_type`: **Authenticated user profile type from CY Login context**
|
|
2071
|
+
- `user.policy`: **The matched CY Login policy name for the current session**
|
|
2061
2072
|
|
|
2062
2073
|
Example structure for a service with ID `my-service`:
|
|
2063
2074
|
|
|
2064
|
-
```js
|
|
2065
|
-
dataLayer = {
|
|
2075
|
+
```js
|
|
2076
|
+
dataLayer = {
|
|
2066
2077
|
'my-service.inputData.index.formData.fullName': 'John Smith',
|
|
2067
2078
|
'my-service.inputData.index.formData.age': '34',
|
|
2068
2079
|
'my-service.inputData.contact-details.formData.telephone': '+35712345678',
|
|
@@ -2072,11 +2083,13 @@ dataLayer = {
|
|
|
2072
2083
|
"permanent_residence"
|
|
2073
2084
|
],
|
|
2074
2085
|
'my-service.inputData.want-to-apply.formData.option-radio': 'yes',
|
|
2075
|
-
'my-service.eligibilityResults.check1.succeeded': true,
|
|
2076
|
-
'my-service.eligibilityResults.check2.ErrorCode': 0
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2086
|
+
'my-service.eligibilityResults.check1.succeeded': true,
|
|
2087
|
+
'my-service.eligibilityResults.check2.ErrorCode': 0,
|
|
2088
|
+
'user.profile_type': 'Individual',
|
|
2089
|
+
'user.policy': 'naturalPerson'
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
```
|
|
2080
2093
|
|
|
2081
2094
|
If any part of the key path is missing (e.g., the page hasn’t been visited yet or a form field was left empty), the expression will safely return `undefined` and **will not throw an error**. This behavior is by design, so that conditional logic expressions can fail silently and fallback gracefully.
|
|
2082
2095
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gov-cy/govcy-express-services",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.",
|
|
5
5
|
"author": "DMRID - DSF Team",
|
|
6
6
|
"license": "MIT",
|
package/src/auth/cyLoginAuth.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const clientId = getEnvVariable('CYLOGIN_CLIENT_ID');
|
|
|
9
9
|
const clientSecret = getEnvVariable('CYLOGIN_CLIENT_SECRET');
|
|
10
10
|
const scope = getEnvVariable('CYLOGIN_SCOPE');
|
|
11
11
|
const redirect_uri = getEnvVariable('CYLOGIN_REDIRECT_URI');
|
|
12
|
+
const policyClaims = ['unique_identifier', 'profile_type', 'legal_unique_identifier'];
|
|
12
13
|
|
|
13
14
|
// Discover OpenID settings with error handling and retry mechanism
|
|
14
15
|
let config = null; // Changed: Initialize config as null
|
|
@@ -99,9 +100,11 @@ export async function handleCallback(req) {
|
|
|
99
100
|
let claims = tokens.claims();
|
|
100
101
|
logger.debug('ID Token Claims', claims);
|
|
101
102
|
|
|
102
|
-
let { sub } = claims;
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
let { sub } = claims;
|
|
104
|
+
const fetchedUserInfo = await client.fetchUserInfo(config, access_token, sub);
|
|
105
|
+
let userInfo = (fetchedUserInfo && typeof fetchedUserInfo === 'object') ? fetchedUserInfo : {};
|
|
106
|
+
backfillPolicyClaimsFromIdToken(userInfo, claims);
|
|
107
|
+
logger.debug('UserInfo Response', userInfo);
|
|
105
108
|
|
|
106
109
|
return { tokens, claims, userInfo };
|
|
107
110
|
} catch (error) {
|
|
@@ -111,6 +114,31 @@ export async function handleCallback(req) {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Checks if a value is missing (null, undefined, or empty string)
|
|
119
|
+
* @param {*} value The value to check
|
|
120
|
+
* @returns {boolean} True if the value is missing, false otherwise
|
|
121
|
+
*/
|
|
122
|
+
function isMissingValue(value) {
|
|
123
|
+
return value == null || (typeof value === 'string' && value.trim() === '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Backfill missing policy claims from ID token claims to userInfo
|
|
128
|
+
* @param {object} userInfo The userInfo object to backfill
|
|
129
|
+
* @param {object} claims The ID token claims to check for missing values
|
|
130
|
+
* @returns {object} The updated userInfo object with backfilled claims
|
|
131
|
+
*/
|
|
132
|
+
export function backfillPolicyClaimsFromIdToken(userInfo = {}, claims = {}) {
|
|
133
|
+
for (const key of policyClaims) {
|
|
134
|
+
if (isMissingValue(userInfo[key]) && !isMissingValue(claims[key])) {
|
|
135
|
+
userInfo[key] = claims[key];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return userInfo;
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
|
|
115
143
|
/**
|
|
116
144
|
* Logout and build end session URL
|
|
@@ -131,4 +159,4 @@ export function getLogoutUrl(id_token_hint = '') {
|
|
|
131
159
|
|
|
132
160
|
// Export config if needed elsewhere
|
|
133
161
|
export { config };
|
|
134
|
-
/* c8 ignore end */
|
|
162
|
+
/* c8 ignore end */
|
|
@@ -140,7 +140,7 @@ export function naturalPersonPolicy(req) {
|
|
|
140
140
|
*
|
|
141
141
|
* @param {object} req The request object
|
|
142
142
|
*/
|
|
143
|
-
export function legalPersonPolicy(req) {
|
|
143
|
+
export function legalPersonPolicy(req) {
|
|
144
144
|
// https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/42/For-Cyprus-Natural-or-Legal-person
|
|
145
145
|
const { profile_type, legal_unique_identifier } = req.session.user || {};
|
|
146
146
|
// Allow only legal persons with approved profiles
|
|
@@ -149,21 +149,46 @@ export function legalPersonPolicy(req) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Deny access if validation fails
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Middleware to enforce eIDAS natural person policy. If the user is not a verified eIDAS natural person, return false.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} req The request object
|
|
159
|
+
*/
|
|
160
|
+
export function eidasNaturalPersonPolicy(req) {
|
|
161
|
+
// https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/43/For-eIDAS-Natural-or-Legal-person
|
|
162
|
+
const { profile_type, unique_identifier } = req.session.user || {};
|
|
163
|
+
// Allow only natural persons with eIDAS unique identifier format: CC/CC/<identifier>
|
|
164
|
+
if (profile_type === 'Individual' && typeof unique_identifier === 'string') {
|
|
165
|
+
const eidasIdentifierPattern = /^[A-Z]{2}\/[A-Z]{2}\/.+$/;
|
|
166
|
+
if (eidasIdentifierPattern.test(unique_identifier)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Deny access if validation fails
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const policyRegistry = {
|
|
176
|
+
naturalPerson: naturalPersonPolicy,
|
|
177
|
+
legalPerson: legalPersonPolicy,
|
|
178
|
+
eidasNaturalPerson: eidasNaturalPersonPolicy,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function cyLoginPolicy(req, res, next) {
|
|
182
|
+
// Check what is allowed in the service configuration
|
|
183
|
+
const allowed = req?.serviceData?.site?.cyLoginPolicies || ["naturalPerson"];
|
|
184
|
+
// Clear stale policy value before policy evaluation
|
|
185
|
+
if (req?.session?.user) {
|
|
186
|
+
delete req.session.user.policy;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check each policy in the allowed list
|
|
190
|
+
for (const name of allowed) {
|
|
191
|
+
const policy = policyRegistry[name];
|
|
167
192
|
// Skip if the policy is not registered
|
|
168
193
|
if (!policy) {
|
|
169
194
|
console.warn(`🚨 Unknown policy: ${name}`);
|
|
@@ -171,9 +196,12 @@ export function cyLoginPolicy(req, res, next) {
|
|
|
171
196
|
};
|
|
172
197
|
|
|
173
198
|
// 🚨 Strict mode: let errors throw naturally if data is malformed
|
|
174
|
-
const passed = policy(req);
|
|
175
|
-
if (passed)
|
|
176
|
-
|
|
199
|
+
const passed = policy(req);
|
|
200
|
+
if (passed) {
|
|
201
|
+
req.session.user.policy = name;
|
|
202
|
+
return next();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
177
205
|
|
|
178
206
|
return handleMiddlewareError(
|
|
179
207
|
"🚨 Access Denied: none of the allowed CY Login policies matched.",
|
|
@@ -193,4 +221,4 @@ export function getUniqueIdentifier(req) {
|
|
|
193
221
|
"";
|
|
194
222
|
|
|
195
223
|
return String(id);
|
|
196
|
-
}
|
|
224
|
+
}
|
|
@@ -94,27 +94,31 @@ export function evaluateExpression(expression, dataLayer = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
* Evaluates expression **with automatic flattening**.
|
|
99
|
-
* This is a convenience wrapper for most use cases.
|
|
100
|
-
*
|
|
101
|
-
* @param {string} expression - JS expression using dataLayer["..."]
|
|
102
|
-
* @param {object} object - Unflattened data object (e.g., session.siteData[siteKey])
|
|
103
|
-
* @param {string} prefix - Prefix to add to all keys (usually the siteKey)
|
|
104
|
-
* @param {object} [req=null] - Express request, used to inject user
|
|
105
|
-
* @returns {*} - The evaluated result
|
|
106
|
-
*/
|
|
107
|
-
export function evaluateExpressionWithFlattening(expression, object, prefix = '', req = null) {
|
|
108
|
-
const baseObject = (object && typeof object === 'object') ? object : {};
|
|
109
|
-
const dataLayer = flattenContext(baseObject, prefix);
|
|
110
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Evaluates expression **with automatic flattening**.
|
|
99
|
+
* This is a convenience wrapper for most use cases.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} expression - JS expression using dataLayer["..."]
|
|
102
|
+
* @param {object} object - Unflattened data object (e.g., session.siteData[siteKey])
|
|
103
|
+
* @param {string} prefix - Prefix to add to all keys (usually the siteKey)
|
|
104
|
+
* @param {object} [req=null] - Express request, used to inject user profile context into the dataLayer
|
|
105
|
+
* @returns {*} - The evaluated result
|
|
106
|
+
*/
|
|
107
|
+
export function evaluateExpressionWithFlattening(expression, object, prefix = '', req = null) {
|
|
108
|
+
const baseObject = (object && typeof object === 'object') ? object : {};
|
|
109
|
+
const dataLayer = flattenContext(baseObject, prefix);
|
|
110
|
+
|
|
111
111
|
if (req) {
|
|
112
|
+
// Inject user profile context into the dataLayer for expression use,
|
|
113
|
+
// if available in either req.user or req.session.user
|
|
112
114
|
const profileType = req?.user?.profile_type ?? req?.session?.user?.profile_type ?? null;
|
|
115
|
+
const policy = req?.user?.policy ?? req?.session?.user?.policy ?? null;
|
|
113
116
|
dataLayer['user.profile_type'] = profileType;
|
|
117
|
+
dataLayer['user.policy'] = policy;
|
|
114
118
|
}
|
|
115
|
-
|
|
116
|
-
return evaluateExpression(expression, dataLayer);
|
|
117
|
-
}
|
|
119
|
+
|
|
120
|
+
return evaluateExpression(expression, dataLayer);
|
|
121
|
+
}
|
|
118
122
|
|
|
119
123
|
|
|
120
124
|
/**
|
|
@@ -174,12 +178,12 @@ export function evaluatePageConditions(page, store, siteKey, req = {}) {
|
|
|
174
178
|
|
|
175
179
|
try {
|
|
176
180
|
// Evaluate the expression using flattened site data
|
|
177
|
-
const result = evaluateExpressionWithFlattening(
|
|
178
|
-
condition.expression,
|
|
179
|
-
siteData,
|
|
180
|
-
siteKey,
|
|
181
|
-
req
|
|
182
|
-
);
|
|
181
|
+
const result = evaluateExpressionWithFlattening(
|
|
182
|
+
condition.expression,
|
|
183
|
+
siteData,
|
|
184
|
+
siteKey,
|
|
185
|
+
req
|
|
186
|
+
);
|
|
183
187
|
|
|
184
188
|
if (typeof result !== 'boolean') {
|
|
185
189
|
logger.debug(`Condition expression on page '${page.pageData?.url || page.id}' returned non-boolean:`, result);
|