From 91242b73bfbab1d637946c523832a5c5f57f9f17 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 13 Sep 2023 16:25:45 -1000 Subject: [PATCH] RAHHHHHHH --- bun.lockb | Bin 199067 -> 199067 bytes database/datasource.ts | 2 +- database/entities/Application.ts | 3 + database/entities/Favourite.ts | 29 ----- database/entities/Renote.ts | 29 ----- database/entities/Status.ts | 17 +-- database/entities/Token.ts | 7 +- index.ts | 5 +- pages/login.html | 13 +++ server/api/auth/login/index.ts | 63 +++++++++++ server/api/v1/apps/index.ts | 55 ++++++++++ server/api/v1/oauth/authorize/index.ts | 22 ++++ server/api/v1/oauth/token/index.ts | 43 ++++++++ tests/oauth.test.ts | 146 +++++++++++++++++++++++++ 14 files changed, 358 insertions(+), 76 deletions(-) delete mode 100644 database/entities/Favourite.ts delete mode 100644 database/entities/Renote.ts create mode 100644 pages/login.html create mode 100644 server/api/auth/login/index.ts create mode 100644 server/api/v1/apps/index.ts create mode 100644 server/api/v1/oauth/authorize/index.ts create mode 100644 server/api/v1/oauth/token/index.ts create mode 100644 tests/oauth.test.ts diff --git a/bun.lockb b/bun.lockb index ffc1c52276972dd7bac12a76edd189417c8a8008..b2a94eb17941b456977dd88715e6e0f4ed767006 100755 GIT binary patch delta 132 zcmbO|nP>K7o(+zQwhRz(fIr|Ff=Wi4?I)@kLszjg#+m9F=oxHRQDZ893;=agAJqT= delta 132 zcmbO|nP>K7o(+zQw%iP0Z~#hkK)4J$GNca$u|hdOY84b5NP&opGlArq>lC-wDKah& y7Xfm@!WkI&K%zkKAWixZ%l5)b#&D+T1(l38+fP(8hOS} User, user => user.id) - actor!: User; - - @ManyToOne(() => Status, status => status.id) - object!: Status; - - @Column("datetime") - published!: Date; -} diff --git a/database/entities/Renote.ts b/database/entities/Renote.ts deleted file mode 100644 index b4c9d782..00000000 --- a/database/entities/Renote.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - BaseEntity, - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; -import { User } from "./User"; -import { Status } from "./Status"; - -/** - * Stores an ActivityPub Renote event - */ -@Entity({ - name: "renotes", -}) -export class Renote extends BaseEntity { - @PrimaryGeneratedColumn("uuid") - id!: string; - - @ManyToOne(() => User, user => user.id) - actor!: User; - - @ManyToOne(() => Status, status => status.id) - object!: Status; - - @Column("datetime") - published!: Date; -} diff --git a/database/entities/Status.ts b/database/entities/Status.ts index d793e3ee..29417e7f 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -13,7 +13,6 @@ import { APIStatus } from "~types/entities/status"; import { User } from "./User"; import { Application } from "./Application"; import { Emoji } from "./Emoji"; -import { Favourite } from "./Favourite"; import { RawActivity } from "./RawActivity"; const config = getConfig(); @@ -70,20 +69,10 @@ export class Status extends BaseEntity { emojis!: Emoji[]; @ManyToMany(() => RawActivity, activity => activity.id, {}) - likes: RawActivity[] = []; + likes!: RawActivity[]; @ManyToMany(() => RawActivity, activity => activity.id, {}) - announces: RawActivity[] = []; - - async getFavourites(): Promise { - return Favourite.find({ - where: { - object: { - id: this.id, - }, - }, - }); - } + announces!: RawActivity[]; async toAPI(): Promise { return { @@ -95,7 +84,7 @@ export class Status extends BaseEntity { this.emojis.map(async emoji => await emoji.toAPI()) ), favourited: false, - favourites_count: (await this.getFavourites()).length, + favourites_count: 0, id: this.id, in_reply_to_account_id: null, in_reply_to_id: null, diff --git a/database/entities/Token.ts b/database/entities/Token.ts index 04e68b86..ab619fd3 100644 --- a/database/entities/Token.ts +++ b/database/entities/Token.ts @@ -10,7 +10,7 @@ import { User } from "./User"; import { Application } from "./Application"; export enum TokenType { - BEARER = "bearer", + BEARER = "Bearer", } @Entity({ @@ -21,7 +21,7 @@ export class Token extends BaseEntity { id!: string; @Column("varchar") - token_type!: TokenType; + token_type: TokenType = TokenType.BEARER; @Column("varchar") scope!: string; @@ -29,6 +29,9 @@ export class Token extends BaseEntity { @Column("varchar") access_token!: string; + @Column("varchar") + code!: string; + @CreateDateColumn() created_at!: Date; diff --git a/index.ts b/index.ts index 3845ce40..058a7193 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import { getConfig } from "@config"; import "reflect-metadata"; +import { AppDataSource } from "~database/datasource"; const router = new Bun.FileSystemRouter({ style: "nextjs", @@ -10,9 +11,11 @@ console.log("[+] Starting FediProject..."); const config = getConfig(); +if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + Bun.serve({ port: config.http.port, - hostname: "0.0.0.0", // defaults to "0.0.0.0" + hostname: config.http.base_url || "0.0.0.0", // defaults to "0.0.0.0" async fetch(req) { const matchedRoute = router.match(req); diff --git a/pages/login.html b/pages/login.html new file mode 100644 index 00000000..0a90d7c1 --- /dev/null +++ b/pages/login.html @@ -0,0 +1,13 @@ + + +Login with FediProject + + + + +
+ + + +
+ \ No newline at end of file diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts new file mode 100644 index 00000000..693cbb1a --- /dev/null +++ b/server/api/auth/login/index.ts @@ -0,0 +1,63 @@ +import { errorResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { randomBytes } from "crypto"; +import { Application } from "~database/entities/Application"; +import { Token } from "~database/entities/Token"; +import { User } from "~database/entities/User"; + +/** + * OAuth Code flow + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const scopes = matchedRoute.query.scopes.replaceAll("+", " ").split(" "); + const redirect_uri = matchedRoute.query.redirect_uri; + const response_type = matchedRoute.query.response_type; + const client_id = matchedRoute.query.client_id; + + const formData = await req.formData(); + + const username = formData.get("username")?.toString() || null; + const password = formData.get("password")?.toString() || null; + + if (response_type !== "code") + return errorResponse("Invalid response type (try 'code')", 400); + + if (!username || !password) + return errorResponse("Missing username or password", 400); + + // Get user + const user = await User.findOneBy({ + username, + }); + + if (!user || !(await Bun.password.verify(password, user.password))) + return errorResponse("Invalid username or password", 401); + + // Get application + const application = await Application.findOneBy({ + client_id, + }); + + if (!application) return errorResponse("Invalid client_id", 404); + + const token = new Token(); + + token.access_token = randomBytes(64).toString("base64url"); + token.code = randomBytes(32).toString("hex"); + token.application = application; + token.scope = scopes.join(" "); + token.user = user; + + await token.save(); + + // Redirect back to application + return new Response(null, { + status: 302, + headers: { + Location: `${redirect_uri}?code=${token.code}`, + }, + }); +}; diff --git a/server/api/v1/apps/index.ts b/server/api/v1/apps/index.ts new file mode 100644 index 00000000..32e282a6 --- /dev/null +++ b/server/api/v1/apps/index.ts @@ -0,0 +1,55 @@ +import { errorResponse, jsonResponse } from "@response"; +import { randomBytes } from "crypto"; +import { Application } from "~database/entities/Application"; + +/** + * Creates a new application to obtain OAuth 2 credentials + */ +export default async (req: Request): Promise => { + const body = await req.formData(); + + const client_name = body.get("client_name")?.toString() || null; + const redirect_uris = body.get("redirect_uris")?.toString() || null; + const scopes = body.get("scopes")?.toString() || null; + const website = body.get("website")?.toString() || null; + + const application = new Application(); + + application.name = client_name || ""; + + // Check if redirect URI is a valid URI, and also an absolute URI + if (redirect_uris) { + try { + const redirect_uri = new URL(redirect_uris); + + if (!redirect_uri.protocol.startsWith("http")) { + return errorResponse( + "Redirect URI must be an absolute URI", + 422 + ); + } + + application.redirect_uris = redirect_uris; + } catch { + return errorResponse("Redirect URI must be a valid URI", 422); + } + } + + application.scopes = scopes || "read"; + application.website = website || null; + + application.client_id = randomBytes(32).toString("base64url"); + application.secret = randomBytes(64).toString("base64url"); + + await application.save(); + + return jsonResponse({ + id: application.id, + name: application.name, + website: application.website, + client_id: application.client_id, + client_secret: application.secret, + redirect_uri: application.redirect_uris, + vapid_link: application.vapid_key, + }); +}; diff --git a/server/api/v1/oauth/authorize/index.ts b/server/api/v1/oauth/authorize/index.ts new file mode 100644 index 00000000..c2b72f31 --- /dev/null +++ b/server/api/v1/oauth/authorize/index.ts @@ -0,0 +1,22 @@ +import { MatchedRoute } from "bun"; + +/** + * Returns an HTML login form + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const html = Bun.file("./pages/login.html"); + return new Response( + (await html.text()).replace( + "{{URL}}", + `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scopes=${matchedRoute.query.scopes}` + ), + { + headers: { + "Content-Type": "text/html", + }, + } + ); +}; diff --git a/server/api/v1/oauth/token/index.ts b/server/api/v1/oauth/token/index.ts new file mode 100644 index 00000000..6d00f772 --- /dev/null +++ b/server/api/v1/oauth/token/index.ts @@ -0,0 +1,43 @@ +import { errorResponse, jsonResponse } from "@response"; +import { Token } from "~database/entities/Token"; + +/** + * Allows getting token from OAuth code + */ +export default async (req: Request): Promise => { + const body = await req.formData(); + + const grant_type = body.get("grant_type")?.toString() || null; + const code = body.get("code")?.toString() || ""; + const redirect_uri = body.get("redirect_uri")?.toString() || ""; + const client_id = body.get("client_id")?.toString() || ""; + const client_secret = body.get("client_secret")?.toString() || ""; + const scope = body.get("scope")?.toString() || null; + + if (grant_type !== "authorization_code") + return errorResponse( + "Invalid grant type (try 'authorization_code')", + 400 + ); + + // Get associated token + const token = await Token.findOneBy({ + code, + application: { + client_id, + secret: client_secret, + redirect_uris: redirect_uri, + }, + scope: scope?.replaceAll("+", " "), + }); + + if (!token) + return errorResponse("Invalid access token or client credentials", 401); + + return jsonResponse({ + access_token: token.access_token, + token_type: token.token_type, + scope: token.scope, + created_at: token.created_at, + }); +}; diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 00000000..985d0c99 --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,146 @@ +import { getConfig } from "@config"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { Application } from "~database/entities/Application"; +import { Token } from "~database/entities/Token"; +import { User } from "~database/entities/User"; + +const config = getConfig(); + +let client_id: string; +let client_secret: string; +let code: string; + +beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + + // Initialize test user + const user = new User(); + + user.email = "test@test.com"; + user.username = "test"; + user.password = await Bun.password.hash("test"); + user.display_name = ""; + user.bio = ""; + + await user.save(); +}); + +describe("POST /v1/apps/", () => { + test("should create an application", async () => { + const formData = new FormData(); + + formData.append("client_name", "Test Application"); + formData.append("website", "https://example.com"); + formData.append("redirect_uris", "https://example.com"); + formData.append("scopes", "read write"); + const response = await fetch( + `${config.http.base_url}:${config.http.port}/v1/apps/`, + { + method: "POST", + body: formData, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json = await response.json(); + + expect(json).toEqual({ + id: expect.any(String), + name: "Test Application", + website: "https://example.com", + client_id: expect.any(String), + client_secret: expect.any(String), + redirect_uri: "https://example.com", + vapid_link: null, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + client_id = json.client_id; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + client_secret = json.client_secret; + }); +}); + +describe("POST /auth/login/", () => { + test("should get a code", async () => { + const formData = new FormData(); + + formData.append("username", "test"); + formData.append("password", "test"); + const response = await fetch( + `${config.http.base_url}:${config.http.port}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`, + { + method: "POST", + body: formData, + } + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toMatch( + /https:\/\/example.com\?code=/ + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + code = response.headers.get("location")?.split("=")[1] || ""; + }); +}); + +describe("POST /v1/oauth/token/", () => { + test("should get an access token", async () => { + const formData = new FormData(); + + formData.append("grant_type", "authorization_code"); + formData.append("code", code); + formData.append("redirect_uri", "https://example.com"); + formData.append("client_id", client_id); + formData.append("client_secret", client_secret); + formData.append("scope", "read write"); + + const response = await fetch( + `${config.http.base_url}:${config.http.port}/v1/oauth/token/`, + { + method: "POST", + body: formData, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual({ + access_token: expect.any(String), + token_type: "bearer", + scope: "read write", + created_at: expect.any(Number), + }); + }); +}); + +afterAll(async () => { + // Clean up user + const user = await User.findOneBy({ + username: "test", + }); + + // Clean up tokens + const tokens = await Token.findBy({ + user: { + username: "test", + }, + }); + + const applications = await Application.findBy({ + client_id, + secret: client_secret, + }); + + await Promise.all(tokens.map(async token => await token.remove())); + await Promise.all( + applications.map(async application => await application.remove()) + ); + + if (user) await user.remove(); +});