import { Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, StringEnum, type TSchema, Type, parse, randomString, transformObject } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Module"; type Input = any; // workaround export type JWTPayload = Parameters[0]; // @todo: add schema to interface to ensure proper inference export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; getMode: () => "form" | "external"; getName: () => string; toJSON: (secrets?: boolean) => any; } export type User = { id: number; email: string; username: string; password: string; role: string; }; export type ProfileExchange = { email?: string; username?: string; sub?: string; password?: string; [key: string]: any; }; export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; export interface UserPool { findBy: (prop: Fields, value: string | number) => Promise; create: (user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds export const cookieConfig = Type.Partial( Type.Object({ path: Type.String({ default: "/" }), sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }), secure: Type.Boolean({ default: true }), httpOnly: Type.Boolean({ default: true }), expires: Type.Number({ default: defaultCookieExpires }), // seconds renew: Type.Boolean({ default: true }), pathSuccess: Type.String({ default: "/" }), pathLoggedOut: Type.String({ default: "/" }) }), { default: {}, additionalProperties: false } ); // @todo: maybe add a config to not allow cookie/api tokens to be used interchangably? // see auth.integration test for further details export const jwtConfig = Type.Object( { // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth secret: Type.String({ default: "" }), alg: Type.Optional(StringEnum(["HS256", "HS384", "HS512"], { default: "HS256" })), expires: Type.Optional(Type.Number()), // seconds issuer: Type.Optional(Type.String()), fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }) }, { default: {}, additionalProperties: false } ); export const authenticatorConfig = Type.Object({ jwt: jwtConfig, cookie: cookieConfig }); type AuthConfig = Static; export type AuthAction = "login" | "register"; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, identifier: string, profile: ProfileExchange ) => Promise; export class Authenticator = Record> { private readonly strategies: Strategies; private readonly config: AuthConfig; private _claims: | undefined | (SafeUser & { iat: number; iss?: string; exp?: number; }); private readonly userResolver: AuthUserResolver; constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); this.strategies = strategies as Strategies; this.config = parse(authenticatorConfig, config ?? {}); } async resolve( action: AuthAction, strategy: Strategy, identifier: string, profile: ProfileExchange ): Promise { //console.log("resolve", { action, strategy: strategy.getName(), profile }); const user = await this.userResolver(action, strategy, identifier, profile); if (user) { return { user, token: await this.jwt(user) }; } throw new Error("User could not be resolved"); } getStrategies(): Strategies { return this.strategies; } isUserLoggedIn(): boolean { return this._claims !== undefined; } getUser(): SafeUser | undefined { if (!this._claims) return; const { iat, exp, iss, ...user } = this._claims; return user; } resetUser() { this._claims = undefined; } strategy< StrategyName extends keyof Strategies, Strat extends Strategy = Strategies[StrategyName] >(strategy: StrategyName): Strat { try { return this.strategies[strategy] as unknown as Strat; } catch (e) { throw new Error(`Strategy "${String(strategy)}" not found`); } } // @todo: add jwt tests async jwt(user: Omit): Promise { const prohibited = ["password"]; for (const prop of prohibited) { if (prop in user) { throw new Error(`Property "${prop}" is prohibited`); } } const payload: JWTPayload = { ...user, iat: Math.floor(Date.now() / 1000) }; // issuer if (this.config.jwt?.issuer) { payload.iss = this.config.jwt.issuer; } // expires in seconds if (this.config.jwt?.expires) { payload.exp = Math.floor(Date.now() / 1000) + this.config.jwt.expires; } const secret = this.config.jwt.secret; if (!secret || secret.length === 0) { throw new Error("Cannot sign JWT without a secret"); } return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } async verify(jwt: string): Promise { try { const payload = await verify( jwt, this.config.jwt?.secret ?? "", this.config.jwt?.alg ?? "HS256" ); // manually verify issuer (hono doesn't support it) if (this.config.jwt?.issuer) { if (payload.iss !== this.config.jwt.issuer) { throw new Exception("Invalid issuer", 403); } } this._claims = payload as any; return true; } catch (e) { this.resetUser(); //console.error(e); } return false; } private get cookieOptions(): CookieOptions { const { expires = defaultCookieExpires, renew, ...cookieConfig } = this.config.cookie; return { ...cookieConfig, expires: new Date(Date.now() + expires * 1000) }; } private async getAuthCookie(c: Context): Promise { try { const secret = this.config.jwt.secret; const token = await getSignedCookie(c, secret, "auth"); if (typeof token !== "string") { return undefined; } return token; } catch (e: any) { if (e instanceof Error) { console.error("[Error:getAuthCookie]", e.message); } return undefined; } } async requestCookieRefresh(c: Context) { if (this.config.cookie.renew) { const token = await this.getAuthCookie(c); if (token) { await this.setAuthCookie(c, token); } } } private async setAuthCookie(c: Context, token: string) { const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { await deleteCookie(c, "auth", this.cookieOptions); } async logout(c: Context) { const cookie = await this.getAuthCookie(c); if (cookie) { await this.deleteAuthCookie(c); await addFlashMessage(c, "Signed out", "info"); } this.resetUser(); } // @todo: move this to a server helper isJsonRequest(c: Context): boolean { //return c.req.header("Content-Type") === "application/x-www-form-urlencoded"; return c.req.header("Content-Type") === "application/json"; } async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { if (this.isJsonRequest(c)) { return c.json(data); } const successPath = this.config.cookie.pathSuccess ?? "/"; const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); console.log("auth respond", { redirect, successUrl, successPath }); if ("token" in data) { await this.setAuthCookie(c, data.token); // can't navigate to "/" – doesn't work on nextjs console.log("auth success, redirecting to", successUrl); return c.redirect(successUrl); } let message = "An error occured"; if (data instanceof Exception) { message = data.message; } await addFlashMessage(c, message, "error"); console.log("auth failed, redirecting to", referer); return c.redirect(referer); } // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; if (c.req.raw.headers.has("Authorization")) { const bearerHeader = String(c.req.header("Authorization")); token = bearerHeader.replace("Bearer ", ""); } else { token = await this.getAuthCookie(c); } if (token) { await this.verify(token); return this.getUser(); } return undefined; } toJSON(secrets?: boolean) { return { ...this.config, jwt: secrets ? this.config.jwt : undefined, strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets)) }; } }