From bd88c5261e30d1f5bbddec655ee3e0a891d31900 Mon Sep 17 00:00:00 2001 From: sugar Date: Sat, 11 Jan 2025 17:59:44 +0100 Subject: [PATCH] implement mastodon oauth authorization --- .../migration/1736599563231-MastodonApp.js | 18 +++ .../mastodon/mastodon-to-misskey-scopes.ts | 41 ++++++ packages/backend/src/models/App.ts | 18 +++ .../src/server/api/MastodonEndpointsModule.ts | 4 + .../src/server/api/mastodon-endpoints.ts | 2 + .../src/server/api/mastodon/apps/v1/post.ts | 80 +++++++++++ .../src/server/oauth/OAuth2ProviderService.ts | 127 ++++++++++++------ 7 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 packages/backend/migration/1736599563231-MastodonApp.js create mode 100644 packages/backend/src/misc/mastodon/mastodon-to-misskey-scopes.ts create mode 100644 packages/backend/src/server/api/mastodon/apps/v1/post.ts diff --git a/packages/backend/migration/1736599563231-MastodonApp.js b/packages/backend/migration/1736599563231-MastodonApp.js new file mode 100644 index 000000000..db0ccbe83 --- /dev/null +++ b/packages/backend/migration/1736599563231-MastodonApp.js @@ -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"`); + } +} diff --git a/packages/backend/src/misc/mastodon/mastodon-to-misskey-scopes.ts b/packages/backend/src/misc/mastodon/mastodon-to-misskey-scopes.ts new file mode 100644 index 000000000..531a9bc6c --- /dev/null +++ b/packages/backend/src/misc/mastodon/mastodon-to-misskey-scopes.ts @@ -0,0 +1,41 @@ +import { permissions } from 'misskey-js'; + +const mastodonToMisskeyScopes: Map = 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 }; diff --git a/packages/backend/src/models/App.ts b/packages/backend/src/models/App.ts index fc61c60bb..176b49ca2 100644 --- a/packages/backend/src/models/App.ts +++ b/packages/backend/src/models/App.ts @@ -63,4 +63,22 @@ export class MiApp { comment: 'The callbackUrl of the App.', }) 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[]; } diff --git a/packages/backend/src/server/api/MastodonEndpointsModule.ts b/packages/backend/src/server/api/MastodonEndpointsModule.ts index 099da28af..4570f66d6 100644 --- a/packages/backend/src/server/api/MastodonEndpointsModule.ts +++ b/packages/backend/src/server/api/MastodonEndpointsModule.ts @@ -2,8 +2,10 @@ import { Module, Provider } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.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 $apps_v1_post: Provider = { provide: 'mep:POST:v1/apps', useClass: mep___apps_v1_post.default }; @Module({ imports: [ @@ -11,9 +13,11 @@ const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup ], providers: [ $accounts_lookup_v1_get, + $apps_v1_post, ], exports: [ $accounts_lookup_v1_get, + $apps_v1_post, ], }) export class MastodonEndpointsModule {} diff --git a/packages/backend/src/server/api/mastodon-endpoints.ts b/packages/backend/src/server/api/mastodon-endpoints.ts index 6c138c206..62356e61d 100644 --- a/packages/backend/src/server/api/mastodon-endpoints.ts +++ b/packages/backend/src/server/api/mastodon-endpoints.ts @@ -1,8 +1,10 @@ import { Schema } from '@/misc/json-schema.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 = [ ['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get], + ['POST', 'v1/apps', mep___apps_v1_post], ]; export interface IMastodonEndpointMeta { diff --git a/packages/backend/src/server/api/mastodon/apps/v1/post.ts b/packages/backend/src/server/api/mastodon/apps/v1/post.ts new file mode 100644 index 000000000..01e9789e1 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/apps/v1/post.ts @@ -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 { // 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, + }; + }); + } +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index beaabc610..fea3a1042 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -34,6 +34,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { AccessTokensRepository, + AppsRepository, IndieAuthClientsRepository, UserProfilesRepository, UsersRepository, @@ -45,9 +46,15 @@ import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; import { StatusError } from '@/misc/status-error.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 { 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 // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. // Upstream the various validations and RFC9207 implementation in that case. @@ -258,6 +265,8 @@ export class OAuth2ProviderService { private usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, private idService: IdService, private cacheService: CacheService, @@ -328,13 +337,25 @@ export class OAuth2ProviderService { if (body.client_id !== granted.clientId) return; if (redirectUri !== granted.redirectUri) return; + const mastodonAppId = extractMastodonAppId(granted.clientId); + // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 - if (!body.code_verifier) return; - if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; + // 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 (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; + } const accessToken = secureRndstr(128); 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 await accessTokensRepository.insert({ id: idService.gen(now.getTime()), @@ -342,8 +363,9 @@ export class OAuth2ProviderService { userId: granted.userId, token: accessToken, hash: accessToken, - name: granted.clientId, - permission: granted.scopes, + name: name, + permission: scopes, + appId: mastodonAppId, }); grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`); @@ -354,7 +376,7 @@ export class OAuth2ProviderService { } 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); return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; @@ -418,50 +440,75 @@ export class OAuth2ProviderService { this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); - const clientUrl = validateClientId(clientID); + 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); - // https://indieauth.spec.indieweb.org/#client-information-discovery - // "the server may want to resolve the domain name first and avoid fetching the document - // if the IP address is within the loopback range defined by [RFC5735] - // or any other implementation-specific internal IP address." - if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { - const lookup = await dns.lookup(clientUrl.hostname); - if (ipaddr.parse(lookup.address).range() !== 'unicast') { - throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request'); + // https://indieauth.spec.indieweb.org/#client-information-discovery + // "the server may want to resolve the domain name first and avoid fetching the document + // if the IP address is within the loopback range defined by [RFC5735] + // or any other implementation-specific internal IP address." + if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { + const lookup = await dns.lookup(clientUrl.hostname); + if (ipaddr.parse(lookup.address).range() !== 'unicast') { + throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request'); + } + } + + // Find client information from the database. + const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null; + // Find client information from the remote. + clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href); + + try { + const scopes = [...new Set(scope)].filter(s => (kinds).includes(s)); + if (!scopes.length) { + throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); + } + areq.scope = scopes; + + // 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: + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack + if (typeof codeChallenge !== 'string') { + throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); + } + if (codeChallengeMethod !== 'S256') { + throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); + } + } catch (err) { + return [err as Error, clientInfo, redirectURI]; } } - - // Find client information from the database. - const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null; - // Find client information from the remote. - const 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 { - const scopes = [...new Set(scope)].filter(s => (kinds).includes(s)); - if (!scopes.length) { - throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); - } - areq.scope = scopes; - - // Require PKCE parameters. - // 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 - if (typeof codeChallenge !== 'string') { - throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); - } - if (codeChallengeMethod !== 'S256') { - throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); - } - } catch (err) { - return [err as Error, clientInfo, redirectURI]; - } - return [null, clientInfo, redirectURI]; })().then(args => done(...args), err => done(err)); }) as ValidateFunctionArity2));