Skip to content

Commit

Permalink
authn: Accept Bearer tokens in Authorization headers
Browse files Browse the repository at this point in the history
Prepares the path for the Nextstrain CLI to make authenticated requests
to existing (and future) API endpoints.  Only tokens minted for the
Nextstrain CLI are accepted for now, but we can easily add additional
clients (first- or third-party) later.

If an Authorization header is present, then it must container a valid id
token issued from our Cognito user pool.  Errors are returned if the
token is invalid.  No session will be persisted for requests with an
Authorization header.

If an Authorization header is not present (or is completely empty), then
request handling passes on to the previous behaviour, which restores any
authenticated user session that's present.
  • Loading branch information
tsibley committed Apr 28, 2021
1 parent 090effb commit 006a3dc
Showing 1 changed file with 68 additions and 7 deletions.
75 changes: 68 additions & 7 deletions authn.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const OAuth2Strategy = require("passport-oauth2").Strategy;
const {jwtVerify} = require('jose/jwt/verify'); // eslint-disable-line import/no-unresolved
const {createRemoteJWKSet} = require('jose/jwks/remote'); // eslint-disable-line import/no-unresolved
const {JOSEError, JWTClaimValidationFailed} = require('jose/util/errors'); // eslint-disable-line import/no-unresolved
const BearerStrategy = require("./src/authn/bearer");
const sources = require("./src/sources");
const utils = require("./src/utils");

Expand Down Expand Up @@ -39,12 +40,23 @@ const COGNITO_CLIENT_ID = PRODUCTION
? "rki99ml8g2jb9sm1qcq9oi5n" // prod client limited to nextstrain.org
: "6q7cmj0ukti9d9kdkqi2dfvh7o"; // dev client limited to localhost and heroku dev instances

/* Registered clients to accept for Bearer tokens.
*
* In the future, we could opt to pull this list dynamically from Cognito at
* 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 = [
"2vmc93kj4fiul8uv40uqge93m5", // Nextstrain CLI
];

/* Arbitrary ids for the various strategies for Passport. Makes explicit the
* implicit defaults; uses constants instead of string literals for better
* grepping, linting, and less magic; would be an enum if JS had them (or we
* had TypeScript).
*/
const STRATEGY_OAUTH2 = "oauth2";
const STRATEGY_BEARER = "bearer";

function setup(app) {
passport.use(
Expand All @@ -64,11 +76,7 @@ function setup(app) {
try {
await verifyToken(accessToken, "access");

const idClaims = await verifyToken(idToken, "id");
const user = {
username: idClaims["cognito:username"],
groups: idClaims["cognito:groups"],
};
const user = await userFromIdToken(idToken);

// All users are ok, as we control the entire user pool.
return done(null, user);
Expand All @@ -81,6 +89,26 @@ function setup(app) {
)
);

passport.use(
STRATEGY_BEARER,
new BearerStrategy(
{
realm: "nextstrain.org",
passIfMissing: true,
},
async (idToken, done) => {
try {
const user = await userFromIdToken(idToken, BEARER_COGNITO_CLIENT_IDS);
return done(null, user);
} catch (e) {
return e instanceof JOSEError
? done(null, false, "Error verifying token")
: done(e);
}
}
)
);

// Serialize the entire user profile to the session store to avoid additional
// requests to Cognito when we need to load back a user profile from their
// session cookie.
Expand Down Expand Up @@ -153,7 +181,19 @@ function setup(app) {
}
})
);

app.use(passport.initialize());

// If an Authorization header is present, then it must container a valid
// Bearer token. No session will be created for Bearer authn.
//
// If an Authorization header is not present, this authn strategy is
// configured (above) to pass to the next request handler (user restoration
// from session).
app.use(passport.authenticate(STRATEGY_BEARER, { session: false }));

// Restore user from the session, if any. If no session, then req.user will
// be null.
app.use(passport.session());

// Set the app's origin centrally so other handlers can use it
Expand Down Expand Up @@ -271,6 +311,24 @@ function setup(app) {
});
}


/**
* Creates a user record from the given `idToken` after verifying it.
*
* @param {String} idToken
* @param {String|String[]} client. Optional. Passed to `verifyToken()`.
* @returns {Object} User record with e.g. `username` and `groups` keys.
*/
async function userFromIdToken(idToken, client = undefined) {
const idClaims = await verifyToken(idToken, "id", client);
const user = {
username: idClaims["cognito:username"],
groups: idClaims["cognito:groups"],
};
return user;
}


/**
* 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`.
Expand All @@ -281,13 +339,16 @@ function setup(app) {
*
* @param {String} token
* @param {String} use
* @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.
* @returns {Object} Verified claims from the token's payload
*/
async function verifyToken(token, use) {
async function verifyToken(token, use, client = COGNITO_CLIENT_ID) {
const {payload: claims} = await jwtVerify(token, COGNITO_JWKS, {
algorithms: ["RS256"],
issuer: COGNITO_USER_POOL_URL,
audience: use !== "access" ? COGNITO_CLIENT_ID : null,
audience: use !== "access" ? client : null,
});

const claimedUse = claims["token_use"];
Expand Down

0 comments on commit 006a3dc

Please sign in to comment.