Merge pull request #81 from bknd-io/feat/app-auth-context

feat/app-auth-context
This commit is contained in:
dswbx
2025-02-14 20:33:12 +01:00
committed by GitHub
10 changed files with 131 additions and 166 deletions

View File

@@ -16,10 +16,8 @@ describe("authorize", () => {
role: "admin" role: "admin"
}; };
guard.setUserContext(user); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(() => guard.granted("something")).toThrow(); expect(() => guard.granted("something")).toThrow();
}); });
@@ -46,10 +44,8 @@ describe("authorize", () => {
role: "admin" role: "admin"
}; };
guard.setUserContext(user); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
}); });
test("guard implicit allow", async () => { test("guard implicit allow", async () => {
@@ -66,12 +62,12 @@ describe("authorize", () => {
} }
}); });
guard.setUserContext({ const user = {
role: "admin" role: "admin"
}); };
expect(guard.granted("read")).toBe(true); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write")).toBe(true); expect(guard.granted("write", user)).toBe(true);
}); });
test("guard with guest role implicit allow", async () => { test("guard with guest role implicit allow", async () => {

View File

@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src"; import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth"; import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
@@ -98,7 +99,7 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
} }
return { return {
Authorization: `Bearer ${token}`, Authorization: token ? `Bearer ${token}` : "",
"Content-Type": "application/json", "Content-Type": "application/json",
...additional ...additional
}; };
@@ -210,4 +211,36 @@ describe("integration auth", () => {
expect(res.status).toBe(403); 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 });
}
});
}); });

View File

@@ -104,10 +104,9 @@ export class AuthController extends Controller {
} }
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { const claims = c.get("auth")?.user;
const claims = this.auth.authenticator.getUser()!; if (claims) {
const { data: user } = await this.userRepo.findId(claims.id); const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user }); return c.json({ user });
} }

View File

@@ -106,17 +106,15 @@ export type AuthUserResolver = (
identifier: string, identifier: string,
profile: ProfileExchange profile: ProfileExchange
) => Promise<SafeUser | undefined>; ) => Promise<SafeUser | undefined>;
type AuthClaims = SafeUser & {
iat: number;
iss?: string;
exp?: number;
};
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> { export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies; private readonly strategies: Strategies;
private readonly config: AuthConfig; private readonly config: AuthConfig;
private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver; private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -148,21 +146,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return this.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< strategy<
StrategyName extends keyof Strategies, StrategyName extends keyof Strategies,
Strat extends Strategy = Strategies[StrategyName] Strat extends Strategy = Strategies[StrategyName]
@@ -206,7 +189,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
} }
async verify(jwt: string): Promise<boolean> { async verify(jwt: string): Promise<AuthClaims | undefined> {
try { try {
const payload = await verify( const payload = await verify(
jwt, jwt,
@@ -221,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
this._claims = payload as any; return payload as any;
return true; } catch (e) {}
} catch (e) {
this.resetUser();
//console.error(e);
}
return false; return;
} }
private get cookieOptions(): CookieOptions { private get cookieOptions(): CookieOptions {
@@ -258,8 +237,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
async requestCookieRefresh(c: Context) { async requestCookieRefresh(c: Context<ServerEnv>) {
if (this.config.cookie.renew && this.isUserLoggedIn()) { if (this.config.cookie.renew && c.get("auth")?.user) {
const token = await this.getAuthCookie(c); const token = await this.getAuthCookie(c);
if (token) { if (token) {
await this.setAuthCookie(c, token); await this.setAuthCookie(c, token);
@@ -276,13 +255,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
await deleteCookie(c, "auth", this.cookieOptions); await deleteCookie(c, "auth", this.cookieOptions);
} }
async logout(c: Context) { async logout(c: Context<ServerEnv>) {
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c); const cookie = await this.getAuthCookie(c);
if (cookie) { if (cookie) {
await this.deleteAuthCookie(c); await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info"); await addFlashMessage(c, "Signed out", "info");
} }
this.resetUser();
} }
// @todo: move this to a server helper // @todo: move this to a server helper
@@ -353,8 +333,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
if (token) { if (token) {
await this.verify(token); return await this.verify(token);
return this.getUser();
} }
return undefined; return undefined;

View File

@@ -1,21 +1,23 @@
import { Exception, Permission } from "core"; import { Exception, Permission } from "core";
import { objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Module";
import { Role } from "./Role"; import { Role } from "./Role";
export type GuardUserContext = { export type GuardUserContext = {
role: string | null | undefined; role?: string | null;
[key: string]: any; [key: string]: any;
}; };
export type GuardConfig = { export type GuardConfig = {
enabled?: boolean; enabled?: boolean;
}; };
export type GuardContext = Context<ServerEnv> | GuardUserContext;
const debug = false; const debug = false;
export class Guard { export class Guard {
permissions: Permission[]; permissions: Permission[];
user?: GuardUserContext;
roles?: Role[]; roles?: Role[];
config?: GuardConfig; config?: GuardConfig;
@@ -89,24 +91,19 @@ export class Guard {
return this; return this;
} }
setUserContext(user: GuardUserContext | undefined) { getUserRole(user?: GuardUserContext): Role | undefined {
this.user = user; if (user && typeof user.role === "string") {
return this; const role = this.roles?.find((role) => role.name === user?.role);
}
getUserRole(): Role | undefined {
if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) { if (role) {
debug && console.log("guard: role found", [this.user.role]); debug && console.log("guard: role found", [user.role]);
return role; return role;
} }
} }
debug && debug &&
console.log("guard: role not found", { console.log("guard: role not found", {
user: this.user, user: user,
role: this.user?.role role: user?.role
}); });
return this.getDefaultRole(); return this.getDefaultRole();
} }
@@ -119,9 +116,9 @@ export class Guard {
return this.config?.enabled === true; return this.config?.enabled === true;
} }
hasPermission(permission: Permission): boolean; hasPermission(permission: Permission, user?: GuardUserContext): boolean;
hasPermission(name: string): boolean; hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string): boolean { hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) { if (!this.isEnabled()) {
//console.log("guard not enabled, allowing"); //console.log("guard not enabled, allowing");
return true; return true;
@@ -133,7 +130,7 @@ export class Guard {
throw new Error(`Permission ${name} does not exist`); throw new Error(`Permission ${name} does not exist`);
} }
const role = this.getUserRole(); const role = this.getUserRole(user);
if (!role) { if (!role) {
debug && console.log("guard: role not found, denying"); debug && console.log("guard: role not found, denying");
@@ -156,12 +153,13 @@ export class Guard {
return !!rolePermission; return !!rolePermission;
} }
granted(permission: Permission | string): boolean { granted(permission: Permission | string, c?: GuardContext): boolean {
return this.hasPermission(permission as any); const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
} }
throwUnlessGranted(permission: Permission | string) { throwUnlessGranted(permission: Permission | string, c: GuardContext) {
if (!this.granted(permission)) { if (!this.granted(permission, c)) {
throw new Exception( throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403 403

View File

@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
} }
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) { export function shouldSkip(c: Context<ServerEnv>, 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; const req = c.req.raw;
if (!skip) return false; if (!skip) return false;
@@ -18,7 +23,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
const path = getPath(req); const path = getPath(req);
const result = skip.some((s) => patternMatch(path, s)); const result = skip.some((s) => patternMatch(path, s));
c.set("auth_skip", result); authCtx.skip = result;
return result; return result;
} }
@@ -26,29 +31,31 @@ export const auth = (options?: {
skip?: (string | RegExp)[]; skip?: (string | RegExp)[];
}) => }) =>
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) {
c.set("auth", {
registered: false,
resolved: false,
skip: false,
user: undefined
});
}
const app = c.get("app"); const app = c.get("app");
const guard = app?.modules.ctx().guard; const authCtx = c.get("auth")!;
const authenticator = app?.module.auth.authenticator; const authenticator = app?.module.auth.authenticator;
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
// make sure to only register once // make sure to only register once
if (c.get("auth_registered")) { if (authCtx.registered) {
skipped = true; skipped = true;
console.warn(`auth middleware already registered for ${getPath(c)}`); console.warn(`auth middleware already registered for ${getPath(c)}`);
} else { } else {
c.set("auth_registered", true); authCtx.registered = true;
if (!skipped) { if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
const resolved = c.get("auth_resolved"); authCtx.user = await authenticator?.resolveAuthFromRequest(c);
if (!resolved) { authCtx.resolved = true;
if (!app?.module.auth.enabled) {
guard?.setUserContext(undefined);
} else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
}
}
} }
} }
@@ -60,9 +67,9 @@ export const auth = (options?: {
} }
// release // release
guard?.setUserContext(undefined); authCtx.skip = false;
authenticator?.resetUser(); authCtx.resolved = false;
c.set("auth_resolved", false); authCtx.user = undefined;
}); });
export const permission = ( export const permission = (
@@ -75,23 +82,26 @@ export const permission = (
// @ts-ignore // @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app"); 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 // 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)}`; const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) { if (app?.module.auth.enabled) {
throw new Error(msg); throw new Error(msg);
} else { } else {
console.warn(msg); console.warn(msg);
} }
} else if (!c.get("auth_skip")) { } else if (!authCtx.skip) {
const guard = app.modules.ctx().guard; const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission]; const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) { if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response; 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); returned = await options?.onGranted?.(c);
} else { } else {
returned = await options?.onDenied?.(c); returned = await options?.onDenied?.(c);
@@ -100,7 +110,7 @@ export const permission = (
return returned; return returned;
} }
} else { } else {
permissions.some((p) => guard.throwUnlessGranted(p)); permissions.some((p) => guard.throwUnlessGranted(p, c));
} }
} }

View File

@@ -327,12 +327,9 @@ export class DataController extends Controller {
// delete one // delete one
.delete( .delete(
"/:entity/:id", "/:entity/:id",
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
const { entity, id } = c.req.param(); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -350,14 +347,11 @@ export class DataController extends Controller {
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where), tb("json", querySchema.properties.where),
async (c) => { async (c) => {
//console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
} }
const where = c.req.valid("json") as RepoQuery["where"]; const where = c.req.valid("json") as RepoQuery["where"];
//console.log("where", where);
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));

View File

@@ -18,13 +18,14 @@ import { isEqual } from "lodash-es";
export type ServerEnv = { export type ServerEnv = {
Variables: { Variables: {
app?: App; app: App;
// to prevent resolving auth multiple times // to prevent resolving auth multiple times
auth_resolved?: boolean; auth?: {
// to only register once resolved: boolean;
auth_registered?: boolean; registered: boolean;
// whether or not to bypass auth skip: boolean;
auth_skip?: boolean; user?: { id: any; role?: string; [key: string]: any };
};
html?: string; html?: string;
}; };
}; };

View File

@@ -69,7 +69,7 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: auth.authenticator?.getUser(), user: c.get("auth")?.user,
logout_route: this.withBasePath(authRoutes.logout), logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme color_scheme: configs.server.admin.color_scheme
}; };
@@ -91,7 +91,7 @@ export class AdminController extends Controller {
// @ts-ignore // @ts-ignore
onGranted: async (c) => { onGranted: async (c) => {
// @todo: add strict test to permissions middleware? // @todo: add strict test to permissions middleware?
if (auth.authenticator.isUserLoggedIn()) { if (c.get("auth")?.user) {
console.log("redirecting to success"); console.log("redirecting to success");
return c.redirect(authRoutes.success); return c.redirect(authRoutes.success);
} }

View File

@@ -40,6 +40,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void { private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares; const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create(); const hono = this.create();
hono.use(permission(SystemPermissions.configRead)); hono.use(permission(SystemPermissions.configRead));
@@ -63,7 +64,7 @@ export class SystemController extends Controller {
const { secrets } = c.req.valid("query"); const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param"); 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); 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 module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets } = c.req.valid("query"); const { config, secrets } = c.req.valid("query");
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead); config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
const { version, ...schema } = this.app.getSchema(); const { version, ...schema } = this.app.getSchema();
@@ -261,73 +262,27 @@ export class SystemController extends Controller {
), ),
async (c) => { async (c) => {
const { sync } = c.req.valid("query") as Record<string, boolean>; const { sync } = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build); this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
await this.app.build({ sync }); await this.app.build({ sync });
return c.json({ success: true, options: { sync } }); return c.json({ success: true, options: { sync } });
} }
); );
hono.get("/ping", async (c) => { hono.get("/ping", (c) => c.json({ pong: true }));
//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("/info", async (c) => { hono.get("/info", (c) =>
return c.json({ c.json({
version: this.app.version(), version: c.get("app")?.version(),
test: 2,
app: c.get("app")?.version(),
runtime: getRuntimeKey() runtime: getRuntimeKey()
}); })
}); );
hono.get("/openapi.json", async (c) => { hono.get("/openapi.json", async (c) => {
//const config = this.app.toJSON();
const config = getDefaultConfig(); const config = getDefaultConfig();
return c.json(generateOpenAPI(config)); 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; return hono;
} }
} }