[mastodon-client] Switch from MiAuth to OAuth

This commit is contained in:
Laura Hausmann 2023-10-13 18:40:10 +02:00
parent b5393e41d0
commit 1c2b914164
Signed by: zotan
GPG key ID: D044E84C5BE01605
18 changed files with 689 additions and 330 deletions

View file

@ -1595,7 +1595,7 @@ _auth:
pleaseGoBack: "Please go back to the application" pleaseGoBack: "Please go back to the application"
callback: "Returning to the application" callback: "Returning to the application"
denied: "Access denied" 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" allPermissions: "Full account access"
_antennaSources: _antennaSources:
all: "All posts" all: "All posts"

View file

@ -76,6 +76,8 @@ import { entities as charts } from "@/services/chart/entities.js";
import { envOption } from "../env.js"; import { envOption } from "../env.js";
import { dbLogger } from "./logger.js"; import { dbLogger } from "./logger.js";
import { redisClient } from "./redis.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); const sqlLogger = dbLogger.createSubLogger("sql", "gray", false);
class MyCustomLogger implements Logger { class MyCustomLogger implements Logger {
@ -176,10 +178,12 @@ export const entities = [
UserPending, UserPending,
Webhook, Webhook,
UserIp, UserIp,
OAuthApp,
OAuthToken,
...charts, ...charts,
]; ];
const log = process.env.NODE_ENV !== "production"; const log = process.env.LOG_SQL === "true";
export const db = new DataSource({ export const db = new DataSource({
type: "postgres", type: "postgres",

View file

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddOAuthTables1697226201723 implements MigrationInterface {
name = 'AddOAuthTables1697226201723'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View file

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

View file

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

View file

@ -66,6 +66,8 @@ import { InstanceRepository } from "./repositories/instance.js";
import { Webhook } from "./entities/webhook.js"; import { Webhook } from "./entities/webhook.js";
import { UserIp } from "./entities/user-ip.js"; import { UserIp } from "./entities/user-ip.js";
import { NoteEdit } from "./entities/note-edit.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 Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead); export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -131,3 +133,5 @@ export const ChannelNotePinings = db.getRepository(ChannelNotePining);
export const RegistryItems = db.getRepository(RegistryItem); export const RegistryItems = db.getRepository(RegistryItem);
export const Webhooks = db.getRepository(Webhook); export const Webhooks = db.getRepository(Webhook);
export const PasswordResetRequests = db.getRepository(PasswordResetRequest); export const PasswordResetRequests = db.getRepository(PasswordResetRequest);
export const OAuthApps = db.getRepository(OAuthApp);
export const OAuthTokens = db.getRepository(OAuthToken);

View file

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

View file

@ -1,63 +1,35 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; import { MiAuth } from "@/server/api/mastodon/middleware/auth.js";
import { v4 as uuid } from "uuid";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { toSingleLast } from "@/prelude/array.js";
export function setupEndpointsAuth(router: Router): void { export function setupEndpointsAuth(router: Router): void {
router.post("/v1/apps", async (ctx) => { router.post("/v1/apps", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query; ctx.body = await AuthHelpers.registerApp(ctx);
let scope = body.scopes; });
if (typeof scope === "string") scope = scope.split(" ");
const scopeArr = AuthConverter.decode(scope); router.get("/v1/apps/verify_credentials", async (ctx) => {
const red = body.redirect_uris; ctx.body = await AuthHelpers.verifyAppCredentials(ctx);
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']); });
ctx.body = {
id: appData.id, router.post("/v1/iceshrimp/apps/info",
name: appData.name, MiAuth(true),
website: body.website, async (ctx) => {
redirect_uri: red, ctx.body = await AuthHelpers.getAppInfo(ctx);
client_id: Buffer.from(appData.url ?? "").toString("base64"), });
client_secret: appData.clientSecret,
}; router.post("/v1/iceshrimp/auth/code",
MiAuth(true),
async (ctx) => {
ctx.body = await AuthHelpers.getAuthCode(ctx);
}); });
} }
export function setupEndpointsAuthRoot(router: Router): void { export function setupEndpointsAuthRoot(router: Router): void {
router.get("/oauth/authorize", async (ctx) => { router.post("/oauth/token", async (ctx) => {
const { client_id, state, redirect_uri } = ctx.request.query; ctx.body = await AuthHelpers.getAuthToken(ctx);
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) => { router.post("/oauth/revoke", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query; ctx.body = await AuthHelpers.revokeAuthToken(ctx);
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),
};
}); });
} }

View file

@ -3,11 +3,10 @@
* Response data when oauth request. * Response data when oauth request.
**/ **/
namespace OAuth { namespace OAuth {
export type AppDataFromServer = { export type Application = {
id: string;
name: string; name: string;
website: string | null; website: string | null;
redirect_uri: string; vapid_key: string | undefined;
client_id: string; client_id: string;
client_secret: string; client_secret: string;
}; };
@ -21,50 +20,6 @@ namespace OAuth {
refresh_token: string | null; 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 { export class TokenData {
public _scope: string; public _scope: string;

View file

@ -1,72 +1,220 @@
import OAuth from "@/server/api/mastodon/entities/oauth/oauth.js"; import OAuth from "@/server/api/mastodon/entities/oauth/oauth.js";
import { secureRndstr } from "@/misc/secure-rndstr.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 { genId } from "@/misc/gen-id.js";
import { v4 as uuid } from "uuid"; import { fetchMeta } from "@/misc/fetch-meta.js";
import config from "@/config/index.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 { export class AuthHelpers {
public static async registerApp(name: string, scopes: string[], redirect_uris: string, website: string | null): Promise<OAuth.AppData> { public static async registerApp(ctx: MastoContext): Promise<OAuth.Application> {
const secret = secureRndstr(32); const body: any = ctx.request.body || ctx.request.query;
const app = await Apps.insert({ const scopes = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? ['read'];
id: genId(), const redirect_uris = body.redirect_uris?.split('\n') as string[] | undefined;
createdAt: new Date(), const client_name = body.client_name;
userId: null, const website = body.website;
name: name,
description: '',
permission: scopes,
callbackUrl: redirect_uris,
secret: secret,
}).then((x) => Apps.findOneByOrFail(x.identifiers[0]));
const appdataPre: OAuth.AppDataFromServer = { if (client_name == null) throw new MastoApiError(400, 'Missing client_name param');
id: app.id, 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, name: app.name,
website: website, website: website,
client_id: "", client_id: app.clientId,
client_secret: app.secret, client_secret: app.clientSecret,
redirect_uri: redirect_uris! vapid_key: await fetchMeta().then(meta => meta.swPublicKey ?? undefined),
};
} }
const appdata = OAuth.AppData.from(appdataPre);
const token = uuid(); public static async getAuthCode(ctx: MastoContext) {
const session = await AuthSessions.insert({ const user = ctx.miauth[0] as ILocalUser;
if (!user) throw new MastoApiError(401, "Unauthorized");
const body = ctx.request.body as any;
const scopes = body.scopes as string[];
const clientId = toSingleLast(body.client_id);
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(), id: genId(),
active: false,
code: secureRndstr(32),
token: secureRndstr(32),
appId: app.id,
userId: user.id,
createdAt: new Date(), createdAt: new Date(),
appId: app.id, scopes: scopes,
token: token, redirectUri: body.redirect_uri,
}).then((x) => AuthSessions.findOneByOrFail(x.identifiers[0])); }).then((x) => OAuthTokens.findOneByOrFail(x.identifiers[0]));
appdata.url = `${config.authUrl}/${session.token}`; return { code: token.code };
appdata.session_token = session.token;
return appdata;
} }
public static async getAuthToken(appSecret: string, token: string) { public static async getAppInfo(ctx: MastoContext) {
// Lookup app const body = ctx.request.body as any;
const app = await Apps.findOneBy({ const clientId = toSingleLast(body.client_id);
secret: appSecret,
});
if (app == null) throw new Error("No such app"); if (clientId == null) throw new MastoApiError(400, "Invalid client_id");
// Fetch token const app = await OAuthApps.findOneBy({ clientId: clientId });
const session = await AuthSessions.findOneBy({
token: token,
appId: app.id,
});
if (session == null) throw new Error("No such session"); if (!app) throw new MastoApiError(400, "Invalid client_id");
if (session.userId == null) throw new Error("This session is still pending");
// Lookup access token return { name: app.name };
const accessToken = await AccessTokens.findOneByOrFail({ }
appId: app.id,
userId: session.userId,
});
// Delete session public static async getAuthToken(ctx: MastoContext) {
AuthSessions.delete(session.id); 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);
return accessToken.token; 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);
} }
} }

View file

@ -1,30 +1,59 @@
import authenticate from "@/server/api/authenticate.js";
import { ILocalUser } from "@/models/entities/user.js"; import { ILocalUser } from "@/models/entities/user.js";
import { MastoContext } from "@/server/api/mastodon/index.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 { 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<any>) { export async function AuthMiddleware(ctx: MastoContext, next: () => Promise<any>) {
const auth = await authenticate(ctx.headers.authorization, null, true).catch(_ => [null, null]); const token = await getTokenFromOAuth(ctx.headers.authorization);
ctx.user = auth[0] ?? null as ILocalUser | null;
ctx.scopes = auth[1]?.permission ?? [] as string[]; ctx.appId = token?.appId;
ctx.user = token?.user ?? null as ILocalUser | null;
ctx.scopes = token?.scopes ?? [] as string[];
await next(); await next();
} }
export async function getTokenFromOAuth(authorization: string | undefined): Promise<OAuthToken | null> {
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[] = []) { export function auth(required: boolean, scopes: string[] = []) {
return async function auth(ctx: MastoContext, next: () => Promise<any>) { return async function auth(ctx: MastoContext, next: () => Promise<any>) {
if (required && !ctx.user) throw new MastoApiError(401, "This method requires an authenticated user"); 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") if (required) throw new MastoApiError(403, "This action is outside the authorized scopes")
ctx.user = null; ctx.user = null;
ctx.scopes = []; ctx.scopes = [];
} }
ctx.scopes = AuthConverter.encode(ctx.scopes); await next();
};
}
export function MiAuth(required: boolean) {
return async function MiAuth(ctx: MastoContext, next: () => Promise<any>) {
ctx.miauth = (await authenticate(ctx.headers.authorization, null, true).catch(_ => [null, null]));
if (required && !ctx.miauth[0]) throw new MastoApiError(401, "Unauthorized");
await next(); await next();
}; };
} }

View file

@ -4,8 +4,9 @@ import { ApiError } from "@/server/api/error.js";
export class MastoApiError extends Error { export class MastoApiError extends Error {
statusCode: number; statusCode: number;
errorDescription?: string;
constructor(statusCode: number, message?: string) { constructor(statusCode: number, message?: string, description?: string) {
if (message == null) { if (message == null) {
switch (statusCode) { switch (statusCode) {
case 404: case 404:
@ -17,6 +18,7 @@ export class MastoApiError extends Error {
} }
} }
super(message); super(message);
this.errorDescription = description;
this.statusCode = statusCode; this.statusCode = statusCode;
} }
} }
@ -27,6 +29,8 @@ export async function CatchErrorsMiddleware(ctx: MastoContext, next: () => Promi
} catch (e: any) { } catch (e: any) {
if (e instanceof MastoApiError) { if (e instanceof MastoApiError) {
ctx.status = e.statusCode; ctx.status = e.statusCode;
ctx.body = { error: e.message, error_description: e.errorDescription };
return;
} else if (e instanceof IdentifiableError) { } else if (e instanceof IdentifiableError) {
if (e.message.length < 1) e.message = e.id; if (e.message.length < 1) e.message = e.id;
ctx.status = 400; ctx.status = 400;

View file

@ -3,7 +3,6 @@ import type * as websocket from "websocket";
import type { ILocalUser, User } from "@/models/entities/user.js"; import type { ILocalUser, User } from "@/models/entities/user.js";
import type { MastodonStream } from "./channel.js"; import type { MastodonStream } from "./channel.js";
import { Blockings, Followings, Mutings, RenoteMutings, UserProfiles, } from "@/models/index.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 type { UserProfile } from "@/models/entities/user-profile.js";
import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js"; import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js";
import { apiLogger } from "@/server/api/logger.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 { ParsedUrlQuery } from "querystring";
import { toSingleLast } from "@/prelude/array.js"; import { toSingleLast } from "@/prelude/array.js";
import { MastodonStreamTag } from "@/server/api/mastodon/streaming/channels/tag.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 logger = apiLogger.createSubLogger("streaming").createSubLogger("mastodon");
const channels: Record<string, any> = { const channels: Record<string, any> = {
@ -41,7 +40,7 @@ export class MastodonStreamingConnection {
public muting: Set<User["id"]> = new Set(); public muting: Set<User["id"]> = new Set();
public renoteMuting: Set<User["id"]> = new Set(); public renoteMuting: Set<User["id"]> = new Set();
public blocking: Set<User["id"]> = new Set(); public blocking: Set<User["id"]> = new Set();
public token?: AccessToken; public token?: OAuthToken;
private wsConnection: websocket.connection; private wsConnection: websocket.connection;
private channels: MastodonStream[] = []; private channels: MastodonStream[] = [];
public subscriber: StreamEventEmitter; public subscriber: StreamEventEmitter;
@ -50,7 +49,7 @@ export class MastodonStreamingConnection {
wsConnection: websocket.connection, wsConnection: websocket.connection,
subscriber: EventEmitter, subscriber: EventEmitter,
user: ILocalUser | null | undefined, user: ILocalUser | null | undefined,
token: AccessToken | null | undefined, token: OAuthToken | null | undefined,
query: ParsedUrlQuery, query: ParsedUrlQuery,
) { ) {
const channel = toSingleLast(query.stream); const channel = toSingleLast(query.stream);
@ -160,12 +159,16 @@ export class MastodonStreamingConnection {
} }
public connectChannel(channel: string, list?: string, tag?: string) { 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 (channels[channel].requireCredential) {
if (this.user == null) { if (this.user == null) {
logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`); logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`);
this.closeConnection(); this.closeConnection();
return; 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`); logger.info(`Refusing connection to channel ${channel} without required OAuth scopes, terminating connection`);
this.closeConnection(); this.closeConnection();
return; return;

View file

@ -9,6 +9,11 @@ import MainStreamConnection from "./stream/index.js";
import authenticate from "./authenticate.js"; import authenticate from "./authenticate.js";
import { apiLogger } from "@/server/api/logger.js"; import { apiLogger } from "@/server/api/logger.js";
import { MastodonStreamingConnection } from "@/server/api/mastodon/streaming/index.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"); 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 headers = request.httpRequest.headers["sec-websocket-protocol"] || "";
const cred = q.i || q.access_token || headers; const cred = q.i || q.access_token || headers;
const accessToken = cred.toString(); const accessToken = cred.toString();
const isMastodon = request.resourceURL.pathname?.startsWith('/api/v1/streaming');
const [user, app] = await authenticate( 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, request.httpRequest.headers.authorization,
accessToken, accessToken,
).catch((err) => { ).catch((err) => {
request.reject(403, err.message); request.reject(403, err.message);
return []; return [];
}); });
if (typeof user === "undefined") {
} else {
token = await getTokenFromOAuth(accessToken);
if (!token || !token.user) {
request.reject(400);
return;
}
user = token.user as ILocalUser;
}
if (!user) {
return; return;
} }
@ -53,15 +76,13 @@ export const initializeStreamingServer = (server: http.Server) => {
const host = `https://${request.host}`; const host = `https://${request.host}`;
const prepareStream = q.stream?.toString(); const prepareStream = q.stream?.toString();
const isMastodon = request.resourceURL.pathname?.startsWith('/api/v1/streaming'); main = isMastodon
? new MastodonStreamingConnection(connection, ev, user, token, q)
const main = isMastodon
? new MastodonStreamingConnection(connection, ev, user, app, q)
: new MainStreamConnection(connection, ev, user, app, host, accessToken, prepareStream); : new MainStreamConnection(connection, ev, user, app, host, accessToken, prepareStream);
const intervalId = user const intervalId = user
? setInterval(() => { ? setInterval(() => {
Users.update(user.id, { Users.update(user!.id, {
lastActiveDate: new Date(), lastActiveDate: new Date(),
}); });
}, 1000 * 60 * 5) }, 1000 * 60 * 5)

View file

@ -62,6 +62,50 @@ export const api = ((
return promise; return promise;
}) as typeof apiClient.request; }) as typeof apiClient.request;
export const apiJson = ((
endpoint: string,
data: Record<string, any> = {},
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 = (( export const apiGet = ((
endpoint: string, endpoint: string,
data: Record<string, any> = {}, data: Record<string, any> = {},

View file

@ -0,0 +1,168 @@
<template>
<MkSpacer :content-max="800">
<div v-if="$i">
<div v-if="state == 'waiting'" class="waiting _section">
<div class="_content">
<MkLoading />
</div>
</div>
<div v-if="state == 'denied'" class="denied _section">
<div class="_content">
<p>{{ i18n.ts._auth.denied }}</p>
</div>
</div>
<div v-else-if="state == 'error'" class="error _section">
<div class="_content">
<p>{{ message }}</p>
</div>
</div>
<div v-else-if="state == 'accepted-oob'" class="accepted-oob _section">
<div class="_content">
<p>{{ i18n.ts._auth.copyAsk }}</p>
<pre>{{ code }}</pre>
</div>
</div>
<div v-else-if="state == 'accepted'" class="accepted _section">
<div class="_content">
<p>
{{ i18n.ts._auth.callback }}<MkEllipsis />
</p>
</div>
</div>
<div v-else class="_section">
<div v-if="name" class="_title">
{{ i18n.t("_auth.shareAccess", { name: name }) }}
</div>
<div v-else class="_title">
{{ i18n.ts._auth.shareAccessAsk }}
</div>
<div class="_content">
<p>{{ i18n.ts._auth.permissionAsk }}</p>
<div :class="[$style.permissions]">
<div
v-for="p in _scopes"
:key="p"
:class="[$style.permission]"
>
<i
:class="[`ph-${getIcon(p)}`]"
class="ph-bold ph-xl"
style="margin-right: 0.5rem"
></i>
<span class="monospace">{{ p }}</span>
</div>
</div>
</div>
<div class="_footer">
<MkButton inline @click="deny">{{
i18n.ts.cancel
}}</MkButton>
<MkButton inline primary @click="accept">{{
i18n.ts.accept
}}</MkButton>
</div>
</div>
</div>
<div v-else class="signin">
<MkSignin @login="onLogin" />
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import {} from "vue";
import MkSignin from "@/components/MkSignin.vue";
import MkButton from "@/components/MkButton.vue";
import * as os from "@/os";
import { $i, login } from "@/account";
import { appendQuery, query } from "@/scripts/url";
import { i18n } from "@/i18n";
const props = defineProps<{
response_type: string;
client_id: string;
redirect_uri: string;
scope?: string;
force_login?: boolean;
lang?: string;
}>();
const _scopes = props.scope?.split(" ") ?? ['read'];
let state = $ref<string | null>(null);
let code = $ref<string | null>(null);
let name = $ref<string | null>(null);
let message = $ref<string>('Unknown error occurred');
if ($i) {
await os.apiJson("v1/iceshrimp/apps/info", {
client_id: props.client_id,
}).then(res => {
name = res.name;
});
}
function getIcon(p: string) {
if (p.startsWith("write")) return "pencil-simple";
else if(p.startsWith("read")) return "eye";
else if (p.startsWith("push")) return "bell-ringing";
else if(p.startsWith("follow")) return "users";
else return "check-fat";
}
async function accept(): Promise<void> {
state = "waiting";
const res = await os.apiJson("v1/iceshrimp/auth/code", {
client_id: props.client_id,
redirect_uri: props.redirect_uri,
scopes: _scopes,
}).catch(r => {
message = r;
state = 'error';
throw r;
});
if (props.redirect_uri !== 'urn:ietf:wg:oauth:2.0:oob') {
state = "accepted";
location.href = appendQuery(
props.redirect_uri,
query({
code: res.code,
}),
);
}
else {
code = res.code;
state = "accepted-oob";
}
}
function deny(): void {
state = "denied";
}
async function onLogin(res): Promise<void> {
await login(res.i);
}
</script>
<style lang="scss" module>
.monospace {
font-family: monospace;
}
.permissions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 2rem;
}
.permission {
display: inline-flex;
padding: 0.5rem 1rem;
border-radius: var(--radius);
background-color: var(--buttonBg);
color: var(--fg);
}
</style>

View file

@ -356,6 +356,18 @@ export const routes = [
path: "/auth/:token", path: "/auth/:token",
component: page(() => import("./pages/auth.vue")), 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", path: "/miauth/:session",
component: page(() => import("./pages/miauth.vue")), component: page(() => import("./pages/miauth.vue")),