@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 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.9.4",
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",
@@ -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
- let userInfo = await client.fetchUserInfo(config, access_token, sub);
104
- logger.debug('UserInfo Response', userInfo);
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
- const policyRegistry = {
156
- naturalPerson: naturalPersonPolicy,
157
- legalPerson: legalPersonPolicy,
158
- };
159
-
160
- export function cyLoginPolicy(req, res, next) {
161
- // Check what is allowed in the service configuration
162
- const allowed = req?.serviceData?.site?.cyLoginPolicies || ["naturalPerson"];
163
-
164
- // Check each policy in the allowed list
165
- for (const name of allowed) {
166
- const policy = policyRegistry[name];
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) return next();
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.profile_type into the context
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);