diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index b6318d6..4ca0ce1 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -16,10 +16,8 @@ describe("authorize", () => { role: "admin" }; - guard.setUserContext(user); - - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); expect(() => guard.granted("something")).toThrow(); }); @@ -46,10 +44,8 @@ describe("authorize", () => { role: "admin" }; - guard.setUserContext(user); - - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); }); test("guard implicit allow", async () => { @@ -66,12 +62,12 @@ describe("authorize", () => { } }); - guard.setUserContext({ + const user = { role: "admin" - }); + }; - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); }); test("guard with guest role implicit allow", async () => { diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index c103848..6f2466c 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { App, createApp } from "../../src"; import type { AuthResponse } from "../../src/auth"; +import { auth } from "../../src/auth/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog } from "../helper"; @@ -98,7 +99,7 @@ const fns = (app: App, mode?: Mode) = } return { - Authorization: `Bearer ${token}`, + Authorization: token ? `Bearer ${token}` : "", "Content-Type": "application/json", ...additional }; @@ -210,4 +211,36 @@ describe("integration auth", () => { expect(res.status).toBe(403); }); }); + + it("context is exclusive", async () => { + const app = createAuthApp(); + await app.build(); + const $fns = fns(app); + + app.server.get("/get", auth(), async (c) => { + return c.json({ + user: c.get("auth").user ?? null + }); + }); + app.server.get("/wait", auth(), async (c) => { + await new Promise((r) => setTimeout(r, 20)); + return c.json({ ok: true }); + }); + + const { data } = await $fns.login(configs.users.normal); + const me = await $fns.me(data.token); + expect(me.user.email).toBe(configs.users.normal.email); + + app.server.request("/wait", { + headers: { Authorization: `Bearer ${data.token}` } + }); + + { + await new Promise((r) => setTimeout(r, 10)); + const res = await app.server.request("/get"); + const data = await res.json(); + expect(data.user).toBe(null); + expect(await $fns.me()).toEqual({ user: null as any }); + } + }); }); diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 2ba24a5..aef4da0 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -104,10 +104,9 @@ export class AuthController extends Controller { } hono.get("/me", auth(), async (c) => { - if (this.auth.authenticator.isUserLoggedIn()) { - const claims = this.auth.authenticator.getUser()!; + const claims = c.get("auth")?.user; + if (claims) { const { data: user } = await this.userRepo.findId(claims.id); - return c.json({ user }); } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 7853dcd..f869318 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -106,17 +106,15 @@ export type AuthUserResolver = ( identifier: string, profile: ProfileExchange ) => Promise; +type AuthClaims = SafeUser & { + iat: number; + iss?: string; + exp?: number; +}; 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) { @@ -148,21 +146,6 @@ export class Authenticator = Record< 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] @@ -206,7 +189,7 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } - async verify(jwt: string): Promise { + async verify(jwt: string): Promise { try { const payload = await verify( jwt, @@ -221,14 +204,10 @@ export class Authenticator = Record< } } - this._claims = payload as any; - return true; - } catch (e) { - this.resetUser(); - //console.error(e); - } + return payload as any; + } catch (e) {} - return false; + return; } private get cookieOptions(): CookieOptions { @@ -258,8 +237,8 @@ export class Authenticator = Record< } } - async requestCookieRefresh(c: Context) { - if (this.config.cookie.renew && this.isUserLoggedIn()) { + async requestCookieRefresh(c: Context) { + if (this.config.cookie.renew && c.get("auth")?.user) { const token = await this.getAuthCookie(c); if (token) { await this.setAuthCookie(c, token); @@ -276,13 +255,14 @@ export class Authenticator = Record< await deleteCookie(c, "auth", this.cookieOptions); } - async logout(c: Context) { + async logout(c: Context) { + c.set("auth", undefined); + 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 @@ -353,8 +333,7 @@ export class Authenticator = Record< } if (token) { - await this.verify(token); - return this.getUser(); + return await this.verify(token); } return undefined; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 15e3af2..8fbeadd 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,21 +1,23 @@ import { Exception, Permission } from "core"; import { objectTransform } from "core/utils"; +import type { Context } from "hono"; +import type { ServerEnv } from "modules/Module"; import { Role } from "./Role"; export type GuardUserContext = { - role: string | null | undefined; + role?: string | null; [key: string]: any; }; export type GuardConfig = { enabled?: boolean; }; +export type GuardContext = Context | GuardUserContext; const debug = false; export class Guard { permissions: Permission[]; - user?: GuardUserContext; roles?: Role[]; config?: GuardConfig; @@ -89,24 +91,19 @@ export class Guard { return this; } - setUserContext(user: GuardUserContext | undefined) { - this.user = user; - return this; - } - - getUserRole(): Role | undefined { - if (this.user && typeof this.user.role === "string") { - const role = this.roles?.find((role) => role.name === this.user?.role); + getUserRole(user?: GuardUserContext): Role | undefined { + if (user && typeof user.role === "string") { + const role = this.roles?.find((role) => role.name === user?.role); if (role) { - debug && console.log("guard: role found", [this.user.role]); + debug && console.log("guard: role found", [user.role]); return role; } } debug && console.log("guard: role not found", { - user: this.user, - role: this.user?.role + user: user, + role: user?.role }); return this.getDefaultRole(); } @@ -119,9 +116,9 @@ export class Guard { return this.config?.enabled === true; } - hasPermission(permission: Permission): boolean; - hasPermission(name: string): boolean; - hasPermission(permissionOrName: Permission | string): boolean { + hasPermission(permission: Permission, user?: GuardUserContext): boolean; + hasPermission(name: string, user?: GuardUserContext): boolean; + hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { if (!this.isEnabled()) { //console.log("guard not enabled, allowing"); return true; @@ -133,7 +130,7 @@ export class Guard { throw new Error(`Permission ${name} does not exist`); } - const role = this.getUserRole(); + const role = this.getUserRole(user); if (!role) { debug && console.log("guard: role not found, denying"); @@ -156,12 +153,13 @@ export class Guard { return !!rolePermission; } - granted(permission: Permission | string): boolean { - return this.hasPermission(permission as any); + granted(permission: Permission | string, c?: GuardContext): boolean { + const user = c && "get" in c ? c.get("auth")?.user : c; + return this.hasPermission(permission as any, user); } - throwUnlessGranted(permission: Permission | string) { - if (!this.granted(permission)) { + throwUnlessGranted(permission: Permission | string, c: GuardContext) { + if (!this.granted(permission, c)) { throw new Exception( `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, 403 diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index f36c94b..677478b 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) { } export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { - if (c.get("auth_skip")) return true; + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } + + if (authCtx.skip) return true; const req = c.req.raw; if (!skip) return false; @@ -18,7 +23,7 @@ export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { const path = getPath(req); const result = skip.some((s) => patternMatch(path, s)); - c.set("auth_skip", result); + authCtx.skip = result; return result; } @@ -26,29 +31,31 @@ export const auth = (options?: { skip?: (string | RegExp)[]; }) => createMiddleware(async (c, next) => { + if (!c.get("auth")) { + c.set("auth", { + registered: false, + resolved: false, + skip: false, + user: undefined + }); + } + const app = c.get("app"); - const guard = app?.modules.ctx().guard; + const authCtx = c.get("auth")!; const authenticator = app?.module.auth.authenticator; let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; // make sure to only register once - if (c.get("auth_registered")) { + if (authCtx.registered) { skipped = true; console.warn(`auth middleware already registered for ${getPath(c)}`); } else { - c.set("auth_registered", true); + authCtx.registered = true; - if (!skipped) { - const resolved = c.get("auth_resolved"); - if (!resolved) { - if (!app?.module.auth.enabled) { - guard?.setUserContext(undefined); - } else { - guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); - c.set("auth_resolved", true); - } - } + if (!skipped && !authCtx.resolved && app?.module.auth.enabled) { + authCtx.user = await authenticator?.resolveAuthFromRequest(c); + authCtx.resolved = true; } } @@ -60,9 +67,9 @@ export const auth = (options?: { } // release - guard?.setUserContext(undefined); - authenticator?.resetUser(); - c.set("auth_resolved", false); + authCtx.skip = false; + authCtx.resolved = false; + authCtx.user = undefined; }); export const permission = ( @@ -75,23 +82,26 @@ export const permission = ( // @ts-ignore createMiddleware(async (c, next) => { const app = c.get("app"); - //console.log("skip?", c.get("auth_skip")); + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } // in tests, app is not defined - if (!c.get("auth_registered") || !app) { + if (!authCtx.registered || !app) { const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; if (app?.module.auth.enabled) { throw new Error(msg); } else { console.warn(msg); } - } else if (!c.get("auth_skip")) { + } else if (!authCtx.skip) { const guard = app.modules.ctx().guard; const permissions = Array.isArray(permission) ? permission : [permission]; if (options?.onGranted || options?.onDenied) { let returned: undefined | void | Response; - if (permissions.every((p) => guard.granted(p))) { + if (permissions.every((p) => guard.granted(p, c))) { returned = await options?.onGranted?.(c); } else { returned = await options?.onDenied?.(c); @@ -100,7 +110,7 @@ export const permission = ( return returned; } } else { - permissions.some((p) => guard.throwUnlessGranted(p)); + permissions.some((p) => guard.throwUnlessGranted(p, c)); } } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 131f3d6..ff27a75 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -327,12 +327,9 @@ export class DataController extends Controller { // delete one .delete( "/:entity/:id", - permission(DataPermissions.entityDelete), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), async (c) => { - this.guard.throwUnlessGranted(DataPermissions.entityDelete); - const { entity, id } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); @@ -350,14 +347,11 @@ export class DataController extends Controller { tb("param", Type.Object({ entity: Type.String() })), tb("json", querySchema.properties.where), async (c) => { - //console.log("request", c.req.raw); const { entity } = c.req.param(); if (!this.entityExists(entity)) { return c.notFound(); } const where = c.req.valid("json") as RepoQuery["where"]; - //console.log("where", where); - const result = await this.em.mutator(entity).deleteWhere(where); return c.json(this.mutatorResult(result)); diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index ef0bc81..1488780 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -18,13 +18,14 @@ import { isEqual } from "lodash-es"; export type ServerEnv = { Variables: { - app?: App; + app: App; // to prevent resolving auth multiple times - auth_resolved?: boolean; - // to only register once - auth_registered?: boolean; - // whether or not to bypass auth - auth_skip?: boolean; + auth?: { + resolved: boolean; + registered: boolean; + skip: boolean; + user?: { id: any; role?: string; [key: string]: any }; + }; html?: string; }; }; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index b7fc900..32c0c4e 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -69,7 +69,7 @@ export class AdminController extends Controller { hono.use("*", async (c, next) => { const obj = { - user: auth.authenticator?.getUser(), + user: c.get("auth")?.user, logout_route: this.withBasePath(authRoutes.logout), color_scheme: configs.server.admin.color_scheme }; @@ -91,7 +91,7 @@ export class AdminController extends Controller { // @ts-ignore onGranted: async (c) => { // @todo: add strict test to permissions middleware? - if (auth.authenticator.isUserLoggedIn()) { + if (c.get("auth")?.user) { console.log("redirecting to success"); return c.redirect(authRoutes.success); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 525deac..ebcec9b 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -40,6 +40,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; + // don't add auth again, it's already added in getController const hono = this.create(); hono.use(permission(SystemPermissions.configRead)); @@ -63,7 +64,7 @@ export class SystemController extends Controller { const { secrets } = c.req.valid("query"); const { module } = c.req.valid("param"); - secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); + secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); const config = this.app.toJSON(secrets); @@ -227,8 +228,8 @@ export class SystemController extends Controller { const module = c.req.param("module") as ModuleKey | undefined; const { config, secrets } = c.req.valid("query"); - config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead); - secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); + config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c); + secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); const { version, ...schema } = this.app.getSchema(); @@ -261,73 +262,27 @@ export class SystemController extends Controller { ), async (c) => { const { sync } = c.req.valid("query") as Record; - this.ctx.guard.throwUnlessGranted(SystemPermissions.build); + this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); await this.app.build({ sync }); return c.json({ success: true, options: { sync } }); } ); - hono.get("/ping", async (c) => { - //console.log("c", c); - try { - // @ts-ignore @todo: fix with env - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - const cf = { - colo: context.colo, - city: context.city, - postal: context.postalCode, - region: context.region, - regionCode: context.regionCode, - continent: context.continent, - country: context.country, - eu: context.isEUCountry, - lat: context.latitude, - lng: context.longitude, - timezone: context.timezone - }; - return c.json({ pong: true }); - } catch (e) { - return c.json({ pong: true }); - } - }); + hono.get("/ping", (c) => c.json({ pong: true })); - hono.get("/info", async (c) => { - return c.json({ - version: this.app.version(), - test: 2, - app: c.get("app")?.version(), + hono.get("/info", (c) => + c.json({ + version: c.get("app")?.version(), runtime: getRuntimeKey() - }); - }); + }) + ); hono.get("/openapi.json", async (c) => { - //const config = this.app.toJSON(); const config = getDefaultConfig(); return c.json(generateOpenAPI(config)); }); - /*hono.get("/test/sql", async (c) => { - // @ts-ignore - const ai = c.env?.AI as Ai; - const messages = [ - { role: "system", content: "You are a friendly assistant" }, - { - role: "user", - content: "just say hello" - } - ]; - - const stream = await ai.run("@cf/meta/llama-3.1-8b-instruct", { - messages, - stream: true - }); - - return new Response(stream, { - headers: { "content-type": "text/event-stream" } - }); - });*/ - return hono; } }