import * as Express from "express"; import Router from "express-promise-router"; import * as jwt from "jsonwebtoken"; import TokenClaims from "@common/TokenClaims"; import { TokenGrantPasswordRequest, TokenGrantRefreshRequest, TokenGrantRequest, TokenGrantResponse, } from "@common/httpApi"; import { ErrorCode } from "@common/ErrorCode"; import { User } from "../models/User"; import { ServerState } from "../state"; import ApiError from "@common/ApiError"; export { TokenClaims }; declare global { namespace Express { interface Request { token?: TokenClaims; } } } const JWT_SECRET = process.env.JWT_SECRET!; if (!JWT_SECRET) { throw new Error("Must specify JWT_SECRET environment variable"); } const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours /** * @param {number} lifetime in seconds */ function getExpTime(lifetime: number) { return Math.floor(Date.now() / 1000) + lifetime; } function signToken(claims: TokenClaims): Promise { return new Promise((resolve, reject) => { jwt.sign(claims, JWT_SECRET, (err: Error, encoded: string) => { if (err) { reject(err); } else { resolve(encoded); } }); }); } export function verifyToken(token: string): Promise { return new Promise((resolve, reject) => { jwt.verify(token, JWT_SECRET, (err, decoded) => { if (err) { if (err.name === "TokenExpiredError") { reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err)); } else if (err.name === "JsonWebTokenError") { reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); } else { reject(err); } } else { resolve(decoded as any); } }); }); } function generateAccessToken(user: User, secret: string): Promise { const access_token_claims: TokenClaims = { iss: "sprinklers3", aud: user.id || "", name: user.name, type: "access", exp: getExpTime(ACCESS_TOKEN_LIFETIME), }; return signToken(access_token_claims); } function generateRefreshToken(user: User, secret: string): Promise { const refresh_token_claims: TokenClaims = { iss: "sprinklers3", aud: user.id || "", name: user.name, type: "refresh", exp: getExpTime(REFRESH_TOKEN_LIFETIME), }; return signToken(refresh_token_claims); } export function authentication(state: ServerState) { const router = Router(); async function passwordGrant(body: TokenGrantPasswordRequest, res: Express.Response): Promise { const { username, password } = body; if (!body || !username || !password) { throw new ApiError("Must specify username and password"); } const user = await User.loadByUsername(state.database, username); if (!user) { throw new ApiError("User does not exist"); } const passwordMatches = await user.comparePassword(password); if (passwordMatches) { return user; } else { throw new ApiError("Invalid user credentials"); } } async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise { const { refresh_token } = body; if (!body || !refresh_token) { throw new ApiError("Must specify a refresh_token"); } const claims = await verifyToken(refresh_token); if (claims.type !== "refresh") { throw new ApiError("Not a refresh token"); } const user = await User.load(state.database, claims.aud); if (!user) { throw new ApiError("User no longer exists"); } return user; } router.post("/token/grant", async (req, res) => { const body: TokenGrantRequest = req.body; let user: User; if (body.grant_type === "password") { user = await passwordGrant(body, res); } else if (body.grant_type === "refresh") { user = await refreshGrant(body, res); } else { throw new ApiError("Invalid grant_type"); } const [access_token, refresh_token] = await Promise.all( [await generateAccessToken(user, JWT_SECRET), await generateRefreshToken(user, JWT_SECRET)]); const response: TokenGrantResponse = { access_token, refresh_token, }; res.json(response); }); router.post("/token/verify", authorizeAccess, async (req, res) => { res.json({ ok: true, token: req.token, }); }); return router; } export async function authorizeAccess(req: Express.Request, res: Express.Response) { const bearer = req.headers.authorization; if (!bearer) { throw new ApiError("No Authorization header specified", ErrorCode.BadToken); } const matches = /^Bearer (.*)$/.exec(bearer); if (!matches || !matches[1]) { throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken); } const token = matches[1]; req.token = await verifyToken(token); }