import { assertNotUndefined, DEFAULT_PARSER } from "./body-parser.js"; import { RequestOptions, RequestResponse, request } from "@miqro/request"; import { Schema } from "@miqro/parser"; import { parse as cookieParse } from "cookie"; import { BadRequestError, checkEnvVariables, ForbiddenError, UnAuthorizedError } from "../common.js"; import { GroupPolicy, GroupPolicySchema, Handler, NoTokenSession, Request, Response, Session, SessionHandlerOptions, SessionHandlerOptionsOptionsSchema, SessionHandlerOptionsSchema } from "../types.js"; //const DEFAULT_TOKEN_LOCATION = "header"; const DEFAULT_TOKEN_LOCATION = "free"; const DEFAULT_TOKEN_HEADER = "Authorization"; const DEFAULT_TOKEN_QUERY = "token"; const DEFAULT_TOKEN_COOKIE = "Authorization"; export const DEFAULT_TOKEN_SET_COOKIE_HTTP_ONLY = "true"; export const DEFAULT_TOKEN_SET_COOKIE_SECURE = "true"; export const DEFAULT_TOKEN_SET_COOKIE_PATH = "/"; export const DEFAULT_TOKEN_SET_COOKIE_SAME_SITE = "strict"; export interface VerifyTokenService { verify(args: { token: string; req: Request }): Promise; } export interface VerifyEndpointServiceOptions { url: string; method: string; tokenLocation: "header" | "query" | "cookie"; tokenLocationName: string | ((req: Request) => Promise); } const NoTokenSessionSchema: Schema = { type: "object", properties: { username: "string", account: "string", groups: "string[]" }, mode: "add_extra" } const VerifyEndpointServiceSchema: Schema = { type: "object", properties: { url: "string1", method: "string1", tokenLocation: { type: "enum", enumValues: ["header", "query", "cookie"] }, tokenLocationName: "string1" } }; export class VerifyEndpointService implements VerifyTokenService { protected options: VerifyEndpointServiceOptions; constructor(options?: VerifyEndpointServiceOptions) { if (!options) { const [url, method] = checkEnvVariables(["TOKEN_VERIFY_ENDPOINT", "TOKEN_VERIFY_ENDPOINT_METHOD"]); const [tokenVerifyLocation] = checkEnvVariables(["TOKEN_VERIFY_LOCATION"], [DEFAULT_TOKEN_LOCATION]); let tokenLocationName; switch (tokenVerifyLocation) { case "header": tokenLocationName = checkEnvVariables(["TOKEN_HEADER"], [DEFAULT_TOKEN_HEADER])[0]; break; case "query": tokenLocationName = checkEnvVariables(["TOKEN_QUERY"], [DEFAULT_TOKEN_QUERY])[0]; break; case "cookie": tokenLocationName = checkEnvVariables(["TOKEN_COOKIE"], [DEFAULT_TOKEN_COOKIE])[0]; break; default: throw new Error(`TOKEN_VERIFY_LOCATION=${tokenVerifyLocation} not supported use (header or query)`); } this.options = { url, method, tokenLocation: tokenVerifyLocation, tokenLocationName } } else { this.options = assertNotUndefined(DEFAULT_PARSER.parse(options, VerifyEndpointServiceSchema, "options"), "bad options"); } if (this.options.tokenLocationName && (this.options.tokenLocationName === "__proto__" || this.options.tokenLocationName === "__prototype__")) { throw new Error(`invalid tokenLocationName ${this.options.tokenLocationName}`); } } public async verify({ token, req }: { token: string; req: Request; }): Promise { try { let response = null; const tokenVerifyLocation = this.options.tokenLocation; const url = this.options.url; const method = this.options.method; switch (tokenVerifyLocation) { case "header": const tokenHeaderLocation = typeof this.options.tokenLocationName === "string" ? this.options.tokenLocationName : await this.options.tokenLocationName(req); req.logger.debug(`verifying [${token}] on TOKEN_VERIFY_ENDPOINT=[${process.env.TOKEN_VERIFY_ENDPOINT}] TOKEN_HEADER=[${tokenHeaderLocation}]`); response = await this.getResponse({ url, headers: { [tokenHeaderLocation]: token }, method }, req); break; case "query": const tokenQueryLocation = typeof this.options.tokenLocationName === "string" ? this.options.tokenLocationName : await this.options.tokenLocationName(req); req.logger.debug(`verifying [${token}] on TOKEN_VERIFY_ENDPOINT=[${process.env.TOKEN_VERIFY_ENDPOINT}] TOKEN_QUERY=[${tokenQueryLocation}]`); response = await this.getResponse({ url, query: { [tokenQueryLocation]: token }, method }, req); break; case "cookie": const tokenCookieLocation = typeof this.options.tokenLocationName === "string" ? this.options.tokenLocationName : await this.options.tokenLocationName(req); req.logger.debug(`verifying [${token}] on TOKEN_VERIFY_ENDPOINT=[${process.env.TOKEN_VERIFY_ENDPOINT}] TOKEN_COOKIE=[${tokenCookieLocation}]`); response = await this.getResponse({ url, method, headers: { Cookie: `${tokenCookieLocation}=${token}; Path=/; HttpOnly;` } }, req); break; default: throw new Error(`TOKEN_VERIFY_LOCATION=${tokenVerifyLocation} not supported use (header, query or cookie)`); } if (response) { const session = await this.decodeSession(response, token, req); if (!this.checkSession(session, req)) { req.logger.warn(`unauthorized token not valid [${token}]`); return null; } else { req.logger.debug("token [%s]authorized!", token); if (tokenVerifyLocation === "cookie" && response.headers["set-cookie"]) { const tokenCookieLocation = typeof this.options.tokenLocationName === "string" ? this.options.tokenLocationName : await this.options.tokenLocationName(req); const cookies = response.headers["set-cookie"].map((c: any) => cookieParse(c)).filter((c: any) => c[tokenCookieLocation] !== undefined); if (cookies.length === 1) { const value = cookies[0][tokenCookieLocation]; if (value) { session.token = value; // replace token because token update via set-cookie } } } return { token, ...session } as Session; } } else { req.logger.warn(`unauthorized token not valid [${token}]`); return null; } } catch (e: any) { throw new UnAuthorizedError(`Fail to authenticate token! error verifying [${token}] [${e.status}][${e.config ? e.config.url : ""}]`); } } /* eslint-disable @typescript-eslint/no-unused-vars */ protected async getResponse(config: RequestOptions, req?: Request): Promise { return request(config); } /* eslint-disable @typescript-eslint/no-unused-vars */ protected checkSession(session: NoTokenSession, req?: Request): boolean { assertNotUndefined(DEFAULT_PARSER.parse(session, NoTokenSessionSchema, "session"), "bad session"); return true; } /* eslint-disable @typescript-eslint/no-unused-vars */ protected async decodeSession(response: RequestResponse, token: string, req?: Request): Promise { const session = response.data; return session as NoTokenSession; } } export function SessionHandler(config: SessionHandlerOptions): Handler { if (!config.options) { const tokenLocation = checkEnvVariables(["TOKEN_LOCATION"], [DEFAULT_TOKEN_LOCATION])[0]; config.options = { tokenLocation: tokenLocation as any, tokenLocationName: "", setCookieOptions: { httpOnly: true, path: "/", sameSite: "strict", secure: true } }; switch (tokenLocation) { case "free": break; case "header": config.options.tokenLocationName = checkEnvVariables(["TOKEN_HEADER"], [DEFAULT_TOKEN_HEADER])[0]; break; case "query": config.options.tokenLocationName = checkEnvVariables(["TOKEN_QUERY"], [DEFAULT_TOKEN_QUERY])[0]; break; case "cookie": config.options.tokenLocationName = checkEnvVariables(["TOKEN_COOKIE"], [DEFAULT_TOKEN_COOKIE])[0]; const [httpOnlyS, secureS, path, sameSite] = checkEnvVariables( ["TOKEN_SET_COOKIE_HTTP_ONLY", "TOKEN_SET_COOKIE_SECURE", "TOKEN_SET_COOKIE_PATH", "TOKEN_SET_COOKIE_SAME_SITE"], [DEFAULT_TOKEN_SET_COOKIE_HTTP_ONLY, DEFAULT_TOKEN_SET_COOKIE_SECURE, DEFAULT_TOKEN_SET_COOKIE_PATH, DEFAULT_TOKEN_SET_COOKIE_SAME_SITE]); config.options.setCookieOptions = { httpOnly: httpOnlyS === "true", secure: secureS === "true", path, sameSite: sameSite as any }; break; default: throw new Error(`TOKEN_LOCATION=${tokenLocation} not supported use (header or query)`); } } else { config.options = assertNotUndefined(DEFAULT_PARSER.parse(config.options as any, SessionHandlerOptionsOptionsSchema, "options"), "bad options"); } if (!config.authService) { throw new Error("authService must be provided!"); } if (!config.options || !config.options.setCookieOptions) { throw new Error("config.options not populated!"); } const tokenLocation = config.options.tokenLocation; const tokenLocationName = config.options.tokenLocationName; const setCookieOptions = config.options.setCookieOptions; return async function SessionHandler(req, res): Promise { try { const tlN = typeof tokenLocationName === "string" ? tokenLocationName : tokenLocationName ? await tokenLocationName(req) : ""; let token = null; switch (tokenLocation) { case "header": token = req.headers[(tlN).toLowerCase()] as string; break; case "free": token = null; break; case "query": token = req.query[tlN] as string; break; case "cookie": token = req.cookies[tlN] ? req.cookies[tlN] : undefined; break; default: throw new Error(`TOKEN_LOCATION=${tokenLocation} not supported use (header, query or cookie)`); } if (!token && tokenLocation !== "free") { req.logger.warn("No token provided!"); throw new ForbiddenError("NO TOKEN"); } else { const session = await config.authService.verify({ token, req, res }); if (!session) { req.logger.warn("fail to authenticate token [%s]!", token); throw new UnAuthorizedError(); } else { if (tokenLocation === "cookie" && session.token !== token) { const newTokenCookie = String(session.token ? session.token : token); const cookiePath = typeof setCookieOptions.path === "string" ? setCookieOptions.path : await setCookieOptions.path(req); res.setCookie(tlN, newTokenCookie, session.expires instanceof Date ? { httpOnly: setCookieOptions.httpOnly, secure: setCookieOptions.secure, path: cookiePath, sameSite: setCookieOptions.sameSite, expires: session.expires } : { httpOnly: setCookieOptions.httpOnly, path: cookiePath, secure: setCookieOptions.secure, sameSite: setCookieOptions.sameSite }) } req.session = session; req.logger.debug("authenticated!"); } } } catch (e: any) { if (e.message && e.message === "NO TOKEN") { throw e; } else { throw new UnAuthorizedError(); } } }; }; export function GroupPolicyHandler(options: GroupPolicy): Handler { options = assertNotUndefined(DEFAULT_PARSER.parse(options, GroupPolicySchema, "options")); return function GroupPolicyHandler(req: Request, res: Response): void { try { if (!req.session) { throw new UnAuthorizedError(`No Session!`); } else { const result = policyCheck(req.session, options); if (result) { req.logger.debug("groups validated!"); } else { req.logger.error("%s groups fail to validate!", req && req.session && req.session.groups ? `[${req.session.groups.join(",")}] ` : ""); throw new ForbiddenError(`Invalid session. You are not permitted to do this!`); } } } catch (e: any) { //(logger as Logger).warn(`request[${req.uuid}] message[${e.message}] stack[${e.stack}]`); if (e.name && e.name !== "Error") { throw e; } else { throw new UnAuthorizedError(`Invalid session. You are not permitted to do this!`); } } }; }; function policyCheck(session: Session, options: GroupPolicy): boolean { switch (options.groupPolicy) { case "at_least_one": for (const group of options.groups) { if (group instanceof Array) { let ret = true; for (const g of group) { if (session.groups.indexOf(g) === -1) { ret = false; break; } } if (ret) { return true; } } else { if (session.groups.indexOf(group) !== -1) { return true; } } } return false; case "all": for (const group of options.groups) { if (group instanceof Array) { for (const g of group) { if (session.groups.indexOf(g) === -1) { return false; } } } else { if (session.groups.indexOf(group) === -1) { return false; } } } return true; default: throw new BadRequestError(`policy [${options.groupPolicy}] not implemented!!`); } };