RAHHHHHHH

This commit is contained in:
Jesse Wierzbinski 2023-09-13 16:25:45 -10:00
parent 298c5bceae
commit 91242b73bf
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
14 changed files with 358 additions and 76 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -11,7 +11,7 @@ const AppDataSource = new DataSource({
password: config.database.password, password: config.database.password,
database: config.database.database, database: config.database.database,
synchronize: true, synchronize: true,
entities: ["./entities/*.ts"], entities: [process.cwd() + "/database/entities/*.ts"],
}); });
export { AppDataSource }; export { AppDataSource };

View file

@ -24,6 +24,9 @@ export class Application extends BaseEntity {
}) })
vapid_key!: string | null; vapid_key!: string | null;
@Column("varchar")
client_id!: string;
@Column("varchar") @Column("varchar")
secret!: string; secret!: string;

View file

@ -1,29 +0,0 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./User";
import { Status } from "./Status";
/**
* Stores an ActivityPub Like event
*/
@Entity({
name: "favourites",
})
export class Favourite extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@ManyToOne(() => User, user => user.id)
actor!: User;
@ManyToOne(() => Status, status => status.id)
object!: Status;
@Column("datetime")
published!: Date;
}

View file

@ -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;
}

View file

@ -13,7 +13,6 @@ import { APIStatus } from "~types/entities/status";
import { User } from "./User"; import { User } from "./User";
import { Application } from "./Application"; import { Application } from "./Application";
import { Emoji } from "./Emoji"; import { Emoji } from "./Emoji";
import { Favourite } from "./Favourite";
import { RawActivity } from "./RawActivity"; import { RawActivity } from "./RawActivity";
const config = getConfig(); const config = getConfig();
@ -70,20 +69,10 @@ export class Status extends BaseEntity {
emojis!: Emoji[]; emojis!: Emoji[];
@ManyToMany(() => RawActivity, activity => activity.id, {}) @ManyToMany(() => RawActivity, activity => activity.id, {})
likes: RawActivity[] = []; likes!: RawActivity[];
@ManyToMany(() => RawActivity, activity => activity.id, {}) @ManyToMany(() => RawActivity, activity => activity.id, {})
announces: RawActivity[] = []; announces!: RawActivity[];
async getFavourites(): Promise<Favourite[]> {
return Favourite.find({
where: {
object: {
id: this.id,
},
},
});
}
async toAPI(): Promise<APIStatus> { async toAPI(): Promise<APIStatus> {
return { return {
@ -95,7 +84,7 @@ export class Status extends BaseEntity {
this.emojis.map(async emoji => await emoji.toAPI()) this.emojis.map(async emoji => await emoji.toAPI())
), ),
favourited: false, favourited: false,
favourites_count: (await this.getFavourites()).length, favourites_count: 0,
id: this.id, id: this.id,
in_reply_to_account_id: null, in_reply_to_account_id: null,
in_reply_to_id: null, in_reply_to_id: null,

View file

@ -10,7 +10,7 @@ import { User } from "./User";
import { Application } from "./Application"; import { Application } from "./Application";
export enum TokenType { export enum TokenType {
BEARER = "bearer", BEARER = "Bearer",
} }
@Entity({ @Entity({
@ -21,7 +21,7 @@ export class Token extends BaseEntity {
id!: string; id!: string;
@Column("varchar") @Column("varchar")
token_type!: TokenType; token_type: TokenType = TokenType.BEARER;
@Column("varchar") @Column("varchar")
scope!: string; scope!: string;
@ -29,6 +29,9 @@ export class Token extends BaseEntity {
@Column("varchar") @Column("varchar")
access_token!: string; access_token!: string;
@Column("varchar")
code!: string;
@CreateDateColumn() @CreateDateColumn()
created_at!: Date; created_at!: Date;

View file

@ -1,5 +1,6 @@
import { getConfig } from "@config"; import { getConfig } from "@config";
import "reflect-metadata"; import "reflect-metadata";
import { AppDataSource } from "~database/datasource";
const router = new Bun.FileSystemRouter({ const router = new Bun.FileSystemRouter({
style: "nextjs", style: "nextjs",
@ -10,9 +11,11 @@ console.log("[+] Starting FediProject...");
const config = getConfig(); const config = getConfig();
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
Bun.serve({ Bun.serve({
port: config.http.port, 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) { async fetch(req) {
const matchedRoute = router.match(req); const matchedRoute = router.match(req);

13
pages/login.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<head>
<title>Login with FediProject</title>
<meta charset="utf-8">
</head>
<body>
<form method="post" action="{{URL}}">
<input type="text" name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<input type="submit" value="Login" />
</form>
</body>

View file

@ -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<Response> => {
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}`,
},
});
};

View file

@ -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<Response> => {
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,
});
};

View file

@ -0,0 +1,22 @@
import { MatchedRoute } from "bun";
/**
* Returns an HTML login form
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
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",
},
}
);
};

View file

@ -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<Response> => {
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,
});
};

146
tests/oauth.test.ts Normal file
View file

@ -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();
});