Skip to content

Commit

Permalink
authn: Support marking user data as "stale before" a timestamp
Browse files Browse the repository at this point in the history
This is a basic cache-invalidation system applied to our authn tokens
(and future snapshots of user info).¹  When the app knowingly makes
changes to user data in Cognito, such as modifying a user's group
memberships, it can mark user data as "stale before" that time.  For
sessions, this triggers automatic server-side token renewal and thus
refresh of user data (e.g.  the new group memberships) from Cognito.
For API clients (like Nextstrain CLI), this triggers a 401 Unauthorized
error prompting the client to retry after renewal.

Timestamps are stored in Redis under a per-user key, if Redis is
available, else they're stored in a transient in-process Map (useful
only for dev).

Currently no code sets these timestamps.  I expect the first usage to be
with the addition of group membership management endpoints to the
RESTful API.

Partially resolves <#81>.

¹ Using JWTs means you're dealing with a cache (e.g. of the data in the
  token), even if you don't normally think about it that way.  While it
  might seem we could use the JWT to just get the username and always
  fetch the latest user info from Cognito's admin API, this isn't
  feasible because of the rate limits imposed by Cognito and the
  additional latency it would introduce.
  • Loading branch information
tsibley committed May 10, 2022
1 parent b085af5 commit 0c0aefc
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 7 deletions.
35 changes: 30 additions & 5 deletions src/authn/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ const {createRemoteJWKSet} = require('jose/jwks/remote'); // eslint-disa
const {JOSEError, JWTClaimValidationFailed, JWTExpired} = require('jose/util/errors'); // eslint-disable-line import/no-unresolved
const partition = require("lodash.partition");
const BearerStrategy = require("./bearer");
const {AuthnRefreshTokenInvalid} = require("../exceptions");
const {AuthnRefreshTokenInvalid, AuthnTokenTooOld} = require("../exceptions");
const {fetch} = require("../fetch");
const {copyCookie} = require("../middleware");
const {REDIS} = require("../redis");
const {userStaleBefore} = require("../user");
const utils = require("../utils");

const PRODUCTION = process.env.NODE_ENV === "production";
Expand Down Expand Up @@ -142,9 +143,13 @@ function setup(app) {
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);
if (e instanceof JOSEError) {
return done(null, false, "Error verifying token");
} else if (e instanceof AuthnTokenTooOld) {
return done(null, false, "Token too old; renew it and try again");
}
// Internal error of some kind
return done(e);
}
}
)
Expand Down Expand Up @@ -208,7 +213,7 @@ function setup(app) {
* refreshToken expired or was revoked since being stored in the
* session.
*/
if (err instanceof JWTExpired) {
if (err instanceof JWTExpired || err instanceof AuthnTokenTooOld) {
debug(`Renewing tokens for ${serializedUser} (session ${req.session.id.substr(0, 7)}…)`);
({idToken, accessToken} = await renewTokens(refreshToken));

Expand Down Expand Up @@ -602,6 +607,26 @@ async function verifyToken(token, use, client = COGNITO_CLIENT_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]];

if (!username) {
throw new JWTClaimValidationFailed("missing username");
}

const staleBefore = await userStaleBefore(username);

if (staleBefore) {
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}`);
}
}

return claims;
}

Expand Down
12 changes: 12 additions & 0 deletions src/exceptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ class NextstrainError extends Error {
class AuthnRefreshTokenInvalid extends NextstrainError {}


/**
* Thrown when a token is unacceptably old, e.g. when it was issued at a time
* known to be before our staleness horizon for the user.
*
* Typically triggers a renewal attempt like an expired token would.
*
* See {@link module:./authn.verifyToken}.
*/
class AuthnTokenTooOld extends NextstrainError {}


/* Thrown when a user is not authorized to do something. Turned into an
* appropriate HTTP response by our server-wide error handler.
*/
Expand All @@ -33,6 +44,7 @@ class NoResourcePathError extends NextstrainError {

module.exports = {
AuthnRefreshTokenInvalid,
AuthnTokenTooOld,
AuthzDenied,
NoResourcePathError,
};
7 changes: 5 additions & 2 deletions src/redis.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/**
* Redis is used for storing user sessions (see ./authn/index.js).
* Redis is used for storing user sessions (see ./authn/index.js) and user
* staleness timestamps (see ./user.js).
*
* The production Redis instance is configured for both volatile (expiring) and
* non-volatile data using the "volatile-ttl" eviction policy. User sessions
* are volatile (but often long-lived) and have a rolling expiration identical
* to the session cookie's.
* to the session cookie's. Staleness timestamps are volatile with a short
* TTL.
*
* If you're storing more data in Redis, note that keys will default to
* non-volatile unless an expiration is set.
*
* @module redis
* @see module:authn
* @see module:user
* @see https://redis.io/docs/manual/eviction/
*/

Expand Down
71 changes: 71 additions & 0 deletions src/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* User management.
*
* @module user
*/

const {REDIS} = require("./redis");


/**
* Transient, in-memory "store" for userStaleBefore timestamps when Redis isn't
* available (e.g. in local dev).
*/
const userStaleBeforeTransientStore = new Map();


/**
* Get the "stale before" timestamp for the given user.
*
* User tokens issued before this timestamp should be considered stale: their
* claims may not reflect the most current user information and they should be
* renewed.
*
* @param {String} username
* @returns {Number|null} timestamp
* @see markUserStaleBeforeNow
*/
async function userStaleBefore(username) {
const timestamp = REDIS
? parseInt(await REDIS.get(`userStaleBefore:${username}`), 10)
: userStaleBeforeTransientStore.get(username);

return Number.isInteger(timestamp)
? timestamp
: null;
}


/**
* Set the "stale before" timestamp for the given user to the current time.
*
* This timestamp should be set whenever the app makes a change to a user's
* information in Cognito, such as modifying a user's group memberships or
* updating a user's email address.
*
* @param {String} username
* @returns {Boolean} True if set succeeded; false if not.
* @see userStaleBefore
*/
async function markUserStaleBeforeNow(username) {
const now = Math.ceil(Date.now() / 1000);
if (REDIS) {
/* The TTL must be greater than the maximum lifetime of an id/access token
* or any other cached user data. AWS Cognito limits the maximum lifetime
* of id/access tokens to 24 hours, and those tokens are the only cached
* user data we're using this timestamp for currently. Set expiration to
* 25 hours to avoid any clock jitter issues.
* -trs, 10 May 2022
*/
const result = await REDIS.set(`userStaleBefore:${username}`, now, "EX", 25 * 60 * 60);
return result === "OK";
}
userStaleBeforeTransientStore.set(username, now);
return true;
}


module.exports = {
userStaleBefore,
markUserStaleBeforeNow,
};

0 comments on commit 0c0aefc

Please sign in to comment.