mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
public commit
This commit is contained in:
269
app/src/auth/AppAuth.ts
Normal file
269
app/src/auth/AppAuth.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import { Const, StringRecord, Type, transformObject } from "core/utils";
|
||||
import {
|
||||
type Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
EnumField,
|
||||
type Field,
|
||||
type Mutator
|
||||
} from "data";
|
||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
||||
import { cloneDeep, mergeWith, omit, pick } from "lodash-es";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare global {
|
||||
interface DB {
|
||||
users: UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
|
||||
override async build() {
|
||||
if (!this.config.enabled) {
|
||||
this.setBuilt();
|
||||
return;
|
||||
}
|
||||
|
||||
// register roles
|
||||
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
||||
//console.log("role", role, name);
|
||||
return Role.create({ name, ...role });
|
||||
});
|
||||
this.ctx.guard.setRoles(Object.values(roles));
|
||||
this.ctx.guard.setConfig(this.config.guard ?? {});
|
||||
|
||||
// build strategies
|
||||
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
|
||||
try {
|
||||
return new STRATEGIES[strategy.type].cls(strategy.config as any);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const { fields, ...jwt } = this.config.jwt;
|
||||
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
||||
jwt
|
||||
});
|
||||
|
||||
this.registerEntities();
|
||||
super.setBuilt();
|
||||
|
||||
const controller = new AuthController(this);
|
||||
//this.ctx.server.use(controller.getMiddleware);
|
||||
this.ctx.server.route(this.config.basepath, controller.getController());
|
||||
}
|
||||
|
||||
getMiddleware() {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new AuthController(this).getMiddleware;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return authConfigSchema;
|
||||
}
|
||||
|
||||
get authenticator(): Authenticator {
|
||||
this.throwIfNotBuilt();
|
||||
return this._authenticator!;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
return this.ctx.em as any;
|
||||
}
|
||||
|
||||
private async resolveUser(
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
): Promise<any> {
|
||||
console.log("***** AppAuth:resolveUser", {
|
||||
action,
|
||||
strategy: strategy.getName(),
|
||||
identifier,
|
||||
profile
|
||||
});
|
||||
|
||||
const fields = this.getUsersEntity()
|
||||
.getFillableFields("create")
|
||||
.map((f) => f.name);
|
||||
const filteredProfile = Object.fromEntries(
|
||||
Object.entries(profile).filter(([key]) => fields.includes(key))
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case "login":
|
||||
return this.login(strategy, identifier, filteredProfile);
|
||||
case "register":
|
||||
return this.register(strategy, identifier, filteredProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private filterUserData(user: any) {
|
||||
console.log(
|
||||
"--filterUserData",
|
||||
user,
|
||||
this.config.jwt.fields,
|
||||
pick(user, this.config.jwt.fields)
|
||||
);
|
||||
return pick(user, this.config.jwt.fields);
|
||||
}
|
||||
|
||||
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });
|
||||
if (!("email" in profile)) {
|
||||
throw new Exception("Profile must have email");
|
||||
}
|
||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||
throw new Exception("Identifier must be a string");
|
||||
}
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const result = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!result.data) {
|
||||
throw new Exception("User not found", 404);
|
||||
}
|
||||
console.log("---login data", result.data, result);
|
||||
|
||||
// compare strategy and identifier
|
||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||
if (result.data.strategy !== strategy.getName()) {
|
||||
console.log("!!! User registered with different strategy");
|
||||
throw new Exception("User registered with different strategy");
|
||||
}
|
||||
|
||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||
if (result.data.strategy_value !== identifier) {
|
||||
console.log("!!! Invalid credentials");
|
||||
throw new Exception("Invalid credentials");
|
||||
}
|
||||
|
||||
return this.filterUserData(result.data);
|
||||
}
|
||||
|
||||
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||
if (!("email" in profile)) {
|
||||
throw new Exception("Profile must have an email");
|
||||
}
|
||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||
throw new Exception("Identifier must be a string");
|
||||
}
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
if (data) {
|
||||
throw new Exception("User already exists");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...profile,
|
||||
strategy: strategy.getName(),
|
||||
strategy_value: identifier
|
||||
};
|
||||
|
||||
const mutator = this.em.mutator(users);
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const createResult = await mutator.insertOne(payload);
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!createResult.data) {
|
||||
throw new Error("Could not create user");
|
||||
}
|
||||
|
||||
return this.filterUserData(createResult.data);
|
||||
}
|
||||
|
||||
private toggleStrategyValueVisibility(visible: boolean) {
|
||||
const field = this.getUsersEntity().field("strategy_value")!;
|
||||
|
||||
field.config.hidden = !visible;
|
||||
field.config.fillable = visible;
|
||||
// @todo: think about a PasswordField that automatically hashes on save?
|
||||
}
|
||||
|
||||
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
|
||||
const entity_name = this.config.entity_name;
|
||||
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
||||
return entity(entity_name as "users", AppAuth.usersFields, undefined, "system");
|
||||
}
|
||||
|
||||
return this.em.entity(entity_name) as any;
|
||||
}
|
||||
|
||||
static usersFields = {
|
||||
email: text().required(),
|
||||
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
|
||||
strategy_value: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["read", "table", "update", "form"]
|
||||
}).required(),
|
||||
role: text()
|
||||
};
|
||||
|
||||
registerEntities() {
|
||||
const users = this.getUsersEntity();
|
||||
|
||||
if (!this.em.hasEntity(users.name)) {
|
||||
this.em.addEntity(users);
|
||||
} else {
|
||||
// if exists, check all fields required are there
|
||||
// @todo: add to context: "needs sync" flag
|
||||
const _entity = this.getUsersEntity(true);
|
||||
for (const field of _entity.fields) {
|
||||
const _field = users.field(field.name);
|
||||
if (!_field) {
|
||||
users.addField(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indices = [
|
||||
new EntityIndex(users, [users.field("email")!], true),
|
||||
new EntityIndex(users, [users.field("strategy")!]),
|
||||
new EntityIndex(users, [users.field("strategy_value")!])
|
||||
];
|
||||
indices.forEach((index) => {
|
||||
if (!this.em.hasIndex(index)) {
|
||||
this.em.addIndex(index);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const roles = Object.keys(this.config.roles ?? {});
|
||||
const field = make("role", enumm({ enum: roles }));
|
||||
this.em.entity(users.name).__experimental_replaceField("role", field);
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const strategies = Object.keys(this.config.strategies ?? {});
|
||||
const field = make("strategy", enumm({ enum: strategies }));
|
||||
this.em.entity(users.name).__experimental_replaceField("strategy", field);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||
if (!this.config.enabled) {
|
||||
return this.configDefault;
|
||||
}
|
||||
|
||||
// fixes freezed config object
|
||||
return mergeWith({ ...this.config }, this.authenticator.toJSON(secrets));
|
||||
}
|
||||
}
|
||||
41
app/src/auth/api/AuthApi.ts
Normal file
41
app/src/auth/api/AuthApi.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
|
||||
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
|
||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||
onTokenUpdate?: (token: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
||||
return {
|
||||
basepath: "/api/auth"
|
||||
};
|
||||
}
|
||||
|
||||
async loginWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async registerWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async me() {
|
||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||
}
|
||||
|
||||
async strategies() {
|
||||
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]);
|
||||
}
|
||||
|
||||
async logout() {}
|
||||
}
|
||||
57
app/src/auth/api/AuthController.ts
Normal file
57
app/src/auth/api/AuthController.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AppAuth } from "auth";
|
||||
import type { ClassController } from "core";
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
|
||||
export class AuthController implements ClassController {
|
||||
constructor(private auth: AppAuth) {}
|
||||
|
||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// @todo: consider adding app name to the payload, because user is not refetched
|
||||
|
||||
//try {
|
||||
if (c.req.raw.headers.has("Authorization")) {
|
||||
const bearerHeader = String(c.req.header("Authorization"));
|
||||
const token = bearerHeader.replace("Bearer ", "");
|
||||
const verified = await this.auth.authenticator.verify(token);
|
||||
|
||||
// @todo: don't extract user from token, but from the database or cache
|
||||
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
|
||||
/*console.log("jwt verified?", {
|
||||
verified,
|
||||
auth: this.auth.authenticator.isUserLoggedIn()
|
||||
});*/
|
||||
} else {
|
||||
this.auth.authenticator.__setUserNull();
|
||||
}
|
||||
/* } catch (e) {
|
||||
this.auth.authenticator.__setUserNull();
|
||||
}*/
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
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}`);
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
}
|
||||
|
||||
hono.get("/me", async (c) => {
|
||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||
return c.json({ user: await this.auth.authenticator.getUser() });
|
||||
}
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
|
||||
hono.get("/strategies", async (c) => {
|
||||
return c.json({ strategies: this.auth.toJSON(false).strategies });
|
||||
});
|
||||
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
85
app/src/auth/auth-schema.ts
Normal file
85
app/src/auth/auth-schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { jwtConfig } from "auth/authenticate/Authenticator";
|
||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
||||
|
||||
export const Strategies = {
|
||||
password: {
|
||||
cls: PasswordStrategy,
|
||||
schema: PasswordStrategy.prototype.getSchema()
|
||||
},
|
||||
oauth: {
|
||||
cls: OAuthStrategy,
|
||||
schema: OAuthStrategy.prototype.getSchema()
|
||||
},
|
||||
custom_oauth: {
|
||||
cls: CustomOAuthStrategy,
|
||||
schema: CustomOAuthStrategy.prototype.getSchema()
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const STRATEGIES = Strategies;
|
||||
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
config: strategy.schema
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
});
|
||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||
});
|
||||
export const guardRoleSchema = Type.Object(
|
||||
{
|
||||
permissions: Type.Optional(Type.Array(Type.String())),
|
||||
is_default: Type.Optional(Type.Boolean()),
|
||||
implicit_allow: Type.Optional(Type.Boolean())
|
||||
},
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
|
||||
export const authConfigSchema = Type.Object(
|
||||
{
|
||||
enabled: Type.Boolean({ default: false }),
|
||||
basepath: Type.String({ default: "/api/auth" }),
|
||||
entity_name: Type.String({ default: "users" }),
|
||||
jwt: Type.Composite(
|
||||
[
|
||||
jwtConfig,
|
||||
Type.Object({
|
||||
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
|
||||
})
|
||||
],
|
||||
{ default: {}, additionalProperties: false }
|
||||
),
|
||||
strategies: Type.Optional(
|
||||
StringRecord(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
config: {
|
||||
hashing: "sha256"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
),
|
||||
guard: Type.Optional(guardConfigSchema),
|
||||
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} }))
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type AppAuthSchema = Static<typeof authConfigSchema>;
|
||||
190
app/src/auth/authenticate/Authenticator.ts
Normal file
190
app/src/auth/authenticate/Authenticator.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
|
||||
import type { Hono } from "hono";
|
||||
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
|
||||
|
||||
type Input = any; // workaround
|
||||
|
||||
// @todo: add schema to interface to ensure proper inference
|
||||
export interface Strategy {
|
||||
getController: (auth: Authenticator) => Hono<any>;
|
||||
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<User, "password">;
|
||||
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
||||
export type AuthResponse = { user: SafeUser; token: string };
|
||||
|
||||
export interface UserPool<Fields = "id" | "email" | "username"> {
|
||||
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
|
||||
create: (user: CreateUser) => Promise<User | undefined>;
|
||||
}
|
||||
|
||||
export const jwtConfig = Type.Object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: Type.String({ default: "secret" }),
|
||||
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
|
||||
expiresIn: Type.Optional(Type.String()),
|
||||
issuer: Type.Optional(Type.String())
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
export const authenticatorConfig = Type.Object({
|
||||
jwt: jwtConfig
|
||||
});
|
||||
|
||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||
export type AuthAction = "login" | "register";
|
||||
export type AuthUserResolver = (
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) => Promise<SafeUser | undefined>;
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
private readonly strategies: Strategies;
|
||||
private readonly config: AuthConfig;
|
||||
private _user: SafeUser | undefined;
|
||||
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 ?? {});
|
||||
|
||||
/*const secret = String(this.config.jwt.secret);
|
||||
if (secret === "secret" || secret.length === 0) {
|
||||
this.config.jwt.secret = randomString(64, true);
|
||||
}*/
|
||||
}
|
||||
|
||||
async resolve(
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) {
|
||||
//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._user !== undefined;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
// @todo: determine what to do exactly
|
||||
__setUserNull() {
|
||||
this._user = 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`);
|
||||
}
|
||||
}
|
||||
|
||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||
const prohibited = ["password"];
|
||||
for (const prop of prohibited) {
|
||||
if (prop in user) {
|
||||
throw new Error(`Property "${prop}" is prohibited`);
|
||||
}
|
||||
}
|
||||
|
||||
const jwt = new SignJWT(user)
|
||||
.setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" })
|
||||
.setIssuedAt();
|
||||
|
||||
if (this.config.jwt?.issuer) {
|
||||
jwt.setIssuer(this.config.jwt.issuer);
|
||||
}
|
||||
|
||||
if (this.config.jwt?.expiresIn) {
|
||||
jwt.setExpirationTime(this.config.jwt.expiresIn);
|
||||
}
|
||||
|
||||
return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? ""));
|
||||
}
|
||||
|
||||
async verify(jwt: string): Promise<boolean> {
|
||||
const options: JWTVerifyOptions = {
|
||||
algorithms: [this.config.jwt?.alg ?? "HS256"]
|
||||
};
|
||||
|
||||
if (this.config.jwt?.issuer) {
|
||||
options.issuer = this.config.jwt.issuer;
|
||||
}
|
||||
|
||||
if (this.config.jwt?.expiresIn) {
|
||||
options.maxTokenAge = this.config.jwt.expiresIn;
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<User>(
|
||||
jwt,
|
||||
new TextEncoder().encode(this.config.jwt?.secret ?? ""),
|
||||
options
|
||||
);
|
||||
this._user = payload;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._user = undefined;
|
||||
//console.error(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
...this.config,
|
||||
jwt: secrets ? this.config.jwt : undefined,
|
||||
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
|
||||
};
|
||||
}
|
||||
}
|
||||
98
app/src/auth/authenticate/strategies/PasswordStrategy.ts
Normal file
98
app/src/auth/authenticate/strategies/PasswordStrategy.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
|
||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
|
||||
const schema = Type.Object({
|
||||
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" })
|
||||
});
|
||||
|
||||
export type PasswordStrategyOptions = Static<typeof schema>;
|
||||
/*export type PasswordStrategyOptions2 = {
|
||||
hashing?: "plain" | "bcrypt" | "sha256";
|
||||
};*/
|
||||
|
||||
export class PasswordStrategy implements Strategy {
|
||||
private options: PasswordStrategyOptions;
|
||||
|
||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
||||
this.options = parse(schema, options);
|
||||
}
|
||||
|
||||
async hash(password: string) {
|
||||
switch (this.options.hashing) {
|
||||
case "sha256":
|
||||
return hash.sha256(password);
|
||||
default:
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
async login(input: LoginSchema) {
|
||||
if (!("email" in input) || !("password" in input)) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hash(input.password);
|
||||
return { ...input, password: hashedPassword };
|
||||
}
|
||||
|
||||
async register(input: RegisterSchema) {
|
||||
if (!input.email || !input.password) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
password: await this.hash(input.password)
|
||||
};
|
||||
}
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
|
||||
return hono
|
||||
.post("/login", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return "password";
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return "form" as const;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "password" as const;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: secrets ? this.options : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
13
app/src/auth/authenticate/strategies/index.ts
Normal file
13
app/src/auth/authenticate/strategies/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CustomOAuthStrategy } from "auth/authenticate/strategies/oauth/CustomOAuthStrategy";
|
||||
import { PasswordStrategy, type PasswordStrategyOptions } from "./PasswordStrategy";
|
||||
import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
|
||||
|
||||
export * as issuers from "./oauth/issuers";
|
||||
|
||||
export {
|
||||
PasswordStrategy,
|
||||
type PasswordStrategyOptions,
|
||||
OAuthStrategy,
|
||||
OAuthCallbackException,
|
||||
CustomOAuthStrategy
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import type * as oauth from "oauth4webapi";
|
||||
import { OAuthStrategy } from "./OAuthStrategy";
|
||||
|
||||
type SupportedTypes = "oauth2" | "oidc";
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
||||
const oauthSchemaCustom = Type.Object(
|
||||
{
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: Type.String(),
|
||||
client: Type.Object(
|
||||
{
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
token_endpoint_auth_method: StringEnum(["client_secret_basic"])
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
),
|
||||
as: Type.Object(
|
||||
{
|
||||
issuer: Type.String(),
|
||||
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
||||
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
||||
scope_separator: Type.Optional(Type.String({ default: " " })),
|
||||
authorization_endpoint: Type.Optional(UrlString),
|
||||
token_endpoint: Type.Optional(UrlString),
|
||||
userinfo_endpoint: Type.Optional(UrlString)
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
)
|
||||
// @todo: profile mapping
|
||||
},
|
||||
{ title: "Custom OAuth", additionalProperties: false }
|
||||
);
|
||||
|
||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type IssuerConfig<UserInfo = any> = {
|
||||
type: SupportedTypes;
|
||||
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
|
||||
as: oauth.AuthorizationServer & {
|
||||
scope_separator?: string;
|
||||
};
|
||||
profile: (
|
||||
info: UserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any
|
||||
) => Promise<UserProfile>;
|
||||
};
|
||||
|
||||
export class CustomOAuthStrategy extends OAuthStrategy {
|
||||
override getIssuerConfig(): IssuerConfig {
|
||||
return { ...this.config, profile: async (info) => info } as any;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
override getSchema() {
|
||||
return oauthSchemaCustom;
|
||||
}
|
||||
|
||||
override getType() {
|
||||
return "custom_oauth";
|
||||
}
|
||||
}
|
||||
431
app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts
Normal file
431
app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
||||
import { Exception } 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";
|
||||
import * as oauth from "oauth4webapi";
|
||||
import * as issuers from "./issuers";
|
||||
|
||||
type ConfiguredIssuers = keyof typeof issuers;
|
||||
type SupportedTypes = "oauth2" | "oidc";
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
const schemaProvided = Type.Object(
|
||||
{
|
||||
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
||||
client: Type.Object(
|
||||
{
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String()
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
)
|
||||
},
|
||||
{ title: "OAuth" }
|
||||
);
|
||||
type ProvidedOAuthConfig = Static<typeof schemaProvided>;
|
||||
|
||||
export type CustomOAuthConfig = {
|
||||
type: SupportedTypes;
|
||||
name: string;
|
||||
} & IssuerConfig & {
|
||||
client: RequireKeys<
|
||||
oauth.Client,
|
||||
"client_id" | "client_secret" | "token_endpoint_auth_method"
|
||||
>;
|
||||
};
|
||||
|
||||
type OAuthConfig = ProvidedOAuthConfig | CustomOAuthConfig;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type IssuerConfig<UserInfo = any> = {
|
||||
type: SupportedTypes;
|
||||
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
|
||||
as: oauth.AuthorizationServer & {
|
||||
scope_separator?: string;
|
||||
};
|
||||
profile: (
|
||||
info: UserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any
|
||||
) => Promise<UserProfile>;
|
||||
};
|
||||
|
||||
export class OAuthCallbackException extends Exception {
|
||||
override name = "OAuthCallbackException";
|
||||
|
||||
constructor(
|
||||
public error: any,
|
||||
public step: string
|
||||
) {
|
||||
super("OAuthCallbackException on " + step);
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthStrategy implements Strategy {
|
||||
constructor(private _config: OAuthConfig) {}
|
||||
|
||||
get config() {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
getIssuerConfig(): IssuerConfig {
|
||||
return issuers[this.config.name];
|
||||
}
|
||||
|
||||
async getConfig(): Promise<
|
||||
IssuerConfig & {
|
||||
client: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
}
|
||||
> {
|
||||
const info = this.getIssuerConfig();
|
||||
|
||||
if (info.type === "oidc") {
|
||||
const issuer = new URL(info.as.issuer);
|
||||
const request = await oauth.discoveryRequest(issuer);
|
||||
info.as = await oauth.processDiscoveryResponse(issuer, request);
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
type: info.type,
|
||||
client: {
|
||||
...info.client,
|
||||
...this._config.client
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getCodeChallenge(as: oauth.AuthorizationServer, state: string, method: "S256" = "S256") {
|
||||
const challenge_supported = as.code_challenge_methods_supported?.includes(method);
|
||||
let challenge: string | undefined;
|
||||
let challenge_method: string | undefined;
|
||||
if (challenge_supported) {
|
||||
challenge = await oauth.calculatePKCECodeChallenge(state);
|
||||
challenge_method = method;
|
||||
}
|
||||
|
||||
return { challenge_supported, challenge, challenge_method };
|
||||
}
|
||||
|
||||
async request(options: { redirect_uri: string; state: string; scopes?: string[] }): Promise<{
|
||||
url: string;
|
||||
endpoint: string;
|
||||
params: Record<string, string>;
|
||||
}> {
|
||||
const { client, as } = await this.getConfig();
|
||||
|
||||
const { challenge_supported, challenge, challenge_method } = await this.getCodeChallenge(
|
||||
as,
|
||||
options.state
|
||||
);
|
||||
|
||||
if (!as.authorization_endpoint) {
|
||||
throw new Error("authorization_endpoint is not provided");
|
||||
}
|
||||
|
||||
const scopes = options.scopes ?? as.scopes_supported;
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new Error("No scopes provided");
|
||||
}
|
||||
|
||||
if (scopes.every((scope) => !as.scopes_supported?.includes(scope))) {
|
||||
throw new Error("Invalid scopes provided");
|
||||
}
|
||||
|
||||
const endpoint = as.authorization_endpoint!;
|
||||
const params: any = {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: options.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: scopes.join(as.scope_separator ?? " ")
|
||||
};
|
||||
if (challenge_supported) {
|
||||
params.code_challenge = challenge;
|
||||
params.code_challenge_method = challenge_method;
|
||||
} else {
|
||||
params.nonce = options.state;
|
||||
}
|
||||
|
||||
return {
|
||||
url: new URL(endpoint) + "?" + new URLSearchParams(params).toString(),
|
||||
endpoint,
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
private async oidc(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
) {
|
||||
const config = await this.getConfig();
|
||||
const { client, as, type } = config;
|
||||
//console.log("config", config);
|
||||
//console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
callbackParams,
|
||||
oauth.expectNoState
|
||||
);
|
||||
if (oauth.isOAuth2Error(parameters)) {
|
||||
//console.log("callback.error", parameters);
|
||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||
}
|
||||
/*console.log(
|
||||
"callback.parameters",
|
||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
|
||||
);*/
|
||||
const response = await oauth.authorizationCodeGrantRequest(
|
||||
as,
|
||||
client,
|
||||
parameters,
|
||||
options.redirect_uri,
|
||||
options.state
|
||||
);
|
||||
//console.log("callback.response", response);
|
||||
|
||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||
if (challenges) {
|
||||
for (const challenge of challenges) {
|
||||
//console.log("callback.challenge", challenge);
|
||||
}
|
||||
// @todo: Handle www-authenticate challenges as needed
|
||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||
}
|
||||
|
||||
const { challenge_supported, challenge } = await this.getCodeChallenge(as, options.state);
|
||||
|
||||
const expectedNonce = challenge_supported ? undefined : challenge;
|
||||
const result = await oauth.processAuthorizationCodeOpenIDResponse(
|
||||
as,
|
||||
client,
|
||||
response,
|
||||
expectedNonce
|
||||
);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
//console.log("callback.error", result);
|
||||
// @todo: Handle OAuth 2.0 response body error
|
||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||
}
|
||||
|
||||
//console.log("callback.result", result);
|
||||
|
||||
const claims = oauth.getValidatedIdTokenClaims(result);
|
||||
//console.log("callback.IDTokenClaims", claims);
|
||||
|
||||
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||
|
||||
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
|
||||
//console.log("callback.resultUser", resultUser);
|
||||
|
||||
return await config.profile(resultUser, config, claims); // @todo: check claims
|
||||
}
|
||||
|
||||
private async oauth2(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
) {
|
||||
const config = await this.getConfig();
|
||||
const { client, type, as, profile } = config;
|
||||
console.log("config", { client, as, type });
|
||||
console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
callbackParams,
|
||||
oauth.expectNoState
|
||||
);
|
||||
if (oauth.isOAuth2Error(parameters)) {
|
||||
console.log("callback.error", parameters);
|
||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||
}
|
||||
console.log(
|
||||
"callback.parameters",
|
||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2)
|
||||
);
|
||||
const response = await oauth.authorizationCodeGrantRequest(
|
||||
as,
|
||||
client,
|
||||
parameters,
|
||||
options.redirect_uri,
|
||||
options.state
|
||||
);
|
||||
|
||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||
if (challenges) {
|
||||
for (const challenge of challenges) {
|
||||
//console.log("callback.challenge", challenge);
|
||||
}
|
||||
// @todo: Handle www-authenticate challenges as needed
|
||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||
}
|
||||
|
||||
// slack does not return valid "token_type"...
|
||||
const copy = response.clone();
|
||||
let result: any = {};
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
console.log("error", result);
|
||||
throw new Error(); // Handle OAuth 2.0 response body error
|
||||
}
|
||||
} catch (e) {
|
||||
result = (await copy.json()) as any;
|
||||
console.log("failed", result);
|
||||
}
|
||||
|
||||
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||
const user = await res2.json();
|
||||
console.log("res2", res2, user);
|
||||
|
||||
console.log("result", result);
|
||||
return await config.profile(user, config, result);
|
||||
}
|
||||
|
||||
async callback(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
): Promise<UserProfile> {
|
||||
const type = this.getIssuerConfig().type;
|
||||
|
||||
console.log("type", type);
|
||||
switch (type) {
|
||||
case "oidc":
|
||||
return await this.oidc(callbackParams, options);
|
||||
case "oauth2":
|
||||
return await this.oauth2(callbackParams, options);
|
||||
default:
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
getController(auth: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const secret = "secret";
|
||||
const cookie_name = "_challenge";
|
||||
|
||||
const setState = async (
|
||||
c: Context,
|
||||
config: { state: string; action: AuthAction; redirect?: string }
|
||||
): Promise<void> => {
|
||||
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 60 * 5 // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
const getState = async (
|
||||
c: Context
|
||||
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
|
||||
const state = await getSignedCookie(c, secret, cookie_name);
|
||||
try {
|
||||
return JSON.parse(state as string);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
};
|
||||
|
||||
hono.get("/callback", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
const state = await getState(c);
|
||||
console.log("url", url);
|
||||
|
||||
const profile = await this.callback(params, {
|
||||
redirect_uri: url.origin + url.pathname,
|
||||
state: state.state
|
||||
});
|
||||
|
||||
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", { user, token });
|
||||
|
||||
if (state.redirect) {
|
||||
console.log("redirect to", state.redirect + "?token=" + token);
|
||||
return c.redirect(state.redirect + "?token=" + token);
|
||||
}
|
||||
|
||||
return c.json({ user, token });
|
||||
});
|
||||
|
||||
hono.get("/:action", async (c) => {
|
||||
const action = c.req.param("action") as AuthAction;
|
||||
if (!["login", "register"].includes(action)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
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 state = await 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") {
|
||||
return c.json({
|
||||
url: response.url,
|
||||
redirect_uri,
|
||||
challenge: state,
|
||||
params: response.params
|
||||
});
|
||||
}
|
||||
|
||||
//return c.text(response.url);
|
||||
console.log("--redirecting to", response.url);
|
||||
|
||||
return c.redirect(response.url);
|
||||
});
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return "oauth";
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return "external" as const;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return schemaProvided;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
||||
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: {
|
||||
type: this.getIssuerConfig().type,
|
||||
...config
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
63
app/src/auth/authenticate/strategies/oauth/issuers/github.ts
Normal file
63
app/src/auth/authenticate/strategies/oauth/issuers/github.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IssuerConfig } from "../OAuthStrategy";
|
||||
|
||||
type GithubUserInfo = {
|
||||
id: number;
|
||||
sub: string;
|
||||
name: string;
|
||||
email: null;
|
||||
avatar_url: string;
|
||||
};
|
||||
|
||||
type GithubUserEmailResponse = {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
visibility: string;
|
||||
}[];
|
||||
|
||||
export const github: IssuerConfig<GithubUserInfo> = {
|
||||
type: "oauth2",
|
||||
client: {
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
},
|
||||
as: {
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
issuer: "https://github.com",
|
||||
scopes_supported: ["read:user", "user:email"],
|
||||
scope_separator: " ",
|
||||
authorization_endpoint: "https://github.com/login/oauth/authorize",
|
||||
token_endpoint: "https://github.com/login/oauth/access_token",
|
||||
userinfo_endpoint: "https://api.github.com/user",
|
||||
},
|
||||
profile: async (
|
||||
info: GithubUserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any,
|
||||
) => {
|
||||
console.log("github info", info, config, tokenResponse);
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
"User-Agent": "bknd", // this is mandatory... *smh*
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${tokenResponse.access_token}`,
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as GithubUserEmailResponse;
|
||||
console.log("data", data);
|
||||
const email = data.find((e: any) => e.primary)?.email;
|
||||
if (!email) {
|
||||
throw new Error("No primary email found");
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
sub: String(info.id),
|
||||
email: email,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error("Couldn't retrive github email");
|
||||
}
|
||||
},
|
||||
};
|
||||
29
app/src/auth/authenticate/strategies/oauth/issuers/google.ts
Normal file
29
app/src/auth/authenticate/strategies/oauth/issuers/google.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IssuerConfig } from "../OAuthStrategy";
|
||||
|
||||
type GoogleUserInfo = {
|
||||
sub: string;
|
||||
name: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
picture: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const google: IssuerConfig<GoogleUserInfo> = {
|
||||
type: "oidc",
|
||||
client: {
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
},
|
||||
as: {
|
||||
issuer: "https://accounts.google.com",
|
||||
},
|
||||
profile: async (info) => {
|
||||
return {
|
||||
...info,
|
||||
sub: info.sub,
|
||||
email: info.email,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { google } from "./google";
|
||||
export { github } from "./github";
|
||||
160
app/src/auth/authorize/Guard.ts
Normal file
160
app/src/auth/authorize/Guard.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { type Static, Type, objectTransform } from "core/utils";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type GuardUserContext = {
|
||||
role: string | null | undefined;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type GuardConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export class Guard {
|
||||
permissions: Permission[];
|
||||
user?: GuardUserContext;
|
||||
roles?: Role[];
|
||||
config?: GuardConfig;
|
||||
|
||||
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
|
||||
this.permissions = permissions;
|
||||
this.roles = roles;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static create(
|
||||
permissionNames: string[],
|
||||
roles?: Record<
|
||||
string,
|
||||
{
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}
|
||||
>,
|
||||
config?: GuardConfig
|
||||
) {
|
||||
const _roles = roles
|
||||
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
|
||||
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
|
||||
})
|
||||
: {};
|
||||
const _permissions = permissionNames.map((name) => new Permission(name));
|
||||
return new Guard(_permissions, Object.values(_roles), config);
|
||||
}
|
||||
|
||||
getPermissionNames(): string[] {
|
||||
return this.permissions.map((permission) => permission.name);
|
||||
}
|
||||
|
||||
getPermissions(): Permission[] {
|
||||
return this.permissions;
|
||||
}
|
||||
|
||||
permissionExists(permissionName: string): boolean {
|
||||
return !!this.permissions.find((p) => p.name === permissionName);
|
||||
}
|
||||
|
||||
setRoles(roles: Role[]) {
|
||||
this.roles = roles;
|
||||
return this;
|
||||
}
|
||||
|
||||
getRoles() {
|
||||
return this.roles;
|
||||
}
|
||||
|
||||
setConfig(config: Partial<GuardConfig>) {
|
||||
this.config = { ...this.config, ...config };
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermission(permission: Permission) {
|
||||
if (this.permissions.find((p) => p.name === permission.name)) {
|
||||
throw new Error(`Permission ${permission.name} already exists`);
|
||||
}
|
||||
|
||||
this.permissions.push(permission);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermissions(permissions: Permission[]) {
|
||||
for (const permission of permissions) {
|
||||
this.registerPermission(permission);
|
||||
}
|
||||
|
||||
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);
|
||||
if (role) {
|
||||
console.log("guard: role found", this.user.role);
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("guard: role not found", this.user, this.user?.role);
|
||||
return this.getDefaultRole();
|
||||
}
|
||||
|
||||
getDefaultRole(): Role | undefined {
|
||||
return this.roles?.find((role) => role.is_default);
|
||||
}
|
||||
|
||||
hasPermission(permission: Permission): boolean;
|
||||
hasPermission(name: string): boolean;
|
||||
hasPermission(permissionOrName: Permission | string): boolean {
|
||||
if (this.config?.enabled !== true) {
|
||||
//console.log("guard not enabled, allowing");
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
||||
const exists = this.permissionExists(name);
|
||||
if (!exists) {
|
||||
throw new Error(`Permission ${name} does not exist`);
|
||||
}
|
||||
|
||||
const role = this.getUserRole();
|
||||
|
||||
if (!role) {
|
||||
console.log("guard: role not found, denying");
|
||||
return false;
|
||||
} else if (role.implicit_allow === true) {
|
||||
console.log("guard: role implicit allow, allowing");
|
||||
return true;
|
||||
}
|
||||
|
||||
const rolePermission = role.permissions.find(
|
||||
(rolePermission) => rolePermission.permission.name === name
|
||||
);
|
||||
|
||||
console.log("guard: rolePermission, allowing?", {
|
||||
permission: name,
|
||||
role: role.name,
|
||||
allowing: !!rolePermission
|
||||
});
|
||||
return !!rolePermission;
|
||||
}
|
||||
|
||||
granted(permission: Permission | string): boolean {
|
||||
return this.hasPermission(permission as any);
|
||||
}
|
||||
|
||||
throwUnlessGranted(permission: Permission | string) {
|
||||
if (!this.granted(permission)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/src/auth/authorize/Role.ts
Normal file
45
app/src/auth/authorize/Role.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Permission } from "core";
|
||||
|
||||
export class RolePermission {
|
||||
constructor(
|
||||
public permission: Permission,
|
||||
public config?: any
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Role {
|
||||
constructor(
|
||||
public name: string,
|
||||
public permissions: RolePermission[] = [],
|
||||
public is_default: boolean = false,
|
||||
public implicit_allow: boolean = false
|
||||
) {}
|
||||
|
||||
static createWithPermissionNames(
|
||||
name: string,
|
||||
permissionNames: string[],
|
||||
is_default: boolean = false,
|
||||
implicit_allow: boolean = false
|
||||
) {
|
||||
return new Role(
|
||||
name,
|
||||
permissionNames.map((name) => new RolePermission(new Permission(name))),
|
||||
is_default,
|
||||
implicit_allow
|
||||
);
|
||||
}
|
||||
|
||||
static create(config: {
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}) {
|
||||
return new Role(
|
||||
config.name,
|
||||
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
|
||||
config.is_default,
|
||||
config.implicit_allow
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/src/auth/errors.ts
Normal file
28
app/src/auth/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Exception } from "core";
|
||||
|
||||
export class UserExistsException extends Exception {
|
||||
override name = "UserExistsException";
|
||||
override code = 422;
|
||||
|
||||
constructor() {
|
||||
super("User already exists");
|
||||
}
|
||||
}
|
||||
|
||||
export class UserNotFoundException extends Exception {
|
||||
override name = "UserNotFoundException";
|
||||
override code = 404;
|
||||
|
||||
constructor() {
|
||||
super("User not found");
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialsException extends Exception {
|
||||
override name = "InvalidCredentialsException";
|
||||
override code = 401;
|
||||
|
||||
constructor() {
|
||||
super("Invalid credentials");
|
||||
}
|
||||
}
|
||||
21
app/src/auth/index.ts
Normal file
21
app/src/auth/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
||||
export { sha256 } from "./utils/hash";
|
||||
export {
|
||||
type ProfileExchange,
|
||||
type Strategy,
|
||||
type User,
|
||||
type SafeUser,
|
||||
type CreateUser,
|
||||
type AuthResponse,
|
||||
type UserPool,
|
||||
type AuthAction,
|
||||
type AuthUserResolver,
|
||||
Authenticator,
|
||||
authenticatorConfig,
|
||||
jwtConfig
|
||||
} from "./authenticate/Authenticator";
|
||||
|
||||
export { AppAuth, type UserFieldSchema } from "./AppAuth";
|
||||
|
||||
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
|
||||
export { Role } from "./authorize/Role";
|
||||
13
app/src/auth/utils/hash.ts
Normal file
13
app/src/auth/utils/hash.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// @deprecated: moved to @bknd/core
|
||||
export async function sha256(password: string, salt?: string) {
|
||||
// 1. Convert password to Uint8Array
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode((salt ?? "") + password);
|
||||
|
||||
// 2. Hash the data using SHA-256
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
|
||||
// 3. Convert hash to hex string for easier display
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
Reference in New Issue
Block a user