From 544b8106b2e85f57ca76ccf53c1ff998abeeef1a Mon Sep 17 00:00:00 2001
From: Kagami Sascha Rosylight <saschanaz@outlook.com>
Date: Wed, 27 Dec 2023 07:10:24 +0100
Subject: [PATCH] feat(backend/oauth): allow CORS for token endpoint (#12814)

* feat(backend/oauth): allow CORS for token endpoint

* no need to explicitly set origin to `*`

* Update CHANGELOG.md
---
 CHANGELOG.md                                  |  11 ++
 packages/backend/package.json                 |   2 +-
 packages/backend/src/server/ServerService.ts  |   3 +-
 .../src/server/WellKnownServerService.ts      |   6 +
 .../src/server/oauth/OAuth2ProviderService.ts |  29 +++++
 packages/backend/test/e2e/nodeinfo.ts         |  40 +++++++
 packages/backend/test/e2e/oauth.ts            |  20 ++++
 packages/backend/test/e2e/well-known.ts       | 111 ++++++++++++++++++
 packages/backend/test/utils.ts                |   2 +
 pnpm-lock.yaml                                |  23 ++--
 10 files changed, 238 insertions(+), 9 deletions(-)
 create mode 100644 packages/backend/test/e2e/nodeinfo.ts
 create mode 100644 packages/backend/test/e2e/well-known.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b71f6540d..53931b44d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,17 @@
 
 -->
 
+## 2023.x.x (unreleased)
+
+### General
+-
+
+### Client
+- 
+
+### Server
+- Enhance: `oauth/token`エンドポイントのCORS対応
+
 ## 2023.12.1
 
 ### General
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 14243b9ba2..224700a77b 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -66,7 +66,7 @@
 		"@discordapp/twemoji": "15.0.2",
 		"@fastify/accepts": "4.3.0",
 		"@fastify/cookie": "9.2.0",
-		"@fastify/cors": "8.4.2",
+		"@fastify/cors": "8.5.0",
 		"@fastify/express": "2.3.0",
 		"@fastify/http-proxy": "9.3.0",
 		"@fastify/multipart": "8.0.0",
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index b6d4175c0c..cf8de4ea56 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -110,7 +110,8 @@ export class ServerService implements OnApplicationShutdown {
 		fastify.register(this.activityPubServerService.createServer);
 		fastify.register(this.nodeinfoServerService.createServer);
 		fastify.register(this.wellKnownServerService.createServer);
-		fastify.register(this.oauth2ProviderService.createServer);
+		fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
+		fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
 
 		fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
 			const path = request.params.path;
diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts
index 8fc3c96de6..c3eaf53a14 100644
--- a/packages/backend/src/server/WellKnownServerService.ts
+++ b/packages/backend/src/server/WellKnownServerService.ts
@@ -16,6 +16,7 @@ import * as Acct from '@/misc/acct.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { bindThis } from '@/decorators.js';
 import { NodeinfoServerService } from './NodeinfoServerService.js';
+import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
 import type { FindOptionsWhere } from 'typeorm';
 import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
 
@@ -30,6 +31,7 @@ export class WellKnownServerService {
 
 		private nodeinfoServerService: NodeinfoServerService,
 		private userEntityService: UserEntityService,
+		private oauth2ProviderService: OAuth2ProviderService,
 	) {
 		//this.createServer = this.createServer.bind(this);
 	}
@@ -87,6 +89,10 @@ export class WellKnownServerService {
 			return { links: this.nodeinfoServerService.getLinks() };
 		});
 
+		fastify.get('/.well-known/oauth-authorization-server', async () => {
+			return this.oauth2ProviderService.generateRFC8414();
+		});
+
 		/* TODO
 fastify.get('/.well-known/change-password', async (request, reply) => {
 });
diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
index 7ccf3a297e..52505ac5bb 100644
--- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts
+++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
@@ -31,6 +31,22 @@ export class OAuth2ProviderService {
 		private config: Config,
 	) { }
 
+	// https://datatracker.ietf.org/doc/html/rfc8414.html
+	// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
+	public generateRFC8414() {
+		return {
+			issuer: this.config.url,
+			authorization_endpoint: new URL('/oauth/authorize', this.config.url),
+			token_endpoint: new URL('/oauth/token', this.config.url),
+			scopes_supported: kinds,
+			response_types_supported: ['code'],
+			grant_types_supported: ['authorization_code'],
+			service_documentation: 'https://misskey-hub.net',
+			code_challenge_methods_supported: ['S256'],
+			authorization_response_iss_parameter_supported: true,
+		};
+	}
+
 	@bindThis
 	public async createServer(fastify: FastifyInstance): Promise<void> {
 		// https://datatracker.ietf.org/doc/html/rfc8414.html
@@ -151,4 +167,17 @@ export class OAuth2ProviderService {
 			}
 		});
 	}
+
+	@bindThis
+	public async createTokenServer(fastify: FastifyInstance): Promise<void> {
+		fastify.register(fastifyCors);
+		fastify.post('', async () => { });
+
+		await fastify.register(fastifyExpress);
+		// Clients may use JSON or urlencoded
+		fastify.use('', bodyParser.urlencoded({ extended: false }));
+		fastify.use('', bodyParser.json({ strict: true }));
+		fastify.use('', this.#server.token());
+		fastify.use('', this.#server.errorHandler());
+	}
 }
diff --git a/packages/backend/test/e2e/nodeinfo.ts b/packages/backend/test/e2e/nodeinfo.ts
new file mode 100644
index 0000000000..7eed39c5ed
--- /dev/null
+++ b/packages/backend/test/e2e/nodeinfo.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { relativeFetch, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('nodeinfo', () => {
+	let app: INestApplicationContext;
+
+	beforeAll(async () => {
+		app = await startServer();
+	}, 1000 * 60 * 2);
+
+	afterAll(async () => {
+		await app.close();
+	});
+
+	test('nodeinfo 2.1', async () => {
+		const res = await relativeFetch('nodeinfo/2.1');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+
+		const nodeInfo = await res.json() as any;
+		assert.strictEqual(nodeInfo.software.name, 'misskey');
+	});
+
+	test('nodeinfo 2.0', async () => {
+		const res = await relativeFetch('nodeinfo/2.0');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+
+		const nodeInfo = await res.json() as any;
+		assert.strictEqual(nodeInfo.software.name, 'misskey');
+	});
+});
diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts
index a029a0d4be..3a5e4ebdae 100644
--- a/packages/backend/test/e2e/oauth.ts
+++ b/packages/backend/test/e2e/oauth.ts
@@ -941,4 +941,24 @@ describe('OAuth', () => {
 		const response = await fetch(new URL('/oauth/foo', host));
 		assert.strictEqual(response.status, 404);
 	});
+
+	describe('CORS', () => {
+		test('Token endpoint should support CORS', async () => {
+			const response = await fetch(new URL('/oauth/token', host), { method: 'POST' });
+			assert.ok(!response.ok);
+			assert.strictEqual(response.headers.get('Access-Control-Allow-Origin'), '*');
+		});
+
+		test('Authorize endpoint should not support CORS', async () => {
+			const response = await fetch(new URL('/oauth/authorize', host), { method: 'GET' });
+			assert.ok(!response.ok);
+			assert.ok(!response.headers.has('Access-Control-Allow-Origin'));
+		});
+
+		test('Decision endpoint should not support CORS', async () => {
+			const response = await fetch(new URL('/oauth/decision', host), { method: 'POST' });
+			assert.ok(!response.ok);
+			assert.ok(!response.headers.has('Access-Control-Allow-Origin'));
+		});
+	});
 });
diff --git a/packages/backend/test/e2e/well-known.ts b/packages/backend/test/e2e/well-known.ts
new file mode 100644
index 0000000000..14e32e1627
--- /dev/null
+++ b/packages/backend/test/e2e/well-known.ts
@@ -0,0 +1,111 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { host, origin, relativeFetch, signup, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
+import type * as misskey from 'misskey-js';
+
+describe('.well-known', () => {
+	let app: INestApplicationContext;
+	let alice: misskey.entities.User;
+
+	beforeAll(async () => {
+		app = await startServer();
+
+		alice = await signup({ username: 'alice' });
+	}, 1000 * 60 * 2);
+
+	afterAll(async () => {
+		await app.close();
+	});
+
+	test('nodeinfo', async () => {
+		const res = await relativeFetch('.well-known/nodeinfo');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+
+		const nodeInfo = await res.json();
+		assert.deepStrictEqual(nodeInfo, {
+			links: [{
+				rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
+				href: `${origin}/nodeinfo/2.1`,
+			}, {
+				rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
+				href: `${origin}/nodeinfo/2.0`,
+			}],
+		});
+	});
+
+	test('webfinger', async () => {
+		const preflight = await relativeFetch(`.well-known/webfinger?resource=acct:alice@${host}`, {
+			method: 'options',
+			headers: {
+				'Access-Control-Request-Method': 'GET',
+				Origin: 'http://example.com',
+			},
+		});
+		assert.ok(preflight.ok);
+		assert.strictEqual(preflight.headers.get('Access-Control-Allow-Headers'), 'Accept');
+
+		const res = await relativeFetch(`.well-known/webfinger?resource=acct:alice@${host}`);
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+		assert.strictEqual(res.headers.get('Access-Control-Expose-Headers'), 'Vary');
+		assert.strictEqual(res.headers.get('Vary'), 'Accept');
+
+		const webfinger = await res.json();
+
+		assert.deepStrictEqual(webfinger, {
+			subject: `acct:alice@${host}`,
+			links: [{
+				rel: 'self',
+				type: 'application/activity+json',
+				href: `${origin}/users/${alice.id}`,
+			}, {
+				rel: 'http://webfinger.net/rel/profile-page',
+				type: 'text/html',
+				href: `${origin}/@alice`,
+			}, {
+				rel: 'http://ostatus.org/schema/1.0/subscribe',
+				template: `${origin}/authorize-follow?acct={uri}`,
+			}],
+		});
+	});
+
+	test('host-meta', async () => {
+		const res = await relativeFetch('.well-known/host-meta');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+	});
+
+	test('host-meta.json', async () => {
+		const res = await relativeFetch('.well-known/host-meta.json');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+
+		const hostMeta = await res.json();
+		assert.deepStrictEqual(hostMeta, {
+			links: [{
+				rel: 'lrdd',
+				type: 'application/jrd+json',
+				template: `${origin}/.well-known/webfinger?resource={uri}`,
+			}],
+		});
+	});
+
+	test('oauth-authorization-server', async () => {
+		const res = await relativeFetch('.well-known/oauth-authorization-server');
+		assert.ok(res.ok);
+		assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*');
+
+		const serverInfo = await res.json() as any;
+		assert.strictEqual(serverInfo.issuer, origin);
+		assert.strictEqual(serverInfo.authorization_endpoint, `${origin}/oauth/authorize`);
+		assert.strictEqual(serverInfo.token_endpoint, `${origin}/oauth/token`);
+	});
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index db7629d2c4..46b8ea9cdd 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -26,6 +26,8 @@ interface UserToken {
 
 const config = loadConfig();
 export const port = config.port;
+export const origin = config.url;
+export const host = new URL(config.url).host;
 
 export const cookie = (me: UserToken): string => {
 	return `token=${me.token};`;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f77a0081c..2db1e628fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,8 +80,8 @@ importers:
         specifier: 9.2.0
         version: 9.2.0
       '@fastify/cors':
-        specifier: 8.4.2
-        version: 8.4.2
+        specifier: 8.5.0
+        version: 8.5.0
       '@fastify/express':
         specifier: 2.3.0
         version: 2.3.0
@@ -4230,11 +4230,11 @@ packages:
       fastify-plugin: 4.5.0
     dev: false
 
-  /@fastify/cors@8.4.2:
-    resolution: {integrity: sha512-IVynbcPG9eWiJ0P/A1B+KynmiU/yTYbu3ooBUSIeHfca/N1XLb9nIJVCws+YTr2q63MA8Y6QLeXQczEv4npM9g==}
+  /@fastify/cors@8.5.0:
+    resolution: {integrity: sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==}
     dependencies:
       fastify-plugin: 4.5.0
-      mnemonist: 0.39.5
+      mnemonist: 0.39.6
     dev: false
 
   /@fastify/deepmerge@1.3.0:
@@ -7221,7 +7221,11 @@ packages:
       ts-dedent: 2.2.0
       type-fest: 2.19.0
       vue: 3.3.12(typescript@5.3.3)
+<<<<<<< HEAD
       vue-component-type-helpers: 1.8.26
+=======
+      vue-component-type-helpers: 1.8.27
+>>>>>>> ad346b6f3 (feat(backend/oauth): allow CORS for token endpoint (#12814))
     transitivePeerDependencies:
       - encoding
       - supports-color
@@ -15138,8 +15142,8 @@ packages:
       ufo: 1.1.2
     dev: true
 
-  /mnemonist@0.39.5:
-    resolution: {integrity: sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ==}
+  /mnemonist@0.39.6:
+    resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
     dependencies:
       obliterator: 2.0.4
     dev: false
@@ -19628,8 +19632,13 @@ packages:
   /vscode-textmate@8.0.0:
     resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
 
+<<<<<<< HEAD
   /vue-component-type-helpers@1.8.26:
     resolution: {integrity: sha512-CIwb7s8cqUuPpHDk+0DY8EJ/x8tzdzqw8ycX8hhw1GnbngTgSsIceHAqrrLjmv8zXi+j5XaiqYRQMw8sKyyjkw==}
+=======
+  /vue-component-type-helpers@1.8.27:
+    resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==}
+>>>>>>> ad346b6f3 (feat(backend/oauth): allow CORS for token endpoint (#12814))
     dev: true
 
   /vue-component-type-helpers@1.8.4:
-- 
GitLab