From ca5dc65b673fed41ef783943a3eb8464b0bb4b73 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 16 May 2026 16:36:45 +0900 Subject: [PATCH] fix: missing spec implementation --- .../src/server/oauth/OAuth2ProviderService.ts | 20 ++++++++++++++ .../backend/src/server/web/views/user.tsx | 3 +++ packages/backend/test/e2e/oauth.ts | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 75532b759b..070a69d691 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -34,6 +34,7 @@ import { MemoryKVCache } from '@/misc/cache.js'; import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; import { StatusError } from '@/misc/status-error.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; import type { FastifyInstance, FastifyReply } from 'fastify'; @@ -392,6 +393,7 @@ export class OAuth2ProviderService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, private cacheService: CacheService, + private userEntityService: UserEntityService, loggerService: LoggerService, private htmlTemplateService: HtmlTemplateService, ) { @@ -556,6 +558,12 @@ export class OAuth2ProviderService { } }); + // https://indieauth.spec.indieweb.org/#profile-url-response + // 11 July 2024 spec also allows redeeming an authorization code at the + // authorization endpoint itself when the client only needs the canonical + // profile URL and not an access token. Misskey currently uses this route + // only for the browser-based consent UI and issues tokens through + // /oauth/token, so the profile-only redemption flow remains unimplemented. fastify.post('/decision', async (request, reply) => { try { const body = toRequestParameters(request.body); @@ -675,10 +683,21 @@ export class OAuth2ProviderService { } checkPKCE(codeVerifier, granted.codeChallenge, 'S256'); + // https://indieauth.spec.indieweb.org/#access-token-response + // The token response MUST include the canonical profile URL as `me`. + // Misskey uses the stable local actor URL so clients can later confirm + // that the returned profile URL declares the same authorization server. + const me = this.userEntityService.genLocalUserUri(granted.userId); + const accessToken = secureRndstr(128); const now = new Date(); // NOTE: we don't have a setup for automatic token expiration + // https://indieauth.spec.indieweb.org/#access-token-response + // `expires_in` is only RECOMMENDED there, and RFC6749 Section 5.1 also + // allows omitting it when the server documents or otherwise defines the + // token lifetime. Misskey currently issues bearer tokens without a + // published expiration timestamp. await this.accessTokensRepository.insert({ id: this.idService.gen(now.getTime()), lastUsedAt: now, @@ -702,6 +721,7 @@ export class OAuth2ProviderService { access_token: accessToken, token_type: 'Bearer', scope: granted.scopes.join(' '), + me, }); } catch (error) { sendOAuthError(reply, normalizeOAuthError(error)); diff --git a/packages/backend/src/server/web/views/user.tsx b/packages/backend/src/server/web/views/user.tsx index 76c2633ab9..def354ef5e 100644 --- a/packages/backend/src/server/web/views/user.tsx +++ b/packages/backend/src/server/web/views/user.tsx @@ -48,6 +48,9 @@ export function UserPage(props: CommonProps<{ {props.sub == null && props.federationEnabled ? ( <> + {props.user.host == null ? : null} + {props.user.host == null ? : null} + {props.user.host == null ? : null} {props.user.host == null ? : null} {props.user.uri != null ? : null} {props.profile.url != null ? : null} diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 82816f705e..0ca1aebe3e 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -83,6 +83,11 @@ function getMeta(html: string): { transactionId: string | undefined, clientName: }; } +function getLinkHref(html: string, rel: string): string | undefined { + const doc = htmlParser.parse(`
${html}
`); + return doc.querySelector(`link[rel="${rel}"]`)?.attributes.href; +} + function fetchDecision(transactionId: string, user: misskey.entities.SignupResponse, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', @@ -232,6 +237,27 @@ describe('OAuth', () => { assert.strictEqual(typeof token.token.access_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); assert.strictEqual(token.token.scope, 'write:notes'); + // https://indieauth.spec.indieweb.org/#access-token-response + assert.strictEqual(token.token.me, `http://misskey.local/users/${alice.id}`); + + // https://indieauth.spec.indieweb.org/#authorization-server-confirmation + // Clients must be able to rediscover the same authorization server + // from the returned canonical profile URL. + const meResponse = await fetch(token.token.me as string); + assert.strictEqual(meResponse.status, 200); + const metadataHref = getLinkHref(await meResponse.text(), 'indieauth-metadata'); + assert.strictEqual(metadataHref, 'http://misskey.local/.well-known/oauth-authorization-server'); + + const metadataResponse = await fetch(metadataHref as string); + assert.strictEqual(metadataResponse.status, 200); + const metadata = await metadataResponse.json() as { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + }; + assert.strictEqual(metadata.issuer, 'http://misskey.local'); + assert.strictEqual(metadata.authorization_endpoint, 'http://misskey.local/oauth/authorize'); + assert.strictEqual(metadata.token_endpoint, 'http://misskey.local/oauth/token'); const createResult = await api('notes/create', { text: 'test' }, { token: token.token.access_token as string,