From a02f929d343b7d2b5ca0c61dce019929fec9b7a0 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Sat, 24 Dec 2022 18:55:14 +1100 Subject: OAuth2 authorize bot flow --- assets/preload-plugins/oauth2.js | 9 + assets/schemas.json | 580 +++++++++++++++++++++++++ src/api/routes/oauth2/authorize.ts | 146 +++++++ src/util/schemas/ApplicationAuthorizeSchema.ts | 7 + src/util/schemas/index.ts | 3 +- 5 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 assets/preload-plugins/oauth2.js create mode 100644 src/api/routes/oauth2/authorize.ts create mode 100644 src/util/schemas/ApplicationAuthorizeSchema.ts diff --git a/assets/preload-plugins/oauth2.js b/assets/preload-plugins/oauth2.js new file mode 100644 index 00000000..5b78ec83 --- /dev/null +++ b/assets/preload-plugins/oauth2.js @@ -0,0 +1,9 @@ +// Fixes /oauth2 endpoints not requesting a CSS file + +if (location.pathname.startsWith("/oauth2/")) { + const link = document.createElement("link"); + link.rel = "stylesheet" + link.type = "text/css" + link.href = "/assets/40532.f7b1e10347ef10e790ac.css" + document.head.appendChild(link) +} \ No newline at end of file diff --git a/assets/schemas.json b/assets/schemas.json index 874c4f88..879d202e 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -26795,6 +26795,586 @@ }, "$schema": "http://json-schema.org/draft-07/schema#" }, + "ApplicationAuthorizeSchema": { + "type": "object", + "properties": { + "authorize": { + "type": "boolean" + }, + "guild_id": { + "type": "string" + }, + "permissions": { + "type": "string" + }, + "captcha_key": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "authorize", + "guild_id", + "permissions" + ], + "definitions": { + "ChannelPermissionOverwriteType": { + "enum": [ + 0, + 1, + 2 + ], + "type": "number" + }, + "Embed": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "type": { + "enum": [ + "article", + "gifv", + "image", + "link", + "rich", + "video" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "url": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "color": { + "type": "integer" + }, + "footer": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "proxy_icon_url": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + }, + "image": { + "$ref": "#/definitions/EmbedImage" + }, + "thumbnail": { + "$ref": "#/definitions/EmbedImage" + }, + "video": { + "$ref": "#/definitions/EmbedImage" + }, + "provider": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "proxy_icon_url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "inline": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "name", + "value" + ] + } + } + }, + "additionalProperties": false + }, + "EmbedImage": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "width": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ChannelModifySchema": { + "type": "object", + "properties": { + "name": { + "maxLength": 100, + "type": "string" + }, + "type": { + "enum": [ + 0, + 1, + 10, + 11, + 12, + 13, + 14, + 15, + 2, + 255, + 3, + 33, + 34, + 35, + 4, + 5, + 6, + 64, + 7, + 8, + 9 + ], + "type": "number" + }, + "topic": { + "type": "string" + }, + "icon": { + "type": [ + "null", + "string" + ] + }, + "bitrate": { + "type": "integer" + }, + "user_limit": { + "type": "integer" + }, + "rate_limit_per_user": { + "type": "integer" + }, + "position": { + "type": "integer" + }, + "permission_overwrites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/ChannelPermissionOverwriteType" + }, + "allow": { + "type": "string" + }, + "deny": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "allow", + "deny", + "id", + "type" + ] + } + }, + "parent_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "rtc_region": { + "type": "string" + }, + "default_auto_archive_duration": { + "type": "integer" + }, + "default_reaction_emoji": { + "type": [ + "null", + "string" + ] + }, + "flags": { + "type": "integer" + }, + "default_thread_rate_limit_per_user": { + "type": "integer" + }, + "video_quality_mode": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ActivitySchema": { + "type": "object", + "properties": { + "afk": { + "type": "boolean" + }, + "status": { + "$ref": "#/definitions/Status" + }, + "activities": { + "type": "array", + "items": { + "$ref": "#/definitions/Activity" + } + }, + "since": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "status" + ] + }, + "Status": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "Activity": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/ActivityType" + }, + "url": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "timestamps": { + "type": "object", + "properties": { + "start": { + "type": "integer" + }, + "end": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "end", + "start" + ] + }, + "application_id": { + "type": "string" + }, + "details": { + "type": "string" + }, + "state": { + "type": "string" + }, + "emoji": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "animated": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "animated", + "name" + ] + }, + "party": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "size": { + "type": "array", + "items": [ + { + "type": "integer" + } + ], + "minItems": 1, + "maxItems": 1 + } + }, + "additionalProperties": false + }, + "assets": { + "type": "object", + "properties": { + "large_image": { + "type": "string" + }, + "large_text": { + "type": "string" + }, + "small_image": { + "type": "string" + }, + "small_text": { + "type": "string" + } + }, + "additionalProperties": false + }, + "secrets": { + "type": "object", + "properties": { + "join": { + "type": "string" + }, + "spectate": { + "type": "string" + }, + "match": { + "type": "string" + } + }, + "additionalProperties": false + }, + "instance": { + "type": "boolean" + }, + "flags": { + "type": "string" + }, + "id": { + "type": "string" + }, + "sync_id": { + "type": "string" + }, + "metadata": { + "type": "object", + "properties": { + "context_uri": { + "type": "string" + }, + "album_id": { + "type": "string" + }, + "artist_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "album_id", + "artist_ids" + ] + }, + "session_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "flags", + "name", + "session_id", + "type" + ] + }, + "ActivityType": { + "enum": [ + 0, + 1, + 2, + 4, + 5 + ], + "type": "number" + }, + "Record": { + "type": "object", + "additionalProperties": false + }, + "CustomStatus": { + "type": "object", + "properties": { + "emoji_id": { + "type": "string" + }, + "emoji_name": { + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "additionalProperties": false + }, + "FriendSourceFlags": { + "type": "object", + "properties": { + "all": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "all" + ] + }, + "GuildFolder": { + "type": "object", + "properties": { + "color": { + "type": "integer" + }, + "guild_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "color", + "guild_ids", + "id", + "name" + ] + }, + "Partial": { + "type": "object", + "properties": { + "message_notifications": { + "type": "integer" + }, + "mute_config": { + "$ref": "#/definitions/MuteConfig" + }, + "muted": { + "type": "boolean" + }, + "channel_id": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "MuteConfig": { + "type": "object", + "properties": { + "end_time": { + "type": "integer" + }, + "selected_time_window": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "end_time", + "selected_time_window" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, "ActivitySchema": { "$ref": "#/definitions/ActivitySchema", "definitions": { diff --git a/src/api/routes/oauth2/authorize.ts b/src/api/routes/oauth2/authorize.ts new file mode 100644 index 00000000..e4c2e986 --- /dev/null +++ b/src/api/routes/oauth2/authorize.ts @@ -0,0 +1,146 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { ApiError, Application, ApplicationAuthorizeSchema, getPermission, DiscordApiErrors, Member, Permissions, User, getRights, Rights, MemberPrivateProjection } from "@fosscord/util"; +const router = Router(); + +// TODO: scopes, other oauth types + +router.get("/", route({}), async (req: Request, res: Response) => { + const { + client_id, + scope, + response_type, + redirect_url, + } = req.query; + + const app = await Application.findOne({ + where: { + id: client_id as string, + }, + relations: ["bot"], + }); + + // TODO: use DiscordApiErrors + // findOneOrFail throws code 404 + if (!app) throw DiscordApiErrors.UNKNOWN_APPLICATION; + if (!app.bot) throw DiscordApiErrors.OAUTH2_APPLICATION_BOT_ABSENT; + + const bot = app.bot; + delete app.bot; + + const user = await User.findOneOrFail({ + where: { + id: req.user_id, + bot: false, + }, + select: ["id", "username", "avatar", "discriminator", "public_flags"] + }); + + const guilds = await Member.find({ + where: { + user: { + id: req.user_id, + }, + }, + relations: ["guild", "roles"], + //@ts-ignore + select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id"] + }); + + const guildsWithPermissions = guilds.map(x => { + const perms = x.guild.owner_id === user.id + ? new Permissions(Permissions.FLAGS.ADMINISTRATOR) + : Permissions.finalPermission({ + user: { + id: user.id, + roles: x.roles?.map(x => x.id) || [], + }, + guild: { + roles: x?.roles || [], + } + }); + + return { + id: x.guild.id, + name: x.guild.name, + icon: x.guild.icon, + mfa_level: x.guild.mfa_level, + permissions: perms.bitfield.toString(), + }; + }); + + return res.json({ + guilds: guildsWithPermissions, + user: { + id: user.id, + username: user.username, + avatar: user.avatar, + avatar_decoration: null, // TODO + discriminator: user.discriminator, + public_flags: user.public_flags, + }, + application: { + id: app.id, + name: app.name, + icon: app.icon, + description: app.description, + summary: app.summary, + type: app.type, + hook: app.hook, + guild_id: null, // TODO support guilds + bot_public: app.bot_public, + bot_require_code_grant: app.bot_require_code_grant, + verify_key: app.verify_key, + flags: app.flags, + }, + bot: { + id: bot.id, + username: bot.username, + avatar: bot.avatar, + avatar_decoration: null, // TODO + discriminator: bot.discriminator, + public_flags: bot.public_flags, + bot: true, + approximated_guild_count: 0, // TODO + }, + authorized: false, + }); +}); + +router.post("/", route({ body: "ApplicationAuthorizeSchema" }), async (req: Request, res: Response) => { + const body = req.body as ApplicationAuthorizeSchema; + const { + client_id, + scope, + response_type, + redirect_url + } = req.query; + + // TODO: captcha verification + // TODO: MFA verification + + const perms = await getPermission(req.user_id, body.guild_id, undefined, { member_relations: ["user"] }); + // getPermission cache won't exist if we're owner + if (Object.keys(perms.cache || {}).length > 0 && perms.cache.member!.user.bot) throw DiscordApiErrors.UNAUTHORIZED; + perms.hasThrow("MANAGE_GUILD"); + + const app = await Application.findOne({ + where: { + id: client_id as string, + }, + relations: ["bot"], + }); + + // TODO: use DiscordApiErrors + // findOneOrFail throws code 404 + if (!app) throw new ApiError("Unknown Application", 10002, 404); + if (!app.bot) throw new ApiError("OAuth2 application does not have a bot", 50010, 400); + + await Member.addToGuild(app.id, body.guild_id); + + return res.json({ + location: "/oauth2/authorized", // redirect URL + }); +}); + +export default router; diff --git a/src/util/schemas/ApplicationAuthorizeSchema.ts b/src/util/schemas/ApplicationAuthorizeSchema.ts new file mode 100644 index 00000000..1a0aae0f --- /dev/null +++ b/src/util/schemas/ApplicationAuthorizeSchema.ts @@ -0,0 +1,7 @@ +export interface ApplicationAuthorizeSchema { + authorize: boolean; + guild_id: string; + permissions: string; + captcha_key?: string; + code?: string; // 2fa code +} \ No newline at end of file diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index a03cebe2..be4be0c7 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -58,4 +58,5 @@ export * from "./ChannelReorderSchema"; export * from "./UserSettingsSchema"; export * from "./BotModifySchema"; export * from "./ApplicationModifySchema"; -export * from "./ApplicationCreateSchema"; \ No newline at end of file +export * from "./ApplicationCreateSchema"; +export * from "./ApplicationAuthorizeSchema"; \ No newline at end of file -- cgit 1.4.1