[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
No known key found for this signature in database
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"
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"

View file

@ -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",

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 { 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);

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

View file

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

View file

@ -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<OAuth.AppData> {
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<OAuth.Application> {
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);
}
}

View file

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

View file

@ -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<any>) {
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<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[] = []) {
return async function auth(ctx: MastoContext, next: () => Promise<any>) {
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<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();
};
}

View file

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

View file

@ -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<string, any> = {
@ -41,7 +40,7 @@ export class MastodonStreamingConnection {
public muting: Set<User["id"]> = new Set();
public renoteMuting: Set<User["id"]> = new Set();
public blocking: Set<User["id"]> = 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;

View file

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

View file

@ -62,6 +62,50 @@ export const api = ((
return promise;
}) 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 = ((
endpoint: string,
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",
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")),