From 582856cdef1b07bbdaf90b5190903edbe476f79a Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Fri, 28 Jul 2023 17:58:15 -0700 Subject: [PATCH 1/2] authn: Generalize support to ~any OIDC/OAuth2 IdP, not just AWS Cognito OIDC is OpenID Connect 1.0, which is an identity/authentication protocol layered on top of OAuth 2.0's authorization protocol. AWS Cognito implements OIDC/OAuth2 but our authn code hardcoded some assumptions about Cognito specifically. Undo that and parameterize and generalize the code to work (in theory) with other OIDC identity providers (IdPs). In practice, some additional changes may be necessary for specific other IdPs, but as-is I can get this generalized authn code to work against a test Azure AD IdP. Outside of authn, there are still some other bits of the codebase which require Cognito. Those will be addressed in subsequent work. This work is motivated by CDC AMD's efforts to host a copy of nextstrain.org internally in order to avail themselves of Groups internally. Related-to: --- aws/cognito/outputs.tf | 18 +++-- docs/infrastructure.md | 20 ++++-- docs/terraform.rst | 9 ++- env/outputs.tf | 24 +++++-- env/production/config.json | 11 ++-- env/testing/config.json | 11 ++-- src/authn/index.js | 130 +++++++++++++++++++++++++------------ src/config.js | 110 ++++++++++++++++++++++++++++--- 8 files changed, 255 insertions(+), 78 deletions(-) diff --git a/aws/cognito/outputs.tf b/aws/cognito/outputs.tf index db9e9c0d6..e8cdd8cfe 100644 --- a/aws/cognito/outputs.tf +++ b/aws/cognito/outputs.tf @@ -2,17 +2,21 @@ output "COGNITO_USER_POOL_ID" { value = aws_cognito_user_pool.nextstrain_dot_org.id } -output "COGNITO_BASE_URL" { - value = format("https://%s", coalesce( - one(aws_cognito_user_pool_domain.custom[*].domain), - "${aws_cognito_user_pool_domain.cognito.domain}.auth.${split("_", aws_cognito_user_pool.nextstrain_dot_org.id)[0]}.amazoncognito.com", - )) +output "OIDC_IDP_URL" { + value = format("https://%s", aws_cognito_user_pool.nextstrain_dot_org.endpoint) } -output "COGNITO_CLIENT_ID" { +output "OAUTH2_CLIENT_ID" { value = aws_cognito_user_pool_client.nextstrain_dot_org.id } -output "COGNITO_CLI_CLIENT_ID" { +output "OAUTH2_CLI_CLIENT_ID" { value = aws_cognito_user_pool_client.nextstrain-cli.id } + +output "OAUTH2_LOGOUT_URL" { + value = format("https://%s/logout", coalesce( + one(aws_cognito_user_pool_domain.custom[*].domain), + "${aws_cognito_user_pool_domain.cognito.domain}.auth.${split("_", aws_cognito_user_pool.nextstrain_dot_org.id)[0]}.amazoncognito.com", + )) +} diff --git a/docs/infrastructure.md b/docs/infrastructure.md index e7fa6b5f1..b995ee248 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -72,13 +72,23 @@ Several variables are required but obtain defaults from a config file (e.g. `env - `COGNITO_USER_POOL_ID` must be set to the id of the Cognito user pool to use for authentication. - - `COGNITO_BASE_URL` must be set to the URL of the Cognito user pool's hosted UI. - In production, this is `https://login.nextstrain.org`. - In development and testing, this would be something like `https://nextstrain-testing.auth.us-east-1.amazoncognito.com`. + - `OIDC_IDP_URL` must be set to the URL of the Cognito user pool's IdP endpoint. + This is something like `https://cognito-idp.{REGION}.amazonaws.com/{REGION}_{ID}`. - - `COGNITO_CLIENT_ID` must be set to the OAuth2 client id for the nextstrain.org client registered with the Cognito user pool. + - `OAUTH2_CLIENT_ID` must be set to the OAuth2 client id for the nextstrain.org client registered with the Cognito user pool. - - `COGNITO_CLI_CLIENT_ID` must be set to the OAuth2 client id for the Nextstrain CLI client registered with the Cognito user pool. + - `OAUTH2_CLI_CLIENT_ID` must be set to the OAuth2 client id for the Nextstrain CLI client registered with the Cognito user pool. + + - `OAUTH2_LOGOUT_URL` overrides any value discovered via IdP metadata. + For Cognito, which doesn't provide a value via metadata, this must be set to the logout URL of the Cognito user pool's hosted UI. + In production, this is `https://login.nextstrain.org/logout`. + In development and testing, this would be something like `https://nextstrain-testing.auth.us-east-1.amazoncognito.com/logout`. + + - `OIDC_USERNAME_CLAIM` must be set to the field in the id token claims which contains the username for a user. + For Cognito, this is `cognito:username`. + + - `OIDC_GROUPS_CLAIM` must be set to the field in the id token claims which contains the list of group names for a user. + For Cognito, this is `cognito:groups`. Variables in the environment override defaults from the config file. diff --git a/docs/terraform.rst b/docs/terraform.rst index 312d332ee..a7810df86 100644 --- a/docs/terraform.rst +++ b/docs/terraform.rst @@ -262,10 +262,13 @@ Each configuration provides outputs of key-value pairs corresponding to environment (or config) variables required by the nextstrain.org server:: $ terraform output - COGNITO_BASE_URL=https://login.nextstrain.org - COGNITO_CLIENT_ID=rki99ml8g2jb9sm1qcq9oi5n - COGNITO_CLI_CLIENT_ID=2vmc93kj4fiul8uv40uqge93m5 COGNITO_USER_POOL_ID=us-east-1_Cg5rcTged + OIDC_IDP_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Cg5rcTged + OAUTH2_CLIENT_ID=rki99ml8g2jb9sm1qcq9oi5n + OAUTH2_CLI_CLIENT_ID=2vmc93kj4fiul8uv40uqge93m5 + OAUTH2_LOGOUT_URL=https://login.nextstrain.org/logout + OIDC_USERNAME_CLAIM=cognito:username + OIDC_GROUPS_CLAIM=cognito:groups Outputs are stored and tracked in the remote state and may be updated when applying configuration changes. We cache non-sensitive outputs in JSON config diff --git a/env/outputs.tf b/env/outputs.tf index 9d50c1a08..b71fa2435 100644 --- a/env/outputs.tf +++ b/env/outputs.tf @@ -6,14 +6,26 @@ output "COGNITO_USER_POOL_ID" { value = module.cognito.COGNITO_USER_POOL_ID } -output "COGNITO_BASE_URL" { - value = module.cognito.COGNITO_BASE_URL +output "OIDC_IDP_URL" { + value = module.cognito.OIDC_IDP_URL } -output "COGNITO_CLIENT_ID" { - value = module.cognito.COGNITO_CLIENT_ID +output "OAUTH2_CLIENT_ID" { + value = module.cognito.OAUTH2_CLIENT_ID } -output "COGNITO_CLI_CLIENT_ID" { - value = module.cognito.COGNITO_CLI_CLIENT_ID +output "OAUTH2_CLI_CLIENT_ID" { + value = module.cognito.OAUTH2_CLI_CLIENT_ID +} + +output "OAUTH2_LOGOUT_URL" { + value = module.cognito.OAUTH2_LOGOUT_URL +} + +output "OIDC_USERNAME_CLAIM" { + value = "cognito:username" +} + +output "OIDC_GROUPS_CLAIM" { + value = "cognito:groups" } diff --git a/env/production/config.json b/env/production/config.json index d4da4ce9c..b571c6348 100644 --- a/env/production/config.json +++ b/env/production/config.json @@ -1,6 +1,9 @@ { - "COGNITO_BASE_URL": "https://login.nextstrain.org", - "COGNITO_CLIENT_ID": "rki99ml8g2jb9sm1qcq9oi5n", - "COGNITO_CLI_CLIENT_ID": "2vmc93kj4fiul8uv40uqge93m5", - "COGNITO_USER_POOL_ID": "us-east-1_Cg5rcTged" + "OIDC_IDP_URL": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Cg5rcTged", + "OAUTH2_CLIENT_ID": "rki99ml8g2jb9sm1qcq9oi5n", + "OAUTH2_CLI_CLIENT_ID": "2vmc93kj4fiul8uv40uqge93m5", + "OAUTH2_LOGOUT_URL": "https://login.nextstrain.org/logout", + "COGNITO_USER_POOL_ID": "us-east-1_Cg5rcTged", + "OIDC_USERNAME_CLAIM": "cognito:username", + "OIDC_GROUPS_CLAIM": "cognito:groups" } diff --git a/env/testing/config.json b/env/testing/config.json index 6ce4d2a8d..08572fe20 100644 --- a/env/testing/config.json +++ b/env/testing/config.json @@ -1,6 +1,9 @@ { - "COGNITO_BASE_URL": "https://nextstrain-testing.auth.us-east-1.amazoncognito.com", - "COGNITO_CLIENT_ID": "6qiojrhr8tibt0f6hphnm1osp1", - "COGNITO_CLI_CLIENT_ID": "9opa27o74f4jsq8g4a34e1mqr", - "COGNITO_USER_POOL_ID": "us-east-1_zqpCrjM7I" + "OIDC_IDP_URL": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_zqpCrjM7I", + "OAUTH2_CLIENT_ID": "6qiojrhr8tibt0f6hphnm1osp1", + "OAUTH2_CLI_CLIENT_ID": "9opa27o74f4jsq8g4a34e1mqr", + "OAUTH2_LOGOUT_URL": "https://nextstrain-testing.auth.us-east-1.amazoncognito.com/logout", + "COGNITO_USER_POOL_ID": "us-east-1_zqpCrjM7I", + "OIDC_USERNAME_CLAIM": "cognito:username", + "OIDC_GROUPS_CLAIM": "cognito:groups" } diff --git a/src/authn/index.js b/src/authn/index.js index 3f040ae35..fce094d2d 100644 --- a/src/authn/index.js +++ b/src/authn/index.js @@ -19,7 +19,7 @@ import { JOSEError, JWTClaimValidationFailed, JWTExpired } from 'jose/util/error import partition from 'lodash.partition'; import BearerStrategy from './bearer.js'; import { getTokens, setTokens, deleteTokens } from './session.js'; -import { PRODUCTION, COGNITO_USER_POOL_ID, COGNITO_BASE_URL, COGNITO_CLIENT_ID, COGNITO_CLI_CLIENT_ID } from '../config.js'; +import { PRODUCTION, OIDC_ISSUER_URL, OIDC_JWKS_URL, OAUTH2_AUTHORIZATION_URL, OAUTH2_TOKEN_URL, OAUTH2_LOGOUT_URL, OAUTH2_SCOPES_SUPPORTED, OIDC_USERNAME_CLAIM, OIDC_GROUPS_CLAIM, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_CLI_CLIENT_ID } from '../config.js'; import { AuthnRefreshTokenInvalid, AuthnTokenTooOld } from '../exceptions.js'; import { fetch } from '../fetch.js'; import { copyCookie } from '../middleware.js'; @@ -56,11 +56,22 @@ const SESSION_SECRET = PRODUCTION const SESSION_MAX_AGE = 30 * 24 * 60 * 60; // 30d in seconds -const COGNITO_REGION = COGNITO_USER_POOL_ID.split("_")[0]; +const OIDC_JWKS = createRemoteJWKSet(new URL(OIDC_JWKS_URL)); -const COGNITO_USER_POOL_URL = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID}`; +// These are all scopes defined by OpenID Connect. +const requiredScopes = ["openid", "profile"]; +const optionalScopes = ["email", "offline_access"]; + +const missingScopes = requiredScopes.filter(s => !OAUTH2_SCOPES_SUPPORTED.has(s)); +if (missingScopes.length) { + throw new Error(`OAuth2 IdP does not advertise support for the required scopes: ${Array.from(missingScopes).join(" ")}`); +} + +const OAUTH2_SCOPES = [ + ...requiredScopes, + ...optionalScopes.filter(s => OAUTH2_SCOPES_SUPPORTED.has(s)), +]; -const COGNITO_JWKS = createRemoteJWKSet(new URL(`${COGNITO_USER_POOL_URL}/.well-known/jwks.json`)); /* Registered clients to accept for Bearer tokens. * @@ -68,8 +79,8 @@ const COGNITO_JWKS = createRemoteJWKSet(new URL(`${COGNITO_USER_POOL_URL}/.well- * server start and might want to if we start having third-party clients, but * avoid a start-time dep for now. */ -const BEARER_COGNITO_CLIENT_IDS = [ - COGNITO_CLI_CLIENT_ID, // Nextstrain CLI +const BEARER_OAUTH2_CLIENT_IDS = [ + OAUTH2_CLI_CLIENT_ID, // Nextstrain CLI ]; /* Arbitrary ids for the various strategies for Passport. Makes explicit the @@ -91,10 +102,12 @@ function setup(app) { STRATEGY_OAUTH2, new OAuth2Strategy( { - authorizationURL: `${COGNITO_BASE_URL}/oauth2/authorize`, - tokenURL: `${COGNITO_BASE_URL}/oauth2/token`, - clientID: COGNITO_CLIENT_ID, + authorizationURL: OAUTH2_AUTHORIZATION_URL, + tokenURL: OAUTH2_TOKEN_URL, + clientID: OAUTH2_CLIENT_ID, callbackURL: "/logged-in", + scope: OAUTH2_SCOPES, + clientSecret: OAUTH2_CLIENT_SECRET, pkce: true, state: true, passReqToCallback: true, @@ -110,9 +123,7 @@ function setup(app) { // All users are ok, as we control the entire user pool. return done(null, user); } catch (e) { - return e instanceof JOSEError - ? done(null, false, "Error verifying token") - : done(e); + return done(e); } } ) @@ -127,7 +138,7 @@ function setup(app) { }, async (idToken, done) => { try { - const user = await userFromIdToken(idToken, BEARER_COGNITO_CLIENT_IDS); + const user = await userFromIdToken(idToken, BEARER_OAUTH2_CLIENT_IDS); return done(null, user); } catch (e) { if (e instanceof JOSEError) { @@ -433,10 +444,10 @@ function setup(app) { app.route("/logout").get((req, res) => { req.session.destroy(() => { const params = { - client_id: COGNITO_CLIENT_ID, + client_id: OAUTH2_CLIENT_ID, logout_uri: req.context.origin }; - res.redirect(`${COGNITO_BASE_URL}/logout?${querystring.stringify(params)}`); + res.redirect(`${OAUTH2_LOGOUT_URL}?${querystring.stringify(params)}`); }); }); } @@ -504,8 +515,8 @@ function authnWithSession(req, res, next) { /** - * Creates a user record from the given `idToken` after verifying it and the - * associated `accessToken`. + * Creates a user record from the given `idToken` after verifying it and + * ensuring the associated `accessToken` and `refreshToken` exist. * * Use this function instead of {@link userFromIdToken} if you have all three * tokens (e.g. after initial OAuth2 login or session token renewal), as it @@ -514,7 +525,7 @@ function authnWithSession(req, res, next) { * @param {String} idToken * @param {String} accessToken * @param {String} refreshToken - * @param {String|String[]} client. Optional. Passed to `verifyToken()`. + * @param {String|String[]} client. Optional. Passed to `verifyIdToken()`. * @returns {User} User record with e.g. `username` and `groups` keys. */ async function userFromTokens({idToken, accessToken, refreshToken}, client = undefined) { @@ -522,11 +533,35 @@ async function userFromTokens({idToken, accessToken, refreshToken}, client = und if (!accessToken) throw new Error("missing accessToken"); if (!refreshToken) throw new Error("missing refreshToken"); - /* Verify access token for good measure, but pull user information from the - * identity token (which is its intended purpose). Note that refresh tokens - * are opaque blobs not subject to verification. + /* Pull user information from the OIDC identity token, which is its intended + * purpose. + * + * The OAuth2 access token¹ is for accessing other resources protected by the + * IdP, such as the OIDC "user info" endpoint², but we don't currently use + * it. It is sometimes verifiable, depending on the IdP and authentication + * flow, but not necessary (nor always possible) to do so in our use.³ We + * treat it thus as an opaque blob, as suggested by the OAuth2 spec.¹ + * + * Note that OAuth2 refresh tokens are opaque blobs never subject to + * verification. + * -trs, 12 Oct 2023 + * + * ¹ + * + * ² + * + * + * ³ AWS Cognito documents its access tokens as JWTs and provides details for + * verifying them. Azure AD's are also JWTs, but various details make + * generalized verification difficult (e.g. issuer is not the IdP we + * authenticate with). Neither provides the OIDC "at_hash" claims in the + * id token that would let us treat the paired access token as an opaque + * but verifiable blob, as we're using the authorization code flow⁴ which + * does not require it.⁵ + * + * ⁴ + * ⁵ */ - await verifyToken(accessToken, "access", client); // Verifies idToken return await userFromIdToken(idToken, client); @@ -537,16 +572,16 @@ async function userFromTokens({idToken, accessToken, refreshToken}, client = und * Creates a user record from the given `idToken` after verifying it. * * @param {String} idToken - * @param {String|String[]} client. Optional. Passed to `verifyToken()`. + * @param {String|String[]} client. Optional. Passed to `verifyIdToken()`. * @returns {User} User record with e.g. `username` and `groups` keys. */ async function userFromIdToken(idToken, client = undefined) { - const idClaims = await verifyToken(idToken, "id", client); + const idClaims = await verifyIdToken(idToken, client); - const {groups, authzRoles, flags, cognitoGroups} = parseCognitoGroups(idClaims["cognito:groups"] || []); + const {groups, authzRoles, flags, cognitoGroups} = parseCognitoGroups(idClaims[OIDC_GROUPS_CLAIM] || []); const user = { - username: idClaims["cognito:username"], + username: idClaims[OIDC_USERNAME_CLAIM], groups, authzRoles, flags, @@ -629,37 +664,47 @@ function splitGroupRole(cognitoGroup) { /** - * Verifies all aspects of the given `token` (a signed JWT from our AWS Cognito - * user pool) which is expected to be used for the given `use`. + * Verifies all aspects of the given OIDC `idToken` (a signed JWT from our AWS + * Cognito user pool). * * Assertions about expected algorithms, audience, issuer, and token use follow * guidelines from * . * - * @param {String} token - * @param {String} use + * @param {String} idToken * @param {String} client. Optional `client_id` or list of `client_id`s - * expected for the token. Only relevant when `use` is not - * `access`. Defaults to this server's client id. + * expected for the token. Defaults to this server's client id. * @returns {Object} Verified claims from the token's payload */ -async function verifyToken(token, use, client = COGNITO_CLIENT_ID) { - const {payload: claims} = await jwtVerify(token, COGNITO_JWKS, { +async function verifyIdToken(idToken, client = OAUTH2_CLIENT_ID) { + const {payload: claims} = await jwtVerify(idToken, OIDC_JWKS, { algorithms: ["RS256"], - issuer: COGNITO_USER_POOL_URL, - audience: use !== "access" ? client : null, + issuer: OIDC_ISSUER_URL, + audience: client, }); + /* AWS Cognito includes the kind of token, id or access, in the claims for + * each token itself, e.g. so if you're expecting id tokens you can verify + * someone handed you an id token and not an access token. This presumably + * helps block token misuse attacks, e.g. when code that's expecting an id + * token is given an access token that looks close enough in terms of claims + * but ends up breaking unasserted expections of the code and allowing an + * authz bypass or privilege escalation. + * + * There is not, AFAICT, a standard claim for this information in OIDC, and + * other IdPs don't provide it, so we only check this claim if it exists. + * -trs, 11 Oct 2023 + */ const claimedUse = claims["token_use"]; - if (claimedUse !== use) { + if (claimedUse !== undefined && claimedUse !== "id") { throw new JWTClaimValidationFailed(`unexpected "token_use" claim value: ${claimedUse}`, "token_use", "check_failed"); } /* Verify the token was issued at (iat) a time more recent than our staleness * horizon for the user. */ - const username = claims[{id: "cognito:username", access: "username"}[claimedUse]]; + const username = claims[OIDC_USERNAME_CLAIM]; if (!username) { throw new JWTClaimValidationFailed("missing username"); @@ -693,11 +738,16 @@ async function verifyToken(token, use, client = COGNITO_CLIENT_ID) { * except by initiating a new login. */ async function renewTokens(refreshToken) { - const response = await fetch(`${COGNITO_BASE_URL}/oauth2/token`, { + const response = await fetch(OAUTH2_TOKEN_URL, { method: "POST", body: new URLSearchParams([ ["grant_type", "refresh_token"], - ["client_id", COGNITO_CLIENT_ID], + ["client_id", OAUTH2_CLIENT_ID], + ...( + OAUTH2_CLIENT_SECRET + ? [["client_secret", OAUTH2_CLIENT_SECRET]] + : [] + ), ["refresh_token", refreshToken], ]), }); diff --git a/src/config.js b/src/config.js index a5272fcb3..116737fa1 100644 --- a/src/config.js +++ b/src/config.js @@ -10,6 +10,7 @@ * @module config */ import { readFile } from 'fs/promises'; +import fetch from 'node-fetch'; import path, { dirname } from 'path'; import process from 'process'; import { fileURLToPath } from 'url'; @@ -54,13 +55,16 @@ const configFile = CONFIG_FILE /** * Obtain a configuration variable from the environment or the config file. * + * Values obtained from the environment will be deserialized from JSON if + * possible. + * * @param {string} name - Variable name, e.g. "COGNITO_USER_POOL_ID" * @param {any} default - Final fallback value * @throws {Error} if no value is found and default is undefined */ const fromEnvOrConfig = (name, default_) => { const value = - process.env[name] + maybeJSON(process.env[name]) || configFile?.[name]; if (!value && default_ === undefined) { @@ -70,6 +74,23 @@ const fromEnvOrConfig = (name, default_) => { }; +/** + * Deserialize a value that might be JSON, passing it thru if it isn't. + * + * @param {any} x - Value which might be JSON + */ +function maybeJSON(x) { + if (typeof x === "string") { + try { + return JSON.parse(x); + } catch (e) { + // no worries + } + } + return x; +} + + /** * Id of our Cognito user pool. * @@ -81,31 +102,102 @@ export const COGNITO_USER_POOL_ID = fromEnvOrConfig("COGNITO_USER_POOL_ID"); /** - * Base URL (i.e. origin) of our Cognito user pool's hosted UI and OAuth2 - * endpoints. + * URL of the OIDC IdP for our user directory (e.g. our Cognito user pool's + * hosted UI and OAuth2 endpoints). * * @type {string} */ -export const COGNITO_BASE_URL = fromEnvOrConfig("COGNITO_BASE_URL"); +export const OIDC_IDP_URL = fromEnvOrConfig("OIDC_IDP_URL"); /** - * OAuth2 client id of nextstrain.org server as registered with our Cognito - * user pool. + * OpenID Connect (OIDC) identity provider configuration document. + * + * Typically this is unspecified in the environment or config file and instead + * populated by fetching it from the OIDC IdP. + * + * Refer to the spec for the + * {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata core metadata fields} + * and the + * {@link https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#authorization-server-metadata IANA metadata field registry} + * for references to other fields. + * + * @type {object} + */ +export const OIDC_CONFIGURATION = fromEnvOrConfig("OIDC_CONFIGURATION", await oidcConfiguration(OIDC_IDP_URL)); + +async function oidcConfiguration(idpUrl) { + const url = `${idpUrl}/.well-known/openid-configuration`; + const response = await fetch(url); + + if (!response.ok) { + console.warn(`Failed to fetch ${url}: ${response.status} ${response.statusText}: ${await response.text()}`); + return; + } + return await response.json(); +} + +/** "issuer" metadata field in */ +export const OIDC_ISSUER_URL = fromEnvOrConfig("OIDC_ISSUER_URL", OIDC_CONFIGURATION.issuer); + +/** "jwks_uri" metadata field in OIDC configuration */ +export const OIDC_JWKS_URL = fromEnvOrConfig("OIDC_JWKS_URL", OIDC_CONFIGURATION.jwks_uri); + +/** "authorization_endpoint" metadata field in OIDC configuration */ +export const OAUTH2_AUTHORIZATION_URL = fromEnvOrConfig("OAUTH2_AUTHORIZATION_URL", OIDC_CONFIGURATION.authorization_endpoint); + +/** "token_endpoint" metadata field in OIDC configuration */ +export const OAUTH2_TOKEN_URL = fromEnvOrConfig("OAUTH2_TOKEN_URL", OIDC_CONFIGURATION.token_endpoint); + +/** "end_session_endpoint" metadata field in OIDC configuration with RP-initiated logout support {@link https://openid.net/specs/openid-connect-rpinitiated-1_0.html#OPMetadata} */ +export const OAUTH2_LOGOUT_URL = fromEnvOrConfig("OAUTH2_LOGOUT_URL", OIDC_CONFIGURATION.end_session_endpoint); + +/** "scopes_supported" metadata field in OIDC configuration */ +export const OAUTH2_SCOPES_SUPPORTED = new Set(fromEnvOrConfig("OAUTH2_SCOPES_SUPPORTED", OIDC_CONFIGURATION.scopes_supported)); + + +/** + * OAuth2 client id of nextstrain.org server as registered with our IdP (e.g. + * our Cognito user pool). + * + * @type {string} + */ +export const OAUTH2_CLIENT_ID = fromEnvOrConfig("OAUTH2_CLIENT_ID"); + + +/** + * Optional OAuth2 client secret corresponding to the {@link OAUTH2_CLIENT_ID}. * * @type {string} */ -export const COGNITO_CLIENT_ID = fromEnvOrConfig("COGNITO_CLIENT_ID"); +export const OAUTH2_CLIENT_SECRET = fromEnvOrConfig("OAUTH2_CLIENT_SECRET", null); /** - * OAuth2 client id of Nextstrain CLI as registered with our Cognito user pool. + * OAuth2 client id of Nextstrain CLI as registered with our IdP (e.g. Cognito + * user pool). * * Used to identify its tokens provided via Bearer auth. * * @type {string} */ -export const COGNITO_CLI_CLIENT_ID = fromEnvOrConfig("COGNITO_CLI_CLIENT_ID"); +export const OAUTH2_CLI_CLIENT_ID = fromEnvOrConfig("OAUTH2_CLI_CLIENT_ID"); + + +/** + * ID token claim field containing the username for a user. + * + * @type {string} + */ +export const OIDC_USERNAME_CLAIM = fromEnvOrConfig("OIDC_USERNAME_CLAIM"); + + +/** + * ID token claim field containing the list of role group names for a user. + * + * @type {string} + */ +export const OIDC_GROUPS_CLAIM = fromEnvOrConfig("OIDC_GROUPS_CLAIM"); /** From e84c672344cd5051b9e22df82ccab64c00f54b34 Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Thu, 12 Oct 2023 23:46:48 -0700 Subject: [PATCH 2/2] authn: Support IdPs that backdate their "iat" claim in id tokens The backdating must be a fixed duration, which is what I've observed with Azure AD (300s) and other IdPs. Backdating is sometimes applied to be more lenient with clients that have a slow clock (i.e. who otherwise might see a correct iat as in the future and reject the token). Without accounting for backdating, our staleBefore marker can cause a temporary deauthentication and repeated renewal attempts as token renewal requests "work" but produce a token with an iat that's still older than the staleBefore. --- src/authn/index.js | 6 +++--- src/config.js | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/authn/index.js b/src/authn/index.js index fce094d2d..175b0c11a 100644 --- a/src/authn/index.js +++ b/src/authn/index.js @@ -19,7 +19,7 @@ import { JOSEError, JWTClaimValidationFailed, JWTExpired } from 'jose/util/error import partition from 'lodash.partition'; import BearerStrategy from './bearer.js'; import { getTokens, setTokens, deleteTokens } from './session.js'; -import { PRODUCTION, OIDC_ISSUER_URL, OIDC_JWKS_URL, OAUTH2_AUTHORIZATION_URL, OAUTH2_TOKEN_URL, OAUTH2_LOGOUT_URL, OAUTH2_SCOPES_SUPPORTED, OIDC_USERNAME_CLAIM, OIDC_GROUPS_CLAIM, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_CLI_CLIENT_ID } from '../config.js'; +import { PRODUCTION, OIDC_ISSUER_URL, OIDC_JWKS_URL, OAUTH2_AUTHORIZATION_URL, OAUTH2_TOKEN_URL, OAUTH2_LOGOUT_URL, OAUTH2_SCOPES_SUPPORTED, OIDC_USERNAME_CLAIM, OIDC_GROUPS_CLAIM, OIDC_IAT_BACKDATED_BY, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_CLI_CLIENT_ID } from '../config.js'; import { AuthnRefreshTokenInvalid, AuthnTokenTooOld } from '../exceptions.js'; import { fetch } from '../fetch.js'; import { copyCookie } from '../middleware.js'; @@ -716,8 +716,8 @@ async function verifyIdToken(idToken, client = OAUTH2_CLIENT_ID) { if (typeof claims.iat !== "number") { throw new JWTClaimValidationFailed(`"iat" claim must be a number`, "iat", "invalid"); } - if (claims.iat < staleBefore) { - throw new AuthnTokenTooOld(`"iat" claim less than user's staleBefore: ${claims.iat} < ${staleBefore}`); + if (claims.iat + OIDC_IAT_BACKDATED_BY < staleBefore) { + throw new AuthnTokenTooOld(`"iat" claim (plus any backdating) less than user's staleBefore: ${claims.iat} + ${OIDC_IAT_BACKDATED_BY} < ${staleBefore}`); } } diff --git a/src/config.js b/src/config.js index 116737fa1..f17284b2e 100644 --- a/src/config.js +++ b/src/config.js @@ -200,6 +200,19 @@ export const OIDC_USERNAME_CLAIM = fromEnvOrConfig("OIDC_USERNAME_CLAIM"); export const OIDC_GROUPS_CLAIM = fromEnvOrConfig("OIDC_GROUPS_CLAIM"); +/** + * Fixed time (in seconds) by which the IdP backdates the "iat" claim of the ID + * token. + * + * @type {number} + */ +export const OIDC_IAT_BACKDATED_BY = fromEnvOrConfig("OIDC_IAT_BACKDATED_BY", 0); + +if (typeof OIDC_IAT_BACKDATED_BY !== 'number') { + throw new Error(`OIDC_IAT_BACKDATED_BY value is not a number; got "${OIDC_IAT_BACKDATED_BY}"`); +} + + /** * Path to a JSON file containing Groups data. *