187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
/**
|
|
* Signs and verifies the server's four kinds of token (session, media,
|
|
* password reset and unsubscribe), all HS256 with the one key. Only a token's signature and
|
|
* expiry are checked here: `lib/auth.ts` checks a session or media token
|
|
* against its user.
|
|
*
|
|
* Importing this module throws when `GERBEUR_JWT_SECRET` is unset.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { jwtVerify, SignJWT } from "@panva/jose";
|
|
|
|
import {
|
|
type AuthPayload,
|
|
isAuthPayload,
|
|
isPasswordResetPayload,
|
|
type PasswordResetPayload,
|
|
} from "../model/auth.ts";
|
|
import { JWT_SECRET } from "../config.ts";
|
|
import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts";
|
|
import type { AuthTokens, Role } from "../../shared/user.ts";
|
|
|
|
if (!JWT_SECRET) {
|
|
throw new Error(
|
|
"GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32",
|
|
);
|
|
}
|
|
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
|
|
|
/**
|
|
* Signs a password reset token for `userId`, valid for an hour. The token
|
|
* isn't single-use by itself: `requestPasswordReset` stores it, and
|
|
* `resetPassword` marks it used.
|
|
*/
|
|
export async function createPasswordResetToken(
|
|
userId: string,
|
|
): Promise<string> {
|
|
return await new SignJWT({ purpose: "password-reset", userId })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
// Two requests in the same second would otherwise sign the same token,
|
|
// which is the primary key of `password_reset_tokens`.
|
|
.setJti(crypto.randomUUID())
|
|
.setExpirationTime("1h")
|
|
.sign(JWT_KEY);
|
|
}
|
|
|
|
/**
|
|
* Returns the payload of a password reset token, or null when it is invalid,
|
|
* expired or another kind of token. Whether it was used is the caller's check.
|
|
*/
|
|
export async function verifyPasswordResetToken(
|
|
token: string,
|
|
): Promise<PasswordResetPayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
|
if (!isPasswordResetPayload(payload)) return null;
|
|
return payload as PasswordResetPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signs a session token carrying `payload`, valid for 7 days.
|
|
* {@link issueTokens} issues it on sign-up, sign-in and password change, and
|
|
* the client sends it as a bearer token.
|
|
*/
|
|
export async function createJWT(
|
|
payload: Omit<AuthPayload, "exp">,
|
|
): Promise<string> {
|
|
return await new SignJWT(payload)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
// A password change revokes tokens by comparing `iat` with
|
|
// `users.tokens_valid_after`.
|
|
.setIssuedAt()
|
|
.setExpirationTime("7d")
|
|
.sign(JWT_KEY);
|
|
}
|
|
|
|
/**
|
|
* Returns a new session token and media token for `user`, as sign-up
|
|
* (`POST /api/users`), sign-in (`POST /api/sessions`) and a password change
|
|
* (`PUT /api/me/password`) answer them.
|
|
*/
|
|
export async function issueTokens(
|
|
user: { id: string; username: string; role: Role },
|
|
): Promise<AuthTokens> {
|
|
return {
|
|
token: await createJWT({
|
|
userId: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
}),
|
|
mediaToken: await createMediaToken(user.id),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Signs a media token for `userId`, valid for 7 days. Media elements can't send
|
|
* an `Authorization` header, so the URLs of private files, thumbnails and
|
|
* playlist covers carry this token as `?token=`, where it lands in logs and
|
|
* browser history. It only reads media, so a leaked URL doesn't expose the
|
|
* account.
|
|
*/
|
|
export async function createMediaToken(userId: string): Promise<string> {
|
|
return await new SignJWT({ purpose: "media", userId })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setIssuedAt()
|
|
.setExpirationTime("7d")
|
|
.sign(JWT_KEY);
|
|
}
|
|
|
|
/**
|
|
* Returns the user id and issue time of a media token, or null when it is
|
|
* invalid, expired or another kind of token.
|
|
*/
|
|
export async function verifyMediaToken(
|
|
token: string,
|
|
): Promise<{ userId: string; iat?: number } | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
|
if (payload.purpose !== "media" || typeof payload.userId !== "string") {
|
|
return null;
|
|
}
|
|
return { userId: payload.userId, iat: payload.iat };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the payload of a session token whose signature and expiry are valid,
|
|
* or null. Call `authenticate` (`lib/auth.ts`) instead, which also checks the
|
|
* token against its user.
|
|
*/
|
|
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
|
|
|
if (!isAuthPayload(payload)) {
|
|
return null;
|
|
}
|
|
|
|
return payload;
|
|
} catch (err) {
|
|
console.error("JWT verification failed:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signs the token of a digest's unsubscribe link, which turns off `cadence`
|
|
* for `userId` without signing in. It never expires, so the link in an old
|
|
* email still works; turning the digest back on doesn't revoke it, which is
|
|
* harmless since all it can do is turn it off again.
|
|
*/
|
|
export async function createUnsubscribeToken(
|
|
userId: string,
|
|
cadence: DigestCadence,
|
|
): Promise<string> {
|
|
return await new SignJWT({ purpose: "unsubscribe", userId, cadence })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.sign(JWT_KEY);
|
|
}
|
|
|
|
/**
|
|
* Returns the user and cadence of an unsubscribe token, or null when it is
|
|
* invalid or another kind of token.
|
|
*/
|
|
export async function verifyUnsubscribeToken(
|
|
token: string,
|
|
): Promise<{ userId: string; cadence: DigestCadence } | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
|
if (
|
|
payload.purpose !== "unsubscribe" || typeof payload.userId !== "string" ||
|
|
!isDigestCadence(payload.cadence)
|
|
) {
|
|
return null;
|
|
}
|
|
return { userId: payload.userId, cadence: payload.cadence };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|