Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial auth functionality #236

Merged
merged 25 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
PG_DATABASE_URL: ${{ secrets.PG_DATABASE_URL }}
PRISMA_DATABASE_URL: ${{ secrets.PRISMA_DATABASE_URL }}
BF_AUTH_SECRET: "somesecret"

jobs:
ci:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
PG_DATABASE_URL: ${{ secrets.PG_DATABASE_URL }}
PRISMA_DATABASE_URL: ${{ secrets.PRISMA_DATABASE_URL }}
BF_AUTH_SECRET: "somesecret"

jobs:
deploy:
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/drizzle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ function toDrizzleWhere(table: any, where: BfWhere<unknown>): any {
const key = Object.keys(where)[0] as Key;
const value = where[key as keyof typeof where];

if (key === "and") {
if (key === "AND") {
const args = [];
for (const [k, v] of Object.entries(value as BfWhere<unknown>)) {
args.push(toDrizzleWhere(table, { [k]: v }));
}

return and(...args);
} else if (key === "or") {
} else if (key === "OR") {
const args = [];
for (const [k, v] of Object.entries(value as BfWhere<unknown>)) {
args.push(toDrizzleWhere(table, { [k]: v }));
Expand Down
20 changes: 13 additions & 7 deletions packages/adapters/prisma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,31 @@ import { type PrismaClient } from "@prisma/client";
export class PrismaAdapter implements BfDatabase {
constructor(private client: PrismaClient) {}

async create<T>(model: string, data: Partial<T>) {
async create<T, U = any>(model: string, data: Partial<T>): Promise<U> {
const db: any = this.client[model as keyof PrismaClient];
return await db.create({ data });
}

async read<T>(model: string, args: { where: BfWhere<T> }) {
async read<T, U = any>(
model: string,
args: { where: BfWhere<T> }
): Promise<U> {
const db: any = this.client[model as keyof PrismaClient];
return await db.findFirst({ where: args.where });
}

async delete<T>(model: string, args: { where: BfWhere<T> }) {
async delete<T, U = any>(
model: string,
args: { where: BfWhere<T> }
): Promise<U> {
const db: any = this.client[model as keyof PrismaClient];
return await db.delete({ where: args.where });
}

async list<T>(
async list<T, U = any>(
model: string,
args: { where?: BfWhere<T>; limit?: number; offset?: number }
) {
): Promise<U> {
const db: any = this.client[model as keyof PrismaClient];
return await db.findMany({
where: args.where,
Expand All @@ -32,10 +38,10 @@ export class PrismaAdapter implements BfDatabase {
});
}

async update<T>(
async update<T, U = any>(
model: string,
args: { where: BfWhere<T>; data: Partial<T> }
) {
): Promise<U> {
const db: any = this.client[model as keyof PrismaClient];
return await db.update({ where: args.where, data: args.data });
}
Expand Down
19 changes: 16 additions & 3 deletions packages/backframe-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"type": "module",
"exports": {
".": "./dist/index.js",
"./providers/*": "./dist/providers/*.js"
"./providers/*": "./dist/providers/*.js",
"./integrations/*": "./dist/integrations/*.js"
},
"scripts": {
"build": "tsc"
Expand All @@ -17,6 +18,7 @@
"@backframe/rest": "workspace:*",
"@backframe/utils": "workspace:*",
"@panva/hkdf": "^1.0.2",
"africastalking": "^0.6.2",
"bcrypt": "^5.1.0",
"cookie": "^0.5.0",
"express-session": "^1.17.3",
Expand All @@ -26,19 +28,30 @@
"node-fetch": "^3.3.0",
"nodemailer": "^6.8.0",
"oauth": "^0.10.0",
"openid-client": "^5.3.1"
"openid-client": "^5.3.1",
"qrcode": "^1.5.3",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"@backframe/core": "workspace:*",
"@backframe/eslint-config": "workspace:*",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.5.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/lodash.merge": "^4.6.7",
"@types/nodemailer": "^6.4.7",
"@types/oauth": "^0.9.1"
"@types/oauth": "^0.9.1",
"@types/qrcode": "^1.5.2",
"@types/speakeasy": "^2.0.7"
},
"peerDependencies": {
"@backframe/core": "workspace:*",
"@backframe/rest": "workspace:*"
},
"eslintIgnore": [
"dist/*"
],
"eslintConfig": {
"extends": "@backframe/eslint-config"
}
}
31 changes: 31 additions & 0 deletions packages/backframe-auth/src/@types/africastalking/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/// <reference types="node" />

declare module "africastalking" {
interface AfricasTalkingOptions {
apiKey: string;
username: string;
}

interface SMSOptions {
to: string[];
message: string;
from: string;
enqueue?: boolean;
}

class SMS {
constructor(options: AfricasTalkingOptions);

send(opts: SMSOptions | Array<SMSOptions>): Promise<void>;
}

export class AfricasTalking {
constructor(options: AfricasTalkingOptions);

SMS: SMS;
}

export default function AfricasTalking(
options: AfricasTalkingOptions
): AfricasTalking;
}
144 changes: 109 additions & 35 deletions packages/backframe-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import type { Plugin } from "@backframe/core";
import { require } from "@backframe/utils";
import { createEnvValidator, z, type Plugin } from "@backframe/core";
import { Context } from "@backframe/rest";
import { logger, require } from "@backframe/utils";
import bcrypt from "bcrypt";
import * as jose from "jose";
import merge from "lodash.merge";
import path from "path";
import { fileURLToPath } from "url";
import { Provider } from "./lib/types.js";
import { evaluatePolicies } from "./lib/policies.js";
import { AuthConfig, JWTPayload, Provider } from "./lib/types.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pkg = require(path.join(__dirname, "../package.json"));

export interface AuthConfig {
prefix?: string;
encode?: <T>(payload: T, options?: unknown) => string | PromiseLike<string>;
decode?: (token: string, options?: unknown) => any;
verify?: (token: string, options?: unknown) => boolean | Promise<boolean>;
hash?: (
data: string | Buffer,
options?: unknown
) => string | PromiseLike<string>;
compare?: (
data: string | Buffer,
encrypted: string
) => boolean | PromiseLike<boolean>;
}
export const env = createEnvValidator({
schema: z.object({
BF_AUTH_SECRET: z.string(),
}),
onValidationError(error) {
const errors: Record<string, string[]> = error.flatten().fieldErrors;
logger.error(`auth: missing required env var, ${errors[0]}`);
console.error(
"❌ Invalid environment variables:",
error.flatten().fieldErrors
);
throw new Error("Invalid environment variables");
},
});

export const DEFAULT_CFG: AuthConfig = {
prefix: "/auth",
// default encode is jwt
async encode(payload, options: { secret: string; alg?: string }) {
const s = new TextEncoder().encode(
options?.secret || process.env.BF_AUTH_SECRET
);
const s = new TextEncoder().encode(options?.secret || env.BF_AUTH_SECRET);

const token = await new jose.SignJWT(payload as jose.JWTPayload)
.setProtectedHeader({ alg: options?.alg ?? "HS256" })
.setIssuedAt()
// .setIssuer("urn:example:issuer")
// .setAudience("urn:example:audience")
.setExpirationTime("2h")
.sign(s);

return token;
},
// default decode is jwt
async decode(token, options: { secret: string }) {
const s = jose.base64url.decode(
options?.secret || process.env.BF_AUTH_SECRET
);
const { payload } = await jose.jwtDecrypt(token, s);
return payload;
decode(token) {
return jose.decodeJwt(token) as unknown as JWTPayload;
},
async verify(token, options: { secret: string }) {
const s = new TextEncoder().encode(
options?.secret || process.env.BF_AUTH_SECRET
);
const s = new TextEncoder().encode(options?.secret || env.BF_AUTH_SECRET);
const { payload } = await jose.jwtVerify(token, s);
return !!payload;
},
Expand All @@ -72,19 +63,21 @@ export const DEFAULT_CFG: AuthConfig = {
};

export default function (cfg = DEFAULT_CFG): Plugin {
cfg = merge(DEFAULT_CFG, cfg);
return {
name: pkg.name,
description:
pkg.description || "Provides authentication for a backframe app",
version: pkg.version || "0.0.0",
onServerInit(bfCfg) {
logger.dev("auth plugin registered");
const { $server } = bfCfg;
const { $app } = bfCfg.$server;
const authCfg = bfCfg.getConfig("authentication");
const providers = authCfg.providers as Provider[];
const settings = bfCfg.$settings.auth;
cfg = merge(merge(DEFAULT_CFG, settings), cfg);

$app.use(bfCfg.withRestPrefix(cfg.prefix), (rq, _rs, nxt) => {
$app.use((rq, _rs, nxt) => {
// @ts-expect-error - it will be there
rq.authCfg = { ...cfg, ...authCfg };
nxt();
Expand All @@ -98,10 +91,91 @@ export default function (cfg = DEFAULT_CFG): Plugin {
ignored.push("signin.credentials.js");
}

async function middleware(
ctx: Context<any>,
{
resourceRoles,
currentActions,
currentResources,
public: isPublic,
}: {
resourceRoles: string[];
currentActions: string[];
currentResources: string[];
public?: boolean;
}
) {
const unauthorized = (msg?: string) => {
return ctx.json(
{
status: "error",
message: msg || "Unauthorized",
description: "Authorization is required to access this resource.",
},
401
);
};

let userId: string;
let userRoles: string[] = [];

// check authenication
if (authCfg.strategy === "token-based") {
const bearer = ctx.request.headers["authorization"];
const token = bearer?.split(" ")[1];

if (token) {
try {
await cfg.verify(token);
} catch (e) {
return unauthorized(e.message || "Invalid JWT token");
}

const payload = cfg.decode(token);
if (!payload) return unauthorized("Invalid JWT token");

userId = payload.sub;
userRoles = payload.roles ?? [];
} else if (!isPublic) {
return unauthorized("No bearer token found");
}
} else {
throw new Error("Not implemented");
}

ctx.auth = {
userId,
roles: userRoles,
};

// check permissions (authz)
if (userId) {
if (userRoles.some((r) => resourceRoles?.includes(r))) {
return ctx.next();
}

const allowed = await evaluatePolicies(ctx, {
roles: userRoles,
status: "before",
attemptedActions: currentActions,
attemptedResources: currentResources,
});

if (!allowed) return ctx.json(unauthorized(), 401);
} else if (!isPublic) {
return unauthorized();
}

return ctx.next();
}

// pass middleware to auth config
bfCfg.$updateAuthOptions({ middleware, evaluatePolicies });

// mount auth pkg routes
$server.$extendFrom(__dirname, {
name: pkg.name,
prefix: cfg.prefix,
prefix: cfg.routePrefix,
ignored,
});
},
Expand Down
Loading
Loading