Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BAU: Adds logout endpoint #189

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to notify the tech writers of these changes as well


### Response Configuration:

Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
};
Expand Down
5 changes: 5 additions & 0 deletions src/components/config/config-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const populateClientConfiguration = (
if (clientConfiguration.clientLoCs !== undefined) {
config.setClientLoCs(clientConfiguration.clientLoCs);
}
if (clientConfiguration.postLogoutRedirectUrls !== undefined) {
config.setPostLogoutRedirectUrls(
clientConfiguration.postLogoutRedirectUrls
);
}
};

const populateResponseConfiguration = (
Expand Down
188 changes: 188 additions & 0 deletions src/components/logout/logout-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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<void> => {
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;
const stateParam = queryParams.state as string | undefined;

if (!idTokenHint || typeof idTokenHint !== "string") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where does the non string bit come from?

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
Comment on lines +53 to +55
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm - in the actual code, we seem to parse the whole JWT earlier, but here we only seem to parse the header - is that correct?

// 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(
Dismissed Show dismissed Hide dismissed
buildRedirectUri(queryParams.post_logout_redirect_uri as string, stateParam)
);
};

const isIdTokenSignatureValid = async (idToken: string): Promise<boolean> => {
try {
const header = decodeProtectedHeader(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}?${Object.entries(queryParams)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&")}`;
};
Comment on lines +185 to +188
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but I wonder if there's not something built into javacript that will do it for us?

2 changes: 1 addition & 1 deletion src/components/token/helper/key-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async function publicJwkFromPrivateKey(privateKeyString: string): Promise<JWK> {
return await exportJWK(publicKey);
}

async function publicJwkWithKidFromPrivateKey(
export async function publicJwkWithKidFromPrivateKey(
privateKeyString: string,
kid: string
): Promise<JWK> {
Expand Down
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/oidc/post-logout"],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've generally been lining these up with the stub endpoints - so in this case it would "http://localhost:8080/signed-out"

claims: process.env.CLAIMS
? (process.env.CLAIMS.split(",") as UserIdentityClaim[])
: ["https://vocab.account.gov.uk/v1/coreIdentityJWT"],
Expand Down Expand Up @@ -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!;
}
Expand Down
Loading