Skip to content

Commit

Permalink
feat(op): implicit consent for internal rp (#86)
Browse files Browse the repository at this point in the history
  • Loading branch information
lucacavallaro authored Dec 11, 2024
1 parent b8928bb commit fd9f25e
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 28 deletions.
87 changes: 62 additions & 25 deletions apps/op-app/src/adapters/express/routes/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SendEventMessageUseCase } from "@/use-cases/send-event-messge.js";
import * as express from "express";
import { requestParamsSchema } from "io-fims-common/domain/audit-event";
import * as assert from "node:assert/strict";
import { Logger } from "pino";
import { z } from "zod";

import { schemas } from "../api-models.js";
Expand All @@ -28,6 +29,8 @@ const consentSchema = z.object({
}),
});

type Consent = z.infer<typeof consentSchema>;

const redirectDisplayNamesSchema = (redirectUri: string) =>
z.object({
// since we don't know [redirectUri] at compile time, we parse it with
Expand Down Expand Up @@ -56,6 +59,44 @@ export const parseRedirectDisplayName = (
return redirectDisplayName.it;
};

const confirmConsent = async (
oidcProvider: Provider,
consent: Consent,
logAccess: LogAccessUseCase,
logger: Logger,
): Promise<string> => {
const grant = new oidcProvider.Grant({
accountId: consent.session.accountId,
clientId: consent.params.client_id,
});

consent.prompt.details.missingOIDCScope.forEach((scope) => {
grant.addOIDCScope(scope);
});

const grantId = await grant.save();
logger.debug({ grantId }, "grant saved");

const client = await oidcProvider.Client.find(consent.params.client_id);
assert.ok(client, new HttpError("Client not found"));

logger.debug(client, "client retrieved from oidc provided");

const redirectDisplayNames = redirectDisplayNamesSchema(
consent.params.redirect_uri,
).parse(client["redirect_display_names"]);

await logAccess.execute(
consent.session.accountId,
{
client_id: consent.params.client_id,
redirect_display_names: redirectDisplayNames,
},
consent.params.redirect_uri,
);
return grantId;
};

/* eslint-disable max-lines-per-function */
export default function createInteractionRouter(
oidcProvider: Provider,
Expand Down Expand Up @@ -101,6 +142,21 @@ export default function createInteractionRouter(

req.log.debug(client, "client retrieved from oidc provided");

if (Object.hasOwn(client, "is_internal") && client["is_internal"]) {
const grantId = await confirmConsent(
oidcProvider,
consent,
logAccess,
req.log,
);

return await oidcProvider.interactionFinished(req, res, {
consent: {
grantId,
},
});
}

const locale = req.getLocale();

const redirectDisplayName = parseRedirectDisplayName(
Expand Down Expand Up @@ -218,35 +274,16 @@ export default function createInteractionRouter(
router[method]("/interaction/:uid/consent", async (req, res, next) => {
try {
req.log.debug("consent route");

const interaction = await oidcProvider.interactionDetails(req, res);
const consent = consentSchema.parse(interaction);
req.log.debug({ consent }, "interaction parsed to consent type");
const grant = new oidcProvider.Grant({
accountId: consent.session.accountId,
clientId: consent.params.client_id,
});
consent.prompt.details.missingOIDCScope.forEach((scope) => {
grant.addOIDCScope(scope);
});
const grantId = await grant.save();
req.log.debug({ grantId }, "grant saved");

const client = await oidcProvider.Client.find(consent.params.client_id);
assert.ok(client, new HttpError("Client not found"));

req.log.debug(client, "client retrieved from oidc provided");

const redirectDisplayNames = redirectDisplayNamesSchema(
consent.params.redirect_uri,
).parse(client["redirect_display_names"]);

await logAccess.execute(
consent.session.accountId,
{
client_id: consent.params.client_id,
redirect_display_names: redirectDisplayNames,
},
consent.params.redirect_uri,
const grantId = await confirmConsent(
oidcProvider,
consent,
logAccess,
req.log,
);

return await oidcProvider.interactionFinished(req, res, {
Expand Down
2 changes: 1 addition & 1 deletion apps/op-app/src/adapters/oidc/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function createProvider(
keys: cookieKeys,
},
extraClientMetadata: {
properties: ["redirect_display_names"],
properties: ["redirect_display_names", "is_internal"],
},
features: {
customKeyStore: {
Expand Down
59 changes: 57 additions & 2 deletions apps/op-app/src/adapters/tests/express.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ const health = new HealthUseCase([]);

const cookieKeys = ["test1"];

const createClient = (): ClientMetadata => ({
const createClient = (internal = false): ClientMetadata => ({
client_id: ulid(),
client_id_issued_at: 1715695157510,
client_secret: "my-secret",
grant_types: ["authorization_code", "implicit"],
is_internal: internal,
redirect_display_names: {
"https://rp.localhost": {
en: "Manage your appointments",
Expand Down Expand Up @@ -156,9 +157,10 @@ async function authenticationRequest(
agent: Agent,
responseType: string,
scopes: Scope[],
internal = false,
) {
// create and register a new client
const client = createClient();
const client = createClient(internal);
store.set(`Client:${client.client_id}`, client);

// this state is unique to the entire transaction
Expand Down Expand Up @@ -263,6 +265,7 @@ const responseTypeFromFlow = (flow: OIDCFlow): "code" | "id_token" => {
}
};

/* eslint-disable max-lines-per-function */
describe("Consent screen", () => {
test.each<{
flow: OIDCFlow;
Expand Down Expand Up @@ -422,7 +425,59 @@ describe("Consent screen", () => {
expect(consent.data.user_metadata).toStrictEqual(expected.user_metadata);
}
});

test.each([false, true])(
"Consent screen by internal status",
async (internal) => {
const user = createUserMetadata();
store.set(`user-metadata:${user.token}`, user.metadata);

const provider = createProvider(
"http://localhost",
sessionRepository,
adapter,
cookieKeys,
);

const login = new LoginUseCase({
identityProvider,
sessionRepository,
});

const eventUseCase = new SendEventMessageUseCase(
sessionRepository,
eventRepository,
eventEmitter,
);

const logAccess = new LogAccessUseCase(sessionRepository, eventEmitter);

const app = createApplication(
provider,
login,
eventUseCase,
logAccess,
health,
logger,
);

// setup agent with _io_fims_token
const agent = request.agent(app).set({
Cookie: `_io_fims_token=${user.token}`,
});

const { response } = await authenticationRequest(
agent,
"id_token",
["openid"],
internal,
);

expect(response.status).toBe(internal ? 303 : 200);
},
);
});
/* eslint-enable max-lines-per-function */
describe("Login", () => {
describe("Recoverable errors", () => {
test("500 error on login error", async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/io-fims-common/src/domain/client-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const clientMetadataSchema = z
client_id_issued_at: z.number(),
client_secret: z.string().min(1),
grant_types: z.array(z.enum(["implicit", "authorization_code"])),
is_internal: z.boolean(),
redirect_display_names: z.record(
z.intersection(
z.record(z.string()),
Expand Down Expand Up @@ -48,6 +49,7 @@ const clientMetadataFromConfig = (
client_id_issued_at,
client_secret,
grant_types: ["authorization_code"],
is_internal: false,
redirect_display_names: config.callbacks.reduce(
(redirectDisplayNames, { displayName, uri }) => ({
...redirectDisplayNames,
Expand Down

0 comments on commit fd9f25e

Please sign in to comment.