diff --git a/packages/backend/package.json b/packages/backend/package.json index 3ab3015f595..0349c653997 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -57,7 +57,6 @@ "@aws-sdk/lib-storage": "3.1047.0", "@fastify/accepts": "5.0.4", "@fastify/cors": "11.2.0", - "@fastify/express": "4.0.5", "@fastify/http-proxy": "11.4.4", "@fastify/multipart": "10.0.0", "@fastify/static": "9.1.3", @@ -83,7 +82,6 @@ "async-mutex": "0.5.0", "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "body-parser": "2.2.2", "bullmq": "5.76.8", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", @@ -122,8 +120,6 @@ "node-html-parser": "7.1.0", "nodemailer": "8.0.7", "nsfwjs": "4.3.0", - "oauth2orize": "1.12.0", - "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", "otpauth": "9.5.1", "pg": "8.20.0", @@ -163,7 +159,6 @@ "@sentry/vue": "10.53.1", "@types/accepts": "1.3.7", "@types/archiver": "7.0.0", - "@types/body-parser": "1.19.6", "@types/color-convert": "3.0.1", "@types/content-disposition": "0.5.9", "@types/fluent-ffmpeg": "2.1.28", @@ -174,8 +169,6 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/nodemailer": "8.0.0", - "@types/oauth2orize": "1.11.5", - "@types/oauth2orize-pkce": "0.1.2", "@types/pg": "8.20.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index 950bc635607..7a71e4f862a 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -73,7 +73,6 @@ export default defineConfig((args) => { 'jsdom', 're2', 'ipaddr.js', - 'oauth2orize', 'file-type', ]; diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 840c34b806e..c507b1d8f3b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -4,18 +4,22 @@ */ import dns from 'node:dns/promises'; -import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as htmlParser from 'node-html-parser'; 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 fastifyCors from '@fastify/cors'; -import bodyParser from 'body-parser'; -import fastifyExpress from '@fastify/express'; import { verifyChallenge } from 'pkce-challenge'; import { permissions as kinds } from 'misskey-js'; +import { + AccessDeniedError, + InvalidGrantError, + InvalidRequestError, + InvalidScopeError, + OAuthProviderError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, +} from './errors.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; @@ -31,8 +35,7 @@ import Logger from '@/logger.js'; import { StatusError } from '@/misc/status-error.js'; import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; -import type { ServerResponse } from 'node:http'; -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply } from 'fastify'; // TODO: Consider migrating to @node-oauth/oauth2-server once // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. @@ -46,7 +49,9 @@ function validateClientId(raw: string): URL { const url = ((): URL => { try { return new URL(raw); - } catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); } + } catch { + throw new InvalidRequestError('client_id must be a valid URL'); + } })(); // "Client identifier URLs MUST have either an https or http scheme" @@ -56,7 +61,7 @@ function validateClientId(raw: string): URL { // 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'); + throw new InvalidRequestError('client_id must be a valid HTTPS URL'); } // "MUST contain a path component (new URL() implicitly adds one)" @@ -64,19 +69,19 @@ function validateClientId(raw: string): URL { // "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'); + throw new InvalidRequestError('client_id must not contain dot path segments'); } // ("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'); + throw new InvalidRequestError('client_id must not contain a fragment component'); } // "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'); + throw new InvalidRequestError('client_id must not contain a username or a password'); } // ("MAY contain a port") @@ -84,7 +89,7 @@ function validateClientId(raw: string): URL { // "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'); + throw new InvalidRequestError('client_id must have a domain name as a host name'); } return url; @@ -97,6 +102,45 @@ interface ClientInformation { logo: string | null; } +interface OAuthRequestParameters { + [key: string]: string | string[] | undefined; +} + +interface AuthorizationRequest { + clientId: string; + redirectUri: string; + state?: string; + scopes: string[]; + codeChallenge: string; + codeChallengeMethod: string; +} + +interface AuthorizationRequestSeed { + clientInfo: ClientInformation; + clientId: string; + redirectUri: string; + state?: string; + requestedScope: string[]; + codeChallenge?: string; + codeChallengeMethod?: string; +} + +interface AuthorizationTransaction { + client: ClientInformation; + request: AuthorizationRequest; +} + +interface AuthorizationCodeGrant { + clientId: string; + userId: string; + redirectUri: string; + codeChallenge: string; + scopes: string[]; + grantedToken?: string; + revoked?: boolean; + used?: boolean; +} + function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: string): { name: string | null; logo: string | null; } { let name: string | null = null; let logo: string | null = null; @@ -139,7 +183,7 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt // redirect_uris discovered after resolving any relative URLs." const linkHeader = res.headers.get('link'); if (linkHeader) { - redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); + redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(link => link.uri)); } const contentType = res.headers.get('content-type'); @@ -163,13 +207,13 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt // "The authorization server MUST verify that the client_id in the document matches the // client_id of the URL where the document was retrieved." if (json.client_id !== id) { - throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request'); + throw new InvalidRequestError('client_id in the document does not match the client_id URL'); } // https://indieauth.spec.indieweb.org/#client-metadata-li-1 // "The client_uri MUST be a prefix of the client_id." if (!json.client_uri || !id.startsWith(json.client_uri)) { - throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request'); + throw new InvalidRequestError('client_uri is not a prefix of client_id'); } if (typeof json.client_name === 'string') { @@ -213,100 +257,132 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt logo, }; } 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 if (err instanceof AuthorizationError) { + throw new InvalidRequestError('Failed to fetch client information'); + } + if (err instanceof OAuthProviderError) { throw err; - } else { - throw new AuthorizationError('Failed to parse client information', 'server_error'); } + + const wrapped = new InvalidRequestError('Failed to parse client information'); + wrapped.status = 500; + wrapped.statusCode = 500; + wrapped.error = 'server_error'; + throw wrapped; } } -type OmitFirstElement = T extends [unknown, ...(infer R)] - ? R - : []; +function firstValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} -interface OAuthParsedRequest extends OAuth2Req { - codeChallenge: string; - codeChallengeMethod: string; +function normalizeScope(scope: string | string[] | undefined): string[] { + const raw = Array.isArray(scope) ? scope : scope != null ? [scope] : []; + return raw.flatMap(value => value.split(/\s+/)).filter(Boolean); +} + +function toRequestParameters(body: unknown): OAuthRequestParameters { + if (body == null || typeof body !== 'object' || Array.isArray(body)) { + return {}; + } + + return body as OAuthRequestParameters; +} + +function applyNoStore(reply: FastifyReply): void { + reply.header('Cache-Control', 'no-store'); + reply.header('Pragma', 'no-cache'); +} + +function createUnsupportedResponseTypeError(): OAuthProviderError { + const error = new UnsupportedResponseTypeError(); + error.status = 501; + error.statusCode = 501; + return error; } -interface OAuthHttpResponse extends ServerResponse { - redirect(location: string): void; +function createForbiddenAccessDenied(description: string): OAuthProviderError { + const error = new AccessDeniedError(description); + error.status = 403; + error.statusCode = 403; + return error; } -interface OAuth2DecisionRequest extends MiddlewareRequest { - body: { - transaction_id: string; - cancel: boolean; - login_token: string; +function normalizeOAuthProviderError(error: unknown): OAuthProviderError { + if (error instanceof OAuthProviderError) { + return error; } + + const wrapped = new InvalidRequestError('request is invalid'); + if (error instanceof Error) { + wrapped.error_description = error.message; + } + return wrapped; +} + +function sendOAuthProviderError(reply: FastifyReply, error: OAuthProviderError): void { + applyNoStore(reply); + reply.code(error.statusCode ?? error.status ?? 400); + reply.send({ + error: error.error, + ...(error.expose && error.error_description ? { error_description: error.error_description } : {}), + }); } -function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { +function appendIssuer(payload: Record, issuerUrl: string): Record { 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); - } + ...payload, - return (res as OAuthHttpResponse).redirect(parsed.toString()); - }, + // 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." + iss: issuerUrl, }; } -/** - * 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); - } +function redirectWithQuery(reply: FastifyReply, redirectUriString: string, payload: Record): void { + applyNoStore(reply); - 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); + const redirectUri = new URL(redirectUriString); + for (const [key, value] of Object.entries(payload)) { + redirectUri.searchParams.set(key, value); } - remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { - this.#cache.delete(tid); - cb(); + reply.code(302).redirect(redirectUri.toString()); +} + +function registerFormBodyParser(fastify: FastifyInstance): void { + if (fastify.hasContentTypeParser('application/x-www-form-urlencoded')) { + return; } + + fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => { + try { + const parsed: OAuthRequestParameters = {}; + for (const [key, value] of new URLSearchParams(typeof body === 'string' ? body : body.toString('utf8')).entries()) { + const current = parsed[key]; + if (current == null) { + parsed[key] = value; + } else if (Array.isArray(current)) { + current.push(value); + } else { + parsed[key] = [current, value]; + } + } + + done(null, parsed); + } catch (error) { + done(error as Error, undefined); + } + }); } @Injectable() export class OAuth2ProviderService { - #server = oauth2orize.createServer({ - store: new OAuth2Store(), - }); + #authorizationTransactionCache = new MemoryKVCache(1000 * 60 * 5); + #grantCodeCache = new MemoryKVCache(1000 * 60 * 5); #logger: Logger; constructor( @@ -314,8 +390,8 @@ export class OAuth2ProviderService { private config: Config, private httpRequestService: HttpRequestService, @Inject(DI.accessTokensRepository) - accessTokensRepository: AccessTokensRepository, - idService: IdService, + private accessTokensRepository: AccessTokensRepository, + private idService: IdService, @Inject(DI.usersRepository) private usersRepository: UsersRepository, private cacheService: CacheService, @@ -323,108 +399,103 @@ export class OAuth2ProviderService { private htmlTemplateService: HtmlTemplateService, ) { 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'); - } + async #resolveAuthorizationRequest(params: OAuthRequestParameters): Promise { + const clientId = firstValue(params.client_id); + const redirectUriValue = firstValue(params.redirect_uri); + const responseType = firstValue(params.response_type); + const state = firstValue(params.state); + const codeChallenge = firstValue(params.code_challenge); + const codeChallengeMethod = firstValue(params.code_challenge_method); + const requestedScope = normalizeScope(params.scope); - this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); + this.#logger.info(`Validating authorization parameters, with client_id: ${clientId}, redirect_uri: ${redirectUriValue}, scope: ${requestedScope.join(' ')}`); - 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; - } + if (responseType !== 'code') { + throw createUnsupportedResponseTypeError(); + } - // 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; + if (!clientId) { + throw new InvalidRequestError('client_id must be provided'); + } - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 - if (body.client_id !== granted.clientId) return; - if (redirectUri !== granted.redirectUri) return; + const clientUrl = validateClientId(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; + // 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 InvalidRequestError('client_id resolves to disallowed IP range.'); + } + } - const accessToken = secureRndstr(128); - const now = new Date(); + // Find client information from the remote. + const clientInfo = await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href); - // NOTE: we don't have a setup for automatic token expiration - await accessTokensRepository.insert({ - id: idService.gen(now.getTime()), - lastUsedAt: now, - userId: granted.userId, - token: accessToken, - hash: accessToken, - name: granted.clientId, - permission: granted.scopes, - }); + // 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 (!redirectUriValue || !clientInfo.redirectUris.includes(redirectUriValue)) { + throw new InvalidRequestError('Invalid redirect_uri'); + } - 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; - } + return { + clientInfo, + clientId: clientInfo.id, + redirectUri: redirectUriValue, + state, + requestedScope, + codeChallenge, + codeChallengeMethod, + }; + } - granted.grantedToken = accessToken; - this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); + #finalizeAuthorizationRequest(seed: AuthorizationRequestSeed): AuthorizationRequest { + const scopes = [...new Set(seed.requestedScope)].filter(scope => (kinds).includes(scope)); + if (!seed.requestedScope.length || !scopes.length) { + throw new InvalidScopeError('`scope` parameter has no known scope', seed.requestedScope.join(' ')); + } + + // 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 seed.codeChallenge !== 'string') { + throw new InvalidRequestError('`code_challenge` parameter is required'); + } + if (seed.codeChallengeMethod !== 'S256') { + throw new InvalidRequestError('`code_challenge_method` parameter must be set as S256'); + } + + return { + clientId: seed.clientId, + redirectUri: seed.redirectUri, + state: seed.state, + scopes, + codeChallenge: seed.codeChallenge, + codeChallengeMethod: seed.codeChallengeMethod, + }; + } + + async #findUserByLoginToken(loginToken: string): Promise { + const user = await this.cacheService.localUserByNativeTokenCache.fetch(loginToken, + () => this.usersRepository.findOneBy({ token: loginToken }) as Promise); + if (!user) { + throw new InvalidRequestError('No such user'); + } - return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; - })().then(args => done(null, ...args ?? []), err => done(err)); - })); + return user; + } + + async #revokeGrantCode(granted: AuthorizationCodeGrant, code: string): Promise { + this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`); + this.#grantCodeCache.delete(code); + granted.revoked = true; + if (granted.grantedToken) { + await this.accessTokensRepository.delete({ token: granted.grantedToken }); + } } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -445,97 +516,101 @@ export class OAuth2ProviderService { @bindThis public async createServer(fastify: FastifyInstance): Promise { + registerFormBodyParser(fastify); + fastify.get('/authorize', async (request, reply) => { - const oauth2 = (request.raw as MiddlewareRequest).oauth2; - if (!oauth2) { - throw new Error('Unexpected lack of authorization information'); - } + let validatedRedirectUri: string | undefined; + let state: string | undefined; + + try { + const seed = await this.#resolveAuthorizationRequest(request.query as OAuthRequestParameters); + const { clientInfo } = seed; + validatedRedirectUri = seed.redirectUri; + state = seed.state; + const authorizationRequest = this.#finalizeAuthorizationRequest(seed); + + const transactionId = secureRndstr(128); + this.#authorizationTransactionCache.set(transactionId, { + client: clientInfo, + request: authorizationRequest, + }); - this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); + this.#logger.info(`Rendering authorization page for "${clientInfo.name}"`); + + applyNoStore(reply); + return await HtmlTemplateService.replyHtml(reply, OAuthPage({ + ...await this.htmlTemplateService.getCommonData(), + transactionId, + clientName: clientInfo.name, + clientLogo: clientInfo.logo ?? undefined, + scope: authorizationRequest.scopes, + })); + } catch (error) { + const OAuthProviderError = normalizeOAuthProviderError(error); + if (validatedRedirectUri && OAuthProviderError.allow_redirect && OAuthProviderError.error !== 'unsupported_response_type') { + redirectWithQuery(reply, validatedRedirectUri, appendIssuer({ + error: OAuthProviderError.error, + ...(state ? { state } : {}), + }, this.config.url)); + return; + } - reply.header('Cache-Control', 'no-store'); - return await HtmlTemplateService.replyHtml(reply, OAuthPage({ - ...await this.htmlTemplateService.getCommonData(), - transactionId: oauth2.transactionID, - clientName: oauth2.client.name, - clientLogo: oauth2.client.logo ?? undefined, - scope: oauth2.req.scope, - })); + sendOAuthProviderError(reply, OAuthProviderError); + } }); - fastify.post('/decision', async () => { }); - - await fastify.register(fastifyExpress); - fastify.use('/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}`); + fastify.post('/decision', async (request, reply) => { + try { + const body = toRequestParameters(request.body); + const transactionId = firstValue(body.transaction_id); + if (!transactionId) { + throw new InvalidRequestError('Missing transaction ID'); + } - const clientUrl = validateClientId(clientID); + const transaction = this.#authorizationTransactionCache.get(transactionId); + if (!transaction) { + throw createForbiddenAccessDenied('Invalid or expired transaction ID'); + } + this.#authorizationTransactionCache.delete(transactionId); + + const cancel = !!firstValue(body.cancel); + this.#logger.info(`Received the decision. Cancel: ${cancel}`); + if (cancel) { + redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({ + error: 'access_denied', + ...(transaction.request.state ? { state: transaction.request.state } : {}), + }, this.config.url)); + return; + } - // 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'); - } + const loginToken = firstValue(body.login_token); + if (!loginToken) { + throw new InvalidRequestError('No user'); } - // Find client information from the remote. - const clientInfo = await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href); + this.#logger.info(`Checking the user before sending authorization code to ${transaction.client.id}`); + const user = await this.#findUserByLoginToken(loginToken); - // 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'); - } + this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${transaction.client.id} through ${transaction.request.redirectUri}, with scope: [${transaction.request.scopes}]`); - 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]; - } + const code = secureRndstr(128); + this.#grantCodeCache.set(code, { + clientId: transaction.client.id, + userId: user.id, + redirectUri: transaction.request.redirectUri, + codeChallenge: transaction.request.codeChallenge, + scopes: transaction.request.scopes, + }); + + redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({ + code, + ...(transaction.request.state ? { state: transaction.request.state } : {}), + }, this.config.url)); + } catch (error) { + sendOAuthProviderError(reply, normalizeOAuthProviderError(error)); + } + }); - return [null, clientInfo, redirectURI]; - })().then(args => done(...args), err => done(err)); - }) as ValidateFunctionArity2)); - fastify.use('/authorize', this.#server.errorHandler({ - mode: 'indirect', - modes: getQueryMode(this.config.url), - })); - fastify.use('/authorize', this.#server.errorHandler()); - - fastify.use('/decision', bodyParser.urlencoded({ extended: false })); - fastify.use('/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('/decision', 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('/*', async (_request, reply) => { reply.code(404); reply.send({ @@ -551,14 +626,93 @@ export class OAuth2ProviderService { @bindThis public async createTokenServer(fastify: FastifyInstance): Promise { + registerFormBodyParser(fastify); fastify.register(fastifyCors); - fastify.post('', async () => { }); - - await fastify.register(fastifyExpress); - // Clients may use JSON or urlencoded - fastify.use('', bodyParser.urlencoded({ extended: false })); - fastify.use('', bodyParser.json({ strict: true })); - fastify.use('', this.#server.token()); - fastify.use('', this.#server.errorHandler()); + + fastify.post('', async (request, reply) => { + applyNoStore(reply); + + try { + const body = toRequestParameters(request.body); + const grantType = firstValue(body.grant_type); + if (!grantType) { + throw new InvalidRequestError('grant_type is required'); + } + if (grantType !== 'authorization_code') { + throw new UnsupportedGrantTypeError(); + } + + const code = firstValue(body.code); + const clientId = firstValue(body.client_id); + const redirectUriValue = firstValue(body.redirect_uri); + const codeVerifier = firstValue(body.code_verifier); + + this.#logger.info('Checking the received authorization code for the exchange'); + if (!code) { + throw new InvalidGrantError('grant request is invalid'); + } + + const granted = this.#grantCodeCache.get(code); + if (!granted) { + throw new InvalidGrantError('grant request is invalid'); + } + + // 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) { + await this.#revokeGrantCode(granted, code); + throw new InvalidGrantError('grant request is invalid'); + } + granted.used = true; + + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 + if (clientId !== granted.clientId || redirectUriValue !== granted.redirectUri) { + throw new InvalidGrantError('grant request is invalid'); + } + + // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 + if (!codeVerifier) { + throw new InvalidGrantError('grant request is invalid'); + } + + const challengeResult = await verifyChallenge(codeVerifier, granted.codeChallenge); + if (!challengeResult) { + throw new InvalidGrantError('grant request is invalid'); + } + + const accessToken = secureRndstr(128); + const now = new Date(); + + // NOTE: we don't have a setup for automatic token expiration + await this.accessTokensRepository.insert({ + id: this.idService.gen(now.getTime()), + 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 this.accessTokensRepository.delete({ token: accessToken }); + throw new InvalidGrantError('grant request is invalid'); + } + + granted.grantedToken = accessToken; + this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); + + reply.send({ + access_token: accessToken, + token_type: 'Bearer', + scope: granted.scopes.join(' '), + }); + } catch (error) { + sendOAuthProviderError(reply, normalizeOAuthProviderError(error)); + } + }); } } diff --git a/packages/backend/src/server/oauth/errors.ts b/packages/backend/src/server/oauth/errors.ts new file mode 100644 index 00000000000..fb7fe9342c1 --- /dev/null +++ b/packages/backend/src/server/oauth/errors.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class OAuthProviderError extends Error { + public error: string; + public error_description?: string; + public expose = true; + public allow_redirect = true; + public status = 400; + public statusCode = 400; + + constructor(error: string, description?: string) { + super(description ?? error); + this.name = new.target.name; + this.error = error; + this.error_description = description; + } +} + +export class AccessDeniedError extends OAuthProviderError { + constructor(description = 'access denied') { + super('access_denied', description); + } +} + +export class InvalidGrantError extends OAuthProviderError { + constructor(description = 'grant request is invalid') { + super('invalid_grant', description); + } +} + +export class InvalidRequestError extends OAuthProviderError { + constructor(description = 'request is invalid') { + super('invalid_request', description); + } +} + +export class InvalidScopeError extends OAuthProviderError { + public scope?: string; + + constructor(description = '`scope` parameter has no known scope', scope?: string) { + super('invalid_scope', description); + this.scope = scope; + } +} + +export class UnsupportedGrantTypeError extends OAuthProviderError { + constructor(description = 'unsupported grant type requested') { + super('unsupported_grant_type', description); + } +} + +export class UnsupportedResponseTypeError extends OAuthProviderError { + constructor(description = 'unsupported response type requested') { + super('unsupported_response_type', description); + } +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ed13336703..1dfac3714f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,9 +102,6 @@ importers: '@fastify/cors': specifier: 11.2.0 version: 11.2.0 - '@fastify/express': - specifier: 4.0.5 - version: 4.0.5 '@fastify/http-proxy': specifier: 11.4.4 version: 11.4.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -180,9 +177,6 @@ importers: blurhash: specifier: 2.0.5 version: 2.0.5 - body-parser: - specifier: 2.2.2 - version: 2.2.2 bullmq: specifier: 5.76.8 version: 5.76.8 @@ -297,12 +291,6 @@ importers: nsfwjs: specifier: 4.3.0 version: 4.3.0(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(buffer@6.0.3) - oauth2orize: - specifier: 1.12.0 - version: 1.12.0 - oauth2orize-pkce: - specifier: 0.1.2 - version: 0.1.2 os-utils: specifier: 0.0.14 version: 0.0.14 @@ -415,9 +403,6 @@ importers: '@types/archiver': specifier: 7.0.0 version: 7.0.0 - '@types/body-parser': - specifier: 1.19.6 - version: 1.19.6 '@types/color-convert': specifier: 3.0.1 version: 3.0.1 @@ -448,12 +433,6 @@ importers: '@types/nodemailer': specifier: 8.0.0 version: 8.0.0 - '@types/oauth2orize': - specifier: 1.11.5 - version: 1.11.5 - '@types/oauth2orize-pkce': - specifier: 0.1.2 - version: 0.1.2 '@types/pg': specifier: 8.20.0 version: 8.20.0 @@ -2169,9 +2148,6 @@ packages: '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - '@fastify/express@4.0.5': - resolution: {integrity: sha512-Sd/6u38XGsOU4ETtw/xwPEZR3cDqEvidL6m+pJQDkvTPPDtXQvcfLZURmbVA1P6uTUhy2HkgV5nFcGatED5+aA==} - '@fastify/fast-json-stringify-compiler@5.0.3': resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} @@ -4217,9 +4193,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} @@ -4272,12 +4245,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/fluent-ffmpeg@2.1.28': resolution: {integrity: sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==} @@ -4290,9 +4257,6 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/http-link-header@1.0.7': resolution: {integrity: sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==} @@ -4353,12 +4317,6 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/oauth2orize-pkce@0.1.2': - resolution: {integrity: sha512-g5rDzqQTTUIJJpY7UWxb0EU1WyURIwOj3TndKC2krEEEmaKrnZXgoEBkR72QY2kp4cJ6N9cF2AqTPJ0Qyg+caA==} - - '@types/oauth2orize@1.11.5': - resolution: {integrity: sha512-C6hrRoh9hCnqis39OpeUZSwgw+TIzcV0CsxwJMGfQjTx4I1r+CLmuEPzoDJr5NRTfc7OMwHNLkQwrGFLKrJjMQ==} - '@types/offscreencanvas@2019.3.0': resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==} @@ -4380,15 +4338,9 @@ packages: '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/qs@6.15.1': - resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - '@types/random-seed@0.3.5': resolution: {integrity: sha512-CftxcDPAHgs0SLHU2dt+ZlDPJfGqLW3sZlC/ATr5vJDSe5tRLeOne7HMvCOJnFyF8e1U41wqzs3h6AMC613xtA==} - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/ratelimiter@3.4.6': resolution: {integrity: sha512-Bv6WLSXPGLVsBjkizXtn+ef78R92e36/DFQo2wXPTHtp1cYXF6rCULMqf9WcZPAtyMZMvQAtIPeYMA1xAyxghw==} @@ -4416,12 +4368,6 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/serviceworker@0.0.74': resolution: {integrity: sha512-HNt7NJHrjGtCmI3h1+rsb1g/ZY0iy5KaeenfEV7zAWPSaCs49hEUvgH++V1BHNwlLfB3sbjPh3pSiNixfYjb1w==} @@ -7905,13 +7851,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - oauth2orize-pkce@0.1.2: - resolution: {integrity: sha512-grto2UYhXHi9GLE3IBgBBbV87xci55+bCyjpVuxKyzol6I5Rg0K1MiTuXE+JZk54R86SG2wqXODMiZYHraPpxw==} - - oauth2orize@1.12.0: - resolution: {integrity: sha512-j4XtFDQUBsvUHPjUmvmNDUDMYed2MphMIJBhyxVVe8hGCjkuYnjIsW+D9qk8c5ciXRdnk6x6tEbiO6PLeOZdCQ==} - engines: {node: '>= 0.4.0'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -9838,9 +9777,6 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - uid2@0.0.4: - resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} - uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -9943,10 +9879,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -11297,13 +11229,6 @@ snapshots: '@fastify/error@4.2.0': {} - '@fastify/express@4.0.5': - dependencies: - express: 5.2.1 - fastify-plugin: 5.1.0 - transitivePeerDependencies: - - supports-color - '@fastify/fast-json-stringify-compiler@5.0.3': dependencies: fast-json-stringify: 6.4.0 @@ -13389,11 +13314,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 24.12.4 - '@types/braces@3.0.5': {} '@types/canvas-confetti@1.9.0': {} @@ -13442,19 +13362,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 24.12.4 - '@types/qs': 6.15.1 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - '@types/fluent-ffmpeg@2.1.28': dependencies: '@types/node': 24.12.4 @@ -13467,8 +13374,6 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} - '@types/http-errors@2.0.5': {} - '@types/http-link-header@1.0.7': dependencies: '@types/node': 24.12.4 @@ -13524,15 +13429,6 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/oauth2orize-pkce@0.1.2': - dependencies: - '@types/oauth2orize': 1.11.5 - - '@types/oauth2orize@1.11.5': - dependencies: - '@types/express': 5.0.6 - '@types/node': 24.12.4 - '@types/offscreencanvas@2019.3.0': {} '@types/offscreencanvas@2019.7.3': {} @@ -13559,12 +13455,8 @@ snapshots: dependencies: '@types/node': 24.12.4 - '@types/qs@6.15.1': {} - '@types/random-seed@0.3.5': {} - '@types/range-parser@1.2.7': {} - '@types/ratelimiter@3.4.6': {} '@types/react@19.2.14': @@ -13589,15 +13481,6 @@ snapshots: '@types/semver@7.7.1': {} - '@types/send@1.2.1': - dependencies: - '@types/node': 24.12.4 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 24.12.4 - '@types/serviceworker@0.0.74': {} '@types/set-cookie-parser@2.4.10': @@ -17664,16 +17547,6 @@ snapshots: dependencies: boolbase: 1.0.0 - oauth2orize-pkce@0.1.2: {} - - oauth2orize@1.12.0: - dependencies: - debug: 2.6.9 - uid2: 0.0.4 - utils-merge: 1.0.1 - transitivePeerDependencies: - - supports-color - object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -19736,8 +19609,6 @@ snapshots: ufo@1.6.4: {} - uid2@0.0.4: {} - uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -19841,8 +19712,6 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@11.1.0: {} uuid@14.0.0: {}