-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: main
Are you sure you want to change the base?
Changes from all commits
3e4a9be
ef56620
c9e852b
b45710b
25233db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"], | ||
|
@@ -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!; | ||
} | ||
|
There was a problem hiding this comment.
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