diff --git a/README.md b/README.md index 680f68dc..1f3cf97a 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,10 @@ sequenceDiagram ``` ### RP Requirements to use the Simulator: -The simulator aims to mirror GOV.UK One Login but does not support all of the features, therefore there are some limitations to what RP features it supports: -- You **must** use the[`private_key_jwt` authentication method](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=OAuth.JWT%5D.-,private_key_jwt,-Clients%20that%20have) at the `/token` endpoint + +The simulator aims to mirror GOV.UK One Login but does not support all of the features, therefore there are some limitations to what RP features it supports: + +- You **must** use the[`private_key_jwt` authentication method](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=OAuth.JWT%5D.-,private_key_jwt,-Clients%20that%20have) at the `/token` endpoint - You **must** attach a `nonce` parameter in the `/authorize` request - You **must** use a `GET` request with query parameters when making a request to `/authorize`. The simulator does not[support request objects](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=%C2%A0TOC-,3.1.2.1.%C2%A0%20Authentication%20Request,-An%20Authentication%20Request) or the `POST` method for authorization requests. @@ -124,16 +126,17 @@ The table below describes the different fields for the client configuration. Whe } ``` -| Field | Description | Environment Variable | Config request field | Valid values | -| ------------------------------- | ------------------------------------------------------------------------------ | --------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Client Id | The public identifier for a client | `CLIENT_ID` | clientId | Any string | -| Public Key | The public key which should be used to validate the client_assertion signature | `PUBLIC_KEY` | publicKey | PEM encoded public key | -| Scopes | The scopes which the client is configured to request | `SCOPES` | scopes | "openid", "email", "phone" | -| Redirect URLs | The redirect URLs for the client, which a user will be redirected to | `REDIRECT_URLS` | redirectUrls | Any valid URLs | -| Claims | The claims which the client is configured to request | `CLAIMS` | claims | "https://vocab.account.gov.uk/v1/passport", "https://vocab.account.gov.uk/v1/address", "https://vocab.account.gov.uk/v1/drivingPermit", "https://vocab.account.gov.uk/v1/socialSecurityRecord", "https://vocab.account.gov.uk/v1/coreIdentityJWT", "https://vocab.account.gov.uk/v1/returnCode", | -| Identity Verification Supported | Whether or not the client has identity verification enabled | `IDENTITY_VERIFICATION_SUPPORTED` | identityVerificationSupported | boolean | -| ID Token Signing Algorithm | The algorithm which the id token should be signed with | `ID_TOKEN_SIGNING_ALGORITHM` | idTokenSigningAlgorithm | "ES256" or "RS256" | -| Client Levels of Confidence | The levels of confidence values which the client can request | `CLIENT_LOCS` | clientLoCs | "P0", "P1", "P2" | +| Field | Description | Environment Variable | Config request field | Valid values | +| ------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Client Id | The public identifier for a client | `CLIENT_ID` | clientId | Any string | +| Public Key | The public key which should be used to validate the client_assertion signature | `PUBLIC_KEY` | publicKey | PEM encoded public key | +| Scopes | The scopes which the client is configured to request | `SCOPES` | scopes | "openid", "email", "phone" | +| Redirect URLs | The redirect URLs for the client, which a user will be redirected to | `REDIRECT_URLS` | redirectUrls | Any valid URLs | +| Claims | The claims which the client is configured to request | `CLAIMS` | claims | "https://vocab.account.gov.uk/v1/passport", "https://vocab.account.gov.uk/v1/address", "https://vocab.account.gov.uk/v1/drivingPermit", "https://vocab.account.gov.uk/v1/socialSecurityRecord", "https://vocab.account.gov.uk/v1/coreIdentityJWT", "https://vocab.account.gov.uk/v1/returnCode", | +| Identity Verification Supported | Whether or not the client has identity verification enabled | `IDENTITY_VERIFICATION_SUPPORTED` | identityVerificationSupported | boolean | +| ID Token Signing Algorithm | The algorithm which the id token should be signed with | `ID_TOKEN_SIGNING_ALGORITHM` | idTokenSigningAlgorithm | "ES256" or "RS256" | +| Client Levels of Confidence | The levels of confidence values which the client can request | `CLIENT_LOCS` | clientLoCs | "P0", "P1", "P2" | +| Post Logout redirect URIs | The redirect URIs configured for a client for a user to be redirected to after being logged out | `POST_LOGOUT_REDIRECT_URLS` | postLogoutRedirectUrls | Any valid URLs | ### Response Configuration: diff --git a/src/app.ts b/src/app.ts index 14b4b93f..d3e3b541 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import { trustmarkController } from "./components/trustmark/trustmark-controller import { generateConfigRequestPropertyValidators } from "./types/config-request"; import { body, checkExact } from "express-validator"; import { didController } from "./components/did/did-controller"; +import { logoutController } from "./components/logout/logout-controller"; const createApp = (): Application => { const app: Express = express(); @@ -40,6 +41,7 @@ const createApp = (): Application => { res.send(JSON.stringify(await generateJWKS())); }); app.get("/.well-known/did.json", didController); + app.get("/logout", logoutController); return app; }; diff --git a/src/components/config/config-controller.ts b/src/components/config/config-controller.ts index d6c73c30..46aa592d 100644 --- a/src/components/config/config-controller.ts +++ b/src/components/config/config-controller.ts @@ -67,6 +67,11 @@ const populateClientConfiguration = ( if (clientConfiguration.clientLoCs !== undefined) { config.setClientLoCs(clientConfiguration.clientLoCs); } + if (clientConfiguration.postLogoutRedirectUrls !== undefined) { + config.setPostLogoutRedirectUrls( + clientConfiguration.postLogoutRedirectUrls + ); + } }; const populateResponseConfiguration = ( diff --git a/src/components/logout/logout-controller.ts b/src/components/logout/logout-controller.ts new file mode 100644 index 00000000..534dd16a --- /dev/null +++ b/src/components/logout/logout-controller.ts @@ -0,0 +1,193 @@ +import { Request, Response } from "express"; +import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose"; +import { Config } from "../../config"; +import { logger } from "../../logger"; +import { publicJwkWithKidFromPrivateKey } from "../token/helper/key-helpers"; +import { + EC_PRIVATE_TOKEN_SIGNING_KEY, + EC_PRIVATE_TOKEN_SIGNING_KEY_ID, + RSA_PRIVATE_TOKEN_SIGNING_KEY, + RSA_PRIVATE_TOKEN_SIGNING_KEY_ID, +} from "../../constants"; + +export const logoutController = async ( + req: Request, + res: Response +): Promise => { + const queryParams = req.query; + logger.info("Logout request received"); + const config = Config.getInstance(); + + const defaultLogoutUrl = `${config.getSimulatorUrl()}/signed-out`; + + if (Object.keys(queryParams).length === 0) { + return res.redirect(defaultLogoutUrl); + } + + const idTokenHint = queryParams.id_token_hint as string | undefined; + const stateParam = queryParams.state as string | undefined; + + if (!idTokenHint) { + logger.info("No Id token hint attached, redirecting to default url"); + return res.redirect(buildRedirectUri(defaultLogoutUrl, stateParam)); + } + + if (!(await isIdTokenSignatureValid(idTokenHint))) { + return res.redirect( + buildRedirectUri(defaultLogoutUrl, stateParam, { + error_code: "invalid_request", + error_description: "unable to validate id_token_hint", + }) + ); + } + + let parsedIdToken: { + clientId: string | undefined; + clientSessionId: string | undefined; + rpPairwiseId: string | undefined; + }; + + try { + parsedIdToken = parseIdTokenHint(idTokenHint); + } catch (error) { + // This error is very unlikely to be hit here and is just as unlikley to be hit in + // the actual code as we need to first parse the id token hint before + // validating it's signature + // https://github.com/govuk-one-login/authentication-api/blob/37642c85a403a5e42bbec9b621aaed079b03c78f/oidc-api/src/main/java/uk/gov/di/authentication/oidc/entity/LogoutRequest.java#L84-L87 + logger.warn("Failed to parse Id Token hint: " + (error as Error).message); + + return res.redirect( + buildRedirectUri(defaultLogoutUrl, stateParam, { + error_code: "invalid_request", + error_description: "invalid id_token_hint", + }) + ); + } + + if (!parsedIdToken.clientId || !parsedIdToken.rpPairwiseId) { + logger.info( + "No client ID or subject claim, redirecting to default logout URI" + ); + return res.redirect(buildRedirectUri(defaultLogoutUrl, stateParam)); + } + + if (parsedIdToken.clientId !== config.getClientId()) { + logger.info("Client not found, redirecting with error"); + return res.redirect( + buildRedirectUri(defaultLogoutUrl, stateParam, { + error_code: "unauthorized_client", + error_description: "client not found", + }) + ); + } + + if (!queryParams.post_logout_redirect_uri) { + logger.info( + "No post_logout_redirect_uri, redirecting to default logout URI" + ); + return res.redirect(buildRedirectUri(defaultLogoutUrl, stateParam)); + } + + if ( + !config + .getPostLogoutRedirectUrls() + .includes(queryParams.post_logout_redirect_uri as string) + ) { + logger.info( + "Post logout redirect uri not present in client config: " + + queryParams.post_logout_redirect_uri + ); + return res.redirect( + buildRedirectUri(defaultLogoutUrl, stateParam, { + error_code: "invalid_request", + error_description: + "client registry does not contain post_logout_redirect_uri", + }) + ); + } + + if (!isValidUrl(queryParams.post_logout_redirect_uri as string)) { + return res.redirect( + buildRedirectUri(defaultLogoutUrl, stateParam, { + error_code: "invalid_request", + error_description: "invalid post logout redirect URI", + }) + ); + } + + res.redirect( + buildRedirectUri(queryParams.post_logout_redirect_uri as string, stateParam) + ); +}; + +const isIdTokenSignatureValid = async (idToken: string): Promise => { + try { + if (idToken.split(".").length !== 3) { + return false; + } + const header = decodeProtectedHeader(idToken); + // This is akin to calling SignedJWT.parse in the real code + // at this point we don't actually need to parse it + // just check that parsing it doesn't throw an error + decodeJwt(idToken); + if (header.alg === "RS256") { + const rsaKey = await publicJwkWithKidFromPrivateKey( + RSA_PRIVATE_TOKEN_SIGNING_KEY, + RSA_PRIVATE_TOKEN_SIGNING_KEY_ID + ); + await jwtVerify(idToken, rsaKey); + return true; + } else { + const ecKey = await publicJwkWithKidFromPrivateKey( + EC_PRIVATE_TOKEN_SIGNING_KEY, + EC_PRIVATE_TOKEN_SIGNING_KEY_ID + ); + await jwtVerify(idToken, ecKey); + return true; + } + } catch (error) { + logger.error( + "Failed to verify signature of ID token: " + (error as Error).message + ); + return false; + } +}; + +const parseIdTokenHint = ( + idTokenHint: string +): { + clientId: string | undefined; + clientSessionId: string | undefined; + rpPairwiseId: string | undefined; +} => { + const jwtClaimsSet = decodeJwt(idTokenHint); + + return { + clientId: Array.isArray(jwtClaimsSet.aud) + ? jwtClaimsSet.aud[0] + : jwtClaimsSet.aud, + clientSessionId: jwtClaimsSet.sid as string | undefined, + rpPairwiseId: jwtClaimsSet.sub, + }; +}; + +const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch (error) { + logger.warn( + "Post logout redirect uri is not valid url: " + url, + "error: " + (error as Error).message + ); + return false; + } +}; +const buildRedirectUri = ( + baseurl: string, + state: string | undefined, + errorOpts?: { error_code: string; error_description: string } +): string => { + const queryParams = { ...errorOpts, ...(state && { state }) }; + return `${baseurl}?${new URLSearchParams(queryParams).toString()}`; +}; diff --git a/src/components/token/helper/key-helpers.ts b/src/components/token/helper/key-helpers.ts index 579ae6c8..14a39136 100644 --- a/src/components/token/helper/key-helpers.ts +++ b/src/components/token/helper/key-helpers.ts @@ -64,7 +64,7 @@ async function publicJwkFromPrivateKey(privateKeyString: string): Promise { return await exportJWK(publicKey); } -async function publicJwkWithKidFromPrivateKey( +export async function publicJwkWithKidFromPrivateKey( privateKeyString: string, kid: string ): Promise { diff --git a/src/config.ts b/src/config.ts index f3293036..e9e29156 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,9 @@ CQIDAQAB redirectUrls: process.env.REDIRECT_URLS ? process.env.REDIRECT_URLS.split(",") : ["http://localhost:8080/oidc/authorization-code/callback"], + postLogoutRedirectUrls: process.env.POST_LOGOUT_REDIRECT_URLS + ? process.env.POST_LOGOUT_REDIRECT_URLS.split(",") + : ["http://localhost:8080/signed-out"], claims: process.env.CLAIMS ? (process.env.CLAIMS.split(",") as UserIdentityClaim[]) : ["https://vocab.account.gov.uk/v1/coreIdentityJWT"], @@ -136,6 +139,14 @@ CQIDAQAB this.clientConfiguration.redirectUrls = redirectUrls; } + public getPostLogoutRedirectUrls(): string[] { + return this.clientConfiguration.postLogoutRedirectUrls as string[]; + } + + public setPostLogoutRedirectUrls(postLogoutRedirectUrls: string[]): void { + this.clientConfiguration.postLogoutRedirectUrls = postLogoutRedirectUrls; + } + public getClaims(): UserIdentityClaim[] { return this.clientConfiguration.claims!; } diff --git a/src/types/client-configuration.ts b/src/types/client-configuration.ts index 0066337c..1ac424de 100644 --- a/src/types/client-configuration.ts +++ b/src/types/client-configuration.ts @@ -18,6 +18,7 @@ export default interface ClientConfiguration { identityVerificationSupported?: boolean; idTokenSigningAlgorithm?: string; clientLoCs?: string[]; + postLogoutRedirectUrls?: string[]; } export const generateClientConfigurationPropertyValidators = ( @@ -52,5 +53,8 @@ export const generateClientConfigurationPropertyValidators = ( bodyOptional( `${prefix}${nameof("clientLoCs")}.*` ).isIn(VALID_LOC_VALUES), + bodyOptional( + `${prefix}${nameof("postLogoutRedirectUrls")}.*` + ).isURL(), ]; }; diff --git a/src/components/authorise/tests/authorise-get-controller.test.ts b/tests/integration/authorise-get-controller.test.ts similarity index 99% rename from src/components/authorise/tests/authorise-get-controller.test.ts rename to tests/integration/authorise-get-controller.test.ts index 7b218ce9..3a05e38a 100644 --- a/src/components/authorise/tests/authorise-get-controller.test.ts +++ b/tests/integration/authorise-get-controller.test.ts @@ -1,7 +1,7 @@ import crypto from "crypto"; -import { createApp } from "../../../app"; +import { createApp } from "./../../src/app"; import request from "supertest"; -import { Config } from "../../../config"; +import { Config } from "./../../src/config"; const authoriseEndpoint = "/authorize"; const knownClientId = "43c729a8f8a8bed3441a872039d45180"; diff --git a/src/components/config/tests/config-controller.test.ts b/tests/integration/config-controller.test.ts similarity index 95% rename from src/components/config/tests/config-controller.test.ts rename to tests/integration/config-controller.test.ts index 2a690363..94a8fcee 100644 --- a/src/components/config/tests/config-controller.test.ts +++ b/tests/integration/config-controller.test.ts @@ -1,11 +1,12 @@ -import { createApp } from "../../../app"; +import { createApp } from "./../../src/app"; import request from "supertest"; -import { Config } from "../../../config"; +import { Config } from "./../../src/config"; const TEST_CLIENT_ID = "test-id"; const TEST_PUBLIC_KEY = "test-public-key"; const TEST_SCOPES = ["scope1", "scope2"]; const TEST_REDIRECT_URLS = ["http://redirect-url.co.uk"]; +const TEST_POST_LOGOUT_REDIRECT_URLS = ["http://example.com/post-logout"]; const TEST_CLAIMS = [ "https://vocab.account.gov.uk/v1/coreIdentityJWT", "https://vocab.account.gov.uk/v1/passport", @@ -39,6 +40,7 @@ describe("Integration: Config POST", () => { identityVerificationSupported: TEST_IDENTITY_VERIFICATION_SUPPORTED, idTokenSigningAlgorithm: TEST_ID_TOKEN_SIGNING_ALGORITHM, clientLoCs: TEST_CLIENT_LOCS, + postLogoutRedirectUrls: TEST_POST_LOGOUT_REDIRECT_URLS, }, responseConfiguration: { sub: TEST_SUB, @@ -62,6 +64,9 @@ describe("Integration: Config POST", () => { expect(config.getPublicKey()).toEqual(TEST_PUBLIC_KEY); expect(config.getScopes()).toEqual(TEST_SCOPES); expect(config.getRedirectUrls()).toEqual(TEST_REDIRECT_URLS); + expect(config.getPostLogoutRedirectUrls()).toEqual( + TEST_POST_LOGOUT_REDIRECT_URLS + ); expect(config.getClaims()).toEqual(TEST_CLAIMS); expect(config.getIdentityVerificationSupported()).toEqual( TEST_IDENTITY_VERIFICATION_SUPPORTED @@ -109,6 +114,9 @@ describe("Integration: Config POST", () => { expect(config.getPublicKey()).toEqual(TEST_PUBLIC_KEY); expect(config.getScopes()).toEqual(["openid", "email", "phone"]); expect(config.getRedirectUrls()).toEqual(TEST_REDIRECT_URLS); + expect(config.getPostLogoutRedirectUrls()).toEqual([ + "http://localhost:8080/signed-out", + ]); expect(config.getClaims()).toEqual(TEST_CLAIMS); expect(config.getIdentityVerificationSupported()).toEqual(true); expect(config.getIdTokenSigningAlgorithm()).toEqual( diff --git a/tests/integration/logout.test.ts b/tests/integration/logout.test.ts new file mode 100644 index 00000000..b883ab59 --- /dev/null +++ b/tests/integration/logout.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "crypto"; +import { createApp } from "../../src/app"; +import request from "supertest"; +import { + EC_PRIVATE_TOKEN_SIGNING_KEY, + EC_PRIVATE_TOKEN_SIGNING_KEY_ID, +} from "../../src/constants"; +import { importPKCS8, SignJWT } from "jose"; +import { Config } from "../../src/config"; + +const LOGOUT_ENDPOINT = "/logout"; +const DEFAULT_LOGGED_OUT_URL = "http://localhost:3000/signed-out"; +const DEFAULT_CLIENT_ID = "HGIOgho9HIRhgoepdIOPFdIUWgewi0jw"; + +const fakeIdToken = async ( + payload: Record +): Promise => { + const keyId = EC_PRIVATE_TOKEN_SIGNING_KEY_ID; + const privateKey = await importPKCS8(EC_PRIVATE_TOKEN_SIGNING_KEY, "ES256"); + return await new SignJWT(payload) + .setExpirationTime("5m") + .setProtectedHeader({ kid: keyId, alg: "ES256" }) + .sign(privateKey); +}; + +describe("logout endpoint", () => { + test("An empty logout request just redirects to the default logout endpoint", async () => { + const app = createApp(); + const response = await request(app).get(LOGOUT_ENDPOINT); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual(DEFAULT_LOGGED_OUT_URL); + }); + + test("An logout request with only containing state redirects to the default logout endpoint with state", async () => { + const state = randomUUID(); + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?${new URLSearchParams({ + state, + }).toString()}` + ); + }); + + test("A logout request which has an id_token_hint with an invalid signature redirects with an error message", async () => { + const state = randomUUID(); + const idTokenHint = + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?${new URLSearchParams({ + error_code: "invalid_request", + error_description: "unable to validate id_token_hint", + state, + }).toString()}` + ); + }); + + test("A logout request which has an id_token_hint with an unknown client redirects to default logout endpoint with error", async () => { + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: randomUUID(), + sid: randomUUID(), + sub: randomUUID(), + }); + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?${new URLSearchParams({ + error_code: "unauthorized_client", + error_description: "client not found", + state, + }).toString()}` + ); + }); + + test("A logout request which has a valid id_token_hint with no clientId (aud) redirects to the default endpoint with no errors", async () => { + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + sid: randomUUID(), + sub: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?state=${state}` + ); + }); + + test("A logout request which has a valid id_token_hint with no rpPairwiseId (sub) redirects to the default endpoint with no errors", async () => { + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: DEFAULT_CLIENT_ID, + sid: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?state=${state}` + ); + }); + + test("A logout request which has a valid id_token_hint with no post_logout_redirect_uri redirects to the default endpoint with no errors", async () => { + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: DEFAULT_CLIENT_ID, + sid: randomUUID(), + sub: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?state=${state}` + ); + }); + + test("A logout request which has a valid id_token_hint but invalid post_logout_redirect_uri will redirect to the default with an error", async () => { + const postLogoutRedirectUri = "https://example.com/oidc/post-logout"; + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: DEFAULT_CLIENT_ID, + sid: randomUUID(), + sub: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?${new URLSearchParams({ + error_code: "invalid_request", + error_description: + "client registry does not contain post_logout_redirect_uri", + state, + }).toString()}` + ); + }); + + test("A logout request which has a valid id_token_hint and a matching redirect url that is not a valid url is redirected to with an error", async () => { + const postLogoutRedirectUri = " "; + Config.getInstance().setPostLogoutRedirectUrls([postLogoutRedirectUri]); + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: DEFAULT_CLIENT_ID, + sid: randomUUID(), + sub: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?state=${state}&id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${DEFAULT_LOGGED_OUT_URL}?${new URLSearchParams({ + error_code: "invalid_request", + error_description: "invalid post logout redirect URI", + state, + }).toString()}` + ); + }); + + test("A valid logout request will redirect to the post_logout_redirect_uri", async () => { + const postLogoutRedirectUri = "https://example.com/oidc/post-logout"; + Config.getInstance().setPostLogoutRedirectUrls([postLogoutRedirectUri]); + const state = randomUUID(); + const idTokenHint = await fakeIdToken({ + aud: DEFAULT_CLIENT_ID, + sid: randomUUID(), + sub: randomUUID(), + }); + + const app = createApp(); + const response = await request(app).get( + `${LOGOUT_ENDPOINT}?&state=${state}&id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}` + ); + expect(response.status).toEqual(302); + expect(response.headers.location).toStrictEqual( + `${postLogoutRedirectUri}?state=${state}` + ); + }); +});