From c2b3ae058dc81043e50e072492226a8345a37c77 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 16:01:12 +0900 Subject: [PATCH 1/9] wip --- packages/backend/package.json | 11 +- packages/backend/rolldown.config.ts | 1 - .../src/@types/oidc-provider-internal.d.ts | 12 + .../src/server/oauth/OAuth2ProviderService.ts | 662 +++++++++++------- pnpm-lock.yaml | 307 +++++--- 5 files changed, 641 insertions(+), 352 deletions(-) create mode 100644 packages/backend/src/@types/oidc-provider-internal.d.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 1a19f02eab5..357cba1cfc3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -57,7 +57,6 @@ "@aws-sdk/lib-storage": "3.1044.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.6", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", @@ -122,12 +120,10 @@ "node-html-parser": "7.1.0", "nodemailer": "8.0.7", "nsfwjs": "4.3.0", - "oauth2orize": "1.12.0", - "oauth2orize-pkce": "0.1.2", + "oidc-provider": "9.8.3", "os-utils": "0.0.14", "otpauth": "9.5.1", "pg": "8.20.0", - "pkce-challenge": "6.0.0", "probe-image-size": "7.3.0", "promise-limit": "2.7.0", "qrcode": "1.5.4", @@ -163,7 +159,6 @@ "@sentry/vue": "10.52.0", "@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,13 +169,13 @@ "@types/ms": "2.1.0", "@types/node": "24.12.2", "@types/nodemailer": "8.0.0", - "@types/oauth2orize": "1.11.5", - "@types/oauth2orize-pkce": "0.1.2", + "@types/oidc-provider": "9.5.0", "@types/pg": "8.20.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", + "pkce-challenge": "6.0.0", "@types/sanitize-html": "2.16.1", "@types/semver": "7.7.1", "@types/simple-oauth2": "5.0.8", 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/@types/oidc-provider-internal.d.ts b/packages/backend/src/@types/oidc-provider-internal.d.ts new file mode 100644 index 00000000000..45e6ff4d1f9 --- /dev/null +++ b/packages/backend/src/@types/oidc-provider-internal.d.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module 'oidc-provider/lib/helpers/redirect_uri.js' { + export default function redirectUri(uri: string, payload: Record, mode: 'query' | 'fragment'): string; +} + +declare module 'oidc-provider/lib/helpers/pkce.js' { + export default function checkPKCE(verifier: string | undefined, challenge: string | undefined, method: string | undefined): void; +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 840c34b806e..3fe6d192f30 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -4,18 +4,15 @@ */ 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 checkPKCE from 'oidc-provider/lib/helpers/pkce.js'; import { permissions as kinds } from 'misskey-js'; +import redirectUri from 'oidc-provider/lib/helpers/redirect_uri.js'; +import { errors as oidcErrors } from 'oidc-provider'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; @@ -31,8 +28,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 +42,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 oidcErrors.InvalidRequest('client_id must be a valid URL'); + } })(); // "Client identifier URLs MUST have either an https or http scheme" @@ -56,7 +54,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 oidcErrors.InvalidRequest('client_id must be a valid HTTPS URL'); } // "MUST contain a path component (new URL() implicitly adds one)" @@ -64,19 +62,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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('client_id must not contain a username or a password'); } // ("MAY contain a port") @@ -84,7 +82,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 oidcErrors.InvalidRequest('client_id must have a domain name as a host name'); } return url; @@ -97,6 +95,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 +176,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 +200,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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('client_uri is not a prefix of client_id'); } if (typeof json.client_name === 'string') { @@ -213,100 +250,126 @@ 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 oidcErrors.InvalidRequest('Failed to fetch client information'); + } + if (err instanceof oidcErrors.OIDCProviderError) { throw err; - } else { - throw new AuthorizationError('Failed to parse client information', 'server_error'); } + + const wrapped = new oidcErrors.InvalidRequest('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); } -interface OAuthHttpResponse extends ServerResponse { - redirect(location: string): void; +function toRequestParameters(body: unknown): OAuthRequestParameters { + if (body == null || typeof body !== 'object' || Array.isArray(body)) { + return {}; + } + + return body as OAuthRequestParameters; } -interface OAuth2DecisionRequest extends MiddlewareRequest { - body: { - transaction_id: string; - cancel: boolean; - login_token: string; +function applyNoStore(reply: FastifyReply): void { + reply.header('Cache-Control', 'no-store'); + reply.header('Pragma', 'no-cache'); +} + +function createUnsupportedResponseTypeError(): oidcErrors.OIDCProviderError { + const error = new oidcErrors.UnsupportedResponseType(); + error.status = 501; + error.statusCode = 501; + return error; +} + +function createForbiddenAccessDenied(description: string): oidcErrors.OIDCProviderError { + const error = new oidcErrors.AccessDenied(description); + error.status = 403; + error.statusCode = 403; + return error; +} + +function normalizeOAuthError(error: unknown): oidcErrors.OIDCProviderError { + if (error instanceof oidcErrors.OIDCProviderError) { + return error; } + + const wrapped = new oidcErrors.InvalidRequest('request is invalid'); + if (error instanceof Error) { + wrapped.error_description = error.message; + } + return wrapped; +} + +function sendOAuthError(reply: FastifyReply, error: oidcErrors.OIDCProviderError): 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); + reply.code(302).redirect(redirectUri(redirectUriString, payload, 'query')); +} - 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); +function registerFormBodyParser(fastify: FastifyInstance): void { + if (fastify.hasContentTypeParser('application/x-www-form-urlencoded')) { + return; } - remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { - this.#cache.delete(tid); - cb(); - } + 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 +377,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 +386,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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidScope('`scope` parameter has no known scope', ''); + } + + // 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 oidcErrors.InvalidRequest('`code_challenge` parameter is required'); + } + if (seed.codeChallengeMethod !== 'S256') { + throw new oidcErrors.InvalidRequest('`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 oidcErrors.InvalidRequest('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 +503,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 authorizationRequest = this.#finalizeAuthorizationRequest(seed); + const { clientInfo } = seed; + validatedRedirectUri = authorizationRequest.redirectUri; + state = authorizationRequest.state; + + 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 oauthError = normalizeOAuthError(error); + if (validatedRedirectUri && oauthError.allow_redirect && oauthError.error !== 'unsupported_response_type') { + redirectWithQuery(reply, validatedRedirectUri, appendIssuer({ + error: oauthError.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, - })); + sendOAuthError(reply, oauthError); + } }); - 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 oidcErrors.InvalidRequest('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 oidcErrors.InvalidRequest('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) { + sendOAuthError(reply, normalizeOAuthError(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 +613,86 @@ 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 oidcErrors.InvalidRequest('grant_type is required'); + } + if (grantType !== 'authorization_code') { + throw new oidcErrors.UnsupportedGrantType(); + } + + 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 || !clientId || !redirectUriValue || !codeVerifier) { + throw new oidcErrors.InvalidGrant('Missing required parameters'); + } + + const granted = this.#grantCodeCache.get(code); + if (!granted) { + throw new oidcErrors.InvalidGrant('Invalid authorization code'); + } + + // 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 oidcErrors.InvalidGrant('Authorization code has already been used'); + } + granted.used = true; + + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 + if (clientId !== granted.clientId || redirectUriValue !== granted.redirectUri) { + throw new oidcErrors.InvalidGrant('client_id or redirect_uri does not match the authorization code'); + } + + // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 + checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); + + 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 oidcErrors.InvalidGrant('Authorization code has been revoked'); + } + + 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) { + sendOAuthError(reply, normalizeOAuthError(error)); + } + }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a753da19b..4b17740b6ed 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.6 version: 5.76.6 @@ -297,12 +291,9 @@ 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 + oidc-provider: + specifier: 9.8.3 + version: 9.8.3 os-utils: specifier: 0.0.14 version: 0.0.14 @@ -312,9 +303,6 @@ importers: pg: specifier: 8.20.0 version: 8.20.0 - pkce-challenge: - specifier: 6.0.0 - version: 6.0.0 probe-image-size: specifier: 7.3.0 version: 7.3.0 @@ -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,9 @@ 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/oidc-provider': + specifier: 9.5.0 + version: 9.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 @@ -532,6 +514,9 @@ importers: pid-port: specifier: 2.1.1 version: 2.1.1 + pkce-challenge: + specifier: 6.0.0 + version: 6.0.0 rolldown: specifier: 1.0.0 version: 1.0.0 @@ -2166,9 +2151,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==} @@ -2475,6 +2457,16 @@ packages: '@kitajs/html': ^4.2.10 typescript: ^5.9.3 + '@koa/cors@5.0.0': + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} + + '@koa/router@15.5.0': + resolution: {integrity: sha512-KSC0oG/5t6ITu5wqX4lJseA/dngoj14hEaohrLZEXtlUT2RRyJvwaJ0KV+5uQoaWrY3A8ClHOrBEU4g8dujn8Q==} + engines: {node: '>= 20'} + peerDependencies: + koa: ^2.0.0 || ^3.0.0 + '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} @@ -4262,6 +4254,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cookies@0.9.2': + resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4307,6 +4302,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-assert@1.5.6': + resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} + '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} @@ -4331,6 +4329,15 @@ packages: '@types/jsonld@1.5.15': resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.9': + resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} + + '@types/koa@3.0.2': + resolution: {integrity: sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==} + '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -4373,18 +4380,15 @@ 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==} '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/oidc-provider@9.5.0': + resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} + '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -5571,6 +5575,10 @@ packages: constantinople@4.0.1: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -5597,6 +5605,10 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + core-js@3.29.1: resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==} @@ -5781,6 +5793,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-equal@2.2.3: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} @@ -5823,6 +5838,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5831,6 +5850,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -6174,6 +6197,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -6418,6 +6445,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -6665,9 +6696,17 @@ packages: htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -7087,6 +7126,9 @@ packages: resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -7193,6 +7235,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7203,6 +7249,13 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa@3.2.0: + resolution: {integrity: sha512-TrM4/tnNY7uJ1aW55sIIa+dqBvc4V14WRIAlGcWat9wV5pRS9Wr5Zk2ZTjQP1jtfIHDoHiSbPuV08P0fUZo2pg==} + engines: {node: '>= 18'} + ky@1.14.3: resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} engines: {node: '>=18'} @@ -7933,13 +7986,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'} @@ -7983,6 +8029,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-provider@9.8.3: + resolution: {integrity: sha512-YkchaAyVAZbsn/l7IQhcEMdeDL3lwSo/PNUtnsXSqPqT7EG8DRko0EAWzHd/n9VfCtKVkxGjYOY4h4UwFcWnUA==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -8643,6 +8692,10 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + random-seed@0.3.0: resolution: {integrity: sha512-y13xtn3kcTlLub3HKWXxJNeC2qK4mB59evwZ5EkeRlolx+Bp2ztF7LbcZmyCnOqlHQrLnfuNbi1sVmm9lPDlDA==} engines: {node: '>= 0.6.0'} @@ -9313,6 +9366,10 @@ packages: engines: {node: ^22 || >=24} hasBin: true + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -9723,6 +9780,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -9868,9 +9929,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'} @@ -9973,10 +10031,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 @@ -11421,13 +11475,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 @@ -11724,6 +11771,20 @@ snapshots: typescript: 5.9.3 yargs: 18.0.0 + '@koa/cors@5.0.0': + dependencies: + vary: 1.1.2 + + '@koa/router@15.5.0(koa@3.2.0)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + http-errors: 2.0.1 + koa: 3.2.0 + koa-compose: 4.1.0 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + '@kurkle/color@0.3.4': {} '@levischuck/tiny-cbor@0.2.11': {} @@ -13733,6 +13794,13 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/cookies@0.9.2': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 5.0.6 + '@types/keygrip': 1.0.6 + '@types/node': 24.12.2 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -13783,6 +13851,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/http-assert@1.5.6': {} + '@types/http-cache-semantics@4.2.0': {} '@types/http-errors@2.0.5': {} @@ -13801,6 +13871,23 @@ snapshots: '@types/jsonld@1.5.15': {} + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.9': + dependencies: + '@types/koa': 3.0.2 + + '@types/koa@3.0.2': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.9 + '@types/cookies': 0.9.2 + '@types/http-assert': 1.5.6 + '@types/http-errors': 2.0.5 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.9 + '@types/node': 24.12.2 + '@types/long@4.0.2': {} '@types/matter-js@0.20.2': {} @@ -13842,19 +13929,16 @@ 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.2 - '@types/offscreencanvas@2019.3.0': {} '@types/offscreencanvas@2019.7.3': {} + '@types/oidc-provider@9.5.0': + dependencies: + '@types/keygrip': 1.0.6 + '@types/koa': 3.0.2 + '@types/node': 24.12.2 + '@types/pg-pool@2.0.7': dependencies: '@types/pg': 8.20.0 @@ -15161,6 +15245,8 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -15175,6 +15261,11 @@ snapshots: cookiejar@2.1.4: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + core-js@3.29.1: {} core-util-is@1.0.2: {} @@ -15422,6 +15513,8 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@1.0.1: {} + deep-equal@2.2.3: dependencies: array-buffer-byte-length: 1.0.2 @@ -15470,15 +15563,18 @@ snapshots: delayed-stream@1.0.0: {} - delegates@1.0.0: - optional: true + delegates@1.0.0: {} denque@2.1.0: {} + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} devlop@1.1.0: @@ -15946,6 +16042,8 @@ snapshots: esutils@2.0.3: {} + eta@4.6.0: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -16293,6 +16391,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@11.3.5: @@ -16597,8 +16697,21 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + http-cache-semantics@4.2.0: {} + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -17000,6 +17113,8 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 + jose@6.2.3: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -17108,6 +17223,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -17118,6 +17237,29 @@ snapshots: kind-of@6.0.3: {} + koa-compose@4.1.0: {} + + koa@3.2.0: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + ky@1.14.3: {} launder@1.7.1: @@ -17986,16 +18128,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: {} @@ -18047,6 +18179,21 @@ snapshots: obug@2.1.1: {} + oidc-provider@9.8.3: + dependencies: + '@koa/cors': 5.0.0 + '@koa/router': 15.5.0(koa@3.2.0) + debug: 4.4.3(supports-color@10.2.2) + eta: 4.6.0 + jose: 6.2.3 + jsesc: 3.1.0 + koa: 3.2.0 + nanoid: 5.1.11 + quick-lru: 7.3.0 + raw-body: 3.0.2 + transitivePeerDependencies: + - supports-color + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -18686,6 +18833,8 @@ snapshots: quick-lru@5.1.1: {} + quick-lru@7.3.0: {} + random-seed@0.3.0: dependencies: json-stringify-safe: 5.0.1 @@ -19491,6 +19640,8 @@ snapshots: transitivePeerDependencies: - supports-color + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -19924,6 +20075,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -20039,8 +20192,6 @@ snapshots: ufo@1.6.4: {} - uid2@0.0.4: {} - uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -20144,8 +20295,6 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@11.1.0: {} uuid@11.1.1: {} From 3b4b3ae256e0a9ae714b91ad0fe31567387d024d Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 16:12:20 +0900 Subject: [PATCH 2/9] fix --- .../src/@types/oidc-provider-internal.d.ts | 11 +++ .../src/server/oauth/OAuth2ProviderService.ts | 82 +++++++++++-------- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/@types/oidc-provider-internal.d.ts b/packages/backend/src/@types/oidc-provider-internal.d.ts index 45e6ff4d1f9..53b7754e6e9 100644 --- a/packages/backend/src/@types/oidc-provider-internal.d.ts +++ b/packages/backend/src/@types/oidc-provider-internal.d.ts @@ -3,6 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +declare module 'oidc-provider/lib/helpers/errors.js' { + import type { errors } from 'oidc-provider'; + export const OIDCProviderError: typeof errors.OIDCProviderError; + export const AccessDenied: typeof errors.AccessDenied; + export const InvalidGrant: typeof errors.InvalidGrant; + export const InvalidRequest: typeof errors.InvalidRequest; + export const InvalidScope: typeof errors.InvalidScope; + export const UnsupportedGrantType: typeof errors.UnsupportedGrantType; + export const UnsupportedResponseType: typeof errors.UnsupportedResponseType; +} + declare module 'oidc-provider/lib/helpers/redirect_uri.js' { export default function redirectUri(uri: string, payload: Record, mode: 'query' | 'fragment'): string; } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 3fe6d192f30..63b1a4b1084 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -12,7 +12,15 @@ import fastifyCors from '@fastify/cors'; import checkPKCE from 'oidc-provider/lib/helpers/pkce.js'; import { permissions as kinds } from 'misskey-js'; import redirectUri from 'oidc-provider/lib/helpers/redirect_uri.js'; -import { errors as oidcErrors } from 'oidc-provider'; +import { + AccessDenied, + InvalidGrant, + InvalidRequest, + InvalidScope, + OIDCProviderError, + UnsupportedGrantType, + UnsupportedResponseType, +} from 'oidc-provider/lib/helpers/errors.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; @@ -30,6 +38,8 @@ import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; import type { FastifyInstance, FastifyReply } from 'fastify'; +type OIDCProviderErrorInstance = InstanceType; + // 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. @@ -43,7 +53,7 @@ function validateClientId(raw: string): URL { try { return new URL(raw); } catch { - throw new oidcErrors.InvalidRequest('client_id must be a valid URL'); + throw new InvalidRequest('client_id must be a valid URL'); } })(); @@ -54,7 +64,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 oidcErrors.InvalidRequest('client_id must be a valid HTTPS URL'); + throw new InvalidRequest('client_id must be a valid HTTPS URL'); } // "MUST contain a path component (new URL() implicitly adds one)" @@ -62,19 +72,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 oidcErrors.InvalidRequest('client_id must not contain dot path segments'); + throw new InvalidRequest('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 oidcErrors.InvalidRequest('client_id must not contain a fragment component'); + throw new InvalidRequest('client_id must not contain a fragment component'); } // "MUST NOT contain a username or password component" if (url.username || url.password) { - throw new oidcErrors.InvalidRequest('client_id must not contain a username or a password'); + throw new InvalidRequest('client_id must not contain a username or a password'); } // ("MAY contain a port") @@ -82,7 +92,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 oidcErrors.InvalidRequest('client_id must have a domain name as a host name'); + throw new InvalidRequest('client_id must have a domain name as a host name'); } return url; @@ -200,13 +210,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 oidcErrors.InvalidRequest('client_id in the document does not match the client_id URL'); + throw new InvalidRequest('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 oidcErrors.InvalidRequest('client_uri is not a prefix of client_id'); + throw new InvalidRequest('client_uri is not a prefix of client_id'); } if (typeof json.client_name === 'string') { @@ -252,13 +262,13 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt } catch (err) { logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { - throw new oidcErrors.InvalidRequest('Failed to fetch client information'); + throw new InvalidRequest('Failed to fetch client information'); } - if (err instanceof oidcErrors.OIDCProviderError) { + if (err instanceof OIDCProviderError) { throw err; } - const wrapped = new oidcErrors.InvalidRequest('Failed to parse client information'); + const wrapped = new InvalidRequest('Failed to parse client information'); wrapped.status = 500; wrapped.statusCode = 500; wrapped.error = 'server_error'; @@ -288,33 +298,33 @@ function applyNoStore(reply: FastifyReply): void { reply.header('Pragma', 'no-cache'); } -function createUnsupportedResponseTypeError(): oidcErrors.OIDCProviderError { - const error = new oidcErrors.UnsupportedResponseType(); +function createUnsupportedResponseTypeError(): OIDCProviderErrorInstance { + const error = new UnsupportedResponseType(); error.status = 501; error.statusCode = 501; return error; } -function createForbiddenAccessDenied(description: string): oidcErrors.OIDCProviderError { - const error = new oidcErrors.AccessDenied(description); +function createForbiddenAccessDenied(description: string): OIDCProviderErrorInstance { + const error = new AccessDenied(description); error.status = 403; error.statusCode = 403; return error; } -function normalizeOAuthError(error: unknown): oidcErrors.OIDCProviderError { - if (error instanceof oidcErrors.OIDCProviderError) { +function normalizeOAuthError(error: unknown): OIDCProviderErrorInstance { + if (error instanceof OIDCProviderError) { return error; } - const wrapped = new oidcErrors.InvalidRequest('request is invalid'); + const wrapped = new InvalidRequest('request is invalid'); if (error instanceof Error) { wrapped.error_description = error.message; } return wrapped; } -function sendOAuthError(reply: FastifyReply, error: oidcErrors.OIDCProviderError): void { +function sendOAuthError(reply: FastifyReply, error: OIDCProviderErrorInstance): void { applyNoStore(reply); reply.code(error.statusCode ?? error.status ?? 400); reply.send({ @@ -404,7 +414,7 @@ export class OAuth2ProviderService { } if (!clientId) { - throw new oidcErrors.InvalidRequest('client_id must be provided'); + throw new InvalidRequest('client_id must be provided'); } const clientUrl = validateClientId(clientId); @@ -416,7 +426,7 @@ export class OAuth2ProviderService { 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 oidcErrors.InvalidRequest('client_id resolves to disallowed IP range.'); + throw new InvalidRequest('client_id resolves to disallowed IP range.'); } } @@ -426,7 +436,7 @@ export class OAuth2ProviderService { // 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 oidcErrors.InvalidRequest('Invalid redirect_uri'); + throw new InvalidRequest('Invalid redirect_uri'); } return { @@ -443,17 +453,17 @@ export class OAuth2ProviderService { #finalizeAuthorizationRequest(seed: AuthorizationRequestSeed): AuthorizationRequest { const scopes = [...new Set(seed.requestedScope)].filter(scope => (kinds).includes(scope)); if (!seed.requestedScope.length || !scopes.length) { - throw new oidcErrors.InvalidScope('`scope` parameter has no known scope', ''); + throw new InvalidScope('`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 oidcErrors.InvalidRequest('`code_challenge` parameter is required'); + throw new InvalidRequest('`code_challenge` parameter is required'); } if (seed.codeChallengeMethod !== 'S256') { - throw new oidcErrors.InvalidRequest('`code_challenge_method` parameter must be set as S256'); + throw new InvalidRequest('`code_challenge_method` parameter must be set as S256'); } return { @@ -470,7 +480,7 @@ export class OAuth2ProviderService { const user = await this.cacheService.localUserByNativeTokenCache.fetch(loginToken, () => this.usersRepository.findOneBy({ token: loginToken }) as Promise); if (!user) { - throw new oidcErrors.InvalidRequest('No such user'); + throw new InvalidRequest('No such user'); } return user; @@ -551,7 +561,7 @@ export class OAuth2ProviderService { const body = toRequestParameters(request.body); const transactionId = firstValue(body.transaction_id); if (!transactionId) { - throw new oidcErrors.InvalidRequest('Missing transaction ID'); + throw new InvalidRequest('Missing transaction ID'); } const transaction = this.#authorizationTransactionCache.get(transactionId); @@ -572,7 +582,7 @@ export class OAuth2ProviderService { const loginToken = firstValue(body.login_token); if (!loginToken) { - throw new oidcErrors.InvalidRequest('No user'); + throw new InvalidRequest('No user'); } this.#logger.info(`Checking the user before sending authorization code to ${transaction.client.id}`); @@ -623,10 +633,10 @@ export class OAuth2ProviderService { const body = toRequestParameters(request.body); const grantType = firstValue(body.grant_type); if (!grantType) { - throw new oidcErrors.InvalidRequest('grant_type is required'); + throw new InvalidRequest('grant_type is required'); } if (grantType !== 'authorization_code') { - throw new oidcErrors.UnsupportedGrantType(); + throw new UnsupportedGrantType(); } const code = firstValue(body.code); @@ -636,12 +646,12 @@ export class OAuth2ProviderService { this.#logger.info('Checking the received authorization code for the exchange'); if (!code || !clientId || !redirectUriValue || !codeVerifier) { - throw new oidcErrors.InvalidGrant('Missing required parameters'); + throw new InvalidGrant('grant request is invalid'); } const granted = this.#grantCodeCache.get(code); if (!granted) { - throw new oidcErrors.InvalidGrant('Invalid authorization code'); + throw new InvalidGrant('grant request is invalid'); } // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 @@ -650,13 +660,13 @@ export class OAuth2ProviderService { // previously issued based on that authorization code." if (granted.used) { await this.#revokeGrantCode(granted, code); - throw new oidcErrors.InvalidGrant('Authorization code has already been used'); + throw new InvalidGrant('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 oidcErrors.InvalidGrant('client_id or redirect_uri does not match the authorization code'); + throw new InvalidGrant('grant request is invalid'); } // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 @@ -679,7 +689,7 @@ export class OAuth2ProviderService { 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 oidcErrors.InvalidGrant('Authorization code has been revoked'); + throw new InvalidGrant('grant request is invalid'); } granted.grantedToken = accessToken; From b25c0311797af78a8e30c1ba11a9298a780501c8 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 16:15:15 +0900 Subject: [PATCH 3/9] fix tests --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 63b1a4b1084..75532b759b0 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -521,10 +521,10 @@ export class OAuth2ProviderService { try { const seed = await this.#resolveAuthorizationRequest(request.query as OAuthRequestParameters); - const authorizationRequest = this.#finalizeAuthorizationRequest(seed); const { clientInfo } = seed; - validatedRedirectUri = authorizationRequest.redirectUri; - state = authorizationRequest.state; + validatedRedirectUri = seed.redirectUri; + state = seed.state; + const authorizationRequest = this.#finalizeAuthorizationRequest(seed); const transactionId = secureRndstr(128); this.#authorizationTransactionCache.set(transactionId, { @@ -645,7 +645,7 @@ export class OAuth2ProviderService { const codeVerifier = firstValue(body.code_verifier); this.#logger.info('Checking the received authorization code for the exchange'); - if (!code || !clientId || !redirectUriValue || !codeVerifier) { + if (!code) { throw new InvalidGrant('grant request is invalid'); } @@ -670,6 +670,9 @@ export class OAuth2ProviderService { } // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 + if (!codeVerifier) { + throw new InvalidGrant('grant request is invalid'); + } checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); const accessToken = secureRndstr(128); From ca5dc65b673fed41ef783943a3eb8464b0bb4b73 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 16:36:45 +0900 Subject: [PATCH 4/9] fix: missing spec implementation --- .../src/server/oauth/OAuth2ProviderService.ts | 20 ++++++++++++++ .../backend/src/server/web/views/user.tsx | 3 +++ packages/backend/test/e2e/oauth.ts | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 75532b759b0..070a69d6918 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -34,6 +34,7 @@ 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 { UserEntityService } from '@/core/entities/UserEntityService.js'; import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; import type { FastifyInstance, FastifyReply } from 'fastify'; @@ -392,6 +393,7 @@ export class OAuth2ProviderService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, private cacheService: CacheService, + private userEntityService: UserEntityService, loggerService: LoggerService, private htmlTemplateService: HtmlTemplateService, ) { @@ -556,6 +558,12 @@ export class OAuth2ProviderService { } }); + // https://indieauth.spec.indieweb.org/#profile-url-response + // 11 July 2024 spec also allows redeeming an authorization code at the + // authorization endpoint itself when the client only needs the canonical + // profile URL and not an access token. Misskey currently uses this route + // only for the browser-based consent UI and issues tokens through + // /oauth/token, so the profile-only redemption flow remains unimplemented. fastify.post('/decision', async (request, reply) => { try { const body = toRequestParameters(request.body); @@ -675,10 +683,21 @@ export class OAuth2ProviderService { } checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); + // https://indieauth.spec.indieweb.org/#access-token-response + // The token response MUST include the canonical profile URL as `me`. + // Misskey uses the stable local actor URL so clients can later confirm + // that the returned profile URL declares the same authorization server. + const me = this.userEntityService.genLocalUserUri(granted.userId); + const accessToken = secureRndstr(128); const now = new Date(); // NOTE: we don't have a setup for automatic token expiration + // https://indieauth.spec.indieweb.org/#access-token-response + // `expires_in` is only RECOMMENDED there, and RFC6749 Section 5.1 also + // allows omitting it when the server documents or otherwise defines the + // token lifetime. Misskey currently issues bearer tokens without a + // published expiration timestamp. await this.accessTokensRepository.insert({ id: this.idService.gen(now.getTime()), lastUsedAt: now, @@ -702,6 +721,7 @@ export class OAuth2ProviderService { access_token: accessToken, token_type: 'Bearer', scope: granted.scopes.join(' '), + me, }); } catch (error) { sendOAuthError(reply, normalizeOAuthError(error)); diff --git a/packages/backend/src/server/web/views/user.tsx b/packages/backend/src/server/web/views/user.tsx index 76c2633ab9b..def354ef5e7 100644 --- a/packages/backend/src/server/web/views/user.tsx +++ b/packages/backend/src/server/web/views/user.tsx @@ -48,6 +48,9 @@ export function UserPage(props: CommonProps<{ {props.sub == null && props.federationEnabled ? ( <> + {props.user.host == null ? : null} + {props.user.host == null ? : null} + {props.user.host == null ? : null} {props.user.host == null ? : null} {props.user.uri != null ? : null} {props.profile.url != null ? : null} diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 82816f705ee..0ca1aebe3e6 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -83,6 +83,11 @@ function getMeta(html: string): { transactionId: string | undefined, clientName: }; } +function getLinkHref(html: string, rel: string): string | undefined { + const doc = htmlParser.parse(`
${html}
`); + return doc.querySelector(`link[rel="${rel}"]`)?.attributes.href; +} + function fetchDecision(transactionId: string, user: misskey.entities.SignupResponse, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', @@ -232,6 +237,27 @@ describe('OAuth', () => { assert.strictEqual(typeof token.token.access_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); assert.strictEqual(token.token.scope, 'write:notes'); + // https://indieauth.spec.indieweb.org/#access-token-response + assert.strictEqual(token.token.me, `http://misskey.local/users/${alice.id}`); + + // https://indieauth.spec.indieweb.org/#authorization-server-confirmation + // Clients must be able to rediscover the same authorization server + // from the returned canonical profile URL. + const meResponse = await fetch(token.token.me as string); + assert.strictEqual(meResponse.status, 200); + const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); + assert.strictEqual(metadataHref, 'http://misskey.local/.well-known/oauth-authorization-server'); + + const metadataResponse = await fetch(metadataHref as string); + assert.strictEqual(metadataResponse.status, 200); + const metadata = await metadataResponse.json() as { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + }; + assert.strictEqual(metadata.issuer, 'http://misskey.local'); + assert.strictEqual(metadata.authorization_endpoint, 'http://misskey.local/oauth/authorize'); + assert.strictEqual(metadata.token_endpoint, 'http://misskey.local/oauth/token'); const createResult = await api('notes/create', { text: 'test' }, { token: token.token.access_token as string, From da85334b739c0d71e64263920bac88568835a84d Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 17:58:27 +0900 Subject: [PATCH 5/9] fix test --- packages/backend/test/e2e/oauth.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 0ca1aebe3e6..cbc06751ed5 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -88,6 +88,15 @@ function getLinkHref(html: string, rel: string): string | undefined { return doc.querySelector(`link[rel="${rel}"]`)?.attributes.href; } +function toReachableTestUrl(url: string): string { + const canonicalUrl = new URL(url); + const reachableUrl = new URL(host); + reachableUrl.pathname = canonicalUrl.pathname; + reachableUrl.search = canonicalUrl.search; + reachableUrl.hash = canonicalUrl.hash; + return reachableUrl.toString(); +} + function fetchDecision(transactionId: string, user: misskey.entities.SignupResponse, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', @@ -243,12 +252,12 @@ describe('OAuth', () => { // https://indieauth.spec.indieweb.org/#authorization-server-confirmation // Clients must be able to rediscover the same authorization server // from the returned canonical profile URL. - const meResponse = await fetch(token.token.me as string); + const meResponse = await fetch(toReachableTestUrl(token.token.me as string)); assert.strictEqual(meResponse.status, 200); const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); assert.strictEqual(metadataHref, 'http://misskey.local/.well-known/oauth-authorization-server'); - const metadataResponse = await fetch(metadataHref as string); + const metadataResponse = await fetch(toReachableTestUrl(metadataHref as string)); assert.strictEqual(metadataResponse.status, 200); const metadata = await metadataResponse.json() as { issuer: string; From e851ee819b84fad148054725e4a120d77916bada Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 18:11:28 +0900 Subject: [PATCH 6/9] attempt to fix test --- packages/backend/test/e2e/oauth.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index cbc06751ed5..47054d00c7f 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -252,7 +252,11 @@ describe('OAuth', () => { // https://indieauth.spec.indieweb.org/#authorization-server-confirmation // Clients must be able to rediscover the same authorization server // from the returned canonical profile URL. - const meResponse = await fetch(toReachableTestUrl(token.token.me as string)); + const meResponse = await fetch(toReachableTestUrl(token.token.me as string), { + headers: { + 'Accept': 'text/html', + }, + }); assert.strictEqual(meResponse.status, 200); const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); assert.strictEqual(metadataHref, 'http://misskey.local/.well-known/oauth-authorization-server'); From 2d24221443b50057ac126ea73b0d359c994c6634 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 18:36:09 +0900 Subject: [PATCH 7/9] fix --- packages/backend/test/e2e/oauth.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 47054d00c7f..d93ab3cbb97 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -259,7 +259,7 @@ describe('OAuth', () => { }); assert.strictEqual(meResponse.status, 200); const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); - assert.strictEqual(metadataHref, 'http://misskey.local/.well-known/oauth-authorization-server'); + assert.strictEqual(metadataHref, `http://127.0.0.1:${clientPort}/.well-known/oauth-authorization-server`); const metadataResponse = await fetch(toReachableTestUrl(metadataHref as string)); assert.strictEqual(metadataResponse.status, 200); @@ -268,9 +268,9 @@ describe('OAuth', () => { authorization_endpoint: string; token_endpoint: string; }; - assert.strictEqual(metadata.issuer, 'http://misskey.local'); - assert.strictEqual(metadata.authorization_endpoint, 'http://misskey.local/oauth/authorize'); - assert.strictEqual(metadata.token_endpoint, 'http://misskey.local/oauth/token'); + assert.strictEqual(metadata.issuer, `http://127.0.0.1:${clientPort}`); + assert.strictEqual(metadata.authorization_endpoint, `http://127.0.0.1:${clientPort}/oauth/authorize`); + assert.strictEqual(metadata.token_endpoint, `http://127.0.0.1:${clientPort}/oauth/token`); const createResult = await api('notes/create', { text: 'test' }, { token: token.token.access_token as string, From 15886c1b80505502171a1eea494e8f62c027048d Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 17 May 2026 10:32:21 +0900 Subject: [PATCH 8/9] Revert "fix: missing spec implementation" This reverts commit ca5dc65b673fed41ef783943a3eb8464b0bb4b73. --- .../src/server/oauth/OAuth2ProviderService.ts | 20 ---------- .../backend/src/server/web/views/user.tsx | 3 -- packages/backend/test/e2e/oauth.ts | 39 ------------------- 3 files changed, 62 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 070a69d6918..75532b759b0 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -34,7 +34,6 @@ 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 { UserEntityService } from '@/core/entities/UserEntityService.js'; import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; import type { FastifyInstance, FastifyReply } from 'fastify'; @@ -393,7 +392,6 @@ export class OAuth2ProviderService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, private cacheService: CacheService, - private userEntityService: UserEntityService, loggerService: LoggerService, private htmlTemplateService: HtmlTemplateService, ) { @@ -558,12 +556,6 @@ export class OAuth2ProviderService { } }); - // https://indieauth.spec.indieweb.org/#profile-url-response - // 11 July 2024 spec also allows redeeming an authorization code at the - // authorization endpoint itself when the client only needs the canonical - // profile URL and not an access token. Misskey currently uses this route - // only for the browser-based consent UI and issues tokens through - // /oauth/token, so the profile-only redemption flow remains unimplemented. fastify.post('/decision', async (request, reply) => { try { const body = toRequestParameters(request.body); @@ -683,21 +675,10 @@ export class OAuth2ProviderService { } checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); - // https://indieauth.spec.indieweb.org/#access-token-response - // The token response MUST include the canonical profile URL as `me`. - // Misskey uses the stable local actor URL so clients can later confirm - // that the returned profile URL declares the same authorization server. - const me = this.userEntityService.genLocalUserUri(granted.userId); - const accessToken = secureRndstr(128); const now = new Date(); // NOTE: we don't have a setup for automatic token expiration - // https://indieauth.spec.indieweb.org/#access-token-response - // `expires_in` is only RECOMMENDED there, and RFC6749 Section 5.1 also - // allows omitting it when the server documents or otherwise defines the - // token lifetime. Misskey currently issues bearer tokens without a - // published expiration timestamp. await this.accessTokensRepository.insert({ id: this.idService.gen(now.getTime()), lastUsedAt: now, @@ -721,7 +702,6 @@ export class OAuth2ProviderService { access_token: accessToken, token_type: 'Bearer', scope: granted.scopes.join(' '), - me, }); } catch (error) { sendOAuthError(reply, normalizeOAuthError(error)); diff --git a/packages/backend/src/server/web/views/user.tsx b/packages/backend/src/server/web/views/user.tsx index def354ef5e7..76c2633ab9b 100644 --- a/packages/backend/src/server/web/views/user.tsx +++ b/packages/backend/src/server/web/views/user.tsx @@ -48,9 +48,6 @@ export function UserPage(props: CommonProps<{ {props.sub == null && props.federationEnabled ? ( <> - {props.user.host == null ? : null} - {props.user.host == null ? : null} - {props.user.host == null ? : null} {props.user.host == null ? : null} {props.user.uri != null ? : null} {props.profile.url != null ? : null} diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index d93ab3cbb97..82816f705ee 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -83,20 +83,6 @@ function getMeta(html: string): { transactionId: string | undefined, clientName: }; } -function getLinkHref(html: string, rel: string): string | undefined { - const doc = htmlParser.parse(`
${html}
`); - return doc.querySelector(`link[rel="${rel}"]`)?.attributes.href; -} - -function toReachableTestUrl(url: string): string { - const canonicalUrl = new URL(url); - const reachableUrl = new URL(host); - reachableUrl.pathname = canonicalUrl.pathname; - reachableUrl.search = canonicalUrl.search; - reachableUrl.hash = canonicalUrl.hash; - return reachableUrl.toString(); -} - function fetchDecision(transactionId: string, user: misskey.entities.SignupResponse, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', @@ -246,31 +232,6 @@ describe('OAuth', () => { assert.strictEqual(typeof token.token.access_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); assert.strictEqual(token.token.scope, 'write:notes'); - // https://indieauth.spec.indieweb.org/#access-token-response - assert.strictEqual(token.token.me, `http://misskey.local/users/${alice.id}`); - - // https://indieauth.spec.indieweb.org/#authorization-server-confirmation - // Clients must be able to rediscover the same authorization server - // from the returned canonical profile URL. - const meResponse = await fetch(toReachableTestUrl(token.token.me as string), { - headers: { - 'Accept': 'text/html', - }, - }); - assert.strictEqual(meResponse.status, 200); - const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); - assert.strictEqual(metadataHref, `http://127.0.0.1:${clientPort}/.well-known/oauth-authorization-server`); - - const metadataResponse = await fetch(toReachableTestUrl(metadataHref as string)); - assert.strictEqual(metadataResponse.status, 200); - const metadata = await metadataResponse.json() as { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; - }; - assert.strictEqual(metadata.issuer, `http://127.0.0.1:${clientPort}`); - assert.strictEqual(metadata.authorization_endpoint, `http://127.0.0.1:${clientPort}/oauth/authorize`); - assert.strictEqual(metadata.token_endpoint, `http://127.0.0.1:${clientPort}/oauth/token`); const createResult = await api('notes/create', { text: 'test' }, { token: token.token.access_token as string, From 5bd906c58cee2d516526066f24b20dd58ae70a81 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 21 May 2026 19:55:25 +0900 Subject: [PATCH 9/9] update --- packages/backend/package.json | 4 +- .../src/@types/oidc-provider-internal.d.ts | 23 -- .../src/server/oauth/OAuth2ProviderService.ts | 119 ++++--- packages/backend/src/server/oauth/errors.ts | 60 ++++ pnpm-lock.yaml | 324 +----------------- 5 files changed, 140 insertions(+), 390 deletions(-) delete mode 100644 packages/backend/src/@types/oidc-provider-internal.d.ts create mode 100644 packages/backend/src/server/oauth/errors.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 5bd804b788c..b7e2249a6cb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -120,10 +120,10 @@ "node-html-parser": "7.1.0", "nodemailer": "8.0.7", "nsfwjs": "4.3.0", - "oidc-provider": "9.8.3", "os-utils": "0.0.14", "otpauth": "9.5.1", "pg": "8.20.0", + "pkce-challenge": "6.0.0", "probe-image-size": "7.3.0", "promise-limit": "2.7.0", "qrcode": "1.5.4", @@ -169,13 +169,11 @@ "@types/ms": "2.1.0", "@types/node": "24.12.2", "@types/nodemailer": "8.0.0", - "@types/oidc-provider": "9.5.0", "@types/pg": "8.20.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "pkce-challenge": "6.0.0", "@types/sanitize-html": "2.16.1", "@types/semver": "7.7.1", "@types/simple-oauth2": "5.0.8", diff --git a/packages/backend/src/@types/oidc-provider-internal.d.ts b/packages/backend/src/@types/oidc-provider-internal.d.ts deleted file mode 100644 index 53b7754e6e9..00000000000 --- a/packages/backend/src/@types/oidc-provider-internal.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module 'oidc-provider/lib/helpers/errors.js' { - import type { errors } from 'oidc-provider'; - export const OIDCProviderError: typeof errors.OIDCProviderError; - export const AccessDenied: typeof errors.AccessDenied; - export const InvalidGrant: typeof errors.InvalidGrant; - export const InvalidRequest: typeof errors.InvalidRequest; - export const InvalidScope: typeof errors.InvalidScope; - export const UnsupportedGrantType: typeof errors.UnsupportedGrantType; - export const UnsupportedResponseType: typeof errors.UnsupportedResponseType; -} - -declare module 'oidc-provider/lib/helpers/redirect_uri.js' { - export default function redirectUri(uri: string, payload: Record, mode: 'query' | 'fragment'): string; -} - -declare module 'oidc-provider/lib/helpers/pkce.js' { - export default function checkPKCE(verifier: string | undefined, challenge: string | undefined, method: string | undefined): void; -} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 75532b759b0..c507b1d8f3b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -9,18 +9,17 @@ import * as htmlParser from 'node-html-parser'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import fastifyCors from '@fastify/cors'; -import checkPKCE from 'oidc-provider/lib/helpers/pkce.js'; +import { verifyChallenge } from 'pkce-challenge'; import { permissions as kinds } from 'misskey-js'; -import redirectUri from 'oidc-provider/lib/helpers/redirect_uri.js'; import { - AccessDenied, - InvalidGrant, - InvalidRequest, - InvalidScope, - OIDCProviderError, - UnsupportedGrantType, - UnsupportedResponseType, -} from 'oidc-provider/lib/helpers/errors.js'; + 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'; @@ -38,8 +37,6 @@ import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; import type { FastifyInstance, FastifyReply } from 'fastify'; -type OIDCProviderErrorInstance = InstanceType; - // 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. @@ -53,7 +50,7 @@ function validateClientId(raw: string): URL { try { return new URL(raw); } catch { - throw new InvalidRequest('client_id must be a valid URL'); + throw new InvalidRequestError('client_id must be a valid URL'); } })(); @@ -64,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 InvalidRequest('client_id must be a valid HTTPS URL'); + throw new InvalidRequestError('client_id must be a valid HTTPS URL'); } // "MUST contain a path component (new URL() implicitly adds one)" @@ -72,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 InvalidRequest('client_id must not contain dot path segments'); + 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 InvalidRequest('client_id must not contain a fragment component'); + 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 InvalidRequest('client_id must not contain a username or a password'); + throw new InvalidRequestError('client_id must not contain a username or a password'); } // ("MAY contain a port") @@ -92,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 InvalidRequest('client_id must have a domain name as a host name'); + throw new InvalidRequestError('client_id must have a domain name as a host name'); } return url; @@ -210,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 InvalidRequest('client_id in the document does not match the client_id URL'); + 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 InvalidRequest('client_uri is not a prefix of client_id'); + throw new InvalidRequestError('client_uri is not a prefix of client_id'); } if (typeof json.client_name === 'string') { @@ -262,13 +259,13 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt } catch (err) { logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { - throw new InvalidRequest('Failed to fetch client information'); + throw new InvalidRequestError('Failed to fetch client information'); } - if (err instanceof OIDCProviderError) { + if (err instanceof OAuthProviderError) { throw err; } - const wrapped = new InvalidRequest('Failed to parse client information'); + const wrapped = new InvalidRequestError('Failed to parse client information'); wrapped.status = 500; wrapped.statusCode = 500; wrapped.error = 'server_error'; @@ -298,33 +295,33 @@ function applyNoStore(reply: FastifyReply): void { reply.header('Pragma', 'no-cache'); } -function createUnsupportedResponseTypeError(): OIDCProviderErrorInstance { - const error = new UnsupportedResponseType(); +function createUnsupportedResponseTypeError(): OAuthProviderError { + const error = new UnsupportedResponseTypeError(); error.status = 501; error.statusCode = 501; return error; } -function createForbiddenAccessDenied(description: string): OIDCProviderErrorInstance { - const error = new AccessDenied(description); +function createForbiddenAccessDenied(description: string): OAuthProviderError { + const error = new AccessDeniedError(description); error.status = 403; error.statusCode = 403; return error; } -function normalizeOAuthError(error: unknown): OIDCProviderErrorInstance { - if (error instanceof OIDCProviderError) { +function normalizeOAuthProviderError(error: unknown): OAuthProviderError { + if (error instanceof OAuthProviderError) { return error; } - const wrapped = new InvalidRequest('request is invalid'); + const wrapped = new InvalidRequestError('request is invalid'); if (error instanceof Error) { wrapped.error_description = error.message; } return wrapped; } -function sendOAuthError(reply: FastifyReply, error: OIDCProviderErrorInstance): void { +function sendOAuthProviderError(reply: FastifyReply, error: OAuthProviderError): void { applyNoStore(reply); reply.code(error.statusCode ?? error.status ?? 400); reply.send({ @@ -347,7 +344,13 @@ function appendIssuer(payload: Record, issuerUrl: string): Recor function redirectWithQuery(reply: FastifyReply, redirectUriString: string, payload: Record): void { applyNoStore(reply); - reply.code(302).redirect(redirectUri(redirectUriString, payload, 'query')); + + const redirectUri = new URL(redirectUriString); + for (const [key, value] of Object.entries(payload)) { + redirectUri.searchParams.set(key, value); + } + + reply.code(302).redirect(redirectUri.toString()); } function registerFormBodyParser(fastify: FastifyInstance): void { @@ -414,7 +417,7 @@ export class OAuth2ProviderService { } if (!clientId) { - throw new InvalidRequest('client_id must be provided'); + throw new InvalidRequestError('client_id must be provided'); } const clientUrl = validateClientId(clientId); @@ -426,7 +429,7 @@ export class OAuth2ProviderService { 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 InvalidRequest('client_id resolves to disallowed IP range.'); + throw new InvalidRequestError('client_id resolves to disallowed IP range.'); } } @@ -436,7 +439,7 @@ export class OAuth2ProviderService { // 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 InvalidRequest('Invalid redirect_uri'); + throw new InvalidRequestError('Invalid redirect_uri'); } return { @@ -453,17 +456,17 @@ export class OAuth2ProviderService { #finalizeAuthorizationRequest(seed: AuthorizationRequestSeed): AuthorizationRequest { const scopes = [...new Set(seed.requestedScope)].filter(scope => (kinds).includes(scope)); if (!seed.requestedScope.length || !scopes.length) { - throw new InvalidScope('`scope` parameter has no known scope', seed.requestedScope.join(' ')); + 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 InvalidRequest('`code_challenge` parameter is required'); + throw new InvalidRequestError('`code_challenge` parameter is required'); } if (seed.codeChallengeMethod !== 'S256') { - throw new InvalidRequest('`code_challenge_method` parameter must be set as S256'); + throw new InvalidRequestError('`code_challenge_method` parameter must be set as S256'); } return { @@ -480,7 +483,7 @@ export class OAuth2ProviderService { const user = await this.cacheService.localUserByNativeTokenCache.fetch(loginToken, () => this.usersRepository.findOneBy({ token: loginToken }) as Promise); if (!user) { - throw new InvalidRequest('No such user'); + throw new InvalidRequestError('No such user'); } return user; @@ -543,16 +546,16 @@ export class OAuth2ProviderService { scope: authorizationRequest.scopes, })); } catch (error) { - const oauthError = normalizeOAuthError(error); - if (validatedRedirectUri && oauthError.allow_redirect && oauthError.error !== 'unsupported_response_type') { + const OAuthProviderError = normalizeOAuthProviderError(error); + if (validatedRedirectUri && OAuthProviderError.allow_redirect && OAuthProviderError.error !== 'unsupported_response_type') { redirectWithQuery(reply, validatedRedirectUri, appendIssuer({ - error: oauthError.error, + error: OAuthProviderError.error, ...(state ? { state } : {}), }, this.config.url)); return; } - sendOAuthError(reply, oauthError); + sendOAuthProviderError(reply, OAuthProviderError); } }); @@ -561,7 +564,7 @@ export class OAuth2ProviderService { const body = toRequestParameters(request.body); const transactionId = firstValue(body.transaction_id); if (!transactionId) { - throw new InvalidRequest('Missing transaction ID'); + throw new InvalidRequestError('Missing transaction ID'); } const transaction = this.#authorizationTransactionCache.get(transactionId); @@ -582,7 +585,7 @@ export class OAuth2ProviderService { const loginToken = firstValue(body.login_token); if (!loginToken) { - throw new InvalidRequest('No user'); + throw new InvalidRequestError('No user'); } this.#logger.info(`Checking the user before sending authorization code to ${transaction.client.id}`); @@ -604,7 +607,7 @@ export class OAuth2ProviderService { ...(transaction.request.state ? { state: transaction.request.state } : {}), }, this.config.url)); } catch (error) { - sendOAuthError(reply, normalizeOAuthError(error)); + sendOAuthProviderError(reply, normalizeOAuthProviderError(error)); } }); @@ -633,10 +636,10 @@ export class OAuth2ProviderService { const body = toRequestParameters(request.body); const grantType = firstValue(body.grant_type); if (!grantType) { - throw new InvalidRequest('grant_type is required'); + throw new InvalidRequestError('grant_type is required'); } if (grantType !== 'authorization_code') { - throw new UnsupportedGrantType(); + throw new UnsupportedGrantTypeError(); } const code = firstValue(body.code); @@ -646,12 +649,12 @@ export class OAuth2ProviderService { this.#logger.info('Checking the received authorization code for the exchange'); if (!code) { - throw new InvalidGrant('grant request is invalid'); + throw new InvalidGrantError('grant request is invalid'); } const granted = this.#grantCodeCache.get(code); if (!granted) { - throw new InvalidGrant('grant request is invalid'); + throw new InvalidGrantError('grant request is invalid'); } // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 @@ -660,20 +663,24 @@ export class OAuth2ProviderService { // previously issued based on that authorization code." if (granted.used) { await this.#revokeGrantCode(granted, code); - throw new InvalidGrant('grant request is invalid'); + 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 InvalidGrant('grant request is invalid'); + throw new InvalidGrantError('grant request is invalid'); } // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 if (!codeVerifier) { - throw new InvalidGrant('grant request is invalid'); + throw new InvalidGrantError('grant request is invalid'); + } + + const challengeResult = await verifyChallenge(codeVerifier, granted.codeChallenge); + if (!challengeResult) { + throw new InvalidGrantError('grant request is invalid'); } - checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); const accessToken = secureRndstr(128); const now = new Date(); @@ -692,7 +699,7 @@ export class OAuth2ProviderService { 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 InvalidGrant('grant request is invalid'); + throw new InvalidGrantError('grant request is invalid'); } granted.grantedToken = accessToken; @@ -704,7 +711,7 @@ export class OAuth2ProviderService { scope: granted.scopes.join(' '), }); } catch (error) { - sendOAuthError(reply, normalizeOAuthError(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 453239ec441..936b3e66b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,9 +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) - oidc-provider: - specifier: 9.8.3 - version: 9.8.3 os-utils: specifier: 0.0.14 version: 0.0.14 @@ -303,6 +300,9 @@ importers: pg: specifier: 8.20.0 version: 8.20.0 + pkce-challenge: + specifier: 6.0.0 + version: 6.0.0 probe-image-size: specifier: 7.3.0 version: 7.3.0 @@ -433,9 +433,6 @@ importers: '@types/nodemailer': specifier: 8.0.0 version: 8.0.0 - '@types/oidc-provider': - specifier: 9.5.0 - version: 9.5.0 '@types/pg': specifier: 8.20.0 version: 8.20.0 @@ -514,9 +511,6 @@ importers: pid-port: specifier: 2.1.1 version: 2.1.1 - pkce-challenge: - specifier: 6.0.0 - version: 6.0.0 rolldown: specifier: 1.0.0 version: 1.0.0 @@ -2457,16 +2451,6 @@ packages: '@kitajs/html': ^4.2.10 typescript: ^5.9.3 - '@koa/cors@5.0.0': - resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} - engines: {node: '>= 14.0.0'} - - '@koa/router@15.5.0': - resolution: {integrity: sha512-KSC0oG/5t6ITu5wqX4lJseA/dngoj14hEaohrLZEXtlUT2RRyJvwaJ0KV+5uQoaWrY3A8ClHOrBEU4g8dujn8Q==} - engines: {node: '>= 20'} - peerDependencies: - koa: ^2.0.0 || ^3.0.0 - '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} @@ -4229,9 +4213,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==} @@ -4254,9 +4235,6 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/cookies@0.9.2': - resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4287,12 +4265,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==} @@ -4302,15 +4274,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/http-assert@1.5.6': - resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} - '@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==} @@ -4329,15 +4295,6 @@ packages: '@types/jsonld@1.5.15': resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==} - '@types/keygrip@1.0.6': - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - - '@types/koa-compose@3.2.9': - resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} - - '@types/koa@3.0.2': - resolution: {integrity: sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==} - '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -4386,9 +4343,6 @@ packages: '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} - '@types/oidc-provider@9.5.0': - resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} - '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -4404,15 +4358,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==} @@ -4440,12 +4388,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==} @@ -4843,7 +4785,7 @@ packages: engines: {node: '>= 14'} aiscript-vscode@https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67: - resolution: {gitHosted: true, integrity: sha512-S4eSTHasZz29AMlnU2/zdGP8zikiDiYfYW9kNooAfwVo8OghXdxKuTDDKDAjWsbBxa1+P4uQHa4BNk9MY74rJQ==, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67} + resolution: {gitHosted: true, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67} version: 0.1.16 engines: {vscode: ^1.83.0} @@ -5575,10 +5517,6 @@ packages: constantinople@4.0.1: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -5605,10 +5543,6 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - cookies@0.9.1: - resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} - engines: {node: '>= 0.8'} - core-js@3.29.1: resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==} @@ -5793,9 +5727,6 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-equal@1.0.1: - resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} - deep-equal@2.2.3: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} @@ -5838,10 +5769,6 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - depd@1.1.2: - resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} - engines: {node: '>= 0.6'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5850,10 +5777,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -6197,10 +6120,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eta@4.6.0: - resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} - engines: {node: '>=20'} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -6445,10 +6364,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -6696,17 +6611,9 @@ packages: htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} - http-assert@1.5.0: - resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} - engines: {node: '>= 0.8'} - http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - http-errors@1.8.1: - resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} - engines: {node: '>= 0.6'} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -7126,9 +7033,6 @@ packages: resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -7235,10 +7139,6 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - keygrip@1.1.0: - resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} - engines: {node: '>= 0.6'} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7249,13 +7149,6 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - koa-compose@4.1.0: - resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} - - koa@3.2.0: - resolution: {integrity: sha512-TrM4/tnNY7uJ1aW55sIIa+dqBvc4V14WRIAlGcWat9wV5pRS9Wr5Zk2ZTjQP1jtfIHDoHiSbPuV08P0fUZo2pg==} - engines: {node: '>= 18'} - ky@1.14.3: resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} engines: {node: '>=18'} @@ -8029,9 +7922,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - oidc-provider@9.8.3: - resolution: {integrity: sha512-YkchaAyVAZbsn/l7IQhcEMdeDL3lwSo/PNUtnsXSqPqT7EG8DRko0EAWzHd/n9VfCtKVkxGjYOY4h4UwFcWnUA==} - on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -8692,10 +8582,6 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - quick-lru@7.3.0: - resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} - engines: {node: '>=18'} - random-seed@0.3.0: resolution: {integrity: sha512-y13xtn3kcTlLub3HKWXxJNeC2qK4mB59evwZ5EkeRlolx+Bp2ztF7LbcZmyCnOqlHQrLnfuNbi1sVmm9lPDlDA==} engines: {node: '>= 0.6.0'} @@ -9366,10 +9252,6 @@ packages: engines: {node: ^22 || >=24} hasBin: true - statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} - engines: {node: '>= 0.6'} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -9382,7 +9264,7 @@ packages: engines: {node: '>= 0.4'} storybook-addon-misskey-theme@https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640: - resolution: {gitHosted: true, integrity: sha512-QaH1uZSlApQ2CZPkHfhmNm89I92L02s3MdbUPG66TmAyqMaqzxd/AvobORBjtTZ0ymUSa3ii482dRXi+fFb19w==, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640} + resolution: {gitHosted: true, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640} version: 0.0.0 peerDependencies: '@storybook/blocks': ^7.0.0-rc.4 @@ -9780,10 +9662,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsscmp@1.0.6: - resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} - engines: {node: '>=0.6.x'} - tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -10238,6 +10116,9 @@ packages: vue-component-type-helpers@3.2.8: resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} + vue-component-type-helpers@3.2.9: + resolution: {integrity: sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==} + vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -10395,18 +10276,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -11498,7 +11367,7 @@ snapshots: '@fastify/reply-from': 12.6.2 fast-querystring: 1.1.2 fastify-plugin: 5.1.0 - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11783,20 +11652,6 @@ snapshots: typescript: 5.9.3 yargs: 18.0.0 - '@koa/cors@5.0.0': - dependencies: - vary: 1.1.2 - - '@koa/router@15.5.0(koa@3.2.0)': - dependencies: - debug: 4.4.3(supports-color@10.2.2) - http-errors: 2.0.1 - koa: 3.2.0 - koa-compose: 4.1.0 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - '@kurkle/color@0.3.4': {} '@levischuck/tiny-cbor@0.2.11': {} @@ -13562,7 +13417,7 @@ snapshots: storybook: 10.3.6(@testing-library/dom@10.4.0)(bufferutil@4.1.0)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@6.0.6) type-fest: 2.19.0 vue: 3.5.34(typescript@5.9.3) - vue-component-type-helpers: 3.2.8 + vue-component-type-helpers: 3.2.9 '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4)': dependencies: @@ -13780,11 +13635,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 24.12.2 - '@types/braces@3.0.5': {} '@types/canvas-confetti@1.9.0': {} @@ -13806,13 +13656,6 @@ snapshots: '@types/cookiejar@2.1.5': {} - '@types/cookies@0.9.2': - dependencies: - '@types/connect': 3.4.38 - '@types/express': 5.0.6 - '@types/keygrip': 1.0.6 - '@types/node': 24.12.2 - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -13840,19 +13683,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 24.12.2 - '@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.2 @@ -13863,12 +13693,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/http-assert@1.5.6': {} - '@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.2 @@ -13883,23 +13709,6 @@ snapshots: '@types/jsonld@1.5.15': {} - '@types/keygrip@1.0.6': {} - - '@types/koa-compose@3.2.9': - dependencies: - '@types/koa': 3.0.2 - - '@types/koa@3.0.2': - dependencies: - '@types/accepts': 1.3.7 - '@types/content-disposition': 0.5.9 - '@types/cookies': 0.9.2 - '@types/http-assert': 1.5.6 - '@types/http-errors': 2.0.5 - '@types/keygrip': 1.0.6 - '@types/koa-compose': 3.2.9 - '@types/node': 24.12.2 - '@types/long@4.0.2': {} '@types/matter-js@0.20.2': {} @@ -13945,12 +13754,6 @@ snapshots: '@types/offscreencanvas@2019.7.3': {} - '@types/oidc-provider@9.5.0': - dependencies: - '@types/keygrip': 1.0.6 - '@types/koa': 3.0.2 - '@types/node': 24.12.2 - '@types/pg-pool@2.0.7': dependencies: '@types/pg': 8.20.0 @@ -13973,12 +13776,8 @@ snapshots: dependencies: '@types/node': 24.12.2 - '@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': @@ -14003,15 +13802,6 @@ snapshots: '@types/semver@7.7.1': {} - '@types/send@1.2.1': - dependencies: - '@types/node': 24.12.2 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 24.12.2 - '@types/serviceworker@0.0.74': {} '@types/set-cookie-parser@2.4.10': @@ -15257,8 +15047,6 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - content-disposition@1.0.1: {} - content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -15273,11 +15061,6 @@ snapshots: cookiejar@2.1.4: {} - cookies@0.9.1: - dependencies: - depd: 2.0.0 - keygrip: 1.1.0 - core-js@3.29.1: {} core-util-is@1.0.2: {} @@ -15525,8 +15308,6 @@ snapshots: deep-eql@5.0.2: {} - deep-equal@1.0.1: {} - deep-equal@2.2.3: dependencies: array-buffer-byte-length: 1.0.2 @@ -15575,18 +15356,15 @@ snapshots: delayed-stream@1.0.0: {} - delegates@1.0.0: {} + delegates@1.0.0: + optional: true denque@2.1.0: {} - depd@1.1.2: {} - depd@2.0.0: {} dequal@2.0.3: {} - destroy@1.2.0: {} - detect-libc@2.1.2: {} devlop@1.1.0: @@ -16054,8 +15832,6 @@ snapshots: esutils@2.0.3: {} - eta@4.6.0: {} - etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -16403,8 +16179,6 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fs-extra@11.3.5: @@ -16611,7 +16385,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -16709,21 +16483,8 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 - http-assert@1.5.0: - dependencies: - deep-equal: 1.0.1 - http-errors: 1.8.1 - http-cache-semantics@4.2.0: {} - http-errors@1.8.1: - dependencies: - depd: 1.1.2 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 1.5.0 - toidentifier: 1.0.1 - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -17125,8 +16886,6 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 - jose@6.2.3: {} - js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -17235,10 +16994,6 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - keygrip@1.1.0: - dependencies: - tsscmp: 1.0.6 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -17249,29 +17004,6 @@ snapshots: kind-of@6.0.3: {} - koa-compose@4.1.0: {} - - koa@3.2.0: - dependencies: - accepts: 1.3.8 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookies: 0.9.1 - delegates: 1.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - fresh: 0.5.2 - http-assert: 1.5.0 - http-errors: 2.0.1 - koa-compose: 4.1.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - ky@1.14.3: {} launder@1.7.1: @@ -18191,21 +17923,6 @@ snapshots: obug@2.1.1: {} - oidc-provider@9.8.3: - dependencies: - '@koa/cors': 5.0.0 - '@koa/router': 15.5.0(koa@3.2.0) - debug: 4.4.3(supports-color@10.2.2) - eta: 4.6.0 - jose: 6.2.3 - jsesc: 3.1.0 - koa: 3.2.0 - nanoid: 5.1.11 - quick-lru: 7.3.0 - raw-body: 3.0.2 - transitivePeerDependencies: - - supports-color - on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -18845,8 +18562,6 @@ snapshots: quick-lru@5.1.1: {} - quick-lru@7.3.0: {} - random-seed@0.3.0: dependencies: json-stringify-safe: 5.0.1 @@ -19652,8 +19367,6 @@ snapshots: transitivePeerDependencies: - supports-color - statuses@1.5.0: {} - statuses@2.0.2: {} std-env@4.1.0: {} @@ -19690,7 +19403,7 @@ snapshots: recast: 0.23.11 semver: 7.7.4 use-sync-external-store: 1.6.0(react@19.2.6) - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: prettier: 3.8.3 transitivePeerDependencies: @@ -20087,8 +19800,6 @@ snapshots: tslib@2.8.1: {} - tsscmp@1.0.6: {} - tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -20455,6 +20166,8 @@ snapshots: vue-component-type-helpers@3.2.8: {} + vue-component-type-helpers@3.2.9: {} + vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)): dependencies: vue: 3.5.34(typescript@5.9.3) @@ -20660,11 +20373,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): - optionalDependencies: - bufferutil: 4.1.0 - utf-8-validate: 6.0.6 - ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0