Compare commits

...

1 commit

Author SHA1 Message Date
bd88c5261e implement mastodon oauth authorization 2025-02-07 20:41:02 +01:00
7 changed files with 250 additions and 40 deletions

View 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"`);
}
}

View file

@ -0,0 +1,41 @@
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']],
['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 };

View file

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

View file

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

View file

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

View 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,
};
});
}
}

View file

@ -34,6 +34,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 +46,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 +265,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 +337,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 +363,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 +376,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 +440,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 +481,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 +490,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,6 +502,12 @@ 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]; return [null, clientInfo, redirectURI];
})().then(args => done(...args), err => done(err)); })().then(args => done(...args), err => done(err));