auth-vir
Version:
Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.
110 lines (109 loc) • 3.9 kB
JavaScript
import { assertWrap, check } from '@augment-vir/assert';
import { calculateRelativeDate, convertDuration, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, toTimestamp, } from 'date-vir';
import { EncryptJWT, jwtDecrypt, jwtVerify, SignJWT } from 'jose';
const encryptionProtectedHeader = {
alg: 'dir',
enc: 'A256GCM',
};
const signingProtectedHeader = {
alg: 'HS512',
};
/**
* Default allowed clock skew for JWT expiration checks. Accounts for differences between server and
* client clocks.
*
* @category Internal
* @default {minutes: 5}
*/
export const defaultAllowedClockSkew = {
minutes: 5,
};
/**
* JWT uses seconds since the epoch per RFC 7519, whereas `toTimestamp` uses milliseconds.
*
* @category Internal
*/
export function toJwtTimestamp(date) {
return Math.floor(toTimestamp(date) / 1000);
}
/**
* Converts a JWT timestamp (in seconds) into a FullDate instance.
*
* @category Internal
*/
export function parseJwtTimestamp(seconds) {
return createUtcFullDate(seconds * 1000);
}
/**
* Creates a signed and encrypted JWT that contains the given data.
*
* @category Internal
*/
export async function createJwt(
/** The data to be included in the JWT. */
data, params) {
const rawJwt = new SignJWT({
data,
})
.setProtectedHeader(signingProtectedHeader)
.setIssuedAt(params.issuedAt
? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
: undefined)
.setIssuer(params.issuer)
.setAudience(params.audience)
.setExpirationTime(toJwtTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)));
if (params.notValidUntil) {
rawJwt.setNotBefore(toJwtTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
}
const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
return await new EncryptJWT({
jwt: signedJwt,
})
.setProtectedHeader(encryptionProtectedHeader)
.encrypt(params.jwtKeys.encryptionKey);
}
/**
* Parse and extract all data from an encrypted and signed JWT.
*
* @category Internal
* @throws Errors if the decryption, signature verification, or other JWT requirements fail
*/
export async function parseJwt(encryptedJwt, params) {
const decryptedJwt = await jwtDecrypt(encryptedJwt, params.jwtKeys.encryptionKey);
if (!check.deepEquals(decryptedJwt.protectedHeader, encryptionProtectedHeader)) {
throw new Error('Invalid encryption protected header.');
}
else if (!check.isString(decryptedJwt.payload.jwt)) {
throw new TypeError('Decrypted jwt is not a string.');
}
const clockToleranceSeconds = convertDuration(params.allowedClockSkew || defaultAllowedClockSkew, {
seconds: true,
}).seconds;
const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
issuer: params.issuer,
audience: params.audience,
requiredClaims: [
'iat',
'aud',
'iss',
],
clockTolerance: clockToleranceSeconds,
});
if (!verifiedJwt.payload.iat ||
verifiedJwt.payload.iat * 1000 > Date.now() + clockToleranceSeconds * 1000) {
throw new Error('"iat" claim timestamp check failed');
}
const data = verifiedJwt.payload.data;
if (!check.deepEquals(verifiedJwt.protectedHeader, signingProtectedHeader)) {
throw new Error('Invalid signing protected header.');
}
const issuedAtSeconds = assertWrap.isDefined(verifiedJwt.payload.iat, 'JWT has no issued at.');
const expirationSeconds = assertWrap.isDefined(verifiedJwt.payload.exp, 'JWT has no expiration.');
const jwtIssuedAt = parseJwtTimestamp(issuedAtSeconds);
const jwtExpiration = parseJwtTimestamp(expirationSeconds);
return {
data: data,
jwtExpiration,
jwtIssuedAt,
};
}