diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 7f763da49..24931661f 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - import querystring from 'querystring'; import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; @@ -29,319 +24,303 @@ import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; const kinds = [ - '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: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', ]; function validateClientId(raw: string): URL { - try { - const url = new URL(raw); - - const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:']; - if (!allowedProtocols.includes(url.protocol)) { - throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); - } - - const segments = url.pathname.split('/'); - if (segments.includes('.') || segments.includes('..')) { - throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); - } - - if (url.hash) { - throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); - } - - if (url.username || url.password) { - throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); - } - - if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { - throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request'); - } - - return url; - } catch (e) { - throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); - } + try { + const url = new URL(raw); + const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:']; + if (!allowedProtocols.includes(url.protocol)) { + throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); + } + const segments = url.pathname.split('/'); + if (segments.includes('.') || segments.includes('..')) { + throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); + } + if (url.hash) { + throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); + } + if (url.username || url.password) { + throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); + } + if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request'); + } + return url; + } catch (e) { + throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); + } } async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise { - try { - const res = await httpRequestService.send(id); - const redirectUris: string[] = []; + try { + const res = await httpRequestService.send(id); + const redirectUris: string[] = []; - const linkHeader = res.headers.get('link'); - if (linkHeader) { - redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); - } + const linkHeader = res.headers.get('link'); + if (linkHeader) { + redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); + } - const text = await res.text(); - const fragment = JSDOM.fragment(text); + const text = await res.text(); + const fragment = JSDOM.fragment(text); - redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); + redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); - let name = id; - if (text) { - const microformats = mf2(text, { baseUrl: res.url }); - const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0]; - if (typeof nameProperty === 'string') { - name = nameProperty; - } - } + let name = id; + if (text) { + const microformats = mf2(text, { baseUrl: res.url }); + const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0]; + if (typeof nameProperty === 'string') { + name = nameProperty; + } + } - return { - id, - redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), - name: typeof name === 'string' ? name : id, - }; - } catch (err) { - logger.error('Error while fetching client information', { err }); - throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); - } + return { + id, + redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), + name: typeof name === 'string' ? name : id, + }; + } catch (err) { + logger.error('Error while fetching client information', { err }); + throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); + } } -function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; +function getClient(BASE_URL: string, authorization: string | undefined, type: string = 'misskey'): MegalodonInterface { + const accessTokenArr = authorization?.split(' ') ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + const generator = (megalodon as any).default; + return generator(type, BASE_URL, accessToken) as MegalodonInterface; } @Injectable() export class OAuth2ProviderService { - #logger: Logger; - #server = oauth2orize.createServer(); - #clientCache = new MemoryKVCache(60 * 60 * 1000); // 1 hour + #logger: Logger; + #server = oauth2orize.createServer(); + #clientCache = new MemoryKVCache(60 * 60 * 1000); - constructor( - @Inject(DI.config) - private config: Config, - private httpRequestService: HttpRequestService, - loggerService: LoggerService, - ) { - this.#logger = loggerService.getLogger('oauth'); - this.#server.grant(oauth2Pkce.extensions()); - } + constructor( + @Inject(DI.config) + private config: Config, + private httpRequestService: HttpRequestService, + loggerService: LoggerService, + ) { + this.#logger = loggerService.getLogger('oauth'); + this.#server.grant(oauth2Pkce.extensions()); + } - // https://datatracker.ietf.org/doc/html/rfc8414.html - // https://indieauth.spec.indieweb.org/#indieauth-server-metadata - public generateRFC8414() { - return { - issuer: this.config.url, - authorization_endpoint: new URL('/oauth/authorize', this.config.url), - token_endpoint: new URL('/oauth/token', this.config.url), - scopes_supported: kinds, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'client_credentials'], - service_documentation: 'https://misskey-hub.net', - code_challenge_methods_supported: ['S256'], - authorization_response_iss_parameter_supported: true, - }; - } + public generateRFC8414() { + return { + issuer: this.config.url, + authorization_endpoint: new URL('/oauth/authorize', this.config.url), + token_endpoint: new URL('/oauth/token', this.config.url), + scopes_supported: kinds, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'client_credentials'], + service_documentation: 'https://misskey-hub.net', + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true, + }; + } - @bindThis - public async createServer(fastify: FastifyInstance): Promise { - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); + @bindThis + public async createServer(fastify: FastifyInstance): Promise { + const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: this.config.maxFileSize || 262144000, + files: 1, + }, + }); - fastify.register(fastifyCors); + fastify.register(fastifyCors); + fastify.register(fastifyView, { + root: fileURLToPath(new URL('../web/views', import.meta.url)), + engine: { pug }, + defaultContext: { + version: this.config.version, + config: this.config, + }, + }); - fastify.register(fastifyView, { - root: fileURLToPath(new URL('../web/views', import.meta.url)), - engine: { pug }, - defaultContext: { - version: this.config.version, - config: this.config, - }, - }); + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Access-Control-Allow-Origin', '*'); + done(); + }); - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); + fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { + let body = ''; + payload.on('data', (data) => { + body += data; + }); + payload.on('end', () => { + try { + const parsed = querystring.parse(body); + done(null, parsed); + } catch (e: any) { + done(e); + } + }); + payload.on('error', done); + }); - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e: any) { - done(e); - } - }); - payload.on('error', done); - }); + fastify.register(multer.contentParser); - fastify.register(multer.contentParser); + fastify.get('/authorize', async (request, reply) => { + const query: any = request.query; + let param = "mastodon=true"; + if (query.state) param += `&state=${query.state}`; + if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; + const client = query.client_id ? query.client_id : ""; - // Handle both Misskey and Mastodon OAuth flows - fastify.get('/authorize', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; + if (client) { + try { + const clientUrl = validateClientId(Buffer.from(client.toString(), 'base64').toString()); + if (process.env.NODE_ENV !== 'test') { + 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'); + } + } + const clientInfo = await this.#clientCache.fetch( + clientUrl.href, + () => discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href), + ); + await this.#clientCache.set(`client:${query.code_challenge}`, clientInfo); + } catch (err) { + reply.code(400).send({ error: err.message }); + return; + } + } - // Misskey OAuth validation - if (client) { - try { - const clientUrl = validateClientId(Buffer.from(client.toString(), 'base64').toString()); - if (process.env.NODE_ENV !== 'test') { - 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'); - } - } + reply.redirect(`${Buffer.from(client.toString(), 'base64').toString()}?${param}`); + }); - // Cache client information - const clientInfo = await this.#clientCache.fetch( - clientUrl.href, - () => discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href), - ); + fastify.get('/authorize/', (request, reply) => { + return this.handleAuthorize(request, reply); + }); - // Store client info for token endpoint - await this.#clientCache.set(`client:${query.code_challenge}`, clientInfo); - } catch (err) { - reply.code(400).send({ error: err.message }); - return; - } - } + fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => { + const body: any = request.body || request.query; + const isMastodon = body.client_id?.includes('mastodon'); - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); + if (body.grant_type === "client_credentials") { + const ret = { + access_token: uuid(), + token_type: "Bearer", + scope: "read", + created_at: Math.floor(new Date().getTime() / 1000), + }; + reply.send(ret); + return; + } - fastify.get('/authorize/', (request, reply) => { - return this.handleAuthorize(request, reply); - }); + let client_id: any = body.client_id; + const BASE_URL = `${request.protocol}://${request.hostname}`; + let token = body.code || null; - fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => { - const body: any = request.body || request.query; + if (client_id instanceof Array) { + client_id = client_id.toString(); + } else if (!client_id) { + client_id = null; + } - // Handle client_credentials grant type - if (body.grant_type === "client_credentials") { - const ret = { - access_token: uuid(), - token_type: "Bearer", - scope: "read", - created_at: Math.floor(new Date().getTime() / 1000), - }; - reply.send(ret); - return; - } + try { + let finalToken = token; + if (finalToken && finalToken.includes('-')) { + finalToken = token; + } else if (finalToken) { + const matches = finalToken.match(/([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/); + if (matches) { + finalToken = `${matches[1]}-${matches[2]}-${matches[3]}-${matches[4]}-${matches[5]}`; + } + } - let client_id: any = body.client_id; - const BASE_URL = `${request.protocol}://${request.hostname}`; - const client = getClient(BASE_URL, ''); - let token = body.code || null; + const client = getClient(BASE_URL, '', isMastodon ? 'mastodon' : 'misskey'); - // Normalize client_id - if (client_id instanceof Array) { - client_id = client_id.toString(); - } else if (!client_id) { - client_id = null; - } + const atData = await client.fetchAccessToken( + client_id, + body.client_secret, + finalToken || "", + { + scope: body.scope || "read write follow push", + grant_type: body.grant_type, + redirect_uri: body.redirect_uri, + code_verifier: body.code_verifier, + } + ); - // PKCE Validation for authorization_code grant - if (body.grant_type === 'authorization_code') { - if (!body.code_verifier) { - reply.code(400).send({ - error: 'invalid_request', - error_description: 'code_verifier is required for authorization_code grant type' - }); - return; - } + reply.send({ + access_token: atData.accessToken, + token_type: "Bearer", + scope: body.scope || "read write follow push", + created_at: Math.floor(new Date().getTime() / 1000), + }); + } catch (err: any) { + this.#logger.error('Token exchange failed', { + error: err, + clientId: client_id, + code: token + }); - // Verify the stored client information matches - const storedClientInfo = await this.#clientCache.get(`client:${body.code_verifier}`); - if (!storedClientInfo) { - reply.code(400).send({ - error: 'invalid_grant', - error_description: 'Invalid authorization code' - }); - return; - } - } + reply.code(401).send(err.response?.data || { + error: 'invalid_grant', + error_description: 'The provided authorization grant is invalid' + }); + } + }); - try { - const atData = await client.fetchAccessToken( - client_id, - body.client_secret, - token || "", - ); - const ret = { - access_token: atData.accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", - created_at: Math.floor(new Date().getTime() / 1000), - }; - reply.send(ret); - } catch (err: any) { - reply.code(401).send(err.response.data); - } - }); + fastify.all('/*', async (_request, reply) => { + reply.code(404).send({ + error: { + message: 'Unknown OAuth endpoint.', + code: 'UNKNOWN_OAUTH_ENDPOINT', + id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', + kind: 'client', + }, + }); + }); + } - fastify.all('/*', async (_request, reply) => { - reply.code(404).send({ - error: { - message: 'Unknown OAuth endpoint.', - code: 'UNKNOWN_OAUTH_ENDPOINT', - id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', - kind: 'client', - }, - }); - }); - } - - private async handleAuthorize(request: any, reply: any) { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - } + private async handleAuthorize(request: any, reply: any) { + const query: any = request.query; + let param = "mastodon=true"; + if (query.state) param += `&state=${query.state}`; + if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; + const client = query.client_id ? query.client_id : ""; + reply.redirect(`${Buffer.from(client.toString(), 'base64').toString()}?${param}`); + } }