diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b71f6540..53931b44d 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 6848d88e0..4d1e9936a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -68,7 +68,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 bb41ab0e4..632a7692c 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -107,7 +107,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 8fc3c96de..c3eaf53a1 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 5c18f452c..225307858 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -11,6 +11,7 @@ import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; import oauth2Pkce from 'oauth2orize-pkce'; +import fastifyCors from '@fastify/cors'; import fastifyView from '@fastify/view'; import pug from 'pug'; import bodyParser from 'body-parser'; @@ -348,25 +349,25 @@ export class OAuth2ProviderService { })); } + // 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 { - // https://datatracker.ietf.org/doc/html/rfc8414.html - // https://indieauth.spec.indieweb.org/#indieauth-server-metadata - fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { - reply.send({ - 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, - }); - }); - - fastify.get('/oauth/authorize', async (request, reply) => { + fastify.get('/authorize', async (request, reply) => { const oauth2 = (request.raw as MiddlewareRequest).oauth2; if (!oauth2) { throw new Error('Unexpected lack of authorization information'); @@ -381,8 +382,7 @@ export class OAuth2ProviderService { scope: oauth2.req.scope.join(' '), }); }); - fastify.post('/oauth/decision', async () => { }); - fastify.post('/oauth/token', async () => { }); + fastify.post('/decision', async () => { }); fastify.register(fastifyView, { root: fileURLToPath(new URL('../web/views', import.meta.url)), @@ -394,7 +394,7 @@ export class OAuth2ProviderService { }); await fastify.register(fastifyExpress); - fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { + fastify.use('/authorize', this.#server.authorize(((areq, done) => { (async (): Promise> => { // This should return client/redirectURI AND the error, or // the handler can't send error to the redirection URI @@ -448,30 +448,24 @@ export class OAuth2ProviderService { return [null, clientInfo, redirectURI]; })().then(args => done(...args), err => done(err)); }) as ValidateFunctionArity2)); - fastify.use('/oauth/authorize', this.#server.errorHandler({ + fastify.use('/authorize', this.#server.errorHandler({ mode: 'indirect', modes: getQueryMode(this.config.url), })); - fastify.use('/oauth/authorize', this.#server.errorHandler()); + fastify.use('/authorize', this.#server.errorHandler()); - fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); - fastify.use('/oauth/decision', this.#server.decision((req, done) => { + fastify.use('/decision', bodyParser.urlencoded({ extended: false })); + fastify.use('/decision', this.#server.decision((req, done) => { const { body } = req as OAuth2DecisionRequest; this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`); req.user = body.login_token; done(null, undefined); })); - fastify.use('/oauth/decision', this.#server.errorHandler()); - - // Clients may use JSON or urlencoded - fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false })); - fastify.use('/oauth/token', bodyParser.json({ strict: true })); - fastify.use('/oauth/token', this.#server.token()); - fastify.use('/oauth/token', this.#server.errorHandler()); + fastify.use('/decision', this.#server.errorHandler()); // Return 404 for any unknown paths under /oauth so that clients can know // whether a certain endpoint is supported or not. - fastify.all('/oauth/*', async (_request, reply) => { + fastify.all('/*', async (_request, reply) => { reply.code(404); reply.send({ error: { @@ -483,4 +477,17 @@ export class OAuth2ProviderService { }); }); } + + @bindThis + public async createTokenServer(fastify: FastifyInstance): Promise { + 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 000000000..7eed39c5e --- /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 a029a0d4b..3a5e4ebda 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 000000000..14e32e162 --- /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 db7629d2c..46b8ea9cd 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 278109f12..b46dcd0e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,8 +84,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 @@ -4303,11 +4303,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: @@ -7281,7 +7281,7 @@ packages: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.3.12(typescript@5.3.3) - vue-component-type-helpers: 1.8.25 + vue-component-type-helpers: 1.8.27 transitivePeerDependencies: - encoding - supports-color @@ -15209,8 +15209,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 @@ -19087,10 +19087,6 @@ packages: /tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - /twemoji-parser@14.0.0: - resolution: {integrity: sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==} - dev: false - /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -19755,8 +19751,8 @@ packages: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} dev: false - /vue-component-type-helpers@1.8.25: - resolution: {integrity: sha512-NCA6sekiJIMnMs4DdORxATXD+/NRkQpS32UC+I1KQJUasx+Z7MZUb3Y+MsKsFmX+PgyTYSteb73JW77AibaCCw==} + /vue-component-type-helpers@1.8.27: + resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==} dev: true /vue-component-type-helpers@1.8.4: