forked from woem.men/forkey
Compare commits
5 commits
cfba325ae7
...
ad3ae73c53
Author | SHA1 | Date | |
---|---|---|---|
ad3ae73c53 | |||
e90e9e016f | |||
125bf6fe93 | |||
32fc7644fc | |||
04c1c0e1e6 |
17 changed files with 3097 additions and 2783 deletions
|
@ -2243,6 +2243,7 @@ _permissions:
|
||||||
"read:clip-favorite": "View favorited clips"
|
"read:clip-favorite": "View favorited clips"
|
||||||
"read:federation": "Get federation data"
|
"read:federation": "Get federation data"
|
||||||
"write:report-abuse": "Report violation"
|
"write:report-abuse": "Report violation"
|
||||||
|
"write:push-notification": "Receive push notifications"
|
||||||
_auth:
|
_auth:
|
||||||
shareAccessTitle: "Granting application permissions"
|
shareAccessTitle: "Granting application permissions"
|
||||||
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
||||||
|
@ -2256,6 +2257,7 @@ _auth:
|
||||||
scopeUser: "Operate as the following user"
|
scopeUser: "Operate as the following user"
|
||||||
pleaseLogin: "Please log in to authorize applications."
|
pleaseLogin: "Please log in to authorize applications."
|
||||||
byClickingYouWillBeRedirectedToThisUrl: "When access is granted, you will automatically be redirected to the following URL"
|
byClickingYouWillBeRedirectedToThisUrl: "When access is granted, you will automatically be redirected to the following URL"
|
||||||
|
pleasePassCodeToApplication: "Please pass the following code to the application:"
|
||||||
_antennaSources:
|
_antennaSources:
|
||||||
all: "All notes"
|
all: "All notes"
|
||||||
homeTimeline: "Notes from followed users"
|
homeTimeline: "Notes from followed users"
|
||||||
|
|
|
@ -53,7 +53,7 @@ function createMembers(record) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function generateDTS() {
|
export default function generateDTS() {
|
||||||
const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
|
const locale = yaml.load(fs.readFileSync(`${__dirname}/en-US.yml`, 'utf-8'));
|
||||||
const members = createMembers(locale);
|
const members = createMembers(locale);
|
||||||
const elements = [
|
const elements = [
|
||||||
ts.factory.createVariableStatement(
|
ts.factory.createVariableStatement(
|
||||||
|
|
5358
locales/index.d.ts
vendored
5358
locales/index.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
@ -72,7 +72,7 @@ export function build() {
|
||||||
.reduce((a, [k, v]) => (a[k] = (() => {
|
.reduce((a, [k, v]) => (a[k] = (() => {
|
||||||
const [lang] = k.split('-');
|
const [lang] = k.split('-');
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case 'ja-JP': return v;
|
case 'ja-JP': return merge(locales['en-US'], v);
|
||||||
case 'ja-KS':
|
case 'ja-KS':
|
||||||
case 'en-US': return merge(locales['ja-JP'], v);
|
case 'en-US': return merge(locales['ja-JP'], v);
|
||||||
default: return merge(
|
default: return merge(
|
||||||
|
|
18
packages/backend/migration/1736599563231-MastodonApp.js
Normal file
18
packages/backend/migration/1736599563231-MastodonApp.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export class MastodonOauth1736599563231 {
|
||||||
|
name = 'MastodonOauth1736599563231'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" ADD "website" character varying(128)`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "app"."website" IS 'Application website.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" ADD "mastodonScopes" character varying(64) array`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "app"."mastodonScopes" IS 'Mastodon app scopes, only set for apps created with Mastodon API.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" ADD "redirectUris" character varying(512) array DEFAULT '{}' NOT NULL`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "app"."redirectUris" IS 'Redirect URIs.'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "website"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "mastodonScopes"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "redirectUris"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { permissions } from 'misskey-js';
|
||||||
|
|
||||||
|
const mastodonToMisskeyScopes: Map<string, (typeof permissions)[number][]> = new Map([
|
||||||
|
['profile', ['read:account']],
|
||||||
|
['follow', ['read:following', 'write:following', 'read:blocks', 'write:blocks', 'read:mutes', 'write:mutes']],
|
||||||
|
['push', ['write:push-notification']],
|
||||||
|
['read:accounts', ['read:account']],
|
||||||
|
['read:blocks', ['read:blocks']],
|
||||||
|
['read:bookmarks', ['read:favorites']],
|
||||||
|
['read:favourites', ['read:reactions']],
|
||||||
|
['read:filters', ['read:account']],
|
||||||
|
['read:follows', ['read:following']],
|
||||||
|
['read:lists', ['read:account']],
|
||||||
|
['read:mutes', ['read:mutes']],
|
||||||
|
['read:notifications', ['read:notifications']],
|
||||||
|
['read:search', []],
|
||||||
|
['read:statuses', []],
|
||||||
|
['write:accounts', ['write:account']],
|
||||||
|
['write:blocks', ['write:blocks']],
|
||||||
|
['write:bookmarks', ['write:favorites']],
|
||||||
|
['write:conversations', ['write:notes']],
|
||||||
|
['write:favourites', ['write:reactions']],
|
||||||
|
['write:filters', ['write:account']],
|
||||||
|
['write:follows', ['write:following']],
|
||||||
|
['write:lists', ['write:account']],
|
||||||
|
['write:media', ['read:drive', 'write:drive']],
|
||||||
|
['write:mutes', ['write:mutes']],
|
||||||
|
['write:notifications', ['write:notifications']],
|
||||||
|
['write:reports', ['write:report-abuse']],
|
||||||
|
['write:statuses', ['write:notes']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function setHighLevelScope(scopeName: string) {
|
||||||
|
const granularScopes = Array.from(mastodonToMisskeyScopes)
|
||||||
|
.flatMap(([key, value]) => key.startsWith(scopeName + ':') ? value : []);
|
||||||
|
mastodonToMisskeyScopes.set(scopeName, Array.from(new Set(granularScopes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighLevelScope('read');
|
||||||
|
setHighLevelScope('write');
|
||||||
|
|
||||||
|
export { mastodonToMisskeyScopes };
|
|
@ -63,4 +63,22 @@ export class MiApp {
|
||||||
comment: 'The callbackUrl of the App.',
|
comment: 'The callbackUrl of the App.',
|
||||||
})
|
})
|
||||||
public callbackUrl: string | null;
|
public callbackUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 128, nullable: true,
|
||||||
|
comment: 'Application website.',
|
||||||
|
})
|
||||||
|
public website: string;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 64, array: true, nullable: true,
|
||||||
|
comment: 'Mastodon app scopes, only set for apps created with Mastodon API.',
|
||||||
|
})
|
||||||
|
public mastodonScopes: string[];
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 512, array: true, nullable: true,
|
||||||
|
comment: 'Redirect URIs.',
|
||||||
|
})
|
||||||
|
public redirectUris: string[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import fastifyFormbody from '@fastify/formbody';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
|
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
|
||||||
|
@ -63,6 +64,13 @@ export class ApiServerService {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.register(this.createMisskeyServer);
|
||||||
|
fastify.register(this.createMastodonServer);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private createMisskeyServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
const ep = {
|
const ep = {
|
||||||
name: endpoint.name,
|
name: endpoint.name,
|
||||||
|
@ -106,32 +114,6 @@ export class ApiServerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
|
|
||||||
name: endpoint.name,
|
|
||||||
method: endpoint.method,
|
|
||||||
meta: endpoint.meta,
|
|
||||||
params: endpoint.params,
|
|
||||||
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
|
|
||||||
});
|
|
||||||
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
|
|
||||||
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
|
|
||||||
for (const { name, endpoints } of groupedMastodonEndpoints) {
|
|
||||||
fastify.all<{
|
|
||||||
Params: { endpoint: string; },
|
|
||||||
Body: Record<string, unknown>,
|
|
||||||
Querystring: Record<string, unknown>,
|
|
||||||
}>('/' + name, async (request, reply) => {
|
|
||||||
const ep = endpoints.get(request.method);
|
|
||||||
if (!ep) {
|
|
||||||
reply.code(405);
|
|
||||||
reply.send();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.apiCallService.handleMastodonRequest(ep, request, reply);
|
|
||||||
return reply;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Body: {
|
Body: {
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -212,4 +194,39 @@ export class ApiServerService {
|
||||||
|
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private createMastodonServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
|
fastify.register(fastifyFormbody);
|
||||||
|
|
||||||
|
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
|
||||||
|
name: endpoint.name,
|
||||||
|
method: endpoint.method,
|
||||||
|
meta: endpoint.meta,
|
||||||
|
params: endpoint.params,
|
||||||
|
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
|
||||||
|
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
|
||||||
|
|
||||||
|
for (const { name, endpoints } of groupedMastodonEndpoints) {
|
||||||
|
fastify.all<{
|
||||||
|
Params: { endpoint: string; },
|
||||||
|
Body: Record<string, unknown>,
|
||||||
|
Querystring: Record<string, unknown>,
|
||||||
|
}>('/' + name, async (request, reply) => {
|
||||||
|
const ep = endpoints.get(request.method);
|
||||||
|
if (!ep) {
|
||||||
|
reply.code(405);
|
||||||
|
reply.send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.apiCallService.handleMastodonRequest(ep, request, reply);
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { Module, Provider } from '@nestjs/common';
|
||||||
|
|
||||||
import { CoreModule } from '@/core/CoreModule.js';
|
import { CoreModule } from '@/core/CoreModule.js';
|
||||||
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||||
|
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
|
||||||
|
|
||||||
const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default };
|
const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default };
|
||||||
|
const $apps_v1_post: Provider = { provide: 'mep:POST:v1/apps', useClass: mep___apps_v1_post.default };
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -11,9 +13,11 @@ const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
$accounts_lookup_v1_get,
|
$accounts_lookup_v1_get,
|
||||||
|
$apps_v1_post,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
$accounts_lookup_v1_get,
|
$accounts_lookup_v1_get,
|
||||||
|
$apps_v1_post,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MastodonEndpointsModule {}
|
export class MastodonEndpointsModule {}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { Schema } from '@/misc/json-schema.js';
|
import { Schema } from '@/misc/json-schema.js';
|
||||||
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||||
|
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
|
||||||
|
|
||||||
const eps = [
|
const eps = [
|
||||||
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
|
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
|
||||||
|
['POST', 'v1/apps', mep___apps_v1_post],
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface IMastodonEndpointMeta {
|
export interface IMastodonEndpointMeta {
|
||||||
|
|
80
packages/backend/src/server/api/mastodon/apps/v1/post.ts
Normal file
80
packages/backend/src/server/api/mastodon/apps/v1/post.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { MastodonEndpoint } from '@/server/api/mastodon-endpoint-base.js';
|
||||||
|
import { MastodonApiError } from '@/server/api/error.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
import type { AppsRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
client_name: { type: 'string', minLength: 1 },
|
||||||
|
redirect_uri: { type: 'string', minLength: 1 },
|
||||||
|
redirect_uris: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
|
||||||
|
{ type: 'string', minLength: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scopes: { type: 'string', minLength: 1 },
|
||||||
|
website: { type: 'string' },
|
||||||
|
},
|
||||||
|
anyOf: [
|
||||||
|
{ required: ['redirect_uri'] },
|
||||||
|
{ required: ['redirect_uris'] },
|
||||||
|
],
|
||||||
|
required: ['client_name'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends MastodonEndpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.appsRepository)
|
||||||
|
private appsRepository: AppsRepository,
|
||||||
|
|
||||||
|
private idService: IdService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const redirectUrlsRawValue = ps.redirect_uris ?? ps.redirect_uri ?? [];
|
||||||
|
const redirectUris = typeof redirectUrlsRawValue === 'string' ? redirectUrlsRawValue.split('\n') : redirectUrlsRawValue;
|
||||||
|
const secret = secureRndstr(32);
|
||||||
|
const mastodonScopes = (ps.scopes ?? 'read').split(' ');
|
||||||
|
const scopes = mastodonScopes.flatMap(scope => {
|
||||||
|
const misskeyScopes = mastodonToMisskeyScopes.get(scope);
|
||||||
|
if (!misskeyScopes) {
|
||||||
|
throw new MastodonApiError('Scopes doesn\'t match configured on the server.', 400);
|
||||||
|
}
|
||||||
|
return misskeyScopes;
|
||||||
|
});
|
||||||
|
const clientId = this.idService.gen();
|
||||||
|
await this.appsRepository.insert({
|
||||||
|
id: clientId,
|
||||||
|
userId: me ? me.id : null,
|
||||||
|
name: ps.client_name,
|
||||||
|
description: ps.website ?? '',
|
||||||
|
permission: scopes,
|
||||||
|
callbackUrl: null,
|
||||||
|
secret: secret,
|
||||||
|
website: ps.website,
|
||||||
|
mastodonScopes: mastodonScopes,
|
||||||
|
redirectUris: redirectUris,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: clientId,
|
||||||
|
name: ps.client_name,
|
||||||
|
website: ps.website,
|
||||||
|
scopes: mastodonScopes,
|
||||||
|
redirect_uri: redirectUris.join('\n'),
|
||||||
|
redirect_uris: redirectUris,
|
||||||
|
client_id: `mastodon:${clientId}`,
|
||||||
|
client_secret: secret,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import dns from 'node:dns/promises';
|
import dns from 'node:dns/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { request } from 'node:http';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import httpLinkHeader from 'http-link-header';
|
import httpLinkHeader from 'http-link-header';
|
||||||
|
@ -34,6 +35,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type {
|
import type {
|
||||||
AccessTokensRepository,
|
AccessTokensRepository,
|
||||||
|
AppsRepository,
|
||||||
IndieAuthClientsRepository,
|
IndieAuthClientsRepository,
|
||||||
UserProfilesRepository,
|
UserProfilesRepository,
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
|
@ -45,9 +47,15 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
import { StatusError } from '@/misc/status-error.js';
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import { normalizeEmailAddress } from '@/misc/normalize-email-address.js';
|
import { normalizeEmailAddress } from '@/misc/normalize-email-address.js';
|
||||||
|
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
|
||||||
import type { ServerResponse } from 'node:http';
|
import type { ServerResponse } from 'node:http';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
function extractMastodonAppId(clientId: string): string | null {
|
||||||
|
const MASTODON_CLIENT_ID_PREFIX = 'mastodon:';
|
||||||
|
return clientId.startsWith(MASTODON_CLIENT_ID_PREFIX) ? clientId.substring(MASTODON_CLIENT_ID_PREFIX.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Consider migrating to @node-oauth/oauth2-server once
|
// TODO: Consider migrating to @node-oauth/oauth2-server once
|
||||||
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
|
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
|
||||||
// Upstream the various validations and RFC9207 implementation in that case.
|
// Upstream the various validations and RFC9207 implementation in that case.
|
||||||
|
@ -258,6 +266,8 @@ export class OAuth2ProviderService {
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
@Inject(DI.appsRepository)
|
||||||
|
private appsRepository: AppsRepository,
|
||||||
|
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
|
@ -328,13 +338,25 @@ export class OAuth2ProviderService {
|
||||||
if (body.client_id !== granted.clientId) return;
|
if (body.client_id !== granted.clientId) return;
|
||||||
if (redirectUri !== granted.redirectUri) return;
|
if (redirectUri !== granted.redirectUri) return;
|
||||||
|
|
||||||
|
const mastodonAppId = extractMastodonAppId(granted.clientId);
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
||||||
|
// For Mastodon API, code verifier isn't necessary (but if code challenge was provided, then it should be verified)
|
||||||
|
if (!mastodonAppId || granted.codeChallenge || body.code_verifier) {
|
||||||
if (!body.code_verifier) return;
|
if (!body.code_verifier) return;
|
||||||
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = secureRndstr(128);
|
const accessToken = secureRndstr(128);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
let scopes = granted.scopes;
|
||||||
|
let name = granted.clientId;
|
||||||
|
if (mastodonAppId) {
|
||||||
|
scopes = [...new Set(granted.scopes.flatMap((scope: string) => mastodonToMisskeyScopes.get(scope)))];
|
||||||
|
name = (await this.appsRepository.findOneBy({ id: mastodonAppId }))?.name ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: we don't have a setup for automatic token expiration
|
// NOTE: we don't have a setup for automatic token expiration
|
||||||
await accessTokensRepository.insert({
|
await accessTokensRepository.insert({
|
||||||
id: idService.gen(now.getTime()),
|
id: idService.gen(now.getTime()),
|
||||||
|
@ -342,8 +364,9 @@ export class OAuth2ProviderService {
|
||||||
userId: granted.userId,
|
userId: granted.userId,
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
hash: accessToken,
|
hash: accessToken,
|
||||||
name: granted.clientId,
|
name: name,
|
||||||
permission: granted.scopes,
|
permission: scopes,
|
||||||
|
appId: mastodonAppId,
|
||||||
});
|
});
|
||||||
|
|
||||||
grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`);
|
grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`);
|
||||||
|
@ -354,7 +377,7 @@ export class OAuth2ProviderService {
|
||||||
}
|
}
|
||||||
|
|
||||||
granted.grantedToken = accessToken;
|
granted.grantedToken = accessToken;
|
||||||
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
|
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${scopes}]`);
|
||||||
await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5);
|
await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5);
|
||||||
|
|
||||||
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
|
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
|
||||||
|
@ -418,6 +441,31 @@ export class OAuth2ProviderService {
|
||||||
|
|
||||||
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
|
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
|
||||||
|
|
||||||
|
let clientInfo: ClientInformation;
|
||||||
|
const mastodonAppId = extractMastodonAppId(clientID);
|
||||||
|
if (mastodonAppId) {
|
||||||
|
const app = await this.appsRepository.findOneBy({ id: mastodonAppId });
|
||||||
|
if (!app) {
|
||||||
|
throw new AuthorizationError('unrecognized client id', 'invalid_request');
|
||||||
|
}
|
||||||
|
clientInfo = {
|
||||||
|
id: clientID,
|
||||||
|
name: app.name,
|
||||||
|
redirectUris: app.redirectUris,
|
||||||
|
};
|
||||||
|
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||||
|
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const scopes = [...new Set(scope)].filter(s => mastodonToMisskeyScopes.has(s));
|
||||||
|
if (!scopes.length) {
|
||||||
|
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||||
|
}
|
||||||
|
areq.scope = scopes;
|
||||||
|
} catch (err) {
|
||||||
|
return [err as Error, clientInfo, redirectURI];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const clientUrl = validateClientId(clientID);
|
const clientUrl = validateClientId(clientID);
|
||||||
|
|
||||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||||
|
@ -434,13 +482,7 @@ export class OAuth2ProviderService {
|
||||||
// Find client information from the database.
|
// Find client information from the database.
|
||||||
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
|
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
|
||||||
// Find client information from the remote.
|
// Find client information from the remote.
|
||||||
const clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
|
clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
|
||||||
|
|
||||||
// Require the redirect URI to be included in an explicit list, per
|
|
||||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
|
|
||||||
if (!clientInfo.redirectUris.includes(redirectURI)) {
|
|
||||||
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
|
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
|
||||||
|
@ -449,7 +491,7 @@ export class OAuth2ProviderService {
|
||||||
}
|
}
|
||||||
areq.scope = scopes;
|
areq.scope = scopes;
|
||||||
|
|
||||||
// Require PKCE parameters.
|
// Require PKCE parameters. This requirement is skipped for Mastodon clients, as Mastodon API doesn't require it.
|
||||||
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
||||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
||||||
if (typeof codeChallenge !== 'string') {
|
if (typeof codeChallenge !== 'string') {
|
||||||
|
@ -461,8 +503,21 @@ export class OAuth2ProviderService {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return [err as Error, clientInfo, redirectURI];
|
return [err as Error, clientInfo, redirectURI];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Require the redirect URI to be included in an explicit list, per
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
|
||||||
|
if (!clientInfo.redirectUris.includes(redirectURI)) {
|
||||||
|
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
||||||
|
}
|
||||||
|
|
||||||
return [null, clientInfo, redirectURI];
|
// Support OAuth2 OOB authentication. This is only supported for Mastodon
|
||||||
|
// clients, as OOB is insecure, however supporting is necessary for
|
||||||
|
// Mastodon compatibility.
|
||||||
|
let newRedirectURI = redirectURI;
|
||||||
|
if (mastodonAppId && redirectURI === 'urn:ietf:wg:oauth:2.0:oob') {
|
||||||
|
newRedirectURI = new URL('/oauth/oob', this.config.url).toString();
|
||||||
|
}
|
||||||
|
return [null, clientInfo, newRedirectURI];
|
||||||
})().then(args => done(...args), err => done(err));
|
})().then(args => done(...args), err => done(err));
|
||||||
}) as ValidateFunctionArity2));
|
}) as ValidateFunctionArity2));
|
||||||
fastify.use('/authorize', this.#server.errorHandler({
|
fastify.use('/authorize', this.#server.errorHandler({
|
||||||
|
|
89
packages/frontend/src/components/MkAuthOob.vue
Normal file
89
packages/frontend/src/components/MkAuthOob.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<div :class="$style.root" class="_gaps">
|
||||||
|
<div :class="$style.header" class="_gaps_s">
|
||||||
|
<template v-if="props.code">
|
||||||
|
<div :class="$style.iconFallback">
|
||||||
|
<i class="ti ti-check"></i>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div>
|
||||||
|
<div :class="$style.headerTextSub">{{ i18n.ts._auth.pleasePassCodeToApplication }}</div>
|
||||||
|
<div :class="$style.headerTextSub">{{ props.code }}</div>
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton rounded @click="copy">{{ i18n.ts.copy }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="props.error">
|
||||||
|
<div :class="$style.iconFallback">
|
||||||
|
<i class="ti ti-x"></i>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.headerText">{{ props.error === 'access_denied' ? i18n.ts._auth.denied : props.error }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
code: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
copyToClipboard(props.code);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon,
|
||||||
|
.iconFallback {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
background-color: #fff;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFallback {
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--accentedBg);
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 54px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText,
|
||||||
|
.headerTextSub {
|
||||||
|
text-align: center;
|
||||||
|
word-break: normal;
|
||||||
|
word-break: auto-phrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
53
packages/frontend/src/pages/oauth-oob.vue
Normal file
53
packages/frontend/src/pages/oauth-oob.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<MkAnimBg style="position: fixed; top: 0;"/>
|
||||||
|
<div :class="$style.formContainer">
|
||||||
|
<div :class="$style.form">
|
||||||
|
<MkAuthOob :code="code" :error="error"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import MkAnimBg from '@/components/MkAnimBg.vue';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
import MkAuthOob from '@/components/MkAuthOob.vue';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
const error = params.get('error');
|
||||||
|
|
||||||
|
definePageMetadata(() => ({
|
||||||
|
title: 'OAuth',
|
||||||
|
icon: 'ti ti-apps',
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.formContainer {
|
||||||
|
min-height: 100svh;
|
||||||
|
padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--panel);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: clip;
|
||||||
|
max-width: 500px;
|
||||||
|
width: calc(100vw - 64px);
|
||||||
|
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -270,6 +270,9 @@ const routes: RouteDef[] = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/oauth/authorize',
|
path: '/oauth/authorize',
|
||||||
component: page(() => import('@/pages/oauth.vue')),
|
component: page(() => import('@/pages/oauth.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/oauth/oob',
|
||||||
|
component: page(() => import('@/pages/oauth-oob.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/sso/:kind/:serviceId',
|
path: '/sso/:kind/:serviceId',
|
||||||
component: page(() => import('@/pages/sso.vue')),
|
component: page(() => import('@/pages/sso.vue')),
|
||||||
|
|
|
@ -2820,7 +2820,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
|
||||||
function parse(acct: string): Acct;
|
function parse(acct: string): Acct;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:push-notification"];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||||
|
|
|
@ -103,6 +103,7 @@ export const permissions = [
|
||||||
'read:clip-favorite',
|
'read:clip-favorite',
|
||||||
'read:federation',
|
'read:federation',
|
||||||
'write:report-abuse',
|
'write:report-abuse',
|
||||||
|
'write:push-notification', // Mastodon permission
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const moderationLogTypes = [
|
export const moderationLogTypes = [
|
||||||
|
|
Loading…
Reference in a new issue