From 22228b6756ff534a94930774d9b6744dfb5961fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 6 Mar 2025 17:05:14 +0900
Subject: [PATCH] =?UTF-8?q?enhance:=20OAuth2=20(IndieAuth)=20=E3=81=A7?=
 =?UTF-8?q?=E3=83=AD=E3=82=B4=E3=81=8C=E6=8F=90=E4=BE=9B=E3=81=95=E3=82=8C?=
 =?UTF-8?q?=E3=81=A6=E3=81=84=E3=82=8B=E5=A0=B4=E5=90=88=E3=81=AF=E8=A1=A8?=
 =?UTF-8?q?=E7=A4=BA=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#1557?=
 =?UTF-8?q?8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance: OAuthでロゴが提供されている場合は表示するように

* Update Changelog

* refactor

* fix

* fix test
---
 CHANGELOG.md                                  |  2 +
 .../src/server/oauth/OAuth2ProviderService.ts | 17 +++++-
 .../backend/src/server/web/views/oauth.pug    |  2 +
 packages/backend/test/e2e/oauth.ts            | 56 ++++++++++++++++++-
 packages/frontend/src/pages/oauth.vue         |  2 +
 5 files changed, 75 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21c589376..197de5aec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ### General
 - Enhance: プロキシアカウントをシステムアカウントとして作成するように
+- Enhance: OAuthで外部アプリからロゴが提供されている場合、それを表示できるように  
+  書式は https://indieauth.spec.indieweb.org/20220212/#example-2 に準じます。
 - Fix: システムアカウントが削除できる問題を修正
 
 ### Client
diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
index e065c451f..cdd710266 100644
--- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts
+++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
@@ -95,6 +95,7 @@ interface ClientInformation {
 	id: string;
 	redirectUris: string[];
 	name: string;
+	logo: string | null;
 }
 
 // https://indieauth.spec.indieweb.org/#client-information-discovery
@@ -124,11 +125,19 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
 		redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
 
 		let name = id;
+		let logo: string | null = null;
 		if (text) {
 			const microformats = mf2(text, { baseUrl: res.url });
-			const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0];
-			if (typeof nameProperty === 'string') {
-				name = nameProperty;
+			const correspondingProperties = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id));
+			if (correspondingProperties) {
+				const nameProperty = correspondingProperties.properties.name?.[0];
+				if (typeof nameProperty === 'string') {
+					name = nameProperty;
+				}
+				const logoProperty = correspondingProperties.properties.logo?.[0];
+				if (typeof logoProperty === 'string') {
+					logo = logoProperty;
+				}
 			}
 		}
 
@@ -136,6 +145,7 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
 			id,
 			redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
 			name: typeof name === 'string' ? name : id,
+			logo,
 		};
 	} catch (err) {
 		console.error(err);
@@ -379,6 +389,7 @@ export class OAuth2ProviderService {
 			return await reply.view('oauth', {
 				transactionId: oauth2.transactionID,
 				clientName: oauth2.client.name,
+				clientLogo: oauth2.client.logo,
 				scope: oauth2.req.scope.join(' '),
 			});
 		});
diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug
index 1470dbfbd..4195ccc3a 100644
--- a/packages/backend/src/server/web/views/oauth.pug
+++ b/packages/backend/src/server/web/views/oauth.pug
@@ -6,4 +6,6 @@ block meta
 	//- XXX: Remove navigation bar in auth page?
 	meta(name='misskey:oauth:transaction-id' content=transactionId)
 	meta(name='misskey:oauth:client-name' content=clientName)
+	if clientLogo
+		meta(name='misskey:oauth:client-logo' content=clientLogo)
 	meta(name='misskey:oauth:scope' content=scope)
diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts
index ef7a6a579..f639f90ea 100644
--- a/packages/backend/test/e2e/oauth.ts
+++ b/packages/backend/test/e2e/oauth.ts
@@ -72,11 +72,12 @@ const clientConfig: ModuleOptions<'client_id'> = {
 	},
 };
 
-function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } {
+function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: 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,
+		clientLogo: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content,
 	};
 }
 
@@ -915,6 +916,59 @@ describe('OAuth', () => {
 			assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
 		});
 
+		test('With Logo', async () => {
+			sender = (reply): void => {
+				reply.header('Link', '</redirect>; rel="redirect_uri"');
+				reply.send(`
+					<!DOCTYPE html>
+					<div class="h-app">
+						<a href="/" class="u-url p-name">Misklient</a>
+						<img src="/logo.png" class="u-logo" />
+					</div>
+				`);
+				reply.send();
+			};
+
+			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 meta = getMeta(await response.text());
+			assert.strictEqual(meta.clientName, 'Misklient');
+			assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
+		});
+
+		test('Missing Logo', async () => {
+			sender = (reply): void => {
+				reply.header('Link', '</redirect>; rel="redirect_uri"');
+				reply.send(`
+					<!DOCTYPE html>
+					<div class="h-app"><a href="/" class="u-url p-name">Misklient
+				`);
+				reply.send();
+			};
+
+			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 meta = getMeta(await response.text());
+			assert.strictEqual(meta.clientName, 'Misklient');
+			assert.strictEqual(meta.clientLogo, undefined);
+		});
+
 		test('Mismatching URL in h-app', async () => {
 			sender = (reply): void => {
 				reply.header('Link', '</redirect>; rel="redirect_uri"');
diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue
index 8719a769e..860c884d1 100644
--- a/packages/frontend/src/pages/oauth.vue
+++ b/packages/frontend/src/pages/oauth.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkAuthConfirm
 				ref="authRoot"
 				:name="name"
+				:icon="logo"
 				:permissions="permissions"
 				:waitOnDeny="true"
 				@accept="onAccept"
@@ -33,6 +34,7 @@ if (transactionIdMeta) {
 }
 
 const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
+const logo = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content;
 const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? [];
 
 function doPost(token: string, decision: 'accept' | 'deny') {