fix: missing spec implementation

This commit is contained in:
kakkokari-gtyih 2026-05-16 16:36:45 +09:00
commit ca5dc65b67
3 changed files with 49 additions and 0 deletions

View file

@ -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));

View file

@ -48,6 +48,9 @@ export function UserPage(props: CommonProps<{
{props.sub == null && props.federationEnabled ? (
<>
{props.user.host == null ? <link rel="indieauth-metadata" href={`${props.config.url}/.well-known/oauth-authorization-server`} /> : null}
{props.user.host == null ? <link rel="authorization_endpoint" href={`${props.config.url}/oauth/authorize`} /> : null}
{props.user.host == null ? <link rel="token_endpoint" href={`${props.config.url}/oauth/token`} /> : null}
{props.user.host == null ? <link rel="alternate" type="application/activity+json" href={`${props.config.url}/users/${props.user.id}`} /> : null}
{props.user.uri != null ? <link rel="alternate" type="application/activity+json" href={props.user.uri} /> : null}
{props.profile.url != null ? <link rel="alternate" type="text/html" href={props.profile.url} /> : null}

View file

@ -83,6 +83,11 @@ function getMeta(html: string): { transactionId: string | undefined, clientName:
};
}
function getLinkHref(html: string, rel: string): string | undefined {
const doc = htmlParser.parse(`<div>${html}</div>`);
return doc.querySelector(`link[rel="${rel}"]`)?.attributes.href;
}
function fetchDecision(transactionId: string, user: misskey.entities.SignupResponse, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
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,