import { randomUUID } from 'crypto'; import { JwtPayload, sign, TokenExpiredError, verify } from 'jsonwebtoken'; import { EntityManager, getManager, Transaction, TransactionManager, } from 'typeorm'; import { User } from '@/entities'; import { ACCESS_TOKEN_EXPIRATION_TIME, REFRESH_TOKEN_EXPIRATION_TIME, } from '@/constants'; import { InvalidAccessTokenError, InvalidRefreshTokenError, IssueNewTokens, RefreshAccessTokenWithRefreshToken, TokenInvalidatedError, VerifyAccessToken, } from './types'; const SECRET_ENCODING_ALGORITHM = 'base64'; const TOKEN_SIGNING_ALGORITHM = 'HS256'; export default class AccessTokenManager { readonly jwtSecret: Buffer; readonly refreshTokenSecret: Buffer; constructor() { if (!process.env.REFRESH_TOKEN_SECRET || !process.env.JWT_SECRET) { throw new Error( 'Cannot start Auth without both REFRESH_TOKEN_SECRET and JWT_SECRET in environment' ); } this.refreshTokenSecret = Buffer.from( process.env.REFRESH_TOKEN_SECRET, SECRET_ENCODING_ALGORITHM ); this.jwtSecret = Buffer.from( process.env.JWT_SECRET, SECRET_ENCODING_ALGORITHM ); } /** * verifies an access token, but if it has expied and `refreshToken` is * provided, attempt to refresh the refreshToken token, and return the * `sub`, and the new refresh token, so that the client can update their * refresh token * @throws {InvalidAccessTokenError} */ async verifyAccessTokenOrFail( accessToken: string, refreshToken?: string ): Promise { try { const payload = verify(accessToken, this.jwtSecret); if (typeof payload === 'string') { throw new InvalidAccessTokenError(); } if (!payload.sub) { throw new InvalidAccessTokenError(); } const finalRefreshToken = await this.optionallyGrantNewRefreshToken( refreshToken ); return { refreshToken: finalRefreshToken, sub: payload.sub, }; } catch (error) { // attempt to refresh if (error instanceof TokenExpiredError) { try { const response = await this.refreshAccessTokenWithRefreshTokenOrFail( refreshToken ); return { refreshToken: response.refreshToken, sub: response.sub, }; } catch (errorRefreshingAccessToken) { // rethrow original error throw error; } } throw new InvalidAccessTokenError(); } } /** * Controls when new refresh tokens are granted to provide conintual, or * not continual access to this service. * * The strategies to consider: * 1. [The one currently used] Continuously refresh on every request, so * that as long as the client makes a request to our server, we will * provide a refresh token that can be used (without invalidating the previous * one) up to the time after normal expriation, and they will not be signed * out. * 2. Require clients to manually request a new refresh token. This would * require the user to sign back in every time their refresh token goes * stale, unless the client requests a new one. * 3. Something in between, e.g. granting a new token after a certain * amount of time since the last one has been granted. */ async shouldGrantNewRefreshToken( // eslint-disable-next-line @typescript-eslint/no-unused-vars _refreshToken: JwtPayload ): Promise { return true; } /** * Optionally grants depending on the result to * {@link AccessTokenManager#shouldGrantNewRefreshToken} a new refresh * token dependent on the behavior we would like a client to experience. * * This method should only be called if the access token is known to be valid. */ async optionallyGrantNewRefreshToken( refreshToken?: string ): Promise { if (!refreshToken) { return undefined; } try { const verifiedRefreshToken = this.getRefreshTokenPayload(refreshToken); if (await this.shouldGrantNewRefreshToken(verifiedRefreshToken)) { // token is valid and // we can send back a new refresh token const user = await User.findOne({ id: verifiedRefreshToken.sub }); if (!user) { return refreshToken; } return this.createRefreshToken({ user }); } } catch (error) { // silently catch errors and move on... } return refreshToken; } /** * Given a refresh token, provides access to a new access token and a new * refresh token * @throws {InvalidRefreshTokenError} if the refresh token is invalid * @throws {TokenInvalidatedError} if the refresh token has been */ async refreshAccessTokenWithRefreshTokenOrFail( refreshTokenToValidate?: string ): Promise { if (!refreshTokenToValidate) { throw new InvalidRefreshTokenError(); } const verifiedRefreshToken = this.getRefreshTokenPayload( refreshTokenToValidate ); // token is valid and // we can send back an access token const user = await User.findOne({ id: verifiedRefreshToken.sub }); if (!user) { throw new InvalidRefreshTokenError(); } if (user.refreshTokenVersion !== verifiedRefreshToken.tokenVersion) { throw new TokenInvalidatedError(); } const { accessToken, refreshToken } = await this.issueNewTokens({ user }); return { refreshToken, accessToken, sub: user.id, }; } getRefreshTokenPayload(refreshTokenToValidate: string): JwtPayload { let verifiedRefreshToken: JwtPayload; try { const verificationResult = verify( refreshTokenToValidate, this.refreshTokenSecret ); if (typeof verificationResult === 'string') { throw new InvalidRefreshTokenError(); } if (!verificationResult?.tokenVersion || !verificationResult?.sub) { throw new InvalidRefreshTokenError(); } verifiedRefreshToken = verificationResult; } catch (err) { throw new InvalidRefreshTokenError(); } return verifiedRefreshToken; } /** * Creates a short-lived access token */ createAccessToken(user: User): string { return sign( { sub: user.id, }, this.jwtSecret, { algorithm: TOKEN_SIGNING_ALGORITHM, expiresIn: ACCESS_TOKEN_EXPIRATION_TIME, } ); } /** * Creates a long-living refresh token */ createRefreshToken({ user, refreshTokenDurationMs, }: { user: User; refreshTokenDurationMs?: number; }): string { return sign( { sub: user.id, tokenVersion: user.refreshTokenVersion, }, this.refreshTokenSecret, { algorithm: TOKEN_SIGNING_ALGORITHM, expiresIn: refreshTokenDurationMs ?? REFRESH_TOKEN_EXPIRATION_TIME, } ); } /** * Revokes the refresh token by generating a new one and ensuring if a new * refresh token is requested, that request is denied. */ @Transaction() async revokeRefreshToken( user: User, @TransactionManager() manager: EntityManager = getManager() ): Promise { user.refreshTokenVersion = randomUUID(); return manager.save(user); } @Transaction() async issueNewTokens( { user, refreshTokenDurationMs, }: { user: User; refreshTokenDurationMs?: number }, @TransactionManager() manager: EntityManager = getManager() ): Promise { /** * Only need to set the refresh token if it's not set at this point. * Otherwise, we use the existing one. To revoke access tokens, @see * {@link AccessTokenManager#revokeRefreshToken} */ if (!user.refreshTokenVersion) { user.refreshTokenVersion = randomUUID(); await manager.save(user); } const refreshToken = this.createRefreshToken({ user, refreshTokenDurationMs, }); const accessToken = this.createAccessToken(user); return { accessToken, refreshToken, }; } }