Skip to content

Commit

Permalink
chore: [IOCOM-1358] FIMS flow IAB saga (#5782)
Browse files Browse the repository at this point in the history
## Short description
Addition of the required saga for LolliPoP auth and IAB url gathering
for FIMS

## List of changes proposed in this pull request
- small mock update 
- addition of handleFimsGetRedirectUrlAndOpenIAB saga
- addition of its required utility functions
- tests to come

## How to test
1) check out [this dev-server
branch](https://github.com/pagopa/io-dev-api-server/tree/feature/fims)
2) in the dev-server's config.ts file, set `withCTA` to `true`
3) start a FIMS flow from the messages_home route
4) make sure it all goes swimmingly and at the end of the flow the app
navigates back and opens the IAB

---------

Co-authored-by: Andrea Piai <[email protected]>
  • Loading branch information
forrest57 and Vangaorth authored May 27, 2024
1 parent b8b56cc commit 98aa311
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 26 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
"react-native-splash-screen": "^3.2.0",
"react-native-svg": "^15.1.0",
"react-native-tab-view": "3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.1.2",
"react-native-vision-camera": "2.15.4",
"react-native-webview": "^13.8.1",
Expand Down
10 changes: 6 additions & 4 deletions ts/features/fims/__mocks__/mockFIMSCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type HttpClientSuccessResponse = {
type: "success";
status: number;
body: string;
headers: Record<string, string>;
headers: Record<string, string | undefined>;
};
export type HttpClientFailureResponse = {
type: "failure";
Expand Down Expand Up @@ -76,7 +76,7 @@ export const mockClearAllCookies = () => fakeCookieStorage.clear();

const hasValidFIMSToken = () => {
const fimsCookie = fakeCookieStorage.get(
`${FakeBaseUrl}_X-IO-Federation-Token`
`${FakeBaseUrl}/fims/provider__io_fims_token`
);
return fimsCookie && fimsCookie.value.trim().length > 0;
};
Expand Down Expand Up @@ -141,8 +141,10 @@ const fastForwardToGrantResponse = () => {
}
};

// eslint-disable-next-line sonarjs/cognitive-complexity
export const mockHttpNativeCall = (config: HttpCallConfig) => {
export const mockHttpNativeCall = (
config: HttpCallConfig
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<HttpClientResponse> => {
const verb = config.verb;
const url = config.url;

Expand Down
18 changes: 12 additions & 6 deletions ts/features/fims/__mocks__/mockFIMSSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ import {
HttpCallConfig,
RPInitialUrl,
FakeBaseUrl,
mockHttpNativeCall,
mockSetNativeCookie
mockHttpNativeCall as nativeCall,
mockSetNativeCookie,
HttpClientSuccessResponse
} from "./mockFIMSCallbacks";

// since we are hardwiring mocks, we are sure that the response is always successful -- no need for error handling
const mockHttpNativeCall = nativeCall as (
config: HttpCallConfig
) => Promise<HttpClientSuccessResponse>;

export function* mockFIMSSaga() {
try {
mockSetNativeCookie(FakeBaseUrl, "X-IO-Federation-Token", "asd");
mockSetNativeCookie(FakeBaseUrl, "_io_fims_token", "asd");
const config: HttpCallConfig = {
verb: "get",
url: RPInitialUrl,
Expand All @@ -20,7 +26,7 @@ export function* mockFIMSSaga() {
const consents = yield* call(mockHttpNativeCall, config);
console.log(`=== ${JSON.stringify(consents)}`);

const confirmUrl = consents.headers["confirm-url"];
const confirmUrl = consents.headers["confirm-url"] as string;
const config2: HttpCallConfig = {
verb: "post",
url: confirmUrl,
Expand All @@ -31,7 +37,7 @@ export function* mockFIMSSaga() {
const output2 = yield* call(mockHttpNativeCall, config2);
console.log(`=== ${JSON.stringify(output2)}`);

const nextUrl = output2.headers.Location;
const nextUrl = output2.headers.Location as string;
const config3: HttpCallConfig = {
verb: "get",
url: nextUrl,
Expand All @@ -41,7 +47,7 @@ export function* mockFIMSSaga() {
const output3 = yield* call(mockHttpNativeCall, config3);
console.log(`=== ${JSON.stringify(output3)}`);

const nextUrl4 = output3.headers.Location;
const nextUrl4 = output3.headers.Location as string;
const config4: HttpCallConfig = {
verb: "get",
url: nextUrl4,
Expand Down
262 changes: 256 additions & 6 deletions ts/features/fims/saga/index.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,296 @@
import { openAuthenticationSession } from "@pagopa/io-react-native-login-utils";
import { StackActions } from "@react-navigation/native";
import * as E from "fp-ts/Either";
import { URL } from "react-native-url-polyfill";
import { SagaIterator } from "redux-saga";
import { call, put, select, takeLatest } from "typed-redux-saga";
import { ActionType } from "typesafe-actions";
import NavigationService from "../../../navigation/NavigationService";
import { fimsTokenSelector } from "../../../store/reducers/authentication";
import { fimsDomainSelector } from "../../../store/reducers/backendStatus";
import { LollipopConfig } from "../../lollipop";
import { generateKeyInfo } from "../../lollipop/saga";
import {
lollipopKeyTagSelector,
lollipopPublicKeySelector
} from "../../lollipop/store/reducers/lollipop";
import { lollipopRequestInit } from "../../lollipop/utils/fetch";
import {
HttpCallConfig,
HttpClientFailureResponse,
HttpClientResponse,
HttpClientSuccessResponse,
mockHttpNativeCall,
mockSetNativeCookie
} from "../__mocks__/mockFIMSCallbacks";
import { fimsGetConsentsListAction } from "../store/actions";
import {
fimsGetConsentsListAction,
fimsGetRedirectUrlAndOpenIABAction
} from "../store/actions";
import { fimsCTAUrlSelector } from "../store/reducers";

export function* watchFimsSaga(): SagaIterator {
yield* takeLatest(
fimsGetConsentsListAction.request,
handleFimsGetConsentsList
);
yield* takeLatest(
fimsGetRedirectUrlAndOpenIABAction.request,
handleFimsGetRedirectUrlAndOpenIAB
);
}

function* handleFimsGetConsentsList() {
// TODO:: maybe move navigation here from utils

const fimsToken = yield* select(fimsTokenSelector);
const oidcProviderUrl = yield* select(fimsDomainSelector);
const fimsCTAUrl = yield* select(fimsCTAUrlSelector);

if (!fimsToken || !oidcProviderUrl || !fimsCTAUrl) {
// TODO:: proper error handling
yield* put(fimsGetConsentsListAction.failure(new Error("missing data")));
yield* put(
fimsGetConsentsListAction.failure(new Error("missing FIMS data"))
);
return;
}

yield* call(
mockSetNativeCookie,
oidcProviderUrl,
"X-IO-Federation-Token",
"_io_fims_token",
fimsToken
);
// TODO:: failure backend response should report a fimsGetConsentsListAction.failure

const getConsentsResult = yield* call(mockHttpNativeCall, {
verb: "get",
followRedirects: true,
url: fimsCTAUrl
});

if (getConsentsResult.type === "failure") {
yield* put(
fimsGetConsentsListAction.failure(new Error("consent data fetch error"))
);
return;
}
yield* put(fimsGetConsentsListAction.success(getConsentsResult));
}

// note: IAB => InAppBrowser
function* handleFimsGetRedirectUrlAndOpenIAB(
action: ActionType<(typeof fimsGetRedirectUrlAndOpenIABAction)["request"]>
) {
const oidcProviderDomain = yield* select(fimsDomainSelector);
if (!oidcProviderDomain) {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("missing FIMS domain")
)
);
return;
}
const { acceptUrl } = action.payload;
if (!acceptUrl) {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("unable to accept grants: invalid URL")
)
);
return;
}

const rpUrl = yield* call(
recurseUntilRPUrl,
{ url: acceptUrl, verb: "post" },
oidcProviderDomain
);
// --------------- lolliPoP -----------------

if (rpUrl.type === "failure") {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("could not get RelyingParty redirect URL")
)
);
return;
}
const relyingPartyRedirectUrl = rpUrl.headers.Location;

const [authCode, lollipopNonce] = getQueryParamsFromUrlString(
relyingPartyRedirectUrl
);

if (!authCode || !lollipopNonce) {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("could not extract auth data from RelyingParty URL")
)
);
return;
}

const lollipopConfig: LollipopConfig = {
nonce: lollipopNonce,
customContentToSign: {
authorization_code: authCode
}
};

const requestInit = { headers: {}, method: "GET" as const };

const keyTag = yield* select(lollipopKeyTagSelector);
const publicKey = yield* select(lollipopPublicKeySelector);
const keyInfo = yield* call(generateKeyInfo, keyTag, publicKey);
const lollipopEither = yield* call(
nonThrowingLollipopRequestInit,
lollipopConfig,
keyInfo,
relyingPartyRedirectUrl,
requestInit
);

if (E.isLeft(lollipopEither)) {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("could not sign request with LolliPoP")
)
);
return;
}
const lollipopInit = lollipopEither.right;

const inAppBrowserUrl = yield* call(mockHttpNativeCall, {
verb: "get",
url: relyingPartyRedirectUrl,
headers: lollipopInit.headers as Record<string, string>,
followRedirects: false
});

const inAppBrowserRedirectUrl = extractValidRedirect(
inAppBrowserUrl,
relyingPartyRedirectUrl
);

if (!inAppBrowserRedirectUrl) {
yield* put(
fimsGetRedirectUrlAndOpenIABAction.failure(
new Error("IAB url call failed or without a valid redirect")
)
);
return;
}

// ----------------- end lolliPoP -----------------

yield* put(fimsGetRedirectUrlAndOpenIABAction.success());
yield* call(NavigationService.dispatchNavigationAction, StackActions.pop());
return openAuthenticationSession(inAppBrowserRedirectUrl, "");
}

// -------------------- UTILS --------------------

const nonThrowingLollipopRequestInit = async (
...props: Parameters<typeof lollipopRequestInit>
) => {
try {
const res = await lollipopRequestInit(...props);
return E.right(res);
} catch (error) {
return E.left(error);
}
};

interface SuccessResponseWithLocationHeader extends HttpClientSuccessResponse {
headers: {
Location: string;
};
}

const isValidRedirectResponse = (
res: HttpClientResponse
): res is SuccessResponseWithLocationHeader =>
res.type === "success" &&
isRedirect(res.status) &&
!!res.headers.Location &&
res.headers.Location.trim().length > 0;

const extractValidRedirect = (
data: HttpClientResponse,
originalRequestUrl: string
) => {
if (!isValidRedirectResponse(data)) {
return undefined;
}
const redirect = data.headers.Location;
try {
const redirectUrl = new URL(redirect);
return redirectUrl.href;
} catch (error) {
try {
const originalUrl = new URL(originalRequestUrl);
const origin = originalUrl.origin;
const composedUrlString = redirect.startsWith("/")
? `${origin}${redirect}`
: `${origin}/${redirect}`;
const composedUrl = new URL(composedUrlString);
return composedUrl.href;
} catch {
return undefined;
}
}
};

const getQueryParamsFromUrlString = (url: string) => {
try {
const constructedUrl = new URL(url);
const params = constructedUrl.searchParams;
return [params.get("authorization_code"), params.get("nonce")];
} catch (error) {
return [undefined, undefined];
}
};

const isRedirect = (statusCode: number) =>
statusCode >= 300 && statusCode < 400;

const recurseUntilRPUrl = async (
httpClientConfig: HttpCallConfig,
oidcDomain: string
): Promise<SuccessResponseWithLocationHeader | HttpClientFailureResponse> => {
const res = await mockHttpNativeCall({
...httpClientConfig,
followRedirects: false
});

const redirectUrl = extractValidRedirect(res, httpClientConfig.url);

if (!redirectUrl) {
if (res.type === "failure") {
return res;
}
// error case
const response: HttpClientFailureResponse = {
code: res.status,
type: "failure",
message: `malformed HTTP redirect response, location header value: ${res.headers.Location}`
};
return response;
}

const isOIDCProviderBaseUrl = redirectUrl
.toLowerCase()
.startsWith(oidcDomain.toLowerCase());

if (!isOIDCProviderBaseUrl) {
return res as SuccessResponseWithLocationHeader;
} else {
return await recurseUntilRPUrl(
{
verb: "get",
url: redirectUrl,
followRedirects: false,
headers: httpClientConfig.headers
},
oidcDomain
);
}
};
Loading

0 comments on commit 98aa311

Please sign in to comment.