Skip to content

Instantly share code, notes, and snippets.

@tsibley
Created August 25, 2023 21:02
Show Gist options
  • Save tsibley/671d6be0df6539751a386306e37fd7eb to your computer and use it in GitHub Desktop.
Save tsibley/671d6be0df6539751a386306e37fd7eb to your computer and use it in GitHub Desktop.
diff --git a/aws/cognito/outputs.tf b/aws/cognito/outputs.tf
index db9e9c0d..3e7f55d2 100644
--- a/aws/cognito/outputs.tf
+++ b/aws/cognito/outputs.tf
@@ -2,6 +2,7 @@ output "COGNITO_USER_POOL_ID" {
value = aws_cognito_user_pool.nextstrain_dot_org.id
}
+# XXX FIXME
output "COGNITO_BASE_URL" {
value = format("https://%s", coalesce(
one(aws_cognito_user_pool_domain.custom[*].domain),
@@ -9,10 +10,12 @@ output "COGNITO_BASE_URL" {
))
}
+# XXX FIXME
output "COGNITO_CLIENT_ID" {
value = aws_cognito_user_pool_client.nextstrain_dot_org.id
}
+# XXX FIXME
output "COGNITO_CLI_CLIENT_ID" {
value = aws_cognito_user_pool_client.nextstrain-cli.id
}
diff --git a/docs/infrastructure.md b/docs/infrastructure.md
index e7fa6b5f..a0df718b 100644
--- a/docs/infrastructure.md
+++ b/docs/infrastructure.md
@@ -72,12 +72,18 @@ 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.
+XXX FIXME
+
- `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`.
+XXX FIXME
+
- `COGNITO_CLIENT_ID` must be set to the OAuth2 client id for the nextstrain.org client registered with the Cognito user pool.
+XXX FIXME
+
- `COGNITO_CLI_CLIENT_ID` must be set to the OAuth2 client id for the Nextstrain CLI client registered with the Cognito user pool.
Variables in the environment override defaults from the config file.
diff --git a/docs/terraform.rst b/docs/terraform.rst
index 312d332e..ae54de81 100644
--- a/docs/terraform.rst
+++ b/docs/terraform.rst
@@ -261,6 +261,8 @@ Outputs
Each configuration provides outputs of key-value pairs corresponding to
environment (or config) variables required by the nextstrain.org server::
+.. XXX FIXME
+
$ terraform output
COGNITO_BASE_URL=https://login.nextstrain.org
COGNITO_CLIENT_ID=rki99ml8g2jb9sm1qcq9oi5n
diff --git a/env/outputs.tf b/env/outputs.tf
index 9d50c1a0..f80cfedc 100644
--- a/env/outputs.tf
+++ b/env/outputs.tf
@@ -6,14 +6,17 @@ output "COGNITO_USER_POOL_ID" {
value = module.cognito.COGNITO_USER_POOL_ID
}
+# XXX FIXME
output "COGNITO_BASE_URL" {
value = module.cognito.COGNITO_BASE_URL
}
+# XXX FIXME
output "COGNITO_CLIENT_ID" {
value = module.cognito.COGNITO_CLIENT_ID
}
+# XXX FIXME
output "COGNITO_CLI_CLIENT_ID" {
value = module.cognito.COGNITO_CLI_CLIENT_ID
}
diff --git a/env/production/config.json b/env/production/config.json
index d4da4ce9..512efdb4 100644
--- a/env/production/config.json
+++ b/env/production/config.json
@@ -1,6 +1,7 @@
{
- "COGNITO_BASE_URL": "https://login.nextstrain.org",
- "COGNITO_CLIENT_ID": "rki99ml8g2jb9sm1qcq9oi5n",
- "COGNITO_CLI_CLIENT_ID": "2vmc93kj4fiul8uv40uqge93m5",
+ "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"
}
diff --git a/env/testing-ad/config.json b/env/testing-ad/config.json
new file mode 100644
index 00000000..e0327c18
--- /dev/null
+++ b/env/testing-ad/config.json
@@ -0,0 +1,9 @@
+{
+ "OIDC_IDP_URL": "https://login.microsoftonline.com/0ce9e8dc-e009-4cb4-8512-7989bd6906a8/v2.0",
+ "OAUTH2_CLIENT_ID": "c3d6647f-dccc-4a85-a2bb-fb8fbc7524a9",
+ "OAUTH2_CLIENT_SECRET": "jdn8Q~van5hyY2-MGBa1gzapqnPYEHrGcGaCfa70",
+ "OAUTH2_CLI_CLIENT_ID": "BOGUS",
+ "OIDC_USERNAME_CLAIM": "preferred_username",
+ "OIDC_GROUPS_CLAIM": "groups",
+ "COGNITO_USER_POOL_ID": "us-east-1_zqpCrjM7I"
+}
diff --git a/env/testing/config.json b/env/testing/config.json
index 6ce4d2a8..a76ac1ae 100644
--- a/env/testing/config.json
+++ b/env/testing/config.json
@@ -1,6 +1,7 @@
{
- "COGNITO_BASE_URL": "https://nextstrain-testing.auth.us-east-1.amazoncognito.com",
- "COGNITO_CLIENT_ID": "6qiojrhr8tibt0f6hphnm1osp1",
- "COGNITO_CLI_CLIENT_ID": "9opa27o74f4jsq8g4a34e1mqr",
+ "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"
}
diff --git a/notes b/notes
new file mode 100644
index 00000000..1b9bed4a
--- /dev/null
+++ b/notes
@@ -0,0 +1 @@
+https://www.cedarpolicy.com/en
diff --git a/src/authn/index.js b/src/authn/index.js
index 3f040ae3..a4a3afa3 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,23 @@ 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(" ")}`);
+}
+
+// XXX FIXME: consider requesting all scopes?? this is the Cognito default, more or less.
+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 +80,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 +103,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 +124,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 +139,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 +445,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)}`);
});
});
}
@@ -514,7 +526,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) {
@@ -526,7 +538,7 @@ async function userFromTokens({idToken, accessToken, refreshToken}, client = und
* identity token (which is its intended purpose). Note that refresh tokens
* are opaque blobs not subject to verification.
*/
- await verifyToken(accessToken, "access", client);
+ // XXX FIXME just treat accessToken as opaque?!?!?!
// Verifies idToken
return await userFromIdToken(idToken, client);
@@ -537,16 +549,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 +641,36 @@ 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
* <https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html>.
*
- * @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,
});
+ // XXX FIXME nonstandard Cognito thing
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 +704,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 a5272fcb..1d302070 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,90 @@ 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));
+
+/** XXX FIXME */
+export const OIDC_USERNAME_CLAIM = fromEnvOrConfig("OIDC_USERNAME_CLAIM", "cognito:username");
+export const OIDC_GROUPS_CLAIM = fromEnvOrConfig("OIDC_GROUPS_CLAIM", "cognito:groups");
+
+
+/**
+ * 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");
/**
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment