diff --git a/locales/en-US.yml b/locales/en-US.yml index e3cddd479..c9a5c29e9 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1595,7 +1595,7 @@ _auth: pleaseGoBack: "Please go back to the application" callback: "Returning to the application" denied: "Access denied" - copyAsk: "Please paste the following authorization code to the application:" + copyAsk: "Please paste the following authorization code in the application:" allPermissions: "Full account access" _antennaSources: all: "All posts" diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 8be877cd5..6dd8fa30f 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -76,6 +76,8 @@ import { entities as charts } from "@/services/chart/entities.js"; import { envOption } from "../env.js"; import { dbLogger } from "./logger.js"; import { redisClient } from "./redis.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); class MyCustomLogger implements Logger { @@ -176,10 +178,12 @@ export const entities = [ UserPending, Webhook, UserIp, + OAuthApp, + OAuthToken, ...charts, ]; -const log = process.env.NODE_ENV !== "production"; +const log = process.env.LOG_SQL === "true"; export const db = new DataSource({ type: "postgres", diff --git a/packages/backend/src/migration/1697226201723-add-oauth-tables.ts b/packages/backend/src/migration/1697226201723-add-oauth-tables.ts new file mode 100644 index 000000000..e4895cd8c --- /dev/null +++ b/packages/backend/src/migration/1697226201723-add-oauth-tables.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddOAuthTables1697226201723 implements MigrationInterface { + name = 'AddOAuthTables1697226201723' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "oauth_app" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "clientId" character varying(64) NOT NULL, "clientSecret" character varying(64) NOT NULL, "name" character varying(128) NOT NULL, "website" character varying(256), "scopes" character varying(64) array NOT NULL, "redirectUris" character varying(64) array NOT NULL, CONSTRAINT "PK_3256b97c0a3ee2d67240805dca4" PRIMARY KEY ("id")); COMMENT ON COLUMN "oauth_app"."createdAt" IS 'The created date of the OAuth application'; COMMENT ON COLUMN "oauth_app"."clientId" IS 'The client id of the OAuth application'; COMMENT ON COLUMN "oauth_app"."clientSecret" IS 'The client secret of the OAuth application'; COMMENT ON COLUMN "oauth_app"."name" IS 'The name of the OAuth application'; COMMENT ON COLUMN "oauth_app"."website" IS 'The website of the OAuth application'; COMMENT ON COLUMN "oauth_app"."scopes" IS 'The scopes requested by the OAuth application'; COMMENT ON COLUMN "oauth_app"."redirectUris" IS 'The redirect URIs of the OAuth application'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_65b61f406c811241e1315a2f82" ON "oauth_app" ("clientId") `); + await queryRunner.query(`CREATE TABLE "oauth_token" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "appId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "code" character varying(64) NOT NULL, "token" character varying(64) NOT NULL, "active" boolean NOT NULL, "scopes" character varying(64) array NOT NULL, "redirectUri" character varying(64) NOT NULL, CONSTRAINT "PK_7e6a25a3cc4395d1658f5b89c73" PRIMARY KEY ("id")); COMMENT ON COLUMN "oauth_token"."createdAt" IS 'The created date of the OAuth token'; COMMENT ON COLUMN "oauth_token"."code" IS 'The auth code for the OAuth token'; COMMENT ON COLUMN "oauth_token"."token" IS 'The OAuth token'; COMMENT ON COLUMN "oauth_token"."active" IS 'Whether or not the token has been activated'; COMMENT ON COLUMN "oauth_token"."scopes" IS 'The scopes requested by the OAuth token'; COMMENT ON COLUMN "oauth_token"."redirectUri" IS 'The redirect URI of the OAuth token'`); + await queryRunner.query(`CREATE INDEX "IDX_dc5fe174a8b59025055f0ec136" ON "oauth_token" ("code") `); + await queryRunner.query(`CREATE INDEX "IDX_2cbeb4b389444bcf4379ef4273" ON "oauth_token" ("token") `); + await queryRunner.query(`ALTER TABLE "oauth_token" ADD CONSTRAINT "FK_6d3ef28ea647b1449ba79690874" FOREIGN KEY ("appId") REFERENCES "oauth_app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "oauth_token" ADD CONSTRAINT "FK_f6b4b1ac66b753feab5d831ba04" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_token" DROP CONSTRAINT "FK_f6b4b1ac66b753feab5d831ba04"`); + await queryRunner.query(`ALTER TABLE "oauth_token" DROP CONSTRAINT "FK_6d3ef28ea647b1449ba79690874"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2cbeb4b389444bcf4379ef4273"`); + await queryRunner.query(`DROP INDEX "public"."IDX_dc5fe174a8b59025055f0ec136"`); + await queryRunner.query(`DROP TABLE "oauth_token"`); + await queryRunner.query(`DROP INDEX "public"."IDX_65b61f406c811241e1315a2f82"`); + await queryRunner.query(`DROP TABLE "oauth_app"`); + } + +} diff --git a/packages/backend/src/models/entities/oauth-app.ts b/packages/backend/src/models/entities/oauth-app.ts new file mode 100644 index 000000000..786ad3026 --- /dev/null +++ b/packages/backend/src/models/entities/oauth-app.ts @@ -0,0 +1,53 @@ +import { Entity, PrimaryColumn, Column, Index } from "typeorm"; +import { id } from "../id.js"; + +@Entity('oauth_app') +export class OAuthApp { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the OAuth application", + }) + public createdAt: Date; + + @Index({ unique: true }) + @Column("varchar", { + length: 64, + comment: "The client id of the OAuth application", + }) + public clientId: string; + + @Column("varchar", { + length: 64, + comment: "The client secret of the OAuth application", + }) + public clientSecret: string; + + @Column("varchar", { + length: 128, + comment: "The name of the OAuth application", + }) + public name: string; + + @Column("varchar", { + length: 256, + nullable: true, + comment: "The website of the OAuth application", + }) + public website: string | null; + + @Column("varchar", { + length: 64, + array: true, + comment: "The scopes requested by the OAuth application", + }) + public scopes: string[]; + + @Column("varchar", { + length: 64, + array: true, + comment: "The redirect URIs of the OAuth application", + }) + public redirectUris: string[]; +} diff --git a/packages/backend/src/models/entities/oauth-token.ts b/packages/backend/src/models/entities/oauth-token.ts new file mode 100644 index 000000000..17d09c800 --- /dev/null +++ b/packages/backend/src/models/entities/oauth-token.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from "typeorm"; +import { id } from "../id.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { User } from "@/models/entities/user.js"; + +@Entity('oauth_token') +export class OAuthToken { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the OAuth token", + }) + public createdAt: Date; + + @Column(id()) + public appId: OAuthApp["id"]; + + @ManyToOne(() => OAuthApp, { + onDelete: "CASCADE", + }) + @JoinColumn() + public app: OAuthApp; + + @Column(id()) + public userId: User["id"]; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User; + + @Index() + @Column("varchar", { + length: 64, + comment: "The auth code for the OAuth token", + }) + public code: string; + + @Index() + @Column("varchar", { + length: 64, + comment: "The OAuth token", + }) + public token: string; + + @Column("boolean", { + comment: "Whether or not the token has been activated", + }) + public active: boolean; + + @Column("varchar", { + length: 64, + array: true, + comment: "The scopes requested by the OAuth token", + }) + public scopes: string[]; + + @Column("varchar", { + length: 64, + comment: "The redirect URI of the OAuth token", + }) + public redirectUri: string; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 53087fa19..bef3d270f 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -66,6 +66,8 @@ import { InstanceRepository } from "./repositories/instance.js"; import { Webhook } from "./entities/webhook.js"; import { UserIp } from "./entities/user-ip.js"; import { NoteEdit } from "./entities/note-edit.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -131,3 +133,5 @@ export const ChannelNotePinings = db.getRepository(ChannelNotePining); export const RegistryItems = db.getRepository(RegistryItem); export const Webhooks = db.getRepository(Webhook); export const PasswordResetRequests = db.getRepository(PasswordResetRequest); +export const OAuthApps = db.getRepository(OAuthApp); +export const OAuthTokens = db.getRepository(OAuthToken); diff --git a/packages/backend/src/server/api/mastodon/converters/auth.ts b/packages/backend/src/server/api/mastodon/converters/auth.ts deleted file mode 100644 index cb029c1b1..000000000 --- a/packages/backend/src/server/api/mastodon/converters/auth.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { unique } from "@/prelude/array.js"; - -export class AuthConverter { - private static readScopes = [ - "read:account", - "read:drive", - "read:blocks", - "read:favorites", - "read:following", - "read:messaging", - "read:mutes", - "read:notifications", - "read:reactions", - ]; - - private static writeScopes = [ - "write:account", - "write:drive", - "write:blocks", - "write:favorites", - "write:following", - "write:messaging", - "write:mutes", - "write:notes", - "write:notifications", - "write:reactions", - "write:votes", - ]; - - private static followScopes = [ - "read:following", - "read:blocks", - "read:mutes", - "write:following", - "write:blocks", - "write:mutes", - ]; - - public static decode(scopes: string[]): string[] { - const res: string[] = []; - - for (const scope of scopes) { - if (scope === "read") - res.push(...this.readScopes); - else if (scope === "write") - res.push(...this.writeScopes); - else if (scope === "follow") - res.push(...this.followScopes); - else if (scope === "read:accounts") - res.push("read:account"); - else if (scope === "read:blocks") - res.push("read:blocks"); - else if (scope === "read:bookmarks") - res.push("read:favorites"); - else if (scope === "read:favourites") - res.push("read:reactions"); - else if (scope === "read:filters") - res.push("read:account") - else if (scope === "read:follows") - res.push("read:following"); - else if (scope === "read:lists") - res.push("read:account"); - else if (scope === "read:mutes") - res.push("read:mutes"); - else if (scope === "read:notifications") - res.push("read:notifications"); - else if (scope === "read:search") - res.push("read:account"); // FIXME: move this to a new scope "read:search" - else if (scope === "read:statuses") - res.push("read:messaging"); - else if (scope === "write:accounts") - res.push("write:account"); - else if (scope === "write:blocks") - res.push("write:blocks"); - else if (scope === "write:bookmarks") - res.push("write:favorites"); - else if (scope === "write:favourites") - res.push("write:reactions"); - else if (scope === "write:filters") - res.push("write:account"); - else if (scope === "write:follows") - res.push("write:following"); - else if (scope === "write:lists") - res.push("write:account"); - else if (scope === "write:media") - res.push("write:drive"); - else if (scope === "write:mutes") - res.push("write:mutes"); - else if (scope === "write:notifications") - res.push("write:notifications"); - else if (scope === "write:reports") - res.push("read:account"); // FIXME: move this to a new scope "write:reports" - else if (scope === "write:statuses") - res.push(...["write:notes", "write:messaging", "write:votes"]); - else if (scope === "write:conversations") - res.push("write:messaging"); - // ignored: "push" - } - - return unique(res); - } - - public static encode(scopes: string[]): string[] { - const res: string[] = []; - - for (const scope of scopes) { - if (scope === "read:account") - res.push(...["read:accounts", "read:filters", "read:search", "read:lists"]); - else if (scope === "read:blocks") - res.push("read:blocks"); - else if (scope === "read:favorites") - res.push("read:bookmarks"); - else if (scope === "read:reactions") - res.push("read:favourites"); - else if (scope === "read:following") - res.push("read:follows"); - else if (scope === "read:mutes") - res.push("read:mutes"); - else if (scope === "read:notifications") - res.push("read:notifications"); - else if (scope === "read:messaging") - res.push("read:statuses"); - else if (scope === "write:account") - res.push(...["write:accounts", "write:lists", "write:filters", "write:reports"]); - else if (scope === "write:blocks") - res.push("write:blocks"); - else if (scope === "write:favorites") - res.push("write:bookmarks"); - else if (scope === "write:reactions") - res.push("write:favourites"); - else if (scope === "write:following") - res.push("write:follows"); - else if (scope === "write:drive") - res.push("write:media"); - else if (scope === "write:mutes") - res.push("write:mutes"); - else if (scope === "write:notifications") - res.push("write:notifications"); - else if (scope === "write:notes") - res.push("write:statuses"); - else if (scope === "write:messaging") - res.push("write:conversations"); - else if (scope === "write:votes") - res.push("write:statuses"); - } - - return unique(res); - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index c0ab5dab5..95baa10bd 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -1,63 +1,35 @@ import Router from "@koa/router"; import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; -import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; -import { v4 as uuid } from "uuid"; -import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; -import { toSingleLast } from "@/prelude/array.js"; +import { MiAuth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsAuth(router: Router): void { router.post("/v1/apps", async (ctx) => { - const body: any = ctx.request.body || ctx.request.query; - let scope = body.scopes; - if (typeof scope === "string") scope = scope.split(" "); - const scopeArr = AuthConverter.decode(scope); - const red = body.redirect_uris; - const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']); - ctx.body = { - id: appData.id, - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url ?? "").toString("base64"), - client_secret: appData.clientSecret, - }; + ctx.body = await AuthHelpers.registerApp(ctx); }); + + router.get("/v1/apps/verify_credentials", async (ctx) => { + ctx.body = await AuthHelpers.verifyAppCredentials(ctx); + }); + + router.post("/v1/iceshrimp/apps/info", + MiAuth(true), + async (ctx) => { + ctx.body = await AuthHelpers.getAppInfo(ctx); + }); + + router.post("/v1/iceshrimp/auth/code", + MiAuth(true), + async (ctx) => { + ctx.body = await AuthHelpers.getAuthCode(ctx); + }); } export function setupEndpointsAuthRoot(router: Router): void { - router.get("/oauth/authorize", async (ctx) => { - const { client_id, state, redirect_uri } = ctx.request.query; - let param = "mastodon=true"; - if (state) param += `&state=${state}`; - const final_redirect_uri = toSingleLast(redirect_uri); - if (final_redirect_uri) param += `&redirect_uri=${encodeURIComponent(final_redirect_uri)}`; - const client = client_id ? client_id : ""; - ctx.redirect(`${Buffer.from(client.toString(), "base64").toString()}?${param}`); + router.post("/oauth/token", async (ctx) => { + ctx.body = await AuthHelpers.getAuthToken(ctx); }); - router.post("/oauth/token", async (ctx) => { - const body: any = ctx.request.body || ctx.request.query; - if (body.grant_type === "client_credentials") { - ctx.body = { - access_token: uuid(), - token_type: "Bearer", - scope: "read", - created_at: Math.floor(new Date().getTime() / 1000), - }; - return; - } - let token = null; - if (body.code) { - token = body.code; - } - const accessToken = await AuthHelpers.getAuthToken(body.client_secret, token ? token : "").catch(_ => { - throw new MastoApiError(401); - }); - ctx.body = { - access_token: accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", - created_at: Math.floor(new Date().getTime() / 1000), - }; + router.post("/oauth/revoke", async (ctx) => { + ctx.body = await AuthHelpers.revokeAuthToken(ctx); }); } diff --git a/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts index 694105ab3..24a7008cd 100644 --- a/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts +++ b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts @@ -3,11 +3,10 @@ * Response data when oauth request. **/ namespace OAuth { - export type AppDataFromServer = { - id: string; + export type Application = { name: string; website: string | null; - redirect_uri: string; + vapid_key: string | undefined; client_id: string; client_secret: string; }; @@ -21,50 +20,6 @@ namespace OAuth { refresh_token: string | null; }; - export class AppData { - public url: string | null; - public session_token: string | null; - - constructor( - public id: string, - public name: string, - public website: string | null, - public redirect_uri: string, - public client_id: string, - public client_secret: string, - ) { - this.url = null; - this.session_token = null; - } - - /** - * Serialize raw application data from server - * @param raw from server - */ - static from(raw: AppDataFromServer) { - return new this( - raw.id, - raw.name, - raw.website, - raw.redirect_uri, - raw.client_id, - raw.client_secret, - ); - } - - get redirectUri() { - return this.redirect_uri; - } - - get clientId() { - return this.client_id; - } - - get clientSecret() { - return this.client_secret; - } - } - export class TokenData { public _scope: string; diff --git a/packages/backend/src/server/api/mastodon/helpers/auth.ts b/packages/backend/src/server/api/mastodon/helpers/auth.ts index 362380332..2974c4f1f 100644 --- a/packages/backend/src/server/api/mastodon/helpers/auth.ts +++ b/packages/backend/src/server/api/mastodon/helpers/auth.ts @@ -1,72 +1,220 @@ import OAuth from "@/server/api/mastodon/entities/oauth/oauth.js"; import { secureRndstr } from "@/misc/secure-rndstr.js"; -import { AccessTokens, Apps, AuthSessions } from "@/models/index.js"; +import { OAuthApps, OAuthTokens } from "@/models/index.js"; import { genId } from "@/misc/gen-id.js"; -import { v4 as uuid } from "uuid"; -import config from "@/config/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { toSingleLast, unique } from "@/prelude/array.js"; +import { ILocalUser } from "@/models/entities/user.js"; export class AuthHelpers { - public static async registerApp(name: string, scopes: string[], redirect_uris: string, website: string | null): Promise { - const secret = secureRndstr(32); - const app = await Apps.insert({ - id: genId(), - createdAt: new Date(), - userId: null, - name: name, - description: '', - permission: scopes, - callbackUrl: redirect_uris, - secret: secret, - }).then((x) => Apps.findOneByOrFail(x.identifiers[0])); + public static async registerApp(ctx: MastoContext): Promise { + const body: any = ctx.request.body || ctx.request.query; + const scopes = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? ['read']; + const redirect_uris = body.redirect_uris?.split('\n') as string[] | undefined; + const client_name = body.client_name; + const website = body.website; - const appdataPre: OAuth.AppDataFromServer = { - id: app.id, + if (client_name == null) throw new MastoApiError(400, 'Missing client_name param'); + if (redirect_uris == null || redirect_uris.length < 1) throw new MastoApiError(400, 'Missing redirect_uris param'); + + try { + redirect_uris.every(u => this.validateRedirectUri(u)); + } catch { + throw new MastoApiError(400, 'Invalid redirect_uris'); + } + + const app = await OAuthApps.insert({ + id: genId(), + clientId: secureRndstr(32), + clientSecret: secureRndstr(32), + createdAt: new Date(), + name: client_name, + website: website, + scopes: scopes, + redirectUris: redirect_uris, + }).then((x) => OAuthApps.findOneByOrFail(x.identifiers[0])); + + return { name: app.name, website: website, - client_id: "", - client_secret: app.secret, - redirect_uri: redirect_uris! - } - const appdata = OAuth.AppData.from(appdataPre); - const token = uuid(); - const session = await AuthSessions.insert({ - id: genId(), - createdAt: new Date(), - appId: app.id, - token: token, - }).then((x) => AuthSessions.findOneByOrFail(x.identifiers[0])); - - appdata.url = `${config.authUrl}/${session.token}`; - appdata.session_token = session.token; - return appdata; + client_id: app.clientId, + client_secret: app.clientSecret, + vapid_key: await fetchMeta().then(meta => meta.swPublicKey ?? undefined), + }; } - public static async getAuthToken(appSecret: string, token: string) { - // Lookup app - const app = await Apps.findOneBy({ - secret: appSecret, - }); + public static async getAuthCode(ctx: MastoContext) { + const user = ctx.miauth[0] as ILocalUser; + if (!user) throw new MastoApiError(401, "Unauthorized"); - if (app == null) throw new Error("No such app"); + const body = ctx.request.body as any; + const scopes = body.scopes as string[]; + const clientId = toSingleLast(body.client_id); - // Fetch token - const session = await AuthSessions.findOneBy({ - token: token, + if (clientId == null) throw new MastoApiError(400, "Invalid client_id"); + + const app = await OAuthApps.findOneBy({ clientId: clientId }); + + this.validateRedirectUri(body.redirect_uri); + if (!app) throw new MastoApiError(400, "Invalid client_id"); + if (!scopes.every(p => app.scopes.includes(p))) throw new MastoApiError(400, "Cannot request more scopes than application"); + if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list"); + + const token = await OAuthTokens.insert({ + id: genId(), + active: false, + code: secureRndstr(32), + token: secureRndstr(32), appId: app.id, - }); + userId: user.id, + createdAt: new Date(), + scopes: scopes, + redirectUri: body.redirect_uri, + }).then((x) => OAuthTokens.findOneByOrFail(x.identifiers[0])); - if (session == null) throw new Error("No such session"); - if (session.userId == null) throw new Error("This session is still pending"); + return { code: token.code }; + } - // Lookup access token - const accessToken = await AccessTokens.findOneByOrFail({ - appId: app.id, - userId: session.userId, - }); + public static async getAppInfo(ctx: MastoContext) { + const body = ctx.request.body as any; + const clientId = toSingleLast(body.client_id); - // Delete session - AuthSessions.delete(session.id); + if (clientId == null) throw new MastoApiError(400, "Invalid client_id"); - return accessToken.token; + const app = await OAuthApps.findOneBy({ clientId: clientId }); + + if (!app) throw new MastoApiError(400, "Invalid client_id"); + + return { name: app.name }; + } + + public static async getAuthToken(ctx: MastoContext) { + const body: any = ctx.request.body || ctx.request.query; + const scopes = body.scopes as string[] ?? ['read']; + const clientId = toSingleLast(body.client_id); + const code = toSingleLast(body.code); + + const invalidScopeError = new MastoApiError(400, "invalid_scope", "The requested scope is invalid, unknown, or malformed."); + const invalidClientError = new MastoApiError(401, "invalid_client", "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method."); + + if (clientId == null) throw invalidClientError; + if (code == null) throw new MastoApiError(401, "Invalid code"); + + const app = await OAuthApps.findOneBy({ clientId: clientId }); + const token = await OAuthTokens.findOneBy({ code: code }); + + this.validateRedirectUri(body.redirect_uri); + if (body.grant_type !== 'authorization_code') throw new MastoApiError(400, "Invalid grant_type"); + if (!app || body.client_secret !== app.clientSecret) throw invalidClientError; + if (!token || app.id !== token.appId) throw new MastoApiError(401, "Invalid code"); + if (!scopes.every(p => app.scopes.includes(p))) throw invalidScopeError; + if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list"); + + await OAuthTokens.update(token.id, { active: true }); + + return { + "access_token": token.token, + "token_type": "Bearer", + "scope": token.scopes.join(' '), + "created_at": Math.floor(token.createdAt.getTime() / 1000) + }; + } + + public static async revokeAuthToken(ctx: MastoContext) { + const error = new MastoApiError(403, "unauthorized_client", "You are not authorized to revoke this token"); + const body: any = ctx.request.body || ctx.request.query; + const clientId = toSingleLast(body.client_id); + const clientSecret = toSingleLast(body.client_secret); + const token = toSingleLast(body.token); + + if (clientId == null || clientSecret == null || token == null) throw error; + + const app = await OAuthApps.findOneBy({ clientId: clientId, clientSecret: clientSecret }); + const oatoken = await OAuthTokens.findOneBy({ token: token }); + + if (!app || !oatoken || app.id !== oatoken.appId) throw error; + + await OAuthTokens.delete(oatoken.id); + + return {}; + } + + public static async verifyAppCredentials(ctx: MastoContext) { + console.log(ctx.appId); + if (!ctx.appId) throw new MastoApiError(401, "The access token is invalid"); + const app = await OAuthApps.findOneByOrFail({ id: ctx.appId }); + return { + name: app.name, + website: app.website, + vapid_key: await fetchMeta().then(meta => meta.swPublicKey ?? undefined), + } + } + + private static validateRedirectUri(redirectUri: string): void { + const error = new MastoApiError(400, "Invalid redirect_uri"); + if (redirectUri == null) throw error; + if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') return; + try { + const url = new URL(redirectUri); + if (["javascript:", "file:", "data:", "mailto:", "tel:"].includes(url.protocol)) throw error; + } catch { + throw error; + } + } + + private static readScopes = [ + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + ]; + private static writeScopes = [ + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + ]; + private static followScopes = [ + "read:follows", + "read:blocks", + "read:mutes", + "write:follows", + "write:blocks", + "write:mutes", + ]; + + public static expandScopes(scopes: string[]): string[] { + const res: string[] = []; + + for (const scope of scopes) { + if (scope === "read") + res.push(...this.readScopes); + else if (scope === "write") + res.push(...this.writeScopes); + else if (scope === "follow") + res.push(...this.followScopes); + else + res.push(scope); + } + + return unique(res); } } diff --git a/packages/backend/src/server/api/mastodon/helpers/mfm.ts b/packages/backend/src/server/api/mastodon/helpers/mfm.ts index c2d20d1db..378fa831a 100644 --- a/packages/backend/src/server/api/mastodon/helpers/mfm.ts +++ b/packages/backend/src/server/api/mastodon/helpers/mfm.ts @@ -137,14 +137,14 @@ export class MfmHelpers { el.setAttribute("class", "h-card"); el.setAttribute("translate", "no"); const a = doc.createElement("a"); - const { username, host} = node.props; + const { username, host } = node.props; const remoteUserInfo = mentionedRemoteUsers.find( (remoteUser) => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host === host, ); const localpart = `@${username}`; const isLocal = host === config.domain || (host == null && objectHost == null); - const acct = isLocal ? localpart: node.props.acct; + const acct = isLocal ? localpart : node.props.acct; a.href = remoteUserInfo ? remoteUserInfo.url ? remoteUserInfo.url diff --git a/packages/backend/src/server/api/mastodon/middleware/auth.ts b/packages/backend/src/server/api/mastodon/middleware/auth.ts index ac3d60c0e..8b97c6da4 100644 --- a/packages/backend/src/server/api/mastodon/middleware/auth.ts +++ b/packages/backend/src/server/api/mastodon/middleware/auth.ts @@ -1,30 +1,59 @@ -import authenticate from "@/server/api/authenticate.js"; import { ILocalUser } from "@/models/entities/user.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; -import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { OAuthTokens } from "@/models/index.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; +import authenticate from "@/server/api/authenticate.js"; +import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; export async function AuthMiddleware(ctx: MastoContext, next: () => Promise) { - const auth = await authenticate(ctx.headers.authorization, null, true).catch(_ => [null, null]); - ctx.user = auth[0] ?? null as ILocalUser | null; - ctx.scopes = auth[1]?.permission ?? [] as string[]; + const token = await getTokenFromOAuth(ctx.headers.authorization); + + ctx.appId = token?.appId; + ctx.user = token?.user ?? null as ILocalUser | null; + ctx.scopes = token?.scopes ?? [] as string[]; await next(); } +export async function getTokenFromOAuth(authorization: string | undefined): Promise { + if (authorization == null) return null; + + if (authorization.substring(0, 7).toLowerCase() === "bearer ") + authorization = authorization.substring(7); + + return OAuthTokens.findOne({ + where: { token: authorization, active: true }, + relations: ['user'], + }).then(token => { + if (!token) return null; + + return { + ...token, + scopes: AuthHelpers.expandScopes(token.scopes), + } + }); +} + export function auth(required: boolean, scopes: string[] = []) { return async function auth(ctx: MastoContext, next: () => Promise) { if (required && !ctx.user) throw new MastoApiError(401, "This method requires an authenticated user"); - if (!AuthConverter.decode(scopes).every(p => ctx.scopes.includes(p))) { + if (!scopes.every(p => ctx.scopes.includes(p))) { if (required) throw new MastoApiError(403, "This action is outside the authorized scopes") ctx.user = null; ctx.scopes = []; } - ctx.scopes = AuthConverter.encode(ctx.scopes); - + await next(); + }; +} + +export function MiAuth(required: boolean) { + return async function MiAuth(ctx: MastoContext, next: () => Promise) { + ctx.miauth = (await authenticate(ctx.headers.authorization, null, true).catch(_ => [null, null])); + if (required && !ctx.miauth[0]) throw new MastoApiError(401, "Unauthorized"); await next(); }; } diff --git a/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts index fb15f0278..6f7ddf26f 100644 --- a/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts +++ b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts @@ -4,8 +4,9 @@ import { ApiError } from "@/server/api/error.js"; export class MastoApiError extends Error { statusCode: number; + errorDescription?: string; - constructor(statusCode: number, message?: string) { + constructor(statusCode: number, message?: string, description?: string) { if (message == null) { switch (statusCode) { case 404: @@ -17,6 +18,7 @@ export class MastoApiError extends Error { } } super(message); + this.errorDescription = description; this.statusCode = statusCode; } } @@ -27,6 +29,8 @@ export async function CatchErrorsMiddleware(ctx: MastoContext, next: () => Promi } catch (e: any) { if (e instanceof MastoApiError) { ctx.status = e.statusCode; + ctx.body = { error: e.message, error_description: e.errorDescription }; + return; } else if (e instanceof IdentifiableError) { if (e.message.length < 1) e.message = e.id; ctx.status = 400; diff --git a/packages/backend/src/server/api/mastodon/streaming/index.ts b/packages/backend/src/server/api/mastodon/streaming/index.ts index 0e20ea28a..fdb2b98f4 100644 --- a/packages/backend/src/server/api/mastodon/streaming/index.ts +++ b/packages/backend/src/server/api/mastodon/streaming/index.ts @@ -3,7 +3,6 @@ import type * as websocket from "websocket"; import type { ILocalUser, User } from "@/models/entities/user.js"; import type { MastodonStream } from "./channel.js"; import { Blockings, Followings, Mutings, RenoteMutings, UserProfiles, } from "@/models/index.js"; -import type { AccessToken } from "@/models/entities/access-token.js"; import type { UserProfile } from "@/models/entities/user-profile.js"; import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js"; import { apiLogger } from "@/server/api/logger.js"; @@ -14,7 +13,7 @@ import { MastodonStreamList } from "@/server/api/mastodon/streaming/channels/lis import { ParsedUrlQuery } from "querystring"; import { toSingleLast } from "@/prelude/array.js"; import { MastodonStreamTag } from "@/server/api/mastodon/streaming/channels/tag.js"; -import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; const logger = apiLogger.createSubLogger("streaming").createSubLogger("mastodon"); const channels: Record = { @@ -41,7 +40,7 @@ export class MastodonStreamingConnection { public muting: Set = new Set(); public renoteMuting: Set = new Set(); public blocking: Set = new Set(); - public token?: AccessToken; + public token?: OAuthToken; private wsConnection: websocket.connection; private channels: MastodonStream[] = []; public subscriber: StreamEventEmitter; @@ -50,7 +49,7 @@ export class MastodonStreamingConnection { wsConnection: websocket.connection, subscriber: EventEmitter, user: ILocalUser | null | undefined, - token: AccessToken | null | undefined, + token: OAuthToken | null | undefined, query: ParsedUrlQuery, ) { const channel = toSingleLast(query.stream); @@ -160,12 +159,16 @@ export class MastodonStreamingConnection { } public connectChannel(channel: string, list?: string, tag?: string) { + if (!channels[channel]) { + logger.info(`Ignoring connection to unknown channel ${channel}`); + return; + } if (channels[channel].requireCredential) { if (this.user == null) { logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`); this.closeConnection(); return; - } else if (!AuthConverter.decode(channels[channel].requiredScopes).every(p => this.token?.permission.includes(p))) { + } else if (!channels[channel].requiredScopes.every((p: string) => this.token?.scopes?.includes(p))) { logger.info(`Refusing connection to channel ${channel} without required OAuth scopes, terminating connection`); this.closeConnection(); return; diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts index 0f656f081..44b82c9f2 100644 --- a/packages/backend/src/server/api/streaming.ts +++ b/packages/backend/src/server/api/streaming.ts @@ -9,6 +9,11 @@ import MainStreamConnection from "./stream/index.js"; import authenticate from "./authenticate.js"; import { apiLogger } from "@/server/api/logger.js"; import { MastodonStreamingConnection } from "@/server/api/mastodon/streaming/index.js"; +import { AccessToken } from "@/models/entities/access-token.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { getTokenFromOAuth } from "@/server/api/mastodon/middleware/auth.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; export const streamingLogger = apiLogger.createSubLogger("streaming"); @@ -23,15 +28,33 @@ export const initializeStreamingServer = (server: http.Server) => { const headers = request.httpRequest.headers["sec-websocket-protocol"] || ""; const cred = q.i || q.access_token || headers; const accessToken = cred.toString(); + const isMastodon = request.resourceURL.pathname?.startsWith('/api/v1/streaming'); - const [user, app] = await authenticate( - request.httpRequest.headers.authorization, - accessToken, - ).catch((err) => { - request.reject(403, err.message); - return []; - }); - if (typeof user === "undefined") { + let main: MainStreamConnection | MastodonStreamingConnection; + let user: ILocalUser | null | undefined; + let app: AccessToken | null | undefined; + let token: OAuthToken | null | undefined; + + if (!isMastodon) { + [user, app] = await authenticate( + request.httpRequest.headers.authorization, + accessToken, + ).catch((err) => { + request.reject(403, err.message); + return []; + }); + + } else { + token = await getTokenFromOAuth(accessToken); + if (!token || !token.user) { + request.reject(400); + return; + } + + user = token.user as ILocalUser; + } + + if (!user) { return; } @@ -53,15 +76,13 @@ export const initializeStreamingServer = (server: http.Server) => { const host = `https://${request.host}`; const prepareStream = q.stream?.toString(); - const isMastodon = request.resourceURL.pathname?.startsWith('/api/v1/streaming'); - - const main = isMastodon - ? new MastodonStreamingConnection(connection, ev, user, app, q) + main = isMastodon + ? new MastodonStreamingConnection(connection, ev, user, token, q) : new MainStreamConnection(connection, ev, user, app, host, accessToken, prepareStream); const intervalId = user ? setInterval(() => { - Users.update(user.id, { + Users.update(user!.id, { lastActiveDate: new Date(), }); }, 1000 * 60 * 5) diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index ca6a1bb2f..0e1e82b07 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -62,6 +62,50 @@ export const api = (( return promise; }) as typeof apiClient.request; +export const apiJson = (( + endpoint: string, + data: Record = {}, + token?: string | null | undefined, +) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const authorizationToken = token ?? $i?.token ?? undefined; + const authorization = authorizationToken + ? `Bearer ${authorizationToken}` + : undefined; + const authHeaders: {} | {authorization: string} = authorization ? { authorization } : {}; + + const promise = new Promise((resolve, reject) => { + fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: "POST", + body: JSON.stringify(data), + credentials: "omit", + cache: "no-cache", + headers: {...authHeaders, "content-type": "application/json" }, + }) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }) + .catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + export const apiGet = (( endpoint: string, data: Record = {}, diff --git a/packages/client/src/pages/oauth.vue b/packages/client/src/pages/oauth.vue new file mode 100644 index 000000000..6fa82c8b2 --- /dev/null +++ b/packages/client/src/pages/oauth.vue @@ -0,0 +1,168 @@ + + + + + \ No newline at end of file diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 924d95cbc..955253779 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -356,6 +356,18 @@ export const routes = [ path: "/auth/:token", component: page(() => import("./pages/auth.vue")), }, + { + path: "/oauth/authorize", + component: page(() => import("./pages/oauth.vue")), + query: { + response_type: "response_type", + client_id: "client_id", + redirect_uri: "redirect_uri", + scope: "scope", + force_login: "force_login", + lang: "lang" + } + }, { path: "/miauth/:session", component: page(() => import("./pages/miauth.vue")),