mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-04 07:24:13 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			966 lines
		
	
	
	
		
			31 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			966 lines
		
	
	
	
		
			31 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
/*
 | 
						|
 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
						|
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
						|
 */
 | 
						|
 | 
						|
/**
 | 
						|
 * 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,
 | 
						|
	type AuthorizationTokenConfig,
 | 
						|
	ClientCredentials,
 | 
						|
	ModuleOptions,
 | 
						|
	ResourceOwnerPassword,
 | 
						|
} from 'simple-oauth2';
 | 
						|
import pkceChallenge from 'pkce-challenge';
 | 
						|
import { JSDOM } from 'jsdom';
 | 
						|
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
 | 
						|
import { api, port, sendEnvUpdateRequest, signup } from '../utils.js';
 | 
						|
import type * as misskey from 'misskey-js';
 | 
						|
 | 
						|
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.SignupResponse, { 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.SignupResponse, { 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.SignupResponse, 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 fastify: FastifyInstance;
 | 
						|
 | 
						|
	let alice: misskey.entities.SignupResponse;
 | 
						|
	let bob: misskey.entities.SignupResponse;
 | 
						|
 | 
						|
	let sender: (reply: FastifyReply) => void;
 | 
						|
 | 
						|
	beforeAll(async () => {
 | 
						|
		alice = await signup({ username: 'alice' });
 | 
						|
		bob = await signup({ username: 'bob' });
 | 
						|
 | 
						|
		fastify = Fastify();
 | 
						|
		fastify.get('/', async (request, reply) => {
 | 
						|
			sender(reply);
 | 
						|
		});
 | 
						|
		await fastify.listen({ port: clientPort });
 | 
						|
	}, 1000 * 60 * 2);
 | 
						|
 | 
						|
	beforeEach(async () => {
 | 
						|
		await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '' });
 | 
						|
		sender = (reply): void => {
 | 
						|
			reply.send(`
 | 
						|
				<!DOCTYPE html>
 | 
						|
				<link rel="redirect_uri" href="/redirect" />
 | 
						|
				<div class="h-app"><a href="/" class="u-url p-name">Misklient
 | 
						|
			`);
 | 
						|
		};
 | 
						|
	});
 | 
						|
 | 
						|
	afterAll(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"><a href="/" class="u-url 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"><a href="/" class="u-url 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"><a href="/" class="u-url 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"><a href="/" class="u-url p-name">Misklient
 | 
						|
					`);
 | 
						|
				},
 | 
						|
			};
 | 
						|
 | 
						|
			for (const [title, replyFunc] of Object.entries(tests)) {
 | 
						|
				test(title, async () => {
 | 
						|
					sender = replyFunc;
 | 
						|
 | 
						|
					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 () => {
 | 
						|
				sender = (reply): void => {
 | 
						|
					reply.send(`
 | 
						|
						<!DOCTYPE html>
 | 
						|
						<div class="h-app"><a href="/" class="u-url p-name">Misklient
 | 
						|
					`);
 | 
						|
				};
 | 
						|
 | 
						|
				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 () => {
 | 
						|
			await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '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 () => {
 | 
						|
			sender = (reply): void => {
 | 
						|
				reply.header('Link', '</redirect>; rel="redirect_uri"');
 | 
						|
				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);
 | 
						|
			assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
 | 
						|
		});
 | 
						|
 | 
						|
		test('Mismatching URL in h-app', async () => {
 | 
						|
			sender = (reply): void => {
 | 
						|
				reply.header('Link', '</redirect>; rel="redirect_uri"');
 | 
						|
				reply.send(`
 | 
						|
					<!DOCTYPE html>
 | 
						|
					<div class="h-app"><a href="/foo" 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);
 | 
						|
			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);
 | 
						|
	});
 | 
						|
 | 
						|
	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'));
 | 
						|
		});
 | 
						|
	});
 | 
						|
});
 |