mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
reworked admin auth to use form and cookie + adjusted oauth to support API and cookie-based auth
This commit is contained in:
@@ -20,6 +20,7 @@ type AuthSchema = Static<typeof authConfigSchema>;
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
_controller!: AuthController;
|
||||
|
||||
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
||||
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
||||
@@ -68,9 +69,17 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
this.registerEntities();
|
||||
super.setBuilt();
|
||||
|
||||
const controller = new AuthController(this);
|
||||
this._controller = new AuthController(this);
|
||||
//this.ctx.server.use(controller.getMiddleware);
|
||||
this.ctx.server.route(this.config.basepath, controller.getController());
|
||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||
}
|
||||
|
||||
get controller(): AuthController {
|
||||
if (!this.isBuilt()) {
|
||||
throw new Error("Can't access controller, AppAuth not built yet");
|
||||
}
|
||||
|
||||
return this._controller;
|
||||
}
|
||||
|
||||
getMiddleware() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AppAuth } from "auth";
|
||||
import type { ClassController } from "core";
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
|
||||
export class AuthController implements ClassController {
|
||||
constructor(private auth: AppAuth) {}
|
||||
@@ -11,19 +10,8 @@ export class AuthController implements ClassController {
|
||||
}
|
||||
|
||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
let token: string | undefined;
|
||||
if (c.req.raw.headers.has("Authorization")) {
|
||||
const bearerHeader = String(c.req.header("Authorization"));
|
||||
token = bearerHeader.replace("Bearer ", "");
|
||||
}
|
||||
|
||||
if (token) {
|
||||
// @todo: don't extract user from token, but from the database or cache
|
||||
await this.auth.authenticator.verify(token);
|
||||
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
|
||||
} else {
|
||||
this.auth.authenticator.__setUserNull();
|
||||
}
|
||||
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
||||
this.auth.ctx.guard.setUserContext(user);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -31,7 +19,6 @@ export class AuthController implements ClassController {
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
//console.log("strategies", strategies);
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
//console.log("registering", name, "at", `/${name}`);
|
||||
@@ -46,6 +33,11 @@ export class AuthController implements ClassController {
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
|
||||
hono.get("/logout", async (c) => {
|
||||
await this.auth.authenticator.logout(c);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
hono.get("/strategies", async (c) => {
|
||||
const { strategies, basepath } = this.auth.toJSON(false);
|
||||
return c.json({ strategies, basepath });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Exception } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
|
||||
import type { Hono } from "hono";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
|
||||
|
||||
type Input = any; // workaround
|
||||
@@ -82,7 +85,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) {
|
||||
): Promise<AuthResponse> {
|
||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
||||
|
||||
@@ -176,6 +179,84 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
return false;
|
||||
}
|
||||
|
||||
// @todo: CookieOptions not exported from hono
|
||||
private get cookieOptions(): any {
|
||||
return {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: true
|
||||
};
|
||||
}
|
||||
|
||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||
const secret = this.config.jwt.secret;
|
||||
|
||||
const token = await getSignedCookie(c, secret, "auth");
|
||||
if (typeof token !== "string") {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private async setAuthCookie(c: Context, token: string) {
|
||||
const secret = this.config.jwt.secret;
|
||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||
}
|
||||
|
||||
async logout(c: Context) {
|
||||
const cookie = await this.getAuthCookie(c);
|
||||
if (cookie) {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
await addFlashMessage(c, "Signed out", "info");
|
||||
}
|
||||
}
|
||||
|
||||
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 referer = new URL(redirect ?? c.req.header("Referer") ?? "/");
|
||||
|
||||
if ("token" in data) {
|
||||
await this.setAuthCookie(c, data.token);
|
||||
return c.redirect("/");
|
||||
}
|
||||
|
||||
let message = "An error occured";
|
||||
if (data instanceof Exception) {
|
||||
message = data.message;
|
||||
}
|
||||
|
||||
await addFlashMessage(c, message, "error");
|
||||
return c.redirect(referer);
|
||||
}
|
||||
|
||||
// @todo: don't extract user from token, but from the database or cache
|
||||
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
|
||||
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._user;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
...this.config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { type Context, Hono } from "hono";
|
||||
|
||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
@@ -54,22 +54,34 @@ export class PasswordStrategy implements Strategy {
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
|
||||
async function getBody(c: Context) {
|
||||
if (authenticator.isJsonRequest(c)) {
|
||||
return await c.req.json();
|
||||
} else {
|
||||
return Object.fromEntries((await c.req.formData()).entries());
|
||||
}
|
||||
}
|
||||
|
||||
return hono
|
||||
.post("/login", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
const body = await getBody(c);
|
||||
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
return await authenticator.respond(c, data);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
}
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
const body = await getBody(c);
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
return await authenticator.respond(c, data);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import { Exception, isDebug } from "core";
|
||||
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
@@ -173,7 +173,7 @@ export class OAuthStrategy implements Strategy {
|
||||
const config = await this.getConfig();
|
||||
const { client, as, type } = config;
|
||||
//console.log("config", config);
|
||||
//console.log("callbackParams", callbackParams, options);
|
||||
console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
@@ -216,7 +216,7 @@ export class OAuthStrategy implements Strategy {
|
||||
expectedNonce
|
||||
);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
//console.log("callback.error", result);
|
||||
console.log("callback.error", result);
|
||||
// @todo: Handle OAuth 2.0 response body error
|
||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||
}
|
||||
@@ -317,10 +317,15 @@ export class OAuthStrategy implements Strategy {
|
||||
const secret = "secret";
|
||||
const cookie_name = "_challenge";
|
||||
|
||||
const setState = async (
|
||||
c: Context,
|
||||
config: { state: string; action: AuthAction; redirect?: string }
|
||||
): Promise<void> => {
|
||||
type TState = {
|
||||
state: string;
|
||||
action: AuthAction;
|
||||
redirect?: string;
|
||||
mode: "token" | "cookie";
|
||||
};
|
||||
|
||||
const setState = async (c: Context, config: TState): Promise<void> => {
|
||||
console.log("--- setting state", config);
|
||||
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
@@ -329,12 +334,18 @@ export class OAuthStrategy implements Strategy {
|
||||
});
|
||||
};
|
||||
|
||||
const getState = async (
|
||||
c: Context
|
||||
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
|
||||
const state = await getSignedCookie(c, secret, cookie_name);
|
||||
const getState = async (c: Context): Promise<TState> => {
|
||||
if (c.req.header("X-State-Challenge")) {
|
||||
return {
|
||||
state: c.req.header("X-State-Challenge"),
|
||||
action: c.req.header("X-State-Action"),
|
||||
mode: "token"
|
||||
} as any;
|
||||
}
|
||||
|
||||
const value = await getSignedCookie(c, secret, cookie_name);
|
||||
try {
|
||||
return JSON.parse(state as string);
|
||||
return JSON.parse(value as string);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
@@ -345,22 +356,68 @@ export class OAuthStrategy implements Strategy {
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
const state = await getState(c);
|
||||
console.log("url", url);
|
||||
console.log("state", state);
|
||||
|
||||
// @todo: add config option to determine if state.action is allowed
|
||||
const redirect_uri =
|
||||
state.mode === "cookie"
|
||||
? url.origin + url.pathname
|
||||
: url.origin + url.pathname.replace("/callback", "/token");
|
||||
|
||||
const profile = await this.callback(params, {
|
||||
redirect_uri: url.origin + url.pathname,
|
||||
redirect_uri,
|
||||
state: state.state
|
||||
});
|
||||
|
||||
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", { user, token });
|
||||
try {
|
||||
const data = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", data);
|
||||
|
||||
if (state.redirect) {
|
||||
console.log("redirect to", state.redirect + "?token=" + token);
|
||||
return c.redirect(state.redirect + "?token=" + token);
|
||||
if (state.mode === "cookie") {
|
||||
return await auth.respond(c, data, state.redirect);
|
||||
}
|
||||
|
||||
return c.json(data);
|
||||
} catch (e) {
|
||||
if (state.mode === "cookie") {
|
||||
return await auth.respond(c, e, state.redirect);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
hono.get("/token", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
return c.json({
|
||||
code: params.get("code") ?? null
|
||||
});
|
||||
});
|
||||
|
||||
hono.post("/:action", async (c) => {
|
||||
const action = c.req.param("action") as AuthAction;
|
||||
if (!["login", "register"].includes(action)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
return c.json({ user, token });
|
||||
const url = new URL(c.req.url);
|
||||
const path = url.pathname.replace(`/${action}`, "");
|
||||
const redirect_uri = url.origin + path + "/callback";
|
||||
const referer = new URL(c.req.header("Referer") ?? "/");
|
||||
|
||||
const state = oauth.generateRandomCodeVerifier();
|
||||
const response = await this.request({
|
||||
redirect_uri,
|
||||
state
|
||||
});
|
||||
//console.log("_state", state);
|
||||
|
||||
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
|
||||
console.log("--redirecting to", response.url);
|
||||
|
||||
return c.redirect(response.url);
|
||||
});
|
||||
|
||||
hono.get("/:action", async (c) => {
|
||||
@@ -371,31 +428,29 @@ export class OAuthStrategy implements Strategy {
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const path = url.pathname.replace(`/${action}`, "");
|
||||
const redirect_uri = url.origin + path + "/callback";
|
||||
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
|
||||
const redirect_uri = url.origin + path + "/token";
|
||||
|
||||
const state = await oauth.generateRandomCodeVerifier();
|
||||
const state = oauth.generateRandomCodeVerifier();
|
||||
const response = await this.request({
|
||||
redirect_uri,
|
||||
state
|
||||
});
|
||||
//console.log("_state", state);
|
||||
|
||||
await setState(c, { state, action, redirect: q_redirect });
|
||||
|
||||
if (c.req.header("Accept") === "application/json") {
|
||||
if (isDebug()) {
|
||||
return c.json({
|
||||
url: response.url,
|
||||
redirect_uri,
|
||||
challenge: state,
|
||||
action,
|
||||
params: response.params
|
||||
});
|
||||
}
|
||||
|
||||
//return c.text(response.url);
|
||||
console.log("--redirecting to", response.url);
|
||||
|
||||
return c.redirect(response.url);
|
||||
return c.json({
|
||||
url: response.url,
|
||||
challenge: state,
|
||||
action
|
||||
});
|
||||
});
|
||||
|
||||
return hono;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { type Static, Type, objectTransform } from "core/utils";
|
||||
import { objectTransform } from "core/utils";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type GuardUserContext = {
|
||||
|
||||
Reference in New Issue
Block a user