diff --git a/packages/backend/package.json b/packages/backend/package.json index b82bd73a0..3bdf1b5b4 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -167,6 +167,7 @@ "typeorm": "0.3.17", "typescript": "5.2.2", "ulid": "2.3.0", + "uuid": "^9.0.1", "vary": "1.1.2", "web-push": "3.6.6", "ws": "8.14.2", @@ -212,6 +213,7 @@ "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.4", "@types/tmp": "0.2.4", + "@types/uuid": "^9.0.4", "@types/vary": "1.1.0", "@types/web-push": "3.6.0", "@types/ws": "8.5.5", diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index cd96cda12..a7e2ea50b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -3,359 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import dns from 'node:dns/promises'; -import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; -import httpLinkHeader from 'http-link-header'; -import ipaddr from 'ipaddr.js'; -import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; -import oauth2Pkce from 'oauth2orize-pkce'; -import fastifyView from '@fastify/view'; -import pug from 'pug'; -import bodyParser from 'body-parser'; -import fastifyExpress from '@fastify/express'; -import { verifyChallenge } from 'pkce-challenge'; -import { mf2 } from 'microformats-parser'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { kinds } from '@/misc/api-permissions.js'; +import megalodon, { MegalodonInterface } from 'megalodon'; +import { v4 as uuid } from 'uuid'; +/* import { kinds } from '@/misc/api-permissions.js'; import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; +import { DI } from '@/di-symbols.js'; */ import { bindThis } from '@/decorators.js'; -import type { AccessTokensRepository, UsersRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import Logger from '@/logger.js'; -import { StatusError } from '@/misc/status-error.js'; -import type { ServerResponse } from 'node:http'; import type { FastifyInstance } from 'fastify'; -const base64regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; - -// 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. - -// Follows https://indieauth.spec.indieweb.org/#client-identifier -// This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation -// although Google has stricter rule. -function validateClientId(raw: string): URL { - // "Clients are identified by a [URL]." - const url = ((): URL => { - try { - if (base64regex.test(raw)) return new URL(atob(raw)); - return new URL(raw); - } catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); } - })(); - - // "Client identifier URLs MUST have either an https or http scheme" - // But then again: - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1 - // 'The redirection endpoint SHOULD require the use of TLS as described - // in Section 1.6 when the requested response type is "code" or "token"' - 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'); - } - - // "MUST contain a path component (new URL() implicitly adds one)" - - // "MUST NOT contain single-dot or double-dot path segments," - const segments = url.pathname.split('/'); - if (segments.includes('.') || segments.includes('..')) { - throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); - } - - // ("MAY contain a query string component") - - // "MUST NOT contain a fragment component" - if (url.hash) { - throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); - } - - // "MUST NOT contain a username or password component" - if (url.username || url.password) { - throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); - } - - // ("MAY contain a port") - - // "host names MUST be domain names or a loopback interface and MUST NOT be - // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]." - 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; -} - -interface ClientInformation { - id: string; - redirectUris: string[]; - name: string; -} - -// https://indieauth.spec.indieweb.org/#client-information-discovery -// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, -// and if there is an [h-app] with a url property matching the client_id URL, -// then it should use the name and icon and display them on the authorization prompt." -// (But we don't display any icon for now) -// https://indieauth.spec.indieweb.org/#redirect-url -// "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute -// of redirect_uri at the client_id URL. -// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST -// look for an exact match of the given redirect_uri in the request against the list of -// redirect_uris discovered after resolving any relative URLs." -async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise { - 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 text = await res.text(); - const fragment = JSDOM.fragment(text); - - 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; - } - } - - return { - id, - redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), - name: typeof name === 'string' ? name : id, - }; - } catch (err) { - console.error(err); - logger.error('Error while fetching client information', { err }); - if (err instanceof StatusError) { - throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); - } else { - throw new AuthorizationError('Failed to parse client information', 'server_error'); - } - } -} - -type OmitFirstElement = T extends [unknown, ...(infer R)] - ? R - : []; - -interface OAuthParsedRequest extends OAuth2Req { - codeChallenge: string; - codeChallengeMethod: string; -} - -interface OAuthHttpResponse extends ServerResponse { - redirect(location: string): void; -} - -interface OAuth2DecisionRequest extends MiddlewareRequest { - body: { - transaction_id: string; - cancel: boolean; - login_token: string; - } -} - -function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { - return { - query: (txn, res, params): void => { - // https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss - // "In authorization responses to the client, including error responses, - // an authorization server supporting this specification MUST indicate its - // identity by including the iss parameter in the response." - params.iss = issuerUrl; - - const parsed = new URL(txn.redirectURI); - for (const [key, value] of Object.entries(params)) { - parsed.searchParams.append(key, value as string); - } - - return (res as OAuthHttpResponse).redirect(parsed.toString()); - }, - }; -} - -/** - * Maps the transaction ID and the oauth/authorize parameters. - * - * Flow: - * 1. oauth/authorize endpoint will call store() to store the parameters - * and puts the generated transaction ID to the dialog page - * 2. oauth/decision will call load() to retrieve the parameters and then remove() - */ -class OAuth2Store { - #cache = new MemoryKVCache(1000 * 60 * 5); // expires after 5min - - load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void { - const { transaction_id } = req.body; - if (!transaction_id) { - cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); - return; - } - const loaded = this.#cache.get(transaction_id); - if (!loaded) { - cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied')); - return; - } - cb(null, loaded); - } - - store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { - const transactionId = secureRndstr(128); - this.#cache.set(transactionId, oauth2); - cb(null, transactionId); - } - - remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { - this.#cache.delete(tid); - cb(); - } -} @Injectable() export class OAuth2ProviderService { - #server = oauth2orize.createServer({ - store: new OAuth2Store(), - }); - #logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - private httpRequestService: HttpRequestService, - @Inject(DI.accessTokensRepository) - accessTokensRepository: AccessTokensRepository, - idService: IdService, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - private cacheService: CacheService, - loggerService: LoggerService, - ) { - this.#logger = loggerService.getLogger('oauth'); - - const grantCodeCache = new MemoryKVCache<{ - clientId: string, - userId: string, - redirectUri: string, - codeChallenge: string, - scopes: string[], - - // fields to prevent multiple code use - grantedToken?: string, - revoked?: boolean, - used?: boolean, - }>(1000 * 60 * 5); // expires after 5m - - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics - // "Authorization servers MUST support PKCE [RFC7636]." - this.#server.grant(oauth2Pkce.extensions()); - this.#server.grant(oauth2orize.grant.code({ - modes: getQueryMode(config.url), - }, (client, redirectUri, token, ares, areq, locals, done) => { - (async (): Promise>> => { - this.#logger.info(`Checking the user before sending authorization code to ${client.id}`); - - if (!token) { - throw new AuthorizationError('No user', 'invalid_request'); - } - const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, - () => this.usersRepository.findOneBy({ token }) as Promise); - if (!user) { - throw new AuthorizationError('No such user', 'invalid_request'); - } - - this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); - - const code = secureRndstr(128); - grantCodeCache.set(code, { - clientId: client.id, - userId: user.id, - redirectUri, - codeChallenge: (areq as OAuthParsedRequest).codeChallenge, - scopes: areq.scope, - }); - return [code]; - })().then(args => done(null, ...args), err => done(err)); - })); - this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { - (async (): Promise> | undefined> => { - this.#logger.info('Checking the received authorization code for the exchange'); - const granted = grantCodeCache.get(code); - if (!granted) { - return; - } - - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 - // "If an authorization code is used more than once, the authorization server - // MUST deny the request and SHOULD revoke (when possible) all tokens - // previously issued based on that authorization code." - if (granted.used) { - this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`); - grantCodeCache.delete(code); - granted.revoked = true; - if (granted.grantedToken) { - await accessTokensRepository.delete({ token: granted.grantedToken }); - } - return; - } - granted.used = true; - - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 - if (body.client_id !== granted.clientId) return; - if (redirectUri !== granted.redirectUri) return; - - // 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; - - const accessToken = secureRndstr(128); - const now = new Date(); - - // NOTE: we don't have a setup for automatic token expiration - await accessTokensRepository.insert({ - id: idService.genId(), - createdAt: now, - lastUsedAt: now, - userId: granted.userId, - token: accessToken, - hash: accessToken, - name: granted.clientId, - permission: granted.scopes, - }); - - if (granted.revoked) { - this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.'); - await accessTokensRepository.delete({ token: accessToken }); - return; - } - - granted.grantedToken = accessToken; - this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); - - return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; - })().then(args => done(null, ...args ?? []), err => done(err)); - })); - } + /* @Inject(DI.config) + private config: Config, */ + ) { } @bindThis public async createServer(fastify: FastifyInstance): Promise { // https://datatracker.ietf.org/doc/html/rfc8414.html // https://indieauth.spec.indieweb.org/#indieauth-server-metadata - fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { + /* fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { reply.send({ issuer: this.config.url, authorization_endpoint: new URL('/oauth/authorize', this.config.url), @@ -367,123 +36,68 @@ export class OAuth2ProviderService { code_challenge_methods_supported: ['S256'], authorization_response_iss_parameter_supported: true, }); - }); + }); */ fastify.get('/oauth/authorize', async (request, reply) => { - const oauth2 = (request.raw as MiddlewareRequest).oauth2; - if (!oauth2) { - throw new Error('Unexpected lack of authorization information'); + 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( + `${atob(client)}?${param}`, + ); + }); + + fastify.post('/oauth/token', async (request, reply) => { + const body: any = request.body || request.query; + 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); + } + let client_id: any = body.client_id; + const BASE_URL = `${request.protocol}://${request.hostname}`; + const generator = (megalodon as any).default; + const client = generator(BASE_URL, null) as MegalodonInterface; + let m = null; + let token = null; + if (body.code) { + //m = body.code.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 (!m.length) { + // ctx.body = { error: "Invalid code" }; + // return; + //} + //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` + //console.log(body.code, token); + token = body.code; + } + if (client_id instanceof Array) { + client_id = client_id.toString(); + } else if (!client_id) { + client_id = null; + } + try { + const atData = await client.fetchAccessToken( + client_id, + body.client_secret, + token ? 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) { + /* console.error(err); */ + reply.code(401).send(err.response); } - - this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); - - reply.header('Cache-Control', 'no-store'); - return await reply.view('oauth', { - transactionId: oauth2.transactionID, - clientName: oauth2.client.name, - scope: oauth2.req.scope.join(' '), - }); - }); - fastify.post('/oauth/decision', async () => { }); - fastify.post('/oauth/token', async () => { }); - - fastify.register(fastifyView, { - root: fileURLToPath(new URL('../web/views', import.meta.url)), - engine: { pug }, - defaultContext: { - version: this.config.version, - config: this.config, - }, - }); - - await fastify.register(fastifyExpress); - fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { - (async (): Promise> => { - // This should return client/redirectURI AND the error, or - // the handler can't send error to the redirection URI - - const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest; - - this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); - - 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'); - } - } - - // Find client information from the remote. - const clientInfo = 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)); - fastify.use('/oauth/authorize', this.#server.errorHandler({ - mode: 'indirect', - modes: getQueryMode(this.config.url), - })); - fastify.use('/oauth/authorize', this.#server.errorHandler()); - - fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); - fastify.use('/oauth/decision', this.#server.decision((req, done) => { - const { body } = req as OAuth2DecisionRequest; - this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`); - req.user = body.login_token; - done(null, undefined); - })); - fastify.use('/oauth/decision', this.#server.errorHandler()); - - // Clients may use JSON or urlencoded - fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false })); - fastify.use('/oauth/token', bodyParser.json({ strict: true })); - fastify.use('/oauth/token', this.#server.token()); - fastify.use('/oauth/token', this.#server.errorHandler()); - - // Return 404 for any unknown paths under /oauth so that clients can know - // whether a certain endpoint is supported or not. - fastify.all('/oauth/*', async (_request, reply) => { - reply.code(404); - reply.send({ - error: { - message: 'Unknown OAuth endpoint.', - code: 'UNKNOWN_OAUTH_ENDPOINT', - id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', - kind: 'client', - }, - }); }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7bbc9531..c870285af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: ulid: specifier: 2.3.0 version: 2.3.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 vary: specifier: 1.1.2 version: 1.1.2 @@ -607,6 +610,9 @@ importers: '@types/tmp': specifier: 0.2.4 version: 0.2.4 + '@types/uuid': + specifier: ^9.0.4 + version: 9.0.4 '@types/vary': specifier: 1.1.0 version: 1.1.0 @@ -19184,7 +19190,7 @@ packages: reflect-metadata: 0.1.13 sha.js: 2.4.11 tslib: 2.5.3 - uuid: 9.0.0 + uuid: 9.0.1 yargs: 17.6.2 transitivePeerDependencies: - supports-color @@ -19453,10 +19459,6 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - /uuid@9.0.0: - resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} - dev: false - /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true