Compare commits
	
		
			82 Commits
		
	
	
		
			2025.3.2-b
			...
			oauth2oriz
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 8aa350ced4 | ||
|   | 93364cb922 | ||
|   | 1f38d624c0 | ||
|   | deb9ba146f | ||
|   | 833df85457 | ||
|   | d340860b8b | ||
|   | d1534ec64e | ||
|   | 16a73dea26 | ||
|   | d0d9b4b19c | ||
|   | ca7c3c6063 | ||
|   | cb2089981a | ||
|   | daa18efc99 | ||
|   | 0b3fd09bb0 | ||
|   | 1567a2ea3e | ||
|   | ecdd1c115a | ||
|   | d7e0e9feca | ||
|   | 7ed8fbbba3 | ||
|   | 5db1126db6 | ||
|   | 628377187a | ||
|   | b57d40ed09 | ||
|   | 1755c75647 | ||
|   | c55d9784fe | ||
|   | 52e7bdd817 | ||
|   | 260ac0ecfc | ||
|   | b81e6eeff9 | ||
|   | 15f859d562 | ||
|   | b938bc7c52 | ||
|   | 20efdc78e2 | ||
|   | aa87fb2f50 | ||
|   | 95dd66a0ba | ||
|   | c83628e5d0 | ||
|   | d0245b59bc | ||
|   | 4c12a9d882 | ||
|   | d245306d90 | ||
|   | 0d2041f5aa | ||
|   | b5df8ca0fd | ||
|   | 3b8b9a658a | ||
|   | 413fa63093 | ||
|   | 347a4a0b93 | ||
|   | bfe6e5abb8 | ||
|   | 78c6bb1cc2 | ||
|   | 9a5fa00f9a | ||
|   | 967989c5f8 | ||
|   | c25836bc1a | ||
|   | 9022971fb9 | ||
|   | cb5cfd4296 | ||
|   | cbaae2201f | ||
|   | 2c6379649a | ||
|   | 150a6f80d0 | ||
|   | c0f63234d7 | ||
|   | 9c29880f8b | ||
|   | 2b23120664 | ||
|   | b6f6819b76 | ||
|   | 77ad8c0ac6 | ||
|   | 92f3ae2d9c | ||
|   | 94ea15d2d7 | ||
|   | 8e7fc1ed98 | ||
|   | 937e9be34e | ||
|   | 027c5734a4 | ||
|   | a688bd1061 | ||
|   | 87dbe5e9fb | ||
|   | f6d9cf1ef1 | ||
|   | 333d6a9283 | ||
|   | deb4429e3a | ||
|   | 6385ca9b0d | ||
|   | 515af3176a | ||
|   | 0cc9d5aa32 | ||
|   | 401575a903 | ||
|   | 88fd7f2758 | ||
|   | 5034e6cd69 | ||
|   | 2f566e4173 | ||
|   | 179640af30 | ||
|   | 098d0670a3 | ||
|   | 71f62b9d89 | ||
|   | 82c9820ac8 | ||
|   | 39526d0225 | ||
|   | 049dbfeb66 | ||
|   | 8ea1288234 | ||
|   | a55d3f7382 | ||
|   | f5a6509663 | ||
|   | a4fb17620c | ||
|   | 0621e94c7d | 
| @@ -61,6 +61,7 @@ | |||||||
| 		"@fastify/accepts": "4.2.0", | 		"@fastify/accepts": "4.2.0", | ||||||
| 		"@fastify/cookie": "8.3.0", | 		"@fastify/cookie": "8.3.0", | ||||||
| 		"@fastify/cors": "8.3.0", | 		"@fastify/cors": "8.3.0", | ||||||
|  | 		"@fastify/express": "^2.3.0", | ||||||
| 		"@fastify/http-proxy": "9.2.1", | 		"@fastify/http-proxy": "9.2.1", | ||||||
| 		"@fastify/multipart": "7.7.0", | 		"@fastify/multipart": "7.7.0", | ||||||
| 		"@fastify/static": "6.10.2", | 		"@fastify/static": "6.10.2", | ||||||
| @@ -78,6 +79,7 @@ | |||||||
| 		"autwh": "0.1.0", | 		"autwh": "0.1.0", | ||||||
| 		"bcryptjs": "2.4.3", | 		"bcryptjs": "2.4.3", | ||||||
| 		"blurhash": "2.0.5", | 		"blurhash": "2.0.5", | ||||||
|  | 		"body-parser": "^1.20.2", | ||||||
| 		"bullmq": "4.1.0", | 		"bullmq": "4.1.0", | ||||||
| 		"cacheable-lookup": "7.0.0", | 		"cacheable-lookup": "7.0.0", | ||||||
| 		"cbor": "9.0.0", | 		"cbor": "9.0.0", | ||||||
| @@ -98,6 +100,7 @@ | |||||||
| 		"got": "13.0.0", | 		"got": "13.0.0", | ||||||
| 		"happy-dom": "9.20.3", | 		"happy-dom": "9.20.3", | ||||||
| 		"hpagent": "1.2.0", | 		"hpagent": "1.2.0", | ||||||
|  | 		"http-link-header": "^1.1.0", | ||||||
| 		"ioredis": "5.3.2", | 		"ioredis": "5.3.2", | ||||||
| 		"ip-cidr": "3.1.0", | 		"ip-cidr": "3.1.0", | ||||||
| 		"ipaddr.js": "2.1.0", | 		"ipaddr.js": "2.1.0", | ||||||
| @@ -117,10 +120,13 @@ | |||||||
| 		"nodemailer": "6.9.3", | 		"nodemailer": "6.9.3", | ||||||
| 		"nsfwjs": "2.4.2", | 		"nsfwjs": "2.4.2", | ||||||
| 		"oauth": "0.10.0", | 		"oauth": "0.10.0", | ||||||
|  | 		"oauth2orize": "^1.11.1", | ||||||
|  | 		"oauth2orize-pkce": "^0.1.2", | ||||||
| 		"os-utils": "0.0.14", | 		"os-utils": "0.0.14", | ||||||
| 		"otpauth": "9.1.2", | 		"otpauth": "9.1.2", | ||||||
| 		"parse5": "7.1.2", | 		"parse5": "7.1.2", | ||||||
| 		"pg": "8.11.0", | 		"pg": "8.11.0", | ||||||
|  | 		"pkce-challenge": "^4.0.1", | ||||||
| 		"probe-image-size": "7.2.3", | 		"probe-image-size": "7.2.3", | ||||||
| 		"promise-limit": "2.7.0", | 		"promise-limit": "2.7.0", | ||||||
| 		"pug": "3.0.2", | 		"pug": "3.0.2", | ||||||
| @@ -166,11 +172,13 @@ | |||||||
| 		"@types/accepts": "1.3.5", | 		"@types/accepts": "1.3.5", | ||||||
| 		"@types/archiver": "5.3.2", | 		"@types/archiver": "5.3.2", | ||||||
| 		"@types/bcryptjs": "2.4.2", | 		"@types/bcryptjs": "2.4.2", | ||||||
|  | 		"@types/body-parser": "^1.19.2", | ||||||
| 		"@types/cbor": "6.0.0", | 		"@types/cbor": "6.0.0", | ||||||
| 		"@types/color-convert": "2.0.0", | 		"@types/color-convert": "2.0.0", | ||||||
| 		"@types/content-disposition": "0.5.5", | 		"@types/content-disposition": "0.5.5", | ||||||
| 		"@types/escape-regexp": "0.0.1", | 		"@types/escape-regexp": "0.0.1", | ||||||
| 		"@types/fluent-ffmpeg": "2.1.21", | 		"@types/fluent-ffmpeg": "2.1.21", | ||||||
|  | 		"@types/http-link-header": "^1.0.3", | ||||||
| 		"@types/jest": "29.5.2", | 		"@types/jest": "29.5.2", | ||||||
| 		"@types/js-yaml": "4.0.5", | 		"@types/js-yaml": "4.0.5", | ||||||
| 		"@types/jsdom": "21.1.1", | 		"@types/jsdom": "21.1.1", | ||||||
| @@ -182,6 +190,7 @@ | |||||||
| 		"@types/node-fetch": "3.0.3", | 		"@types/node-fetch": "3.0.3", | ||||||
| 		"@types/nodemailer": "6.4.8", | 		"@types/nodemailer": "6.4.8", | ||||||
| 		"@types/oauth": "0.9.1", | 		"@types/oauth": "0.9.1", | ||||||
|  | 		"@types/oauth2orize": "^1.11.0", | ||||||
| 		"@types/pg": "8.10.2", | 		"@types/pg": "8.10.2", | ||||||
| 		"@types/pug": "2.0.6", | 		"@types/pug": "2.0.6", | ||||||
| 		"@types/punycode": "2.1.0", | 		"@types/punycode": "2.1.0", | ||||||
| @@ -193,6 +202,7 @@ | |||||||
| 		"@types/sanitize-html": "2.9.0", | 		"@types/sanitize-html": "2.9.0", | ||||||
| 		"@types/semver": "7.5.0", | 		"@types/semver": "7.5.0", | ||||||
| 		"@types/sharp": "0.32.0", | 		"@types/sharp": "0.32.0", | ||||||
|  | 		"@types/simple-oauth2": "^5.0.4", | ||||||
| 		"@types/sinonjs__fake-timers": "8.1.2", | 		"@types/sinonjs__fake-timers": "8.1.2", | ||||||
| 		"@types/tinycolor2": "1.4.3", | 		"@types/tinycolor2": "1.4.3", | ||||||
| 		"@types/tmp": "0.2.3", | 		"@types/tmp": "0.2.3", | ||||||
| @@ -210,6 +220,7 @@ | |||||||
| 		"eslint-plugin-import": "2.27.5", | 		"eslint-plugin-import": "2.27.5", | ||||||
| 		"execa": "6.1.0", | 		"execa": "6.1.0", | ||||||
| 		"jest": "29.5.0", | 		"jest": "29.5.0", | ||||||
| 		"jest-mock": "29.5.0" | 		"jest-mock": "29.5.0", | ||||||
|  | 		"simple-oauth2": "^5.0.0" | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								packages/backend/src/@types/oauth2orize-pkce.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/backend/src/@types/oauth2orize-pkce.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | declare module 'oauth2orize-pkce' { | ||||||
|  | 	export default { | ||||||
|  | 		extensions(): any; | ||||||
|  | 	}; | ||||||
|  | } | ||||||
| @@ -36,6 +36,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; | |||||||
| import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | ||||||
| import { ClientLoggerService } from './web/ClientLoggerService.js'; | import { ClientLoggerService } from './web/ClientLoggerService.js'; | ||||||
| import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; | import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; | ||||||
|  | import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
| 	imports: [ | 	imports: [ | ||||||
| @@ -78,6 +79,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. | |||||||
| 		ServerStatsChannelService, | 		ServerStatsChannelService, | ||||||
| 		UserListChannelService, | 		UserListChannelService, | ||||||
| 		OpenApiServerService, | 		OpenApiServerService, | ||||||
|  | 		OAuth2ProviderService, | ||||||
| 	], | 	], | ||||||
| 	exports: [ | 	exports: [ | ||||||
| 		ServerService, | 		ServerService, | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ import { WellKnownServerService } from './WellKnownServerService.js'; | |||||||
| import { FileServerService } from './FileServerService.js'; | import { FileServerService } from './FileServerService.js'; | ||||||
| import { ClientServerService } from './web/ClientServerService.js'; | import { ClientServerService } from './web/ClientServerService.js'; | ||||||
| import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | ||||||
|  | import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; | ||||||
|  |  | ||||||
| const _dirname = fileURLToPath(new URL('.', import.meta.url)); | const _dirname = fileURLToPath(new URL('.', import.meta.url)); | ||||||
|  |  | ||||||
| @@ -56,12 +57,13 @@ export class ServerService implements OnApplicationShutdown { | |||||||
| 		private clientServerService: ClientServerService, | 		private clientServerService: ClientServerService, | ||||||
| 		private globalEventService: GlobalEventService, | 		private globalEventService: GlobalEventService, | ||||||
| 		private loggerService: LoggerService, | 		private loggerService: LoggerService, | ||||||
|  | 		private oauth2ProviderService: OAuth2ProviderService, | ||||||
| 	) { | 	) { | ||||||
| 		this.logger = this.loggerService.getLogger('server', 'gray', false); | 		this.logger = this.loggerService.getLogger('server', 'gray', false); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	@bindThis | 	@bindThis | ||||||
| 	public async launch() { | 	public async launch(): Promise<void> { | ||||||
| 		const fastify = Fastify({ | 		const fastify = Fastify({ | ||||||
| 			trustProxy: true, | 			trustProxy: true, | ||||||
| 			logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), | 			logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), | ||||||
| @@ -90,6 +92,7 @@ export class ServerService implements OnApplicationShutdown { | |||||||
| 		fastify.register(this.activityPubServerService.createServer); | 		fastify.register(this.activityPubServerService.createServer); | ||||||
| 		fastify.register(this.nodeinfoServerService.createServer); | 		fastify.register(this.nodeinfoServerService.createServer); | ||||||
| 		fastify.register(this.wellKnownServerService.createServer); | 		fastify.register(this.wellKnownServerService.createServer); | ||||||
|  | 		fastify.register(this.oauth2ProviderService.createServer); | ||||||
|  |  | ||||||
| 		fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { | 		fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { | ||||||
| 			const path = request.params.path; | 			const path = request.params.path; | ||||||
|   | |||||||
							
								
								
									
										466
									
								
								packages/backend/src/server/oauth/OAuth2ProviderService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										466
									
								
								packages/backend/src/server/oauth/OAuth2ProviderService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,466 @@ | |||||||
|  | import dns from 'node:dns/promises'; | ||||||
|  | import { fileURLToPath } from 'node:url'; | ||||||
|  | import { Inject, Injectable } from '@nestjs/common'; | ||||||
|  | import { JSDOM } from 'jsdom'; | ||||||
|  | import httpLinkHeader from 'http-link-header'; | ||||||
|  | import ipaddr from 'ipaddr.js'; | ||||||
|  | import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; | ||||||
|  | import oauth2Pkce from 'oauth2orize-pkce'; | ||||||
|  | import fastifyView from '@fastify/view'; | ||||||
|  | import pug from 'pug'; | ||||||
|  | import bodyParser from 'body-parser'; | ||||||
|  | import fastifyExpress from '@fastify/express'; | ||||||
|  | import { verifyChallenge } from 'pkce-challenge'; | ||||||
|  | import { secureRndstr } from '@/misc/secure-rndstr.js'; | ||||||
|  | import { HttpRequestService } from '@/core/HttpRequestService.js'; | ||||||
|  | import { kinds } from '@/misc/api-permissions.js'; | ||||||
|  | import type { Config } from '@/config.js'; | ||||||
|  | import { DI } from '@/di-symbols.js'; | ||||||
|  | import { bindThis } from '@/decorators.js'; | ||||||
|  | import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; | ||||||
|  | import { IdService } from '@/core/IdService.js'; | ||||||
|  | import { CacheService } from '@/core/CacheService.js'; | ||||||
|  | import type { LocalUser } from '@/models/entities/User.js'; | ||||||
|  | import { MemoryKVCache } from '@/misc/cache.js'; | ||||||
|  | import { LoggerService } from '@/core/LoggerService.js'; | ||||||
|  | import Logger from '@/logger.js'; | ||||||
|  | import type { ServerResponse } from 'node:http'; | ||||||
|  | import type { FastifyInstance } from 'fastify'; | ||||||
|  |  | ||||||
|  | // TODO: Consider migrating to @node-oauth/oauth2-server once | ||||||
|  | // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. | ||||||
|  | // Upstream the various validations and RFC9207 implementation in that case. | ||||||
|  |  | ||||||
|  | // Follows https://indieauth.spec.indieweb.org/#client-identifier | ||||||
|  | // This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation | ||||||
|  | // although Google has stricter rule. | ||||||
|  | function validateClientId(raw: string): URL { | ||||||
|  | 	// "Clients are identified by a [URL]." | ||||||
|  | 	const url = ((): URL => { | ||||||
|  | 		try { | ||||||
|  | 			return new URL(raw); | ||||||
|  | 		} catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); } | ||||||
|  | 	})(); | ||||||
|  |  | ||||||
|  | 	// "Client identifier URLs MUST have either an https or http scheme" | ||||||
|  | 	// But then again: | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1 | ||||||
|  | 	// 'The redirection endpoint SHOULD require the use of TLS as described | ||||||
|  | 	// in Section 1.6 when the requested response type is "code" or "token"' | ||||||
|  | 	// TODO: Consider allowing custom URIs per RFC 8252. | ||||||
|  | 	const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:']; | ||||||
|  | 	if (!allowedProtocols.includes(url.protocol)) { | ||||||
|  | 		throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// "MUST contain a path component (new URL() implicitly adds one)" | ||||||
|  |  | ||||||
|  | 	// "MUST NOT contain single-dot or double-dot path segments," | ||||||
|  | 	const segments = url.pathname.split('/'); | ||||||
|  | 	if (segments.includes('.') || segments.includes('..')) { | ||||||
|  | 		throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// ("MAY contain a query string component") | ||||||
|  |  | ||||||
|  | 	// "MUST NOT contain a fragment component" | ||||||
|  | 	if (url.hash) { | ||||||
|  | 		throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// "MUST NOT contain a username or password component" | ||||||
|  | 	if (url.username || url.password) { | ||||||
|  | 		throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// ("MAY contain a port") | ||||||
|  |  | ||||||
|  | 	// "host names MUST be domain names or a loopback interface and MUST NOT be | ||||||
|  | 	// IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]." | ||||||
|  | 	if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { | ||||||
|  | 		throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return url; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface ClientInformation { | ||||||
|  | 	id: string; | ||||||
|  | 	redirectUris: string[]; | ||||||
|  | 	name: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // https://indieauth.spec.indieweb.org/#client-information-discovery | ||||||
|  | // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, | ||||||
|  | // and if there is an [h-app] with a url property matching the client_id URL, | ||||||
|  | // then it should use the name and icon and display them on the authorization prompt." | ||||||
|  | // (But we don't display any icon for now) | ||||||
|  | // https://indieauth.spec.indieweb.org/#redirect-url | ||||||
|  | // "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute | ||||||
|  | // of redirect_uri at the client_id URL. | ||||||
|  | // Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST | ||||||
|  | // look for an exact match of the given redirect_uri in the request against the list of | ||||||
|  | // redirect_uris discovered after resolving any relative URLs." | ||||||
|  | async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> { | ||||||
|  | 	try { | ||||||
|  | 		const res = await httpRequestService.send(id); | ||||||
|  | 		const redirectUris: string[] = []; | ||||||
|  |  | ||||||
|  | 		const linkHeader = res.headers.get('link'); | ||||||
|  | 		if (linkHeader) { | ||||||
|  | 			redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const fragment = JSDOM.fragment(await res.text()); | ||||||
|  |  | ||||||
|  | 		redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href)); | ||||||
|  |  | ||||||
|  | 		const name = fragment.querySelector<HTMLElement>('.h-app .p-name')?.textContent?.trim() ?? id; | ||||||
|  |  | ||||||
|  | 		return { | ||||||
|  | 			id, | ||||||
|  | 			redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), | ||||||
|  | 			name, | ||||||
|  | 		}; | ||||||
|  | 	} catch { | ||||||
|  | 		throw new AuthorizationError('Failed to fetch client information', 'server_error'); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)] | ||||||
|  | 	? R | ||||||
|  | 	: []; | ||||||
|  |  | ||||||
|  | interface OAuthParsedRequest extends OAuth2Req { | ||||||
|  | 	codeChallenge: string; | ||||||
|  | 	codeChallengeMethod: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface OAuthHttpResponse extends ServerResponse { | ||||||
|  | 	redirect(location: string): void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface OAuth2DecisionRequest extends MiddlewareRequest { | ||||||
|  | 	body: { | ||||||
|  | 		transaction_id: string; | ||||||
|  | 		cancel: boolean; | ||||||
|  | 		login_token: string; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { | ||||||
|  | 	return { | ||||||
|  | 		query: (txn, res, params): void => { | ||||||
|  | 			// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss | ||||||
|  | 			// "In authorization responses to the client, including error responses, | ||||||
|  | 			// an authorization server supporting this specification MUST indicate its | ||||||
|  | 			// identity by including the iss parameter in the response." | ||||||
|  | 			params.iss = issuerUrl; | ||||||
|  |  | ||||||
|  | 			const parsed = new URL(txn.redirectURI); | ||||||
|  | 			for (const [key, value] of Object.entries(params)) { | ||||||
|  | 				parsed.searchParams.append(key, value as string); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			return (res as OAuthHttpResponse).redirect(parsed.toString()); | ||||||
|  | 		}, | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Maps the transaction ID and the oauth/authorize parameters. | ||||||
|  |  * | ||||||
|  |  * Flow: | ||||||
|  |  * 1. oauth/authorize endpoint will call store() to store the parameters | ||||||
|  |  *    and puts the generated transaction ID to the dialog page | ||||||
|  |  * 2. oauth/decision will call load() to retrieve the parameters and then remove() | ||||||
|  |  */ | ||||||
|  | class OAuth2Store { | ||||||
|  | 	#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // expires after 5min | ||||||
|  |  | ||||||
|  | 	load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void { | ||||||
|  | 		const { transaction_id } = req.body; | ||||||
|  | 		if (!transaction_id) { | ||||||
|  | 			cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		const loaded = this.#cache.get(transaction_id); | ||||||
|  | 		if (!loaded) { | ||||||
|  | 			cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied')); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		cb(null, loaded); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { | ||||||
|  | 		const transactionId = secureRndstr(128, true); | ||||||
|  | 		this.#cache.set(transactionId, oauth2); | ||||||
|  | 		cb(null, transactionId); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { | ||||||
|  | 		this.#cache.delete(tid); | ||||||
|  | 		cb(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Injectable() | ||||||
|  | export class OAuth2ProviderService { | ||||||
|  | 	#server = oauth2orize.createServer({ | ||||||
|  | 		store: new OAuth2Store(), | ||||||
|  | 	}); | ||||||
|  | 	#logger: Logger; | ||||||
|  |  | ||||||
|  | 	constructor( | ||||||
|  | 		@Inject(DI.config) | ||||||
|  | 		private config: Config, | ||||||
|  | 		private httpRequestService: HttpRequestService, | ||||||
|  | 		@Inject(DI.accessTokensRepository) | ||||||
|  | 		accessTokensRepository: AccessTokensRepository, | ||||||
|  | 		idService: IdService, | ||||||
|  | 		@Inject(DI.usersRepository) | ||||||
|  | 		private usersRepository: UsersRepository, | ||||||
|  | 		private cacheService: CacheService, | ||||||
|  | 		loggerService: LoggerService, | ||||||
|  | 	) { | ||||||
|  | 		this.#logger = loggerService.getLogger('oauth'); | ||||||
|  |  | ||||||
|  | 		const grantCodeCache = new MemoryKVCache<{ | ||||||
|  | 			clientId: string, | ||||||
|  | 			userId: string, | ||||||
|  | 			redirectUri: string, | ||||||
|  | 			codeChallenge: string, | ||||||
|  | 			scopes: string[], | ||||||
|  |  | ||||||
|  | 			// fields to prevent multiple code use | ||||||
|  | 			grantedToken?: string, | ||||||
|  | 			revoked?: boolean, | ||||||
|  | 			used?: boolean, | ||||||
|  | 		}>(1000 * 60 * 5); // expires after 5m | ||||||
|  |  | ||||||
|  | 		// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics | ||||||
|  | 		// "Authorization servers MUST support PKCE [RFC7636]." | ||||||
|  | 		this.#server.grant(oauth2Pkce.extensions()); | ||||||
|  | 		this.#server.grant(oauth2orize.grant.code({ | ||||||
|  | 			modes: getQueryMode(config.url), | ||||||
|  | 		}, (client, redirectUri, token, ares, areq, locals, done) => { | ||||||
|  | 			(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => { | ||||||
|  | 				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<LocalUser | null>); | ||||||
|  | 				if (!user) { | ||||||
|  | 					throw new AuthorizationError('No such user', 'invalid_request'); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); | ||||||
|  |  | ||||||
|  | 				const code = secureRndstr(128, true); | ||||||
|  | 				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<OmitFirstElement<Parameters<typeof done>> | undefined> => { | ||||||
|  | 				this.#logger.info('Checking the received authorization code for the exchange'); | ||||||
|  | 				const granted = grantCodeCache.get(code); | ||||||
|  | 				if (!granted) { | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 | ||||||
|  | 				// "If an authorization code is used more than once, the authorization server | ||||||
|  | 				// MUST deny the request and SHOULD revoke (when possible) all tokens | ||||||
|  | 				// previously issued based on that authorization code." | ||||||
|  | 				if (granted.used) { | ||||||
|  | 					this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`); | ||||||
|  | 					grantCodeCache.delete(code); | ||||||
|  | 					granted.revoked = true; | ||||||
|  | 					if (granted.grantedToken) { | ||||||
|  | 						await accessTokensRepository.delete({ token: granted.grantedToken }); | ||||||
|  | 					} | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  | 				granted.used = true; | ||||||
|  |  | ||||||
|  | 				// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 | ||||||
|  | 				if (body.client_id !== granted.clientId) return; | ||||||
|  | 				if (redirectUri !== granted.redirectUri) return; | ||||||
|  |  | ||||||
|  | 				// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 | ||||||
|  | 				if (!body.code_verifier) return; | ||||||
|  | 				if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; | ||||||
|  |  | ||||||
|  | 				const accessToken = secureRndstr(128, true); | ||||||
|  | 				const now = new Date(); | ||||||
|  |  | ||||||
|  | 				// NOTE: we don't have a setup for automatic token expiration | ||||||
|  | 				await accessTokensRepository.insert({ | ||||||
|  | 					id: idService.genId(), | ||||||
|  | 					createdAt: now, | ||||||
|  | 					lastUsedAt: now, | ||||||
|  | 					userId: granted.userId, | ||||||
|  | 					token: accessToken, | ||||||
|  | 					hash: accessToken, | ||||||
|  | 					name: granted.clientId, | ||||||
|  | 					permission: granted.scopes, | ||||||
|  | 				}); | ||||||
|  |  | ||||||
|  | 				if (granted.revoked) { | ||||||
|  | 					this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.'); | ||||||
|  | 					await accessTokensRepository.delete({ token: accessToken }); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				granted.grantedToken = accessToken; | ||||||
|  | 				this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); | ||||||
|  |  | ||||||
|  | 				return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; | ||||||
|  | 			})().then(args => done(null, ...args ?? []), err => done(err)); | ||||||
|  | 		})); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	@bindThis | ||||||
|  | 	public async createServer(fastify: FastifyInstance): Promise<void> { | ||||||
|  | 		// https://datatracker.ietf.org/doc/html/rfc8414.html | ||||||
|  | 		// https://indieauth.spec.indieweb.org/#indieauth-server-metadata | ||||||
|  | 		fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { | ||||||
|  | 			reply.send({ | ||||||
|  | 				issuer: this.config.url, | ||||||
|  | 				authorization_endpoint: new URL('/oauth/authorize', this.config.url), | ||||||
|  | 				token_endpoint: new URL('/oauth/token', this.config.url), | ||||||
|  | 				scopes_supported: kinds, | ||||||
|  | 				response_types_supported: ['code'], | ||||||
|  | 				grant_types_supported: ['authorization_code'], | ||||||
|  | 				service_documentation: 'https://misskey-hub.net', | ||||||
|  | 				code_challenge_methods_supported: ['S256'], | ||||||
|  | 				authorization_response_iss_parameter_supported: true, | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		fastify.get('/oauth/authorize', async (request, reply) => { | ||||||
|  | 			const oauth2 = (request.raw as MiddlewareRequest).oauth2; | ||||||
|  | 			if (!oauth2) { | ||||||
|  | 				throw new Error('Unexpected lack of authorization information'); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); | ||||||
|  |  | ||||||
|  | 			reply.header('Cache-Control', 'no-store'); | ||||||
|  | 			return await reply.view('oauth', { | ||||||
|  | 				transactionId: oauth2.transactionID, | ||||||
|  | 				clientName: oauth2.client.name, | ||||||
|  | 				scope: oauth2.req.scope.join(' '), | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 		fastify.post('/oauth/decision', async () => { }); | ||||||
|  | 		fastify.post('/oauth/token', async () => { }); | ||||||
|  |  | ||||||
|  | 		fastify.register(fastifyView, { | ||||||
|  | 			root: fileURLToPath(new URL('../web/views', import.meta.url)), | ||||||
|  | 			engine: { pug }, | ||||||
|  | 			defaultContext: { | ||||||
|  | 				version: this.config.version, | ||||||
|  | 				config: this.config, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		await fastify.register(fastifyExpress); | ||||||
|  | 		fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { | ||||||
|  | 			(async (): Promise<Parameters<typeof done>> => { | ||||||
|  | 				// This should return client/redirectURI AND the error, or | ||||||
|  | 				// the handler can't send error to the redirection URI | ||||||
|  |  | ||||||
|  | 				const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest; | ||||||
|  |  | ||||||
|  | 				this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); | ||||||
|  |  | ||||||
|  | 				const clientUrl = validateClientId(clientID); | ||||||
|  |  | ||||||
|  | 				// TODO: Consider allowing localhost for native apps (RFC 8252) | ||||||
|  | 				// This is currently blocked by the redirect_uri check below, but we can theoretically | ||||||
|  | 				// loosen the rule for localhost as the data never leaves the client machine. | ||||||
|  | 				if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { | ||||||
|  | 					const lookup = await dns.lookup(clientUrl.hostname); | ||||||
|  | 					if (ipaddr.parse(lookup.address).range() !== 'unicast') { | ||||||
|  | 						throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request'); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Find client information from the remote. | ||||||
|  | 				const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); | ||||||
|  |  | ||||||
|  | 				// Require the redirect URI to be included in an explicit list, per | ||||||
|  | 				// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 | ||||||
|  | 				if (!clientInfo.redirectUris.includes(redirectURI)) { | ||||||
|  | 					throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				try { | ||||||
|  | 					const scopes = [...new Set(scope)].filter(s => kinds.includes(s)); | ||||||
|  | 					if (!scopes.length) { | ||||||
|  | 						throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); | ||||||
|  | 					} | ||||||
|  | 					areq.scope = scopes; | ||||||
|  |  | ||||||
|  | 					// Require PKCE parameters. | ||||||
|  | 					// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack: | ||||||
|  | 					// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack | ||||||
|  | 					if (typeof codeChallenge !== 'string') { | ||||||
|  | 						throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); | ||||||
|  | 					} | ||||||
|  | 					if (codeChallengeMethod !== 'S256') { | ||||||
|  | 						throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); | ||||||
|  | 					} | ||||||
|  | 				} catch (err) { | ||||||
|  | 					return [err as Error, clientInfo, redirectURI]; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				return [null, clientInfo, redirectURI]; | ||||||
|  | 			})().then(args => done(...args), err => done(err)); | ||||||
|  | 		}) as ValidateFunctionArity2)); | ||||||
|  | 		fastify.use('/oauth/authorize', this.#server.errorHandler({ | ||||||
|  | 			mode: 'indirect', | ||||||
|  | 			modes: getQueryMode(this.config.url), | ||||||
|  | 		})); | ||||||
|  | 		fastify.use('/oauth/authorize', this.#server.errorHandler()); | ||||||
|  |  | ||||||
|  | 		fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); | ||||||
|  | 		fastify.use('/oauth/decision', this.#server.decision((req, done) => { | ||||||
|  | 			const { body } = req as OAuth2DecisionRequest; | ||||||
|  | 			this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`); | ||||||
|  | 			req.user = body.login_token; | ||||||
|  | 			done(null, undefined); | ||||||
|  | 		})); | ||||||
|  | 		fastify.use('/oauth/decision', this.#server.errorHandler()); | ||||||
|  |  | ||||||
|  | 		// Clients may use JSON or urlencoded | ||||||
|  | 		fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false })); | ||||||
|  | 		fastify.use('/oauth/token', bodyParser.json({ strict: true })); | ||||||
|  | 		fastify.use('/oauth/token', this.#server.token()); | ||||||
|  | 		fastify.use('/oauth/token', this.#server.errorHandler()); | ||||||
|  |  | ||||||
|  | 		// Return 404 for any unknown paths under /oauth so that clients can know | ||||||
|  | 		// whether a certain endpoint is supported or not. | ||||||
|  | 		fastify.all('/oauth/*', async (_request, reply) => { | ||||||
|  | 			reply.code(404); | ||||||
|  | 			reply.send({ | ||||||
|  | 				error: { | ||||||
|  | 					message: 'Unknown OAuth endpoint.', | ||||||
|  | 					code: 'UNKNOWN_OAUTH_ENDPOINT', | ||||||
|  | 					id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', | ||||||
|  | 					kind: 'client', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								packages/backend/src/server/web/views/oauth.pug
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/backend/src/server/web/views/oauth.pug
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | extends ./base | ||||||
|  |  | ||||||
|  | block meta | ||||||
|  | 	//- Should be removed by the page when it loads, so that it won't needlessly | ||||||
|  | 	//- stay when user navigates away via the navigation bar | ||||||
|  | 	//- XXX: Remove navigation bar in auth page? | ||||||
|  | 	meta(name='misskey:oauth:transaction-id' content=transactionId) | ||||||
|  | 	meta(name='misskey:oauth:client-name' content=clientName) | ||||||
|  | 	meta(name='misskey:oauth:scope' content=scope) | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| process.env.NODE_ENV = 'test'; | process.env.NODE_ENV = 'test'; | ||||||
|  |  | ||||||
| import * as assert from 'assert'; | import * as assert from 'assert'; | ||||||
| import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js'; | import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch } from '../utils.js'; | ||||||
| import type { INestApplicationContext } from '@nestjs/common'; | import type { INestApplicationContext } from '@nestjs/common'; | ||||||
| import type * as misskey from 'misskey-js'; | import type * as misskey from 'misskey-js'; | ||||||
| import { IncomingMessage } from 'http'; | import { IncomingMessage } from 'http'; | ||||||
| @@ -218,6 +218,42 @@ describe('API', () => { | |||||||
| 			assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description')); | 			assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description')); | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		// TODO: insufficient_scope test (authテストが全然なくて書けない) | 		describe('invalid bearer format', () => { | ||||||
|  | 			test('No preceding bearer', async () => { | ||||||
|  | 				const result = await relativeFetch('api/notes/create', { | ||||||
|  | 					method: 'POST', | ||||||
|  | 					headers: { | ||||||
|  | 						Authorization: alice.token, | ||||||
|  | 						'Content-Type': 'application/json', | ||||||
|  | 					}, | ||||||
|  | 					body: JSON.stringify({ text: 'test' }), | ||||||
|  | 				}); | ||||||
|  | 				assert.strictEqual(result.status, 401); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			test('Lowercase bearer', async () => { | ||||||
|  | 				const result = await relativeFetch('api/notes/create', { | ||||||
|  | 					method: 'POST', | ||||||
|  | 					headers: { | ||||||
|  | 						Authorization: `bearer ${alice.token}`, | ||||||
|  | 						'Content-Type': 'application/json', | ||||||
|  | 					}, | ||||||
|  | 					body: JSON.stringify({ text: 'test' }), | ||||||
|  | 				}); | ||||||
|  | 				assert.strictEqual(result.status, 401); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			test('No space after bearer', async () => { | ||||||
|  | 				const result = await relativeFetch('api/notes/create', { | ||||||
|  | 					method: 'POST', | ||||||
|  | 					headers: { | ||||||
|  | 						Authorization: `Bearer${alice.token}`, | ||||||
|  | 						'Content-Type': 'application/json', | ||||||
|  | 					}, | ||||||
|  | 					body: JSON.stringify({ text: 'test' }), | ||||||
|  | 				}); | ||||||
|  | 				assert.strictEqual(result.status, 401); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
| 	}); | 	}); | ||||||
| }); | }); | ||||||
|   | |||||||
							
								
								
									
										925
									
								
								packages/backend/test/e2e/oauth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										925
									
								
								packages/backend/test/e2e/oauth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,925 @@ | |||||||
|  | /** | ||||||
|  |  * Basic OAuth tests to make sure the library is correctly integrated to Misskey | ||||||
|  |  * and not regressed by version updates or potential migration to another library. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | process.env.NODE_ENV = 'test'; | ||||||
|  |  | ||||||
|  | import * as assert from 'assert'; | ||||||
|  | import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2'; | ||||||
|  | import pkceChallenge from 'pkce-challenge'; | ||||||
|  | import { JSDOM } from 'jsdom'; | ||||||
|  | import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify'; | ||||||
|  | import { api, port, signup, startServer } from '../utils.js'; | ||||||
|  | import type * as misskey from 'misskey-js'; | ||||||
|  | import type { INestApplicationContext } from '@nestjs/common'; | ||||||
|  |  | ||||||
|  | const host = `http://127.0.0.1:${port}`; | ||||||
|  |  | ||||||
|  | const clientPort = port + 1; | ||||||
|  | const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; | ||||||
|  |  | ||||||
|  | const basicAuthParams: AuthorizationParamsExtended = { | ||||||
|  | 	redirect_uri, | ||||||
|  | 	scope: 'write:notes', | ||||||
|  | 	state: 'state', | ||||||
|  | 	code_challenge: 'code', | ||||||
|  | 	code_challenge_method: 'S256', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface AuthorizationParamsExtended { | ||||||
|  | 	redirect_uri: string; | ||||||
|  | 	scope: string | string[]; | ||||||
|  | 	state: string; | ||||||
|  | 	code_challenge?: string; | ||||||
|  | 	code_challenge_method?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig { | ||||||
|  | 	code_verifier: string | undefined; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface GetTokenError { | ||||||
|  | 	data: { | ||||||
|  | 		payload: { | ||||||
|  | 			error: string; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const clientConfig: ModuleOptions<'client_id'> = { | ||||||
|  | 	client: { | ||||||
|  | 		id: `http://127.0.0.1:${clientPort}/`, | ||||||
|  | 		secret: '', | ||||||
|  | 	}, | ||||||
|  | 	auth: { | ||||||
|  | 		tokenHost: host, | ||||||
|  | 		tokenPath: '/oauth/token', | ||||||
|  | 		authorizePath: '/oauth/authorize', | ||||||
|  | 	}, | ||||||
|  | 	options: { | ||||||
|  | 		authorizationMethod: 'body', | ||||||
|  | 	}, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } { | ||||||
|  | 	const fragment = JSDOM.fragment(html); | ||||||
|  | 	return { | ||||||
|  | 		transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content, | ||||||
|  | 		clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content, | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> { | ||||||
|  | 	return fetch(new URL('/oauth/decision', host), { | ||||||
|  | 		method: 'post', | ||||||
|  | 		body: new URLSearchParams({ | ||||||
|  | 			transaction_id: transactionId, | ||||||
|  | 			login_token: user.token, | ||||||
|  | 			cancel: cancel ? 'cancel' : '', | ||||||
|  | 		}), | ||||||
|  | 		redirect: 'manual', | ||||||
|  | 		headers: { | ||||||
|  | 			'content-type': 'application/x-www-form-urlencoded', | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> { | ||||||
|  | 	const { transactionId } = getMeta(await response.text()); | ||||||
|  | 	assert.ok(transactionId); | ||||||
|  |  | ||||||
|  | 	return await fetchDecision(transactionId, user, { cancel }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { | ||||||
|  | 	const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 	const response = await fetch(client.authorizeURL({ | ||||||
|  | 		redirect_uri, | ||||||
|  | 		scope, | ||||||
|  | 		state: 'state', | ||||||
|  | 		code_challenge, | ||||||
|  | 		code_challenge_method: 'S256', | ||||||
|  | 	} as AuthorizationParamsExtended)); | ||||||
|  | 	assert.strictEqual(response.status, 200); | ||||||
|  |  | ||||||
|  | 	const decisionResponse = await fetchDecisionFromResponse(response, user); | ||||||
|  | 	assert.strictEqual(decisionResponse.status, 302); | ||||||
|  |  | ||||||
|  | 	const locationHeader = decisionResponse.headers.get('location'); | ||||||
|  | 	assert.ok(locationHeader); | ||||||
|  |  | ||||||
|  | 	const location = new URL(locationHeader); | ||||||
|  | 	assert.ok(location.searchParams.has('code')); | ||||||
|  |  | ||||||
|  | 	const code = new URL(location).searchParams.get('code'); | ||||||
|  | 	assert.ok(code); | ||||||
|  |  | ||||||
|  | 	return { client, code }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function assertIndirectError(response: Response, error: string): void { | ||||||
|  | 	assert.strictEqual(response.status, 302); | ||||||
|  |  | ||||||
|  | 	const locationHeader = response.headers.get('location'); | ||||||
|  | 	assert.ok(locationHeader); | ||||||
|  |  | ||||||
|  | 	const location = new URL(locationHeader); | ||||||
|  | 	assert.strictEqual(location.searchParams.get('error'), error); | ||||||
|  |  | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss | ||||||
|  | 	assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 | ||||||
|  | 	assert.ok(location.searchParams.has('state')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function assertDirectError(response: Response, status: number, error: string): Promise<void> { | ||||||
|  | 	assert.strictEqual(response.status, status); | ||||||
|  |  | ||||||
|  | 	const data = await response.json(); | ||||||
|  | 	assert.strictEqual(data.error, error); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | describe('OAuth', () => { | ||||||
|  | 	let app: INestApplicationContext; | ||||||
|  | 	let fastify: FastifyInstance; | ||||||
|  |  | ||||||
|  | 	let alice: misskey.entities.MeSignup; | ||||||
|  | 	let bob: misskey.entities.MeSignup; | ||||||
|  |  | ||||||
|  | 	beforeAll(async () => { | ||||||
|  | 		app = await startServer(); | ||||||
|  | 		alice = await signup({ username: 'alice' }); | ||||||
|  | 		bob = await signup({ username: 'bob' }); | ||||||
|  | 	}, 1000 * 60 * 2); | ||||||
|  |  | ||||||
|  | 	beforeEach(async () => { | ||||||
|  | 		process.env.MISSKEY_TEST_CHECK_IP_RANGE = ''; | ||||||
|  | 		fastify = Fastify(); | ||||||
|  | 		fastify.get('/', async (request, reply) => { | ||||||
|  | 			reply.send(` | ||||||
|  | 				<!DOCTYPE html> | ||||||
|  | 				<link rel="redirect_uri" href="/redirect" /> | ||||||
|  | 				<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 			`); | ||||||
|  | 		}); | ||||||
|  | 		await fastify.listen({ port: clientPort }); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	afterAll(async () => { | ||||||
|  | 		await app.close(); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	afterEach(async () => { | ||||||
|  | 		await fastify.close(); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	test('Full flow', async () => { | ||||||
|  | 		const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 		const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 		const response = await fetch(client.authorizeURL({ | ||||||
|  | 			redirect_uri, | ||||||
|  | 			scope: 'write:notes', | ||||||
|  | 			state: 'state', | ||||||
|  | 			code_challenge, | ||||||
|  | 			code_challenge_method: 'S256', | ||||||
|  | 		} as AuthorizationParamsExtended)); | ||||||
|  | 		assert.strictEqual(response.status, 200); | ||||||
|  |  | ||||||
|  | 		const meta = getMeta(await response.text()); | ||||||
|  | 		assert.strictEqual(typeof meta.transactionId, 'string'); | ||||||
|  | 		assert.ok(meta.transactionId); | ||||||
|  | 		assert.strictEqual(meta.clientName, 'Misklient'); | ||||||
|  |  | ||||||
|  | 		const decisionResponse = await fetchDecision(meta.transactionId, alice); | ||||||
|  | 		assert.strictEqual(decisionResponse.status, 302); | ||||||
|  | 		assert.ok(decisionResponse.headers.has('location')); | ||||||
|  |  | ||||||
|  | 		const locationHeader = decisionResponse.headers.get('location'); | ||||||
|  | 		assert.ok(locationHeader); | ||||||
|  |  | ||||||
|  | 		const location = new URL(locationHeader); | ||||||
|  | 		assert.strictEqual(location.origin + location.pathname, redirect_uri); | ||||||
|  | 		assert.ok(location.searchParams.has('code')); | ||||||
|  | 		assert.strictEqual(location.searchParams.get('state'), 'state'); | ||||||
|  | 		// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss | ||||||
|  | 		assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); | ||||||
|  |  | ||||||
|  | 		const code = new URL(location).searchParams.get('code'); | ||||||
|  | 		assert.ok(code); | ||||||
|  |  | ||||||
|  | 		const token = await client.getToken({ | ||||||
|  | 			code, | ||||||
|  | 			redirect_uri, | ||||||
|  | 			code_verifier, | ||||||
|  | 		} as AuthorizationTokenConfigExtended); | ||||||
|  | 		assert.strictEqual(typeof token.token.access_token, 'string'); | ||||||
|  | 		assert.strictEqual(token.token.token_type, 'Bearer'); | ||||||
|  | 		assert.strictEqual(token.token.scope, 'write:notes'); | ||||||
|  |  | ||||||
|  | 		const createResult = await api('notes/create', { text: 'test' }, { | ||||||
|  | 			token: token.token.access_token as string, | ||||||
|  | 			bearer: true, | ||||||
|  | 		}); | ||||||
|  | 		assert.strictEqual(createResult.status, 200); | ||||||
|  |  | ||||||
|  | 		const createResultBody = createResult.body as misskey.Endpoints['notes/create']['res']; | ||||||
|  | 		assert.strictEqual(createResultBody.createdNote.text, 'test'); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	test('Two concurrent flows', async () => { | ||||||
|  | 		const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 		const pkceAlice = await pkceChallenge(128); | ||||||
|  | 		const pkceBob = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 		const responseAlice = await fetch(client.authorizeURL({ | ||||||
|  | 			redirect_uri, | ||||||
|  | 			scope: 'write:notes', | ||||||
|  | 			state: 'state', | ||||||
|  | 			code_challenge: pkceAlice.code_challenge, | ||||||
|  | 			code_challenge_method: 'S256', | ||||||
|  | 		} as AuthorizationParamsExtended)); | ||||||
|  | 		assert.strictEqual(responseAlice.status, 200); | ||||||
|  |  | ||||||
|  | 		const responseBob = await fetch(client.authorizeURL({ | ||||||
|  | 			redirect_uri, | ||||||
|  | 			scope: 'write:notes', | ||||||
|  | 			state: 'state', | ||||||
|  | 			code_challenge: pkceBob.code_challenge, | ||||||
|  | 			code_challenge_method: 'S256', | ||||||
|  | 		} as AuthorizationParamsExtended)); | ||||||
|  | 		assert.strictEqual(responseBob.status, 200); | ||||||
|  |  | ||||||
|  | 		const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice); | ||||||
|  | 		assert.strictEqual(decisionResponseAlice.status, 302); | ||||||
|  |  | ||||||
|  | 		const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob); | ||||||
|  | 		assert.strictEqual(decisionResponseBob.status, 302); | ||||||
|  |  | ||||||
|  | 		const locationHeaderAlice = decisionResponseAlice.headers.get('location'); | ||||||
|  | 		assert.ok(locationHeaderAlice); | ||||||
|  | 		const locationAlice = new URL(locationHeaderAlice); | ||||||
|  |  | ||||||
|  | 		const locationHeaderBob = decisionResponseBob.headers.get('location'); | ||||||
|  | 		assert.ok(locationHeaderBob); | ||||||
|  | 		const locationBob = new URL(locationHeaderBob); | ||||||
|  |  | ||||||
|  | 		const codeAlice = locationAlice.searchParams.get('code'); | ||||||
|  | 		assert.ok(codeAlice); | ||||||
|  | 		const codeBob = locationBob.searchParams.get('code'); | ||||||
|  | 		assert.ok(codeBob); | ||||||
|  |  | ||||||
|  | 		const tokenAlice = await client.getToken({ | ||||||
|  | 			code: codeAlice, | ||||||
|  | 			redirect_uri, | ||||||
|  | 			code_verifier: pkceAlice.code_verifier, | ||||||
|  | 		} as AuthorizationTokenConfigExtended); | ||||||
|  |  | ||||||
|  | 		const tokenBob = await client.getToken({ | ||||||
|  | 			code: codeBob, | ||||||
|  | 			redirect_uri, | ||||||
|  | 			code_verifier: pkceBob.code_verifier, | ||||||
|  | 		} as AuthorizationTokenConfigExtended); | ||||||
|  |  | ||||||
|  | 		const createResultAlice = await api('notes/create', { text: 'test' }, { | ||||||
|  | 			token: tokenAlice.token.access_token as string, | ||||||
|  | 			bearer: true, | ||||||
|  | 		}); | ||||||
|  | 		assert.strictEqual(createResultAlice.status, 200); | ||||||
|  |  | ||||||
|  | 		const createResultBob = await api('notes/create', { text: 'test' }, { | ||||||
|  | 			token: tokenBob.token.access_token as string, | ||||||
|  | 			bearer: true, | ||||||
|  | 		}); | ||||||
|  | 		assert.strictEqual(createResultAlice.status, 200); | ||||||
|  |  | ||||||
|  | 		const createResultBodyAlice = await createResultAlice.body as misskey.Endpoints['notes/create']['res']; | ||||||
|  | 		assert.strictEqual(createResultBodyAlice.createdNote.user.username, 'alice'); | ||||||
|  |  | ||||||
|  | 		const createResultBodyBob = await createResultBob.body as misskey.Endpoints['notes/create']['res']; | ||||||
|  | 		assert.strictEqual(createResultBodyBob.createdNote.user.username, 'bob'); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc7636.html | ||||||
|  | 	describe('PKCE', () => { | ||||||
|  | 		// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.4.1 | ||||||
|  | 		// '... the authorization endpoint MUST return the authorization | ||||||
|  | 		// error response with the "error" value set to "invalid_request".' | ||||||
|  | 		test('Require PKCE', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			// Pattern 1: No PKCE fields at all | ||||||
|  | 			let response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 			}), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_request'); | ||||||
|  |  | ||||||
|  | 			// Pattern 2: Only code_challenge | ||||||
|  | 			response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_request'); | ||||||
|  |  | ||||||
|  | 			// Pattern 3: Only code_challenge_method | ||||||
|  | 			response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_request'); | ||||||
|  |  | ||||||
|  | 			// Pattern 4: Unsupported code_challenge_method | ||||||
|  | 			response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'SSSS', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		// Use precomputed challenge/verifier set here for deterministic test | ||||||
|  | 		const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs'; | ||||||
|  | 		const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8'; | ||||||
|  |  | ||||||
|  | 		const tests: Record<string, string | undefined> = { | ||||||
|  | 			'Code followed by some junk code': code_verifier + 'x', | ||||||
|  | 			'Clipped code': code_verifier.slice(0, 80), | ||||||
|  | 			'Some part of code is replaced': code_verifier.slice(0, -10) + 'x'.repeat(10), | ||||||
|  | 			'No verifier': undefined, | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		describe('Verify PKCE', () => { | ||||||
|  | 			for (const [title, wrong_verifier] of Object.entries(tests)) { | ||||||
|  | 				test(title, async () => { | ||||||
|  | 					const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 					await assert.rejects(client.getToken({ | ||||||
|  | 						code, | ||||||
|  | 						redirect_uri, | ||||||
|  | 						code_verifier: wrong_verifier, | ||||||
|  | 					} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 						assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 						return true; | ||||||
|  | 					}); | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// 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." | ||||||
|  | 	describe('Revoking authorization code', () => { | ||||||
|  | 		test('On success', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			await client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('On failure', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Revoke the already granted access token', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			const token = await client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended); | ||||||
|  |  | ||||||
|  | 			const createResult = await api('notes/create', { text: 'test' }, { | ||||||
|  | 				token: token.token.access_token as string, | ||||||
|  | 				bearer: true, | ||||||
|  | 			}); | ||||||
|  | 			assert.strictEqual(createResult.status, 200); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			const createResult2 = await api('notes/create', { text: 'test' }, { | ||||||
|  | 				token: token.token.access_token as string, | ||||||
|  | 				bearer: true, | ||||||
|  | 			}); | ||||||
|  | 			assert.strictEqual(createResult2.status, 401); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	test('Cancellation', async () => { | ||||||
|  | 		const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 		const response = await fetch(client.authorizeURL({ | ||||||
|  | 			redirect_uri, | ||||||
|  | 			scope: 'write:notes', | ||||||
|  | 			state: 'state', | ||||||
|  | 			code_challenge: 'code', | ||||||
|  | 			code_challenge_method: 'S256', | ||||||
|  | 		} as AuthorizationParamsExtended)); | ||||||
|  | 		assert.strictEqual(response.status, 200); | ||||||
|  |  | ||||||
|  | 		const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); | ||||||
|  | 		assert.strictEqual(decisionResponse.status, 302); | ||||||
|  |  | ||||||
|  | 		const locationHeader = decisionResponse.headers.get('location'); | ||||||
|  | 		assert.ok(locationHeader); | ||||||
|  |  | ||||||
|  | 		const location = new URL(locationHeader); | ||||||
|  | 		assert.ok(!location.searchParams.has('code')); | ||||||
|  | 		assert.ok(location.searchParams.has('error')); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.3 | ||||||
|  | 	describe('Scope', () => { | ||||||
|  | 		// "If the client omits the scope parameter when requesting | ||||||
|  | 		// authorization, the authorization server MUST either process the | ||||||
|  | 		// request using a pre-defined default value or fail the request | ||||||
|  | 		// indicating an invalid scope." | ||||||
|  | 		// (And Misskey does the latter) | ||||||
|  | 		test('Missing scope', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_scope'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Empty scope', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: '', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_scope'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Unknown scopes', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'test:unknown test:unknown2', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended), { redirect: 'manual' }); | ||||||
|  | 			assertIndirectError(response, 'invalid_scope'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		// "If the issued access token scope | ||||||
|  | 		// is different from the one requested by the client, the authorization | ||||||
|  | 		// server MUST include the "scope" response parameter to inform the | ||||||
|  | 		// client of the actual scope granted." | ||||||
|  | 		// (Although Misskey always return scope, which is also fine) | ||||||
|  | 		test('Partially known scopes', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			// Just get the known scope for this case for backward compatibility | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode( | ||||||
|  | 				alice, | ||||||
|  | 				'write:notes test:unknown test:unknown2', | ||||||
|  | 				code_challenge, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			const token = await client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended); | ||||||
|  |  | ||||||
|  | 			assert.strictEqual(token.token.scope, 'write:notes'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Known scopes', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes read:account', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  |  | ||||||
|  | 			assert.strictEqual(response.status, 200); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Duplicated scopes', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode( | ||||||
|  | 				alice, | ||||||
|  | 				'write:notes write:notes read:account read:account', | ||||||
|  | 				code_challenge, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			const token = await client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended); | ||||||
|  | 			assert.strictEqual(token.token.scope, 'write:notes read:account'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Scope check by API', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge); | ||||||
|  |  | ||||||
|  | 			const token = await client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended); | ||||||
|  | 			assert.strictEqual(typeof token.token.access_token, 'string'); | ||||||
|  |  | ||||||
|  | 			const createResult = await api('notes/create', { text: 'test' }, { | ||||||
|  | 				token: token.token.access_token as string, | ||||||
|  | 				bearer: true, | ||||||
|  | 			}); | ||||||
|  | 			assert.strictEqual(createResult.status, 403); | ||||||
|  | 			assert.ok(createResult.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="insufficient_scope", error_description')); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4 | ||||||
|  | 	// "If an authorization request fails validation due to a missing, | ||||||
|  | 	// invalid, or mismatching redirection URI, the authorization server | ||||||
|  | 	// SHOULD inform the resource owner of the error and MUST NOT | ||||||
|  | 	// automatically redirect the user-agent to the invalid redirection URI." | ||||||
|  | 	describe('Redirection', () => { | ||||||
|  | 		test('Invalid redirect_uri at authorization endpoint', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri: 'http://127.0.0.2/', | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  | 			await assertDirectError(response, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri: 'http://127.0.0.1/redirection', | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  | 			await assertDirectError(response, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('No redirect_uri at authorization endpoint', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  | 			await assertDirectError(response, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Invalid redirect_uri at token endpoint', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri: 'http://127.0.0.2/', | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Invalid redirect_uri including the valid one at token endpoint', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				redirect_uri: 'http://127.0.0.1/redirection', | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('No redirect_uri at token endpoint', async () => { | ||||||
|  | 			const { code_challenge, code_verifier } = await pkceChallenge(128); | ||||||
|  |  | ||||||
|  | 			const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				code, | ||||||
|  | 				code_verifier, | ||||||
|  | 			} as AuthorizationTokenConfigExtended), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'invalid_grant'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// https://datatracker.ietf.org/doc/html/rfc8414 | ||||||
|  | 	test('Server metadata', async () => { | ||||||
|  | 		const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); | ||||||
|  | 		assert.strictEqual(response.status, 200); | ||||||
|  |  | ||||||
|  | 		const body = await response.json(); | ||||||
|  | 		assert.strictEqual(body.issuer, 'http://misskey.local'); | ||||||
|  | 		assert.ok(body.scopes_supported.includes('write:notes')); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Any error on decision endpoint is solely on Misskey side and nothing to do with the client. | ||||||
|  | 	// Do not use indirect error here. | ||||||
|  | 	describe('Decision endpoint', () => { | ||||||
|  | 		test('No login token', async () => { | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL(basicAuthParams)); | ||||||
|  | 			assert.strictEqual(response.status, 200); | ||||||
|  |  | ||||||
|  | 			const { transactionId } = getMeta(await response.text()); | ||||||
|  | 			assert.ok(transactionId); | ||||||
|  |  | ||||||
|  | 			const decisionResponse = await fetch(new URL('/oauth/decision', host), { | ||||||
|  | 				method: 'post', | ||||||
|  | 				body: new URLSearchParams({ | ||||||
|  | 					transaction_id: transactionId, | ||||||
|  | 				}), | ||||||
|  | 				redirect: 'manual', | ||||||
|  | 				headers: { | ||||||
|  | 					'content-type': 'application/x-www-form-urlencoded', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 			await assertDirectError(decisionResponse, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('No transaction ID', async () => { | ||||||
|  | 			const decisionResponse = await fetch(new URL('/oauth/decision', host), { | ||||||
|  | 				method: 'post', | ||||||
|  | 				body: new URLSearchParams({ | ||||||
|  | 					login_token: alice.token, | ||||||
|  | 				}), | ||||||
|  | 				redirect: 'manual', | ||||||
|  | 				headers: { | ||||||
|  | 					'content-type': 'application/x-www-form-urlencoded', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 			await assertDirectError(decisionResponse, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Invalid transaction ID', async () => { | ||||||
|  | 			const decisionResponse = await fetch(new URL('/oauth/decision', host), { | ||||||
|  | 				method: 'post', | ||||||
|  | 				body: new URLSearchParams({ | ||||||
|  | 					login_token: alice.token, | ||||||
|  | 					transaction_id: 'invalid_id', | ||||||
|  | 				}), | ||||||
|  | 				redirect: 'manual', | ||||||
|  | 				headers: { | ||||||
|  | 					'content-type': 'application/x-www-form-urlencoded', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 			await assertDirectError(decisionResponse, 403, 'access_denied'); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Only authorization code grant is supported | ||||||
|  | 	describe('Grant type', () => { | ||||||
|  | 		test('Implicit grant is not supported', async () => { | ||||||
|  | 			const url = new URL('/oauth/authorize', host); | ||||||
|  | 			url.searchParams.append('response_type', 'token'); | ||||||
|  | 			const response = await fetch(url); | ||||||
|  | 			assertDirectError(response, 501, 'unsupported_response_type'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Resource owner grant is not supported', async () => { | ||||||
|  | 			const client = new ResourceOwnerPassword({ | ||||||
|  | 				...clientConfig, | ||||||
|  | 				auth: { | ||||||
|  | 					tokenHost: host, | ||||||
|  | 					tokenPath: '/oauth/token', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({ | ||||||
|  | 				username: 'alice', | ||||||
|  | 				password: 'test', | ||||||
|  | 			}), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Client credential grant is not supported', async () => { | ||||||
|  | 			const client = new ClientCredentials({ | ||||||
|  | 				...clientConfig, | ||||||
|  | 				auth: { | ||||||
|  | 					tokenHost: host, | ||||||
|  | 					tokenPath: '/oauth/token', | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			await assert.rejects(client.getToken({}), (err: GetTokenError) => { | ||||||
|  | 				assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// https://indieauth.spec.indieweb.org/#client-information-discovery | ||||||
|  | 	describe('Client Information Discovery', () => { | ||||||
|  | 		describe('Redirection', () => { | ||||||
|  | 			const tests: Record<string, (reply: FastifyReply) => void> = { | ||||||
|  | 				'Read HTTP header': reply => { | ||||||
|  | 					reply.header('Link', '</redirect>; rel="redirect_uri"'); | ||||||
|  | 					reply.send(` | ||||||
|  | 						<!DOCTYPE html> | ||||||
|  | 						<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 					`); | ||||||
|  | 				}, | ||||||
|  | 				'Mixed links': reply => { | ||||||
|  | 					reply.header('Link', '</redirect>; rel="redirect_uri"'); | ||||||
|  | 					reply.send(` | ||||||
|  | 						<!DOCTYPE html> | ||||||
|  | 						<link rel="redirect_uri" href="/redirect2" /> | ||||||
|  | 						<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 					`); | ||||||
|  | 				}, | ||||||
|  | 				'Multiple items in Link header': reply => { | ||||||
|  | 					reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"'); | ||||||
|  | 					reply.send(` | ||||||
|  | 						<!DOCTYPE html> | ||||||
|  | 						<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 					`); | ||||||
|  | 				}, | ||||||
|  | 				'Multiple items in HTML': reply => { | ||||||
|  | 					reply.send(` | ||||||
|  | 						<!DOCTYPE html> | ||||||
|  | 						<link rel="redirect_uri" href="/redirect2" /> | ||||||
|  | 						<link rel="redirect_uri" href="/redirect" /> | ||||||
|  | 						<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 					`); | ||||||
|  | 				}, | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			for (const [title, replyFunc] of Object.entries(tests)) { | ||||||
|  | 				test(title, async () => { | ||||||
|  | 					await fastify.close(); | ||||||
|  |  | ||||||
|  | 					fastify = Fastify(); | ||||||
|  | 					fastify.get('/', async (request, reply) => replyFunc(reply)); | ||||||
|  | 					await fastify.listen({ port: clientPort }); | ||||||
|  |  | ||||||
|  | 					const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 					const response = await fetch(client.authorizeURL({ | ||||||
|  | 						redirect_uri, | ||||||
|  | 						scope: 'write:notes', | ||||||
|  | 						state: 'state', | ||||||
|  | 						code_challenge: 'code', | ||||||
|  | 						code_challenge_method: 'S256', | ||||||
|  | 					} as AuthorizationParamsExtended)); | ||||||
|  | 					assert.strictEqual(response.status, 200); | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			test('No item', async () => { | ||||||
|  | 				await fastify.close(); | ||||||
|  |  | ||||||
|  | 				fastify = Fastify(); | ||||||
|  | 				fastify.get('/', async (request, reply) => { | ||||||
|  | 					reply.send(` | ||||||
|  | 					<!DOCTYPE html> | ||||||
|  | 					<div class="h-app"><div class="p-name">Misklient | ||||||
|  | 				`); | ||||||
|  | 				}); | ||||||
|  | 				await fastify.listen({ port: clientPort }); | ||||||
|  |  | ||||||
|  | 				const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 				const response = await fetch(client.authorizeURL({ | ||||||
|  | 					redirect_uri, | ||||||
|  | 					scope: 'write:notes', | ||||||
|  | 					state: 'state', | ||||||
|  | 					code_challenge: 'code', | ||||||
|  | 					code_challenge_method: 'S256', | ||||||
|  | 				} as AuthorizationParamsExtended)); | ||||||
|  |  | ||||||
|  | 				// direct error because there's no redirect URI to ping | ||||||
|  | 				await assertDirectError(response, 400, 'invalid_request'); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Disallow loopback', async () => { | ||||||
|  | 			process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1'; | ||||||
|  |  | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  | 			await assertDirectError(response, 400, 'invalid_request'); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		test('Missing name', async () => { | ||||||
|  | 			await fastify.close(); | ||||||
|  |  | ||||||
|  | 			fastify = Fastify(); | ||||||
|  | 			fastify.get('/', async (request, reply) => { | ||||||
|  | 				reply.header('Link', '</redirect>; rel="redirect_uri"'); | ||||||
|  | 				reply.send(); | ||||||
|  | 			}); | ||||||
|  | 			await fastify.listen({ port: clientPort }); | ||||||
|  |  | ||||||
|  | 			const client = new AuthorizationCode(clientConfig); | ||||||
|  |  | ||||||
|  | 			const response = await fetch(client.authorizeURL({ | ||||||
|  | 				redirect_uri, | ||||||
|  | 				scope: 'write:notes', | ||||||
|  | 				state: 'state', | ||||||
|  | 				code_challenge: 'code', | ||||||
|  | 				code_challenge_method: 'S256', | ||||||
|  | 			} as AuthorizationParamsExtended)); | ||||||
|  | 			assert.strictEqual(response.status, 200); | ||||||
|  | 			assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	test('Unknown OAuth endpoint', async () => { | ||||||
|  | 		const response = await fetch(new URL('/oauth/foo', host)); | ||||||
|  | 		assert.strictEqual(response.status, 404); | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
| @@ -90,7 +90,7 @@ const request = async (path: string, params: any, me?: UserToken): Promise<{ sta | |||||||
| 	}; | 	}; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const relativeFetch = async (path: string, init?: RequestInit | undefined) => { | export const relativeFetch = async (path: string, init?: RequestInit | undefined) => { | ||||||
| 	return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); | 	return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ | |||||||
| 	ref="el" class="_button" | 	ref="el" class="_button" | ||||||
| 	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" | 	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" | ||||||
| 	:type="type" | 	:type="type" | ||||||
|  | 	:name="name" | ||||||
|  | 	:value="value" | ||||||
| 	@click="emit('click', $event)" | 	@click="emit('click', $event)" | ||||||
| 	@mousedown="onMousedown" | 	@mousedown="onMousedown" | ||||||
| > | > | ||||||
| @@ -44,6 +46,8 @@ const props = defineProps<{ | |||||||
| 	large?: boolean; | 	large?: boolean; | ||||||
| 	transparent?: boolean; | 	transparent?: boolean; | ||||||
| 	asLike?: boolean; | 	asLike?: boolean; | ||||||
|  | 	name?: string; | ||||||
|  | 	value?: string; | ||||||
| }>(); | }>(); | ||||||
|  |  | ||||||
| const emit = defineEmits<{ | const emit = defineEmits<{ | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								packages/frontend/src/pages/oauth.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								packages/frontend/src/pages/oauth.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | <template> | ||||||
|  | <MkStickyContainer> | ||||||
|  | 	<template #header><MkPageHeader/></template> | ||||||
|  | 	<MkSpacer :contentMax="800"> | ||||||
|  | 		<div v-if="$i"> | ||||||
|  | 			<div v-if="permissions.length > 0"> | ||||||
|  | 				<p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p> | ||||||
|  | 				<p v-else>{{ i18n.ts._auth.permissionAsk }}</p> | ||||||
|  | 				<ul> | ||||||
|  | 					<li v-for="p in permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> | ||||||
|  | 				</ul> | ||||||
|  | 			</div> | ||||||
|  | 			<div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div> | ||||||
|  | 			<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> | ||||||
|  | 			<form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post"> | ||||||
|  | 				<input name="login_token" type="hidden" :value="$i.token"/> | ||||||
|  | 				<input name="transaction_id" type="hidden" :value="transactionIdMeta?.content"/> | ||||||
|  | 				<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton> | ||||||
|  | 				<MkButton inline primary>{{ i18n.ts.accept }}</MkButton> | ||||||
|  | 			</form> | ||||||
|  | 		</div> | ||||||
|  | 		<div v-else> | ||||||
|  | 			<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> | ||||||
|  | 			<MkSignin @login="onLogin"/> | ||||||
|  | 		</div> | ||||||
|  | 	</MkSpacer> | ||||||
|  | </MkStickyContainer> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import MkSignin from '@/components/MkSignin.vue'; | ||||||
|  | import MkButton from '@/components/MkButton.vue'; | ||||||
|  | import { $i, login } from '@/account'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
|  | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
|  |  | ||||||
|  | const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); | ||||||
|  | if (transactionIdMeta) { | ||||||
|  | 	transactionIdMeta.remove(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; | ||||||
|  | const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; | ||||||
|  |  | ||||||
|  | function onLogin(res): void { | ||||||
|  | 	login(res.i); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | definePageMetadata({ | ||||||
|  | 	title: 'OAuth', | ||||||
|  | 	icon: 'ti ti-apps', | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss" module> | ||||||
|  | .buttons { | ||||||
|  | 	margin-top: 16px; | ||||||
|  | 	display: flex; | ||||||
|  | 	gap: 8px; | ||||||
|  | 	flex-wrap: wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .loginMessage { | ||||||
|  | 	text-align: center; | ||||||
|  | 	margin: 8px 0 24px; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -254,6 +254,9 @@ export const routes = [{ | |||||||
| 		icon: 'icon', | 		icon: 'icon', | ||||||
| 		permission: 'permission', | 		permission: 'permission', | ||||||
| 	}, | 	}, | ||||||
|  | }, { | ||||||
|  | 	path: '/oauth/authorize', | ||||||
|  | 	component: page(() => import('./pages/oauth.vue')), | ||||||
| }, { | }, { | ||||||
| 	path: '/tags/:tag', | 	path: '/tags/:tag', | ||||||
| 	component: page(() => import('./pages/tag.vue')), | 	component: page(() => import('./pages/tag.vue')), | ||||||
|   | |||||||
							
								
								
									
										184
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										184
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -98,6 +98,9 @@ importers: | |||||||
|       '@fastify/cors': |       '@fastify/cors': | ||||||
|         specifier: 8.3.0 |         specifier: 8.3.0 | ||||||
|         version: 8.3.0 |         version: 8.3.0 | ||||||
|  |       '@fastify/express': | ||||||
|  |         specifier: ^2.3.0 | ||||||
|  |         version: 2.3.0 | ||||||
|       '@fastify/http-proxy': |       '@fastify/http-proxy': | ||||||
|         specifier: 9.2.1 |         specifier: 9.2.1 | ||||||
|         version: 9.2.1(bufferutil@4.0.7)(utf-8-validate@6.0.3) |         version: 9.2.1(bufferutil@4.0.7)(utf-8-validate@6.0.3) | ||||||
| @@ -149,6 +152,9 @@ importers: | |||||||
|       blurhash: |       blurhash: | ||||||
|         specifier: 2.0.5 |         specifier: 2.0.5 | ||||||
|         version: 2.0.5 |         version: 2.0.5 | ||||||
|  |       body-parser: | ||||||
|  |         specifier: ^1.20.2 | ||||||
|  |         version: 1.20.2 | ||||||
|       bullmq: |       bullmq: | ||||||
|         specifier: 4.1.0 |         specifier: 4.1.0 | ||||||
|         version: 4.1.0 |         version: 4.1.0 | ||||||
| @@ -209,6 +215,9 @@ importers: | |||||||
|       hpagent: |       hpagent: | ||||||
|         specifier: 1.2.0 |         specifier: 1.2.0 | ||||||
|         version: 1.2.0 |         version: 1.2.0 | ||||||
|  |       http-link-header: | ||||||
|  |         specifier: ^1.1.0 | ||||||
|  |         version: 1.1.0 | ||||||
|       ioredis: |       ioredis: | ||||||
|         specifier: 5.3.2 |         specifier: 5.3.2 | ||||||
|         version: 5.3.2 |         version: 5.3.2 | ||||||
| @@ -266,6 +275,12 @@ importers: | |||||||
|       oauth: |       oauth: | ||||||
|         specifier: 0.10.0 |         specifier: 0.10.0 | ||||||
|         version: 0.10.0 |         version: 0.10.0 | ||||||
|  |       oauth2orize: | ||||||
|  |         specifier: ^1.11.1 | ||||||
|  |         version: 1.11.1 | ||||||
|  |       oauth2orize-pkce: | ||||||
|  |         specifier: ^0.1.2 | ||||||
|  |         version: 0.1.2 | ||||||
|       os-utils: |       os-utils: | ||||||
|         specifier: 0.0.14 |         specifier: 0.0.14 | ||||||
|         version: 0.0.14 |         version: 0.0.14 | ||||||
| @@ -278,6 +293,9 @@ importers: | |||||||
|       pg: |       pg: | ||||||
|         specifier: 8.11.0 |         specifier: 8.11.0 | ||||||
|         version: 8.11.0 |         version: 8.11.0 | ||||||
|  |       pkce-challenge: | ||||||
|  |         specifier: ^4.0.1 | ||||||
|  |         version: 4.0.1 | ||||||
|       probe-image-size: |       probe-image-size: | ||||||
|         specifier: 7.2.3 |         specifier: 7.2.3 | ||||||
|         version: 7.2.3 |         version: 7.2.3 | ||||||
| @@ -490,6 +508,9 @@ importers: | |||||||
|       '@types/bcryptjs': |       '@types/bcryptjs': | ||||||
|         specifier: 2.4.2 |         specifier: 2.4.2 | ||||||
|         version: 2.4.2 |         version: 2.4.2 | ||||||
|  |       '@types/body-parser': | ||||||
|  |         specifier: ^1.19.2 | ||||||
|  |         version: 1.19.2 | ||||||
|       '@types/cbor': |       '@types/cbor': | ||||||
|         specifier: 6.0.0 |         specifier: 6.0.0 | ||||||
|         version: 6.0.0 |         version: 6.0.0 | ||||||
| @@ -505,6 +526,9 @@ importers: | |||||||
|       '@types/fluent-ffmpeg': |       '@types/fluent-ffmpeg': | ||||||
|         specifier: 2.1.21 |         specifier: 2.1.21 | ||||||
|         version: 2.1.21 |         version: 2.1.21 | ||||||
|  |       '@types/http-link-header': | ||||||
|  |         specifier: ^1.0.3 | ||||||
|  |         version: 1.0.3 | ||||||
|       '@types/jest': |       '@types/jest': | ||||||
|         specifier: 29.5.2 |         specifier: 29.5.2 | ||||||
|         version: 29.5.2 |         version: 29.5.2 | ||||||
| @@ -538,6 +562,9 @@ importers: | |||||||
|       '@types/oauth': |       '@types/oauth': | ||||||
|         specifier: 0.9.1 |         specifier: 0.9.1 | ||||||
|         version: 0.9.1 |         version: 0.9.1 | ||||||
|  |       '@types/oauth2orize': | ||||||
|  |         specifier: ^1.11.0 | ||||||
|  |         version: 1.11.0 | ||||||
|       '@types/pg': |       '@types/pg': | ||||||
|         specifier: 8.10.2 |         specifier: 8.10.2 | ||||||
|         version: 8.10.2 |         version: 8.10.2 | ||||||
| @@ -571,6 +598,9 @@ importers: | |||||||
|       '@types/sharp': |       '@types/sharp': | ||||||
|         specifier: 0.32.0 |         specifier: 0.32.0 | ||||||
|         version: 0.32.0 |         version: 0.32.0 | ||||||
|  |       '@types/simple-oauth2': | ||||||
|  |         specifier: ^5.0.4 | ||||||
|  |         version: 5.0.4 | ||||||
|       '@types/sinonjs__fake-timers': |       '@types/sinonjs__fake-timers': | ||||||
|         specifier: 8.1.2 |         specifier: 8.1.2 | ||||||
|         version: 8.1.2 |         version: 8.1.2 | ||||||
| @@ -625,6 +655,9 @@ importers: | |||||||
|       jest-mock: |       jest-mock: | ||||||
|         specifier: 29.5.0 |         specifier: 29.5.0 | ||||||
|         version: 29.5.0 |         version: 29.5.0 | ||||||
|  |       simple-oauth2: | ||||||
|  |         specifier: ^5.0.0 | ||||||
|  |         version: 5.0.0 | ||||||
|  |  | ||||||
|   packages/frontend: |   packages/frontend: | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -4802,6 +4835,15 @@ packages: | |||||||
|     resolution: {integrity: sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ==} |     resolution: {integrity: sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ==} | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|  |   /@fastify/express@2.3.0: | ||||||
|  |     resolution: {integrity: sha512-jvvjlPPCfJsSHfF6tQDyARJ3+c3xXiqcxVZu6bi3xMWCWB3fl07vrjFDeaqnwqKhLZ9+m6cog5dw7gIMKEsTnQ==} | ||||||
|  |     dependencies: | ||||||
|  |       express: 4.18.2 | ||||||
|  |       fastify-plugin: 4.5.0 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /@fastify/fast-json-stringify-compiler@4.3.0: |   /@fastify/fast-json-stringify-compiler@4.3.0: | ||||||
|     resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} |     resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -4898,6 +4940,24 @@ packages: | |||||||
|       hashlru: 2.3.0 |       hashlru: 2.3.0 | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|  |   /@hapi/boom@10.0.1: | ||||||
|  |     resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} | ||||||
|  |     dependencies: | ||||||
|  |       '@hapi/hoek': 11.0.2 | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|  |   /@hapi/bourne@3.0.0: | ||||||
|  |     resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|  |   /@hapi/hoek@10.0.1: | ||||||
|  |     resolution: {integrity: sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw==} | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|  |   /@hapi/hoek@11.0.2: | ||||||
|  |     resolution: {integrity: sha512-aKmlCO57XFZ26wso4rJsW4oTUnrgTFw2jh3io7CAtO9w4UltBNwRXvXIVzzyfkaaLRo3nluP/19msA8vDUUuKw==} | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@hapi/hoek@9.3.0: |   /@hapi/hoek@9.3.0: | ||||||
|     resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} |     resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} | ||||||
|     dev: true |     dev: true | ||||||
| @@ -4908,6 +4968,14 @@ packages: | |||||||
|       '@hapi/hoek': 9.3.0 |       '@hapi/hoek': 9.3.0 | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|  |   /@hapi/wreck@18.0.1: | ||||||
|  |     resolution: {integrity: sha512-OLHER70+rZxvDl75xq3xXOfd3e8XIvz8fWY0dqg92UvhZ29zo24vQgfqgHSYhB5ZiuFpSLeriOisAlxAo/1jWg==} | ||||||
|  |     dependencies: | ||||||
|  |       '@hapi/boom': 10.0.1 | ||||||
|  |       '@hapi/bourne': 3.0.0 | ||||||
|  |       '@hapi/hoek': 11.0.2 | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@humanwhocodes/config-array@0.11.10: |   /@humanwhocodes/config-array@0.11.10: | ||||||
|     resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} |     resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} | ||||||
|     engines: {node: '>=10.10.0'} |     engines: {node: '>=10.10.0'} | ||||||
| @@ -7619,6 +7687,12 @@ packages: | |||||||
|   /@types/http-cache-semantics@4.0.1: |   /@types/http-cache-semantics@4.0.1: | ||||||
|     resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} |     resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} | ||||||
|  |  | ||||||
|  |   /@types/http-link-header@1.0.3: | ||||||
|  |     resolution: {integrity: sha512-y8HkoD/vyid+5MrJ3aas0FvU3/BVBGcyG9kgxL0Zn4JwstA8CglFPnrR0RuzOjRCXwqzL5uxWC2IO7Ub0rMU2A==} | ||||||
|  |     dependencies: | ||||||
|  |       '@types/node': 20.3.1 | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@types/istanbul-lib-coverage@2.0.4: |   /@types/istanbul-lib-coverage@2.0.4: | ||||||
|     resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} |     resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} | ||||||
|     dev: true |     dev: true | ||||||
| @@ -7762,6 +7836,13 @@ packages: | |||||||
|     resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} |     resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|  |   /@types/oauth2orize@1.11.0: | ||||||
|  |     resolution: {integrity: sha512-jmnP/Ip36XBzs+nIn/I8wNBZkQcn/agmp8K9V81he+wOllLYMec8T8AqbRPJCFbnFwaL03bbR8gI3CknMCXohw==} | ||||||
|  |     dependencies: | ||||||
|  |       '@types/express': 4.17.17 | ||||||
|  |       '@types/node': 20.3.1 | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@types/oauth@0.9.1: |   /@types/oauth@0.9.1: | ||||||
|     resolution: {integrity: sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A==} |     resolution: {integrity: sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -7898,6 +7979,10 @@ packages: | |||||||
|       sharp: 0.32.1 |       sharp: 0.32.1 | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|  |   /@types/simple-oauth2@5.0.4: | ||||||
|  |     resolution: {integrity: sha512-4SvTfmAa1fGUa1d07j9vIiC4o92bGh0ihPXmtS05udMMmNwVIaU2nZ706cC4wI8cJxOlHD4P/d5tzqvWYd+KxA==} | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@types/sinon@10.0.13: |   /@types/sinon@10.0.13: | ||||||
|     resolution: {integrity: sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==} |     resolution: {integrity: sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -8820,7 +8905,6 @@ packages: | |||||||
|  |  | ||||||
|   /array-flatten@1.1.1: |   /array-flatten@1.1.1: | ||||||
|     resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} |     resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /array-includes@3.1.6: |   /array-includes@3.1.6: | ||||||
|     resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} |     resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} | ||||||
| @@ -9416,7 +9500,26 @@ packages: | |||||||
|       unpipe: 1.0.0 |       unpipe: 1.0.0 | ||||||
|     transitivePeerDependencies: |     transitivePeerDependencies: | ||||||
|       - supports-color |       - supports-color | ||||||
|     dev: true |  | ||||||
|  |   /body-parser@1.20.2: | ||||||
|  |     resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} | ||||||
|  |     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} | ||||||
|  |     dependencies: | ||||||
|  |       bytes: 3.1.2 | ||||||
|  |       content-type: 1.0.5 | ||||||
|  |       debug: 2.6.9 | ||||||
|  |       depd: 2.0.0 | ||||||
|  |       destroy: 1.2.0 | ||||||
|  |       http-errors: 2.0.0 | ||||||
|  |       iconv-lite: 0.4.24 | ||||||
|  |       on-finished: 2.4.1 | ||||||
|  |       qs: 6.11.0 | ||||||
|  |       raw-body: 2.5.2 | ||||||
|  |       type-is: 1.6.18 | ||||||
|  |       unpipe: 1.0.0 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /boolbase@1.0.0: |   /boolbase@1.0.0: | ||||||
|     resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} |     resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} | ||||||
| @@ -9617,7 +9720,6 @@ packages: | |||||||
|   /bytes@3.1.2: |   /bytes@3.1.2: | ||||||
|     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} |     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} | ||||||
|     engines: {node: '>= 0.8'} |     engines: {node: '>= 0.8'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /c8@7.13.0: |   /c8@7.13.0: | ||||||
|     resolution: {integrity: sha512-/NL4hQTv1gBL6J6ei80zu3IiTrmePDKXKXOTLpHvcIWZTVYQlDhVWjjWvkhICylE8EwwnMVzDZugCvdx0/DIIA==} |     resolution: {integrity: sha512-/NL4hQTv1gBL6J6ei80zu3IiTrmePDKXKXOTLpHvcIWZTVYQlDhVWjjWvkhICylE8EwwnMVzDZugCvdx0/DIIA==} | ||||||
| @@ -10398,7 +10500,6 @@ packages: | |||||||
|   /content-type@1.0.5: |   /content-type@1.0.5: | ||||||
|     resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} |     resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /convert-source-map@1.9.0: |   /convert-source-map@1.9.0: | ||||||
|     resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} |     resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} | ||||||
| @@ -10409,7 +10510,6 @@ packages: | |||||||
|  |  | ||||||
|   /cookie-signature@1.0.6: |   /cookie-signature@1.0.6: | ||||||
|     resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} |     resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /cookie@0.4.2: |   /cookie@0.4.2: | ||||||
|     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} |     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} | ||||||
| @@ -10952,7 +11052,6 @@ packages: | |||||||
|   /destroy@1.2.0: |   /destroy@1.2.0: | ||||||
|     resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} |     resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} | ||||||
|     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} |     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /detect-file@1.0.0: |   /detect-file@1.0.0: | ||||||
|     resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} |     resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} | ||||||
| @@ -11132,7 +11231,6 @@ packages: | |||||||
|  |  | ||||||
|   /ee-first@1.1.1: |   /ee-first@1.1.1: | ||||||
|     resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} |     resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /ejs@3.1.8: |   /ejs@3.1.8: | ||||||
|     resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} |     resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} | ||||||
| @@ -11167,7 +11265,6 @@ packages: | |||||||
|   /encodeurl@1.0.2: |   /encodeurl@1.0.2: | ||||||
|     resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} |     resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} | ||||||
|     engines: {node: '>= 0.8'} |     engines: {node: '>= 0.8'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /encoding@0.1.13: |   /encoding@0.1.13: | ||||||
|     resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} |     resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} | ||||||
| @@ -11665,7 +11762,6 @@ packages: | |||||||
|   /etag@1.8.1: |   /etag@1.8.1: | ||||||
|     resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} |     resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /event-stream@3.3.4: |   /event-stream@3.3.4: | ||||||
|     resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} |     resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} | ||||||
| @@ -11845,7 +11941,6 @@ packages: | |||||||
|       vary: 1.1.2 |       vary: 1.1.2 | ||||||
|     transitivePeerDependencies: |     transitivePeerDependencies: | ||||||
|       - supports-color |       - supports-color | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /ext-list@2.2.2: |   /ext-list@2.2.2: | ||||||
|     resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} |     resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} | ||||||
| @@ -12181,7 +12276,6 @@ packages: | |||||||
|       unpipe: 1.0.0 |       unpipe: 1.0.0 | ||||||
|     transitivePeerDependencies: |     transitivePeerDependencies: | ||||||
|       - supports-color |       - supports-color | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /find-cache-dir@2.1.0: |   /find-cache-dir@2.1.0: | ||||||
|     resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} |     resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} | ||||||
| @@ -12422,7 +12516,6 @@ packages: | |||||||
|   /fresh@0.5.2: |   /fresh@0.5.2: | ||||||
|     resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} |     resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /from@0.1.7: |   /from@0.1.7: | ||||||
|     resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} |     resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} | ||||||
| @@ -13248,6 +13341,11 @@ packages: | |||||||
|       statuses: 2.0.1 |       statuses: 2.0.1 | ||||||
|       toidentifier: 1.0.1 |       toidentifier: 1.0.1 | ||||||
|  |  | ||||||
|  |   /http-link-header@1.1.0: | ||||||
|  |     resolution: {integrity: sha512-pj6N1yxOz/ANO8HHsWGg/OoIL1kmRYvQnXQ7PIRpgp+15AnEsRH8fmIJE6D1OdWG2Bov+BJHVla1fFXxg1JbbA==} | ||||||
|  |     engines: {node: '>=6.0.0'} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /http-proxy-agent@5.0.0: |   /http-proxy-agent@5.0.0: | ||||||
|     resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} |     resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} | ||||||
|     engines: {node: '>= 6'} |     engines: {node: '>= 6'} | ||||||
| @@ -15377,7 +15475,6 @@ packages: | |||||||
|   /media-typer@0.3.0: |   /media-typer@0.3.0: | ||||||
|     resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} |     resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /meilisearch@0.33.0: |   /meilisearch@0.33.0: | ||||||
|     resolution: {integrity: sha512-bYPb9WyITnJfzf92e7QFK8Rc50DmshFWxypXCs3ILlpNh8pT15A7KSu9Xgnnk/K3G/4vb3wkxxtFS4sxNkWB8w==} |     resolution: {integrity: sha512-bYPb9WyITnJfzf92e7QFK8Rc50DmshFWxypXCs3ILlpNh8pT15A7KSu9Xgnnk/K3G/4vb3wkxxtFS4sxNkWB8w==} | ||||||
| @@ -15413,7 +15510,6 @@ packages: | |||||||
|  |  | ||||||
|   /merge-descriptors@1.0.1: |   /merge-descriptors@1.0.1: | ||||||
|     resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} |     resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /merge-stream@2.0.0: |   /merge-stream@2.0.0: | ||||||
|     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} |     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} | ||||||
| @@ -15425,7 +15521,6 @@ packages: | |||||||
|   /methods@1.1.2: |   /methods@1.1.2: | ||||||
|     resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} |     resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /mfm-js@0.23.3: |   /mfm-js@0.23.3: | ||||||
|     resolution: {integrity: sha512-o8scYmbey6rMUmWAlT3k3ntt6khaCLdxlmHhAWV5wTTMj2OK1atQvZfRUq0SIVm1Jig08qlZg/ps71xUqrScNA==} |     resolution: {integrity: sha512-o8scYmbey6rMUmWAlT3k3ntt6khaCLdxlmHhAWV5wTTMj2OK1atQvZfRUq0SIVm1Jig08qlZg/ps71xUqrScNA==} | ||||||
| @@ -15474,7 +15569,6 @@ packages: | |||||||
|     resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} |     resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} | ||||||
|     engines: {node: '>=4'} |     engines: {node: '>=4'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /mime@2.6.0: |   /mime@2.6.0: | ||||||
|     resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} |     resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} | ||||||
| @@ -16156,6 +16250,21 @@ packages: | |||||||
|     resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} |     resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|  |   /oauth2orize-pkce@0.1.2: | ||||||
|  |     resolution: {integrity: sha512-grto2UYhXHi9GLE3IBgBBbV87xci55+bCyjpVuxKyzol6I5Rg0K1MiTuXE+JZk54R86SG2wqXODMiZYHraPpxw==} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|  |   /oauth2orize@1.11.1: | ||||||
|  |     resolution: {integrity: sha512-9dSx/Gwm0J2Rvj4RH9+h7iXVnRXZ6biwWRgb2dCeQhCosODS0nYdM9I/G7BUGsjbgn0pHjGcn1zcCRtzj2SlRA==} | ||||||
|  |     engines: {node: '>= 0.4.0'} | ||||||
|  |     dependencies: | ||||||
|  |       debug: 2.6.9 | ||||||
|  |       uid2: 0.0.4 | ||||||
|  |       utils-merge: 1.0.1 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /oauth@0.10.0: |   /oauth@0.10.0: | ||||||
|     resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==} |     resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==} | ||||||
|     dev: false |     dev: false | ||||||
| @@ -16178,7 +16287,6 @@ packages: | |||||||
|  |  | ||||||
|   /object-inspect@1.12.2: |   /object-inspect@1.12.2: | ||||||
|     resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} |     resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /object-is@1.1.5: |   /object-is@1.1.5: | ||||||
|     resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} |     resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} | ||||||
| @@ -16273,7 +16381,6 @@ packages: | |||||||
|     engines: {node: '>= 0.8'} |     engines: {node: '>= 0.8'} | ||||||
|     dependencies: |     dependencies: | ||||||
|       ee-first: 1.1.1 |       ee-first: 1.1.1 | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /on-headers@1.0.2: |   /on-headers@1.0.2: | ||||||
|     resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} |     resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} | ||||||
| @@ -16583,7 +16690,6 @@ packages: | |||||||
|   /parseurl@1.3.3: |   /parseurl@1.3.3: | ||||||
|     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} |     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} | ||||||
|     engines: {node: '>= 0.8'} |     engines: {node: '>= 0.8'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /pascalcase@0.1.1: |   /pascalcase@0.1.1: | ||||||
|     resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} |     resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} | ||||||
| @@ -16652,7 +16758,6 @@ packages: | |||||||
|  |  | ||||||
|   /path-to-regexp@0.1.7: |   /path-to-regexp@0.1.7: | ||||||
|     resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} |     resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /path-to-regexp@1.8.0: |   /path-to-regexp@1.8.0: | ||||||
|     resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} |     resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} | ||||||
| @@ -16860,6 +16965,11 @@ packages: | |||||||
|     engines: {node: '>= 6'} |     engines: {node: '>= 6'} | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|  |   /pkce-challenge@4.0.1: | ||||||
|  |     resolution: {integrity: sha512-WGmtS1stcStsvRwNXix3iR1ujFcDaJR+sEODRa2ZFruT0lM4lhPAFTL5SUpqD5vTJdRlgtuMQhcp1kIEJx4LUw==} | ||||||
|  |     engines: {node: '>=16.20.0'} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /pkg-dir@3.0.0: |   /pkg-dir@3.0.0: | ||||||
|     resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} |     resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} | ||||||
|     engines: {node: '>=6'} |     engines: {node: '>=6'} | ||||||
| @@ -17566,7 +17676,6 @@ packages: | |||||||
|     engines: {node: '>=0.6'} |     engines: {node: '>=0.6'} | ||||||
|     dependencies: |     dependencies: | ||||||
|       side-channel: 1.0.4 |       side-channel: 1.0.4 | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /qs@6.11.1: |   /qs@6.11.1: | ||||||
|     resolution: {integrity: sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==} |     resolution: {integrity: sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==} | ||||||
| @@ -17631,7 +17740,6 @@ packages: | |||||||
|   /range-parser@1.2.1: |   /range-parser@1.2.1: | ||||||
|     resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} |     resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} | ||||||
|     engines: {node: '>= 0.6'} |     engines: {node: '>= 0.6'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /ratelimiter@3.4.1: |   /ratelimiter@3.4.1: | ||||||
|     resolution: {integrity: sha512-5FJbRW/Jkkdk29ksedAfWFkQkhbUrMx3QJGwMKAypeIiQf4yrLW+gtPKZiaWt4zPrtw1uGufOjGO7UGM6VllsQ==} |     resolution: {integrity: sha512-5FJbRW/Jkkdk29ksedAfWFkQkhbUrMx3QJGwMKAypeIiQf4yrLW+gtPKZiaWt4zPrtw1uGufOjGO7UGM6VllsQ==} | ||||||
| @@ -17645,7 +17753,16 @@ packages: | |||||||
|       http-errors: 2.0.0 |       http-errors: 2.0.0 | ||||||
|       iconv-lite: 0.4.24 |       iconv-lite: 0.4.24 | ||||||
|       unpipe: 1.0.0 |       unpipe: 1.0.0 | ||||||
|     dev: true |  | ||||||
|  |   /raw-body@2.5.2: | ||||||
|  |     resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} | ||||||
|  |     engines: {node: '>= 0.8'} | ||||||
|  |     dependencies: | ||||||
|  |       bytes: 3.1.2 | ||||||
|  |       http-errors: 2.0.0 | ||||||
|  |       iconv-lite: 0.4.24 | ||||||
|  |       unpipe: 1.0.0 | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /rc@1.2.8: |   /rc@1.2.8: | ||||||
|     resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} |     resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} | ||||||
| @@ -18483,7 +18600,6 @@ packages: | |||||||
|       statuses: 2.0.1 |       statuses: 2.0.1 | ||||||
|     transitivePeerDependencies: |     transitivePeerDependencies: | ||||||
|       - supports-color |       - supports-color | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /serve-favicon@2.5.0: |   /serve-favicon@2.5.0: | ||||||
|     resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} |     resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} | ||||||
| @@ -18506,7 +18622,6 @@ packages: | |||||||
|       send: 0.18.0 |       send: 0.18.0 | ||||||
|     transitivePeerDependencies: |     transitivePeerDependencies: | ||||||
|       - supports-color |       - supports-color | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /set-blocking@2.0.0: |   /set-blocking@2.0.0: | ||||||
|     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} |     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} | ||||||
| @@ -18612,7 +18727,6 @@ packages: | |||||||
|       call-bind: 1.0.2 |       call-bind: 1.0.2 | ||||||
|       get-intrinsic: 1.2.0 |       get-intrinsic: 1.2.0 | ||||||
|       object-inspect: 1.12.2 |       object-inspect: 1.12.2 | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /siginfo@2.0.0: |   /siginfo@2.0.0: | ||||||
|     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} |     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} | ||||||
| @@ -18640,6 +18754,17 @@ packages: | |||||||
|       once: 1.4.0 |       once: 1.4.0 | ||||||
|       simple-concat: 1.0.1 |       simple-concat: 1.0.1 | ||||||
|  |  | ||||||
|  |   /simple-oauth2@5.0.0: | ||||||
|  |     resolution: {integrity: sha512-8291lo/z5ZdpmiOFzOs1kF3cxn22bMj5FFH+DNUppLJrpoIlM1QnFiE7KpshHu3J3i21TVcx4yW+gXYjdCKDLQ==} | ||||||
|  |     dependencies: | ||||||
|  |       '@hapi/hoek': 10.0.1 | ||||||
|  |       '@hapi/wreck': 18.0.1 | ||||||
|  |       debug: 4.3.4(supports-color@8.1.1) | ||||||
|  |       joi: 17.7.0 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /simple-swizzle@0.2.2: |   /simple-swizzle@0.2.2: | ||||||
|     resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} |     resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -19883,7 +20008,6 @@ packages: | |||||||
|     dependencies: |     dependencies: | ||||||
|       media-typer: 0.3.0 |       media-typer: 0.3.0 | ||||||
|       mime-types: 2.1.35 |       mime-types: 2.1.35 | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /type@1.2.0: |   /type@1.2.0: | ||||||
|     resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} |     resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} | ||||||
| @@ -19998,6 +20122,10 @@ packages: | |||||||
|     dev: true |     dev: true | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|  |   /uid2@0.0.4: | ||||||
|  |     resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /uid@2.0.2: |   /uid@2.0.2: | ||||||
|     resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} |     resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} | ||||||
|     engines: {node: '>=8'} |     engines: {node: '>=8'} | ||||||
| @@ -20163,7 +20291,6 @@ packages: | |||||||
|   /unpipe@1.0.0: |   /unpipe@1.0.0: | ||||||
|     resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} |     resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} | ||||||
|     engines: {node: '>= 0.8'} |     engines: {node: '>= 0.8'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /unplugin@0.10.2: |   /unplugin@0.10.2: | ||||||
|     resolution: {integrity: sha512-6rk7GUa4ICYjae5PrAllvcDeuT8pA9+j5J5EkxbMFaV+SalHhxZ7X2dohMzu6C3XzsMT+6jwR/+pwPNR3uK9MA==} |     resolution: {integrity: sha512-6rk7GUa4ICYjae5PrAllvcDeuT8pA9+j5J5EkxbMFaV+SalHhxZ7X2dohMzu6C3XzsMT+6jwR/+pwPNR3uK9MA==} | ||||||
| @@ -20279,7 +20406,6 @@ packages: | |||||||
|   /utils-merge@1.0.1: |   /utils-merge@1.0.1: | ||||||
|     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} |     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} | ||||||
|     engines: {node: '>= 0.4.0'} |     engines: {node: '>= 0.4.0'} | ||||||
|     dev: true |  | ||||||
|  |  | ||||||
|   /uuid@3.4.0: |   /uuid@3.4.0: | ||||||
|     resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} |     resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user