mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Add integration tests for auth, improve auth middleware and cookies handling
This commit is contained in:
@@ -40,7 +40,7 @@ const _oldConsoles = {
|
|||||||
error: console.error
|
error: console.error
|
||||||
};
|
};
|
||||||
|
|
||||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||||
severities.forEach((severity) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
|
|||||||
203
app/__test__/integration/auth.integration.test.ts
Normal file
203
app/__test__/integration/auth.integration.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { App, createApp } from "../../src";
|
||||||
|
import type { AuthResponse } from "../../src/auth";
|
||||||
|
import { randomString, secureRandomString } from "../../src/core/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
const roles = {
|
||||||
|
sloppy: {
|
||||||
|
guest: {
|
||||||
|
permissions: [
|
||||||
|
"system.access.admin",
|
||||||
|
"system.schema.read",
|
||||||
|
"system.access.api",
|
||||||
|
"system.config.read",
|
||||||
|
"data.entity.read"
|
||||||
|
],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
strict: {
|
||||||
|
guest: {
|
||||||
|
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const configs = {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
entity_name: "users",
|
||||||
|
jwt: {
|
||||||
|
secret: secureRandomString(20),
|
||||||
|
issuer: randomString(10)
|
||||||
|
},
|
||||||
|
roles: roles.strict,
|
||||||
|
guard: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
normal: {
|
||||||
|
email: "normal@bknd.io",
|
||||||
|
password: "12345678"
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
email: "admin@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthApp() {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: configs.auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppFirstBoot,
|
||||||
|
async () => {
|
||||||
|
await app.createUser(configs.users.normal);
|
||||||
|
await app.createUser(configs.users.admin);
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(r: Response, name: string) {
|
||||||
|
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
|
||||||
|
if (!cookies) return;
|
||||||
|
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
|
||||||
|
if (!cookie) return;
|
||||||
|
return cookie.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
|
||||||
|
function headers(token?: string, additional?: Record<string, string>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
return {
|
||||||
|
cookie: `auth=${token};`,
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function body(obj?: Record<string, any>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const key in obj) {
|
||||||
|
formData.append(key, obj[key]);
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: async (
|
||||||
|
user: any
|
||||||
|
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(),
|
||||||
|
body: body(user)
|
||||||
|
})) as Response;
|
||||||
|
|
||||||
|
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||||
|
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers(token)
|
||||||
|
})) as Response;
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("integration auth", () => {
|
||||||
|
it("should create users on boot", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const { data: users } = await app.em.repository("users").findMany();
|
||||||
|
expect(users.length).toBe(2);
|
||||||
|
expect(users[0].email).toBe(configs.users.normal.email);
|
||||||
|
expect(users[1].email).toBe(configs.users.admin.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with API", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// login api
|
||||||
|
const { data } = await $fns.login(configs.users.normal);
|
||||||
|
const me = await $fns.me(data.token);
|
||||||
|
|
||||||
|
expect(data.user.email).toBe(me.user.email);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// expect no user with no token
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
|
||||||
|
// expect no user with invalid token
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with form and cookie", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app, "cookie");
|
||||||
|
|
||||||
|
const { res, data: token } = await $fns.login(configs.users.normal);
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
expect(res.status).toBe(302); // because it redirects
|
||||||
|
|
||||||
|
// test cookie jwt interchangability
|
||||||
|
{
|
||||||
|
// expect token to not work as-is for api endpoints
|
||||||
|
expect(await fns(app).me(token)).toEqual({ user: null as any });
|
||||||
|
// hono adds an additional segment to cookies
|
||||||
|
const apified_token = token.split(".").slice(0, -1).join(".");
|
||||||
|
// now it should work
|
||||||
|
// @todo: maybe add a config to don't allow re-use?
|
||||||
|
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test cookie with me endpoint
|
||||||
|
{
|
||||||
|
const me = await $fns.me(token);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// check with invalid & empty
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,9 @@ const types = args.includes("--types");
|
|||||||
const sourcemap = args.includes("--sourcemap");
|
const sourcemap = args.includes("--sourcemap");
|
||||||
const clean = args.includes("--clean");
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
|
// keep console logs if not minified
|
||||||
|
const debugging = minify;
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.log("Cleaning dist");
|
console.log("Cleaning dist");
|
||||||
await $`rm -rf dist`;
|
await $`rm -rf dist`;
|
||||||
@@ -38,7 +41,7 @@ function buildTypes() {
|
|||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,7 @@ const result = await esbuild.build({
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
splitting: true,
|
splitting: true,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
drop: ["console", "debugger"],
|
drop: debugging ? undefined : ["console", "debugger"],
|
||||||
inject: ["src/ui/inject.js"],
|
inject: ["src/ui/inject.js"],
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
format: "esm",
|
format: "esm",
|
||||||
|
|||||||
@@ -114,12 +114,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
console.log("***** AppAuth:resolveUser", {
|
/*console.log("***** AppAuth:resolveUser", {
|
||||||
action,
|
action,
|
||||||
strategy: strategy.getName(),
|
strategy: strategy.getName(),
|
||||||
identifier,
|
identifier,
|
||||||
profile
|
profile
|
||||||
});
|
});*/
|
||||||
if (!this.config.allow_register && action === "register") {
|
if (!this.config.allow_register && action === "register") {
|
||||||
throw new Exception("Registration is not allowed", 403);
|
throw new Exception("Registration is not allowed", 403);
|
||||||
}
|
}
|
||||||
@@ -140,12 +140,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterUserData(user: any) {
|
private filterUserData(user: any) {
|
||||||
console.log(
|
/*console.log(
|
||||||
"--filterUserData",
|
"--filterUserData",
|
||||||
user,
|
user,
|
||||||
this.config.jwt.fields,
|
this.config.jwt.fields,
|
||||||
pick(user, this.config.jwt.fields)
|
pick(user, this.config.jwt.fields)
|
||||||
);
|
);*/
|
||||||
return pick(user, this.config.jwt.fields);
|
return pick(user, this.config.jwt.fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +171,18 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Exception("User not found", 404);
|
throw new Exception("User not found", 404);
|
||||||
}
|
}
|
||||||
console.log("---login data", result.data, result);
|
//console.log("---login data", result.data, result);
|
||||||
|
|
||||||
// compare strategy and identifier
|
// compare strategy and identifier
|
||||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||||
if (result.data.strategy !== strategy.getName()) {
|
if (result.data.strategy !== strategy.getName()) {
|
||||||
console.log("!!! User registered with different strategy");
|
//console.log("!!! User registered with different strategy");
|
||||||
throw new Exception("User registered with different strategy");
|
throw new Exception("User registered with different strategy");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
//console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||||
if (result.data.strategy_value !== identifier) {
|
if (result.data.strategy_value !== identifier) {
|
||||||
console.log("!!! Invalid credentials");
|
//console.log("!!! Invalid credentials");
|
||||||
throw new Exception("Invalid credentials");
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const cookieConfig = Type.Partial(
|
|||||||
{ default: {}, additionalProperties: false }
|
{ 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(
|
export const jwtConfig = Type.Object(
|
||||||
{
|
{
|
||||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||||
@@ -139,7 +142,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @todo: determine what to do exactly
|
// @todo: determine what to do exactly
|
||||||
__setUserNull() {
|
resetUser() {
|
||||||
this._user = undefined;
|
this._user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +206,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._user = undefined;
|
this.resetUser();
|
||||||
console.error(e);
|
//console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -222,10 +225,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const secret = this.config.jwt.secret;
|
const secret = this.config.jwt.secret;
|
||||||
|
|
||||||
const token = await getSignedCookie(c, secret, "auth");
|
const token = await getSignedCookie(c, secret, "auth");
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,12 +254,17 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async deleteAuthCookie(c: Context) {
|
||||||
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
async logout(c: Context) {
|
async logout(c: Context) {
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
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
|
||||||
|
|||||||
@@ -3,24 +3,6 @@ import type { Context } from "hono";
|
|||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import type { ServerEnv } from "modules/Module";
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
async function resolveAuth(app: ServerEnv["Variables"]["app"], c: Context<ServerEnv>) {
|
|
||||||
const resolved = c.get("auth_resolved") ?? false;
|
|
||||||
if (resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!app.module.auth.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticator = app.module.auth.authenticator;
|
|
||||||
const guard = app.modules.ctx().guard;
|
|
||||||
|
|
||||||
guard.setUserContext(await authenticator.resolveAuthFromRequest(c));
|
|
||||||
|
|
||||||
// renew cookie if applicable
|
|
||||||
authenticator.requestCookieRefresh(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldSkipAuth(req: Request) {
|
export function shouldSkipAuth(req: Request) {
|
||||||
const skip = new URL(req.url).pathname.startsWith(config.server.assets_path);
|
const skip = new URL(req.url).pathname.startsWith(config.server.assets_path);
|
||||||
if (skip) {
|
if (skip) {
|
||||||
@@ -30,22 +12,46 @@ export function shouldSkipAuth(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const auth = createMiddleware<ServerEnv>(async (c, next) => {
|
export const auth = createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
if (!shouldSkipAuth(c.req.raw)) {
|
|
||||||
// make sure to only register once
|
// make sure to only register once
|
||||||
if (c.get("auth_registered")) {
|
if (c.get("auth_registered")) {
|
||||||
return;
|
throw new Error("auth middleware already registered");
|
||||||
}
|
}
|
||||||
|
|
||||||
await resolveAuth(c.get("app"), c);
|
|
||||||
c.set("auth_registered", true);
|
c.set("auth_registered", true);
|
||||||
|
|
||||||
|
const skipped = shouldSkipAuth(c.req.raw);
|
||||||
|
const app = c.get("app");
|
||||||
|
const guard = app.modules.ctx().guard;
|
||||||
|
const authenticator = app.module.auth.authenticator;
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
// renew cookie if applicable
|
||||||
|
authenticator.requestCookieRefresh(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
|
||||||
|
// release
|
||||||
|
guard.setUserContext(undefined);
|
||||||
|
authenticator.resetUser();
|
||||||
|
c.set("auth_resolved", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permission = (...permissions: Permission[]) =>
|
export const permission = (...permissions: Permission[]) =>
|
||||||
createMiddleware<ServerEnv>(async (c, next) => {
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
if (!shouldSkipAuth) {
|
if (!c.get("auth_registered")) {
|
||||||
|
throw new Error("auth middleware not registered, cannot check permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSkipAuth(c.req.raw)) {
|
||||||
const app = c.get("app");
|
const app = c.get("app");
|
||||||
if (app) {
|
if (app) {
|
||||||
const p = Array.isArray(permissions) ? permissions : [permissions];
|
const p = Array.isArray(permissions) ? permissions : [permissions];
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ if (run_example) {
|
|||||||
initialConfig = config;
|
initialConfig = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let app: App;
|
||||||
|
const recreate = true;
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request) {
|
async fetch(request: Request) {
|
||||||
const app = App.create({ connection, initialConfig });
|
if (!app || recreate) {
|
||||||
|
app = App.create({ connection, initialConfig });
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -45,6 +48,7 @@ export default {
|
|||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
await app.build();
|
await app.build();
|
||||||
|
}
|
||||||
|
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user