Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ services:
- PAR_CALLBACK_NAME=get_requestUri
- PAR_CALLBACK_TIMEOUT=5000
- DPOP_CALLBACK_NAME=get_dpop_jkt
- CODE_CHALLENGE=get_code_challenge
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
10 changes: 9 additions & 1 deletion mock-relying-party-service/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ app.get("/dpopJKT", rateLimiter, async (req, res) => {

app.get("/requestUri/:clientId", async (req, res) => {
try {
const { ui_locales, state, dpop_jkt, code_challenge, code_challenge_method } = req.query;
res.send(
await post_GetRequestUri(req.params.clientId, req.query.ui_locales, req.query.state, req.query.dpop_jkt),
await post_GetRequestUri(
req.params.clientId,
ui_locales,
state,
dpop_jkt,
code_challenge,
code_challenge_method
)
);
} catch (error) {
console.log(error);
Expand Down
15 changes: 13 additions & 2 deletions mock-relying-party-service/esignetService.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,22 @@ const post_GetToken = async ({
client_id,
redirect_uri,
grant_type,
code_verifier,
}) => {
let request = new URLSearchParams({
const request = new URLSearchParams({
code: code,
client_id: client_id,
redirect_uri: redirect_uri,
grant_type: grant_type,
client_assertion_type: CLIENT_ASSERTION_TYPE,
client_assertion: await generateSignedJwt(client_id, ESIGNET_AUD_URL),
});

// Add code_verifier if provided
if (code_verifier) {
request.append("code_verifier", code_verifier);
}

const endpoint = baseUrl + getTokenEndPoint;
const dpopHeaders = await buildDpopHeaders({
clientId: client_id,
Expand Down Expand Up @@ -85,7 +92,7 @@ const post_GetToken = async ({
* @param {string} clientId clientId
* @returns requestUri
*/
const post_GetRequestUri = async (clientId, uiLocales, state, dpop_jkt) => {
const post_GetRequestUri = async (clientId, uiLocales, state, dpop_jkt, code_challenge, code_challenge_method) => {
const clientAssertion = await generateSignedJwt(
clientId,
ESIGNET_PAR_AUD_URL
Expand All @@ -108,6 +115,10 @@ const post_GetRequestUri = async (clientId, uiLocales, state, dpop_jkt) => {
if (dpop_jkt) {
params.append("dpop_jkt", dpop_jkt);
}
if (code_challenge && code_challenge_method) {
params.append("code_challenge", code_challenge);
params.append("code_challenge_method", code_challenge_method);
}
const endpoint = clientDetails.parEndpoint;
const dpopHeaders = await buildDpopHeaders({
clientId,
Expand Down
3 changes: 3 additions & 0 deletions mock-relying-party-ui/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ ARG fallback_lang
ARG par_callback_name
ARG par_callback_timeout
ARG dpop_callback_name
ARG code_challenge

ENV ESIGNET_UI_BASE_URL=$esignet_ui_base_url
ENV MOCK_RELYING_PARTY_SERVER_URL=$mock_relying_party_server_url
Expand All @@ -44,6 +45,7 @@ ENV FALLBACK_LANG=$fallback_lang
ENV PAR_CALLBACK_NAME=$par_callback_name
ENV PAR_CALLBACK_TIMEOUT=$par_callback_timeout
ENV DPOP_CALLBACK_NAME=$dpop_callback_name
ENV CODE_CHALLENGE=$code_challenge

# Set the environment variable as a placeholder for PUBLIC_URL
ENV PUBLIC_URL=_PUBLIC_URL_
Expand Down Expand Up @@ -126,6 +128,7 @@ RUN echo "ESIGNET_UI_BASE_URL=$ESIGNET_UI_BASE_URL" >> ${work_dir}/env.env \
&& echo "PAR_CALLBACK_NAME=$PAR_CALLBACK_NAME" >> ${work_dir}/env.env \
&& echo "PAR_CALLBACK_TIMEOUT=$PAR_CALLBACK_TIMEOUT" >> ${work_dir}/env.env \
&& echo "DPOP_CALLBACK_NAME=$DPOP_CALLBACK_NAME" >> ${work_dir}/env.env \
&& echo "CODE_CHALLENGE=$CODE_CHALLENGE" >> ${work_dir}/env.env \
&& chmod +x configure_start.sh \
&& chown ${container_user}:${container_user} configure_start.sh \
&& chown -R ${container_user}:${container_user} /home/${container_user} ${work_dir}
Expand Down
7 changes: 5 additions & 2 deletions mock-relying-party-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@ The application runs on PORT=5000 by default.
(Example: par_callback_timeout: 5000)
- DPOP_CALLBACK_NAME: **Feature flag** to enable DPoP (Demonstration of Proof-of-Possession) flow
Required value: `get_dpop_jkt` (hardcoded function name - not configurable)
- CODE_CHALLENGE: **Feature flag** to enable PKCE (Proof Key for Code Exchange) flow
Required value: `get_code_challenge` (hardcoded function name - not configurable)
When enabled, the PKCE method is automatically fetched from the authorization server's `.well-known/openid-configuration` endpoint

> **Important:** PAR_CALLBACK_NAME and DPOP_CALLBACK_NAME act as feature toggles. The values correspond to hardcoded function names in the codebase and are not configurable. Include these variables to enable the respective flows, or omit them to disable the functionality.
> **Important:** PAR_CALLBACK_NAME, DPOP_CALLBACK_NAME, and CODE_CHALLENGE act as feature toggles. The values correspond to hardcoded function names in the codebase and are not configurable. Include these variables to enable the respective flows, or omit them to disable the functionality.

- Build and run Docker for a service:

```
$ docker build -t <dockerImageName>:<tag> .
$ docker run -it -d -p 5000:5000 -e ESIGNET_UI_BASE_URL='http://localhost:3000' -e MOCK_RELYING_PARTY_BASE_URL=http://localhost:8888 -e REDIRECT_URI=http://localhost:5000/userprofile -e CLIENT_ID=healthservices -e ACRS="mosip:esignet:acr:static-code" -e MAX_AGE=21 -e DISPLAY=page -e PROMPT=consent -e GRANT_TYPE=authorization_code -e SIGN_IN_BUTTON_PLUGIN_URL='http://127.0.0.1:5500/dist/iife/index.js' -e SCOPE_USER_PROFILE='openid%20profile%20resident-service' -e PAR_CALLBACK_NAME='get_requestUri' -e DPOP_CALLBACK_NAME='get_dpop_jkt' -e <dockerImageName>:<tag>
$ docker run -it -d -p 5000:5000 -e ESIGNET_UI_BASE_URL='http://localhost:3000' -e MOCK_RELYING_PARTY_BASE_URL=http://localhost:8888 -e REDIRECT_URI=http://localhost:5000/userprofile -e CLIENT_ID=healthservices -e ACRS="mosip:esignet:acr:static-code" -e MAX_AGE=21 -e DISPLAY=page -e PROMPT=consent -e GRANT_TYPE=authorization_code -e SIGN_IN_BUTTON_PLUGIN_URL='http://127.0.0.1:5500/dist/iife/index.js' -e SCOPE_USER_PROFILE='openid%20profile%20resident-service' -e PAR_CALLBACK_NAME='get_requestUri' -e DPOP_CALLBACK_NAME='get_dpop_jkt' -e CODE_CHALLENGE='get_code_challenge' <dockerImageName>:<tag>
```

To host the mock relying party UI on a context path:
Expand Down
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/env-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ window._env_ = {
PAR_CALLBACK_NAME: "get_requestUri",
PAR_CALLBACK_TIMEOUT: 5000,
DPOP_CALLBACK_NAME: "get_dpop_jkt",
};
CODE_CHALLENGE: "get_code_challenge"
};
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@
"request_uri_timeout": "انتهت مهلة الطلب أثناء جلب رابط URI الخاص بالطلب. يُرجى المحاولة لاحقًا.",
"web_socket_fail": "لم يتم إكمال عملية التحقق من eKYC. يُرجى المحاولة مرة أخرى.",
"dpop_failed": "فشل التحقق من DPoP. يُرجى المحاولة مرة أخرى.",
"invalid_dpop_proof": "لم نتمكن من إتمام المصادقة. يرجى المحاولة مرة أخرى"
"invalid_dpop_proof": "لم نتمكن من إتمام المصادقة. يرجى المحاولة مرة أخرى",
"code_challenge_failed": "فشل في إنشاء تحدي الشفرة. يرجى المحاولة مرة أخرى."
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
"request_uri_timeout": "Request timed out while fetching Request URI. Please try again later.",
"web_socket_fail": "eKYC verification was not completed. Please try again.",
"dpop_failed": "DPoP verification failed. Please try again.",
"invalid_dpop_proof": "Authentication could not be completed. Please try again"
"invalid_dpop_proof": "Authentication could not be completed. Please try again",
"code_challenge_failed": "Failed to generate code challenge. Please try again."
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@
"request_uri_timeout": "अनुरोध URI प्राप्त करते समय अनुरोध का समय समाप्त हो गया। कृपया बाद में पुनः प्रयास करें।",
"web_socket_fail": "eKYC सत्यापन पूरा नहीं हुआ। कृपया पुनः प्रयास करें।",
"dpop_failed": "DPoP सत्यापन विफल हुआ. कृपया पुनः प्रयास करें।",
"invalid_dpop_proof": "प्रमाणीकरण पूरा नहीं हो सका। कृपया पुनः प्रयास करें"
"invalid_dpop_proof": "प्रमाणीकरण पूरा नहीं हो सका। कृपया पुनः प्रयास करें",
"code_challenge_failed": "कोड चुनौती उत्पन्न करने में विफल। कृपया पुनः प्रयास करें।"
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/km.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
"request_uri_timeout": "សំណើអស់ពេលពេលទៅយកសំណើ URI ។ សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ។",
"web_socket_fail": "ការផ្ទៀងផ្ទាត់ eKYC មិនត្រូវបានបញ្ចប់ទេ។ សូមព្យាយាមម្តងទៀត។",
"dpop_failed": "ការផ្ទៀងផ្ទាត់ DPoP បានបរាជ័យ។ សូមព្យាយាមម្តងទៀត។",
"invalid_dpop_proof": "ការផ្ទៀងផ្ទាត់មិនអាចបញ្ចប់បាន។ សូមព្យាយាមម្ដងទៀត"
"invalid_dpop_proof": "ការផ្ទៀងផ្ទាត់មិនអាចបញ្ចប់បាន។ សូមព្យាយាមម្ដងទៀត",
"code_challenge_failed": "ការបង្កើតការប្រឈមកូដបានបរាជ័យ។ សូមព្យាយាមម្ដងទៀត។"
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/kn.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@
"request_uri_timeout": "ವಿನಂತಿ URI ಅನ್ನು ಪಡೆಯುವಾಗ ವಿನಂತಿಯ ಸಮಯ ಮೀರಿದೆ. ದಯವಿಟ್ಟು ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
"web_socket_fail": "eKYC ಪರಿಶೀಲನೆ ಪೂರ್ಣಗೊಂಡಿಲ್ಲ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
"dpop_failed": "DPoP ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
"invalid_dpop_proof": "ಪ್ರಾಮಾಣೀಕರಣವನ್ನು ಪೂರ್ಣಗೊಳಿಸಲಾಗಲಿಲ್ಲ. ದಯವಿಟ್ಟು ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ"
"invalid_dpop_proof": "ಪ್ರಾಮಾಣೀಕರಣವನ್ನು ಪೂರ್ಣಗೊಳಿಸಲಾಗಲಿಲ್ಲ. ದಯವಿಟ್ಟು ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ",
"code_challenge_failed": "ಕೋಡ್ ಚಾಲೆಂಜ್ ರಚಿಸಲು ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ."
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/public/locales/ta.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@
"request_uri_timeout": "கோரிக்கை URI-ஐப் பெறும்போது கோரிக்கை நேரம் முடிந்தது. தயவுசெய்து பின்னர் மீண்டும் முயற்சிக்கவும்.",
"web_socket_fail": "eKYC சரிபார்ப்பு முடிக்கப்படவில்லை. மீண்டும் முயற்சிக்கவும்.",
"dpop_failed": "DPoP சரிபார்ப்பு தோல்வியடைந்தது. மீண்டும் முயற்சிக்கவும்.",
"invalid_dpop_proof": "அங்கீகாரம் முடிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்"
"invalid_dpop_proof": "அங்கீகாரம் முடிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்",
"code_challenge_failed": "கோட் சவால் உருவாக்க முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்."
}
}
3 changes: 2 additions & 1 deletion mock-relying-party-ui/src/components/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export default function Login({ i18nKeyPrefix = "login" }) {
claims: JSON.parse(decodeURIComponent(clientDetails.userProfileClaims)),
par_callback: relyingPartyService[clientDetails.par_callback_name],
par_callback_timeout: clientDetails.par_callback_timeout,
dpop_callback: relyingPartyService[clientDetails.dpop_callback_name]
dpop_callback: relyingPartyService[clientDetails.dpop_callback_name],
code_challenge: relyingPartyService[clientDetails.code_challenge],
};

window.SignInWithEsignetButton?.init({
Expand Down
7 changes: 6 additions & 1 deletion mock-relying-party-ui/src/components/Sidenav.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import clientDetails from "../constants/clientDetails";
import { LoadingStates as states } from "../constants/states";
Expand Down Expand Up @@ -29,6 +29,7 @@ export default function Sidenav({
const [emailAddress, setEmailAddress] = useState(null);
const [showMenu, setShowMenu] = useState(false);
const navigate = useNavigate();
const hasFetchedRef = useRef(false);

function getAllKeys(input) {
if (Array.isArray(input)) {
Expand Down Expand Up @@ -80,6 +81,10 @@ export default function Sidenav({

useEffect(() => {
const getSearchParams = async () => {
// Prevent duplicate API calls during React.StrictMode double mounting
if (hasFetchedRef.current) return;
hasFetchedRef.current = true;

let authCode = searchParams.get("code");
let errorCode = searchParams.get("error");
let error_desc = searchParams.get("error_description");
Expand Down
2 changes: 2 additions & 0 deletions mock-relying-party-ui/src/constants/clientDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const par_callback_timeout = checkEmptyNullValue(
5000
);
const dpop_callback_name = window._env_.DPOP_CALLBACK_NAME;
const code_challenge = window._env_.CODE_CHALLENGE;
const claims = {
userinfo: {
given_name: {
Expand Down Expand Up @@ -108,6 +109,7 @@ const clientDetails = {
par_callback_name: par_callback_name,
par_callback_timeout: par_callback_timeout,
dpop_callback_name: dpop_callback_name,
code_challenge: code_challenge,
};

export default clientDetails;
118 changes: 106 additions & 12 deletions mock-relying-party-ui/src/services/relyingPartyService.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,81 @@ import {
GET_DPOP_JKT,
} from "../constants/routes";

/**
* Base64URL encoding utility for PKCE
* @param {Uint8Array} buffer - Byte array to encode
* @returns {string} - Base64URL encoded string
*/
const base64UrlEncode = (buffer) => {
let binary = '';
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};

/**
* Generates a cryptographically secure code verifier
* @returns {string} - Base64URL encoded code verifier
*/
const generateCodeVerifier = () => {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
};

/**
* Fetches supported PKCE methods from auth server's .well-known
* @returns {Promise<string>} - Supported code challenge method (defaults to 'S256')
*/
const get_code_challenge_method = async () => {
try {
const baseUrl = window._env_.ESIGNET_UI_BASE_URL;
const response = await axios.get(`${baseUrl}/.well-known/openid-configuration`);
const supportedMethods = response.data?.code_challenge_methods_supported || [];

// Default to S256
if (!supportedMethods || supportedMethods.length === 0) return 'S256';

// If method contains S256, use S256; otherwise fallback to first supported method.
return supportedMethods.includes('S256') ? 'S256' : supportedMethods[0];
} catch (error) {
console.warn('Failed to fetch PKCE methods, defaulting to S256:', error);
return 'S256';
}
};

/**
* Generates PKCE code challenge and stores verifier in sessionStorage
* @param {string} clientId - Registered client ID
* @param {string} state - Unique state value
* @returns {Promise<Object>} - Object with code_challenge and code_challenge_method
*/
const get_code_challenge = async (clientId, state) => {
const method = await get_code_challenge_method();

if (!method) {
console.warn('PKCE disabled: no supported method found');
return null;
}

const codeVerifier = generateCodeVerifier();
let codeChallenge;

if (method === 'plain') {
codeChallenge = codeVerifier;
} else {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
codeChallenge = base64UrlEncode(new Uint8Array(hashBuffer));
}

sessionStorage.setItem(`pkce_${clientId}_${state}`, codeVerifier);
return {
code_challenge: codeChallenge,
code_challenge_method: method
};
};

/**
* Fetches the DPoP JWK thumbprint (JKT) from the relying party server.
* @param {string} clientId - Registered client ID
Expand Down Expand Up @@ -35,15 +110,19 @@ const get_dpop_jkt = async (clientId, state) => {
* @param {string} clientId - Registered client ID
* @param {string} state - Unique state value for the authorization request
* @param {string} ui_locales - Locale/language preference
* @param {string} dpop_jkt - DPoP JWK thumbprint (optional)
* @param {string} code_challenge - PKCE code challenge (optional)
* @param {string} code_challenge_method - PKCE method (optional)
* @returns {Promise<string>} - Request URI (URN format)
*/
const get_requestUri = async (clientId, state, ui_locales, dpop_jkt) => {
const get_requestUri = async (clientId, state, ui_locales, dpop_jkt, code_challenge, code_challenge_method) => {
try {
const params = new URLSearchParams({
state,
ui_locales,
dpop_jkt
});
const params = new URLSearchParams({ state, ui_locales });
if (dpop_jkt) params.append("dpop_jkt", dpop_jkt);
if (code_challenge && code_challenge_method) {
params.append("code_challenge", code_challenge);
params.append("code_challenge_method", code_challenge_method);
}
const endpoint = `${BASE_URL}${GET_REQUEST_URI}/${clientId}?${params.toString()}`;
const response = await axios.get(endpoint, {
headers: {
Expand All @@ -61,6 +140,7 @@ const get_requestUri = async (clientId, state, ui_locales, dpop_jkt) => {
* Typically called after receiving the auth code from the authorization server.
*
* @param {string} code - Authorization code received after user consent
* @param {string} state - State value from authorization request
* @param {string} client_id - Registered client ID
* @param {string} redirect_uri - Redirect URI used during authorization
* @param {string} grant_type - OAuth 2.0 grant type (usually "authorization_code")
Expand All @@ -73,19 +153,31 @@ const post_fetchUserInfo = async (
redirect_uri,
grant_type
) => {
let request = {
code: code,
state: state,
client_id: client_id,
redirect_uri: redirect_uri,
grant_type: grant_type,
const request = {
code,
state,
client_id,
redirect_uri,
grant_type,
};

// Retrieve and include code_verifier if it exists in sessionStorage (PKCE enabled)
const codeVerifier = sessionStorage.getItem(`pkce_${client_id}_${state}`);
if (codeVerifier) {
request.code_verifier = codeVerifier;
}

const endpoint = BASE_URL + GET_USER_INFO;
const response = await axios.post(endpoint, request, {
headers: {
"Content-Type": "application/json",
},
});

if (codeVerifier) {
sessionStorage.removeItem(`pkce_${client_id}_${state}`);
}

return response.data;
};

Expand Down Expand Up @@ -187,6 +279,8 @@ const relyingPartyService = {
get_nextAppointment,
get_requestUri,
get_dpop_jkt,
get_code_challenge,
get_code_challenge_method
};

export default relyingPartyService;