From 0621e94c7d30ece6f8bfc3639149350510aa0897 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 18 Mar 2023 16:07:12 +0100 Subject: [PATCH 01/82] tmp --- packages/backend/package.json | 1 + packages/backend/src/server/ServerModule.ts | 2 + packages/backend/src/server/ServerService.ts | 6 +- .../src/server/oauth/OAuth2ProviderService.ts | 216 ++++++++++++++++++ pnpm-lock.yaml | 209 +++++++++++++++-- 5 files changed, 418 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/server/oauth/OAuth2ProviderService.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 6de4e634fd..34d4b206a0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -117,6 +117,7 @@ "nodemailer": "6.9.3", "nsfwjs": "2.4.2", "oauth": "0.10.0", + "oidc-provider": "^8.1.1", "os-utils": "0.0.14", "otpauth": "9.1.2", "parse5": "7.1.2", diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index da86b2c1d3..5d80eb4e0c 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -36,6 +36,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; +import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; @Module({ imports: [ @@ -78,6 +79,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. ServerStatsChannelService, UserListChannelService, OpenApiServerService, + OAuth2ProviderService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index c3d45e4ad6..2ef074cb56 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -24,6 +24,7 @@ import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -56,12 +57,13 @@ export class ServerService implements OnApplicationShutdown { private clientServerService: ClientServerService, private globalEventService: GlobalEventService, private loggerService: LoggerService, + private oauth2ProviderService: OAuth2ProviderService, ) { this.logger = this.loggerService.getLogger('server', 'gray', false); } @bindThis - public async launch() { + public async launch(): Promise { const fastify = Fastify({ trustProxy: true, logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), @@ -90,6 +92,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.createServerWildcard); + fastify.register(this.oauth2ProviderService.createServer); 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/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts new file mode 100644 index 0000000000..347b147534 --- /dev/null +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -0,0 +1,216 @@ +import dns from 'node:dns/promises'; +import { Inject, Injectable } from '@nestjs/common'; +import Provider, { type Adapter, type Account, AdapterPayload } from 'oidc-provider'; +import fastifyMiddie from '@fastify/middie'; +import { JSDOM } from 'jsdom'; +import parseLinkHeader from 'parse-link-header'; +import ipaddr from 'ipaddr.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { kinds } from '@/misc/api-permissions.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { FastifyInstance } from 'fastify'; + + +// TODO: For now let's focus on letting oidc-provider use the existing miauth infra. +// Supporting IndieAuth is a separate project. +// Allow client_id created by apps/create or not? It's already marked as old method. + +// https://indieauth.spec.indieweb.org/#client-identifier +function validateClientId(raw: string): URL { + // Clients are identified by a [URL]. + const url = ((): URL => { + try { + return new URL(raw); + } catch { throw new Error('client_id must be a valid URL'); } + })(); + + // Client identifier URLs MUST have either an https or http scheme + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('client_id must be either https or http URL'); + } + + // MUST contain a path component (new URL() implicitly adds one) + + // MUST NOT contain single-dot or double-dot path segments, + // url. + const segments = url.pathname.split('/'); + if (segments.includes('.') || segments.includes('..')) { + throw new Error('client_id must not contain dot path segments'); + } + + // MUST NOT contain a fragment component + if (url.hash) { + throw new Error('client_id must not contain a fragment component'); + } + + // MUST NOT contain a username or password component + if (url.username || url.password) { + throw new Error('client_id must not contain a username or a password'); + } + + // MUST NOT contain a port + if (url.port) { + throw new Error('client_id must not contain a port'); + } + + // host names MUST be domain names or a loopback interface and MUST NOT be + // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]. + // (But in https://indieauth.spec.indieweb.org/#redirect-url we need to only + // fetch non-loopback URLs, so exclude them here.) + if (!url.hostname.match(/\.\w+$/)) { + throw new Error('client_id must have a domain name as a host name'); + } + + return url; +} + +async function fetchFromClientId(httpRequestService: HttpRequestService, id: string): Promise { + try { + const res = await httpRequestService.send(id); + let redirectUri = parseLinkHeader(res.headers.get('link'))?.redirect_uri?.url; + if (redirectUri) { + return new URL(redirectUri, res.url).toString(); + } + + const { window } = new JSDOM(await res.text()); + redirectUri = window.document.querySelector('link[rel=redirect_uri][href]')?.href; + if (redirectUri) { + return new URL(redirectUri, res.url).toString(); + } + } catch { + throw new Error('Failed to fetch client information'); + } +} + +class MisskeyAdapter implements Adapter { + constructor(private httpRequestService: HttpRequestService) { } + + upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { + console.log('oauth upsert', id, payload, expiresIn); + throw new Error('Method not implemented.'); + } + async find(id: string): Promise { + // Find client information from the remote. + + console.log('oauth find', id); + const url = validateClientId(id); + + if (process.env.NODE_ENV !== 'test') { + const lookup = await dns.lookup(url.hostname); + if (ipaddr.parse(lookup.address).range() === 'loopback') { + throw new Error('client_id unexpectedly resolves to loopback IP.'); + } + } + + const redirectUri = await fetchFromClientId(this.httpRequestService, id); + if (!redirectUri) { + // IndieAuth also implicitly allows any path under the same scheme+host, + // but oidc-provider does not have such option. + throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML element.'); + } + + return { + client_id: id, + token_endpoint_auth_method: 'none', + redirect_uris: [redirectUri], + }; + } + async findByUserCode(userCode: string): Promise { + console.log('oauth findByUserCode', userCode); + throw new Error('Method not implemented.'); + } + async findByUid(uid: string): Promise { + console.log('oauth findByUid', uid); + throw new Error('Method not implemented.'); + } + async consume(id: string): Promise { + console.log('oauth consume', id); + throw new Error('Method not implemented.'); + } + async destroy(id: string): Promise { + console.log('oauth destroy', id); + throw new Error('Method not implemented.'); + } + async revokeByGrantId(grantId: string): Promise { + console.log('oauth revokeByGrandId', grantId); + throw new Error('Method not implemented.'); + } +} + +@Injectable() +export class OAuth2ProviderService { + #provider: Provider; + + constructor( + @Inject(DI.config) + private config: Config, + httpRequestService: HttpRequestService, + ) { + this.#provider = new Provider(config.url, { + clientAuthMethods: ['none'], + pkce: { + // This is the default, but be explicit here as we announce it below + methods: ['S256'], + }, + routes: { + // defaults to '/auth' but '/authorize' is more consistent with many + // other services eg. Mastodon/Twitter/Facebook/GitLab/GitHub/etc. + authorization: '/authorize', + }, + scopes: kinds, + async findAccount(ctx, id): Promise { + console.log(id); + return undefined; + }, + adapter(): MisskeyAdapter { + return new MisskeyAdapter(httpRequestService); + }, + async renderError(ctx, out, error): Promise { + console.log(error); + }, + }); + } + + // Return 404 for any unknown paths under /oauth so that clients can know + // certain endpoints are unsupported. + // Registering separately because otherwise fastify.use() will match the + // wildcard too. + @bindThis + public async createServerWildcard(fastify: FastifyInstance): Promise { + fastify.all('/oauth/*', async (_request, reply) => { + reply.code(404); + reply.send({ + error: { + message: 'Unknown OAuth endpoint.', + code: 'UNKNOWN_OAUTH_ENDPOINT', + id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', + kind: 'client', + }, + }); + }); + } + + @bindThis + public async createServer(fastify: FastifyInstance): Promise { + 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), + code_challenge_methods_supported: ['S256'], + }); + }); + + // oidc-provider provides many more endpoints for OpenID support and there's + // no way to turn it off. + // For now only allow the basic OAuth endpoints, to start small and evaluate + // this feature for some time, given that this is security related. + fastify.get('/oauth/authorize', async () => { }); + fastify.post('/oauth/token', async () => { }); + + await fastify.register(fastifyMiddie); + fastify.use('/oauth', this.#provider.callback()); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be9204ad76..af9c08e95e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: oauth: specifier: 0.10.0 version: 0.10.0 + oidc-provider: + specifier: ^8.1.1 + version: 8.1.1 os-utils: specifier: 0.0.14 version: 0.0.14 @@ -5251,6 +5254,23 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: true + /@koa/cors@4.0.0: + resolution: {integrity: sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ==} + engines: {node: '>= 14.0.0'} + dependencies: + vary: 1.1.2 + dev: false + + /@koa/router@12.0.0: + resolution: {integrity: sha512-cnnxeKHXlt7XARJptflGURdJaO+ITpNkOHmQu7NHmCoRinPbyvFzce/EG/E8Zy81yQ1W9MoSdtklc3nyaDReUw==} + engines: {node: '>= 12'} + dependencies: + http-errors: 2.0.0 + koa-compose: 4.1.0 + methods: 1.1.2 + path-to-regexp: 6.2.1 + dev: false + /@kurkle/color@0.3.2: resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} dev: false @@ -9617,7 +9637,6 @@ packages: /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - dev: true /c8@7.13.0: resolution: {integrity: sha512-/NL4hQTv1gBL6J6ei80zu3IiTrmePDKXKXOTLpHvcIWZTVYQlDhVWjjWvkhICylE8EwwnMVzDZugCvdx0/DIIA==} @@ -9675,6 +9694,14 @@ packages: union-value: 1.0.1 unset-value: 1.0.0 + /cache-content-type@1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + dependencies: + mime-types: 2.1.35 + ylru: 1.3.2 + dev: false + /cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -10164,7 +10191,6 @@ packages: /co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - dev: true /coa@1.0.4: resolution: {integrity: sha512-KAGck/eNAmCL0dcT3BiuYwLbExK6lduR8DxM3C1TyDzaXhZHyZ8ooX5I5+na2e3dPFuibfxrGdorr0/Lr7RYCQ==} @@ -10398,7 +10424,6 @@ packages: /content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - dev: true /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -10420,6 +10445,14 @@ packages: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} + /cookies@0.8.0: + resolution: {integrity: sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + dev: false + /copy-descriptor@0.1.1: resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==} engines: {node: '>=0.10.0'} @@ -10806,6 +10839,10 @@ packages: type-detect: 4.0.8 dev: true + /deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + dev: false + /deep-equal@2.2.0: resolution: {integrity: sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==} dependencies: @@ -10952,7 +10989,6 @@ packages: /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dev: true /detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} @@ -11132,7 +11168,6 @@ packages: /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - dev: true /ejs@3.1.8: resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} @@ -11167,7 +11202,6 @@ packages: /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} - dev: true /encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -11662,6 +11696,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /eta@2.0.1: + resolution: {integrity: sha512-46E2qDPDm7QA+usjffUWz9KfXsxVZclPOuKsXs4ZWZdI/X1wpDF7AO424pt7fdYohCzWsIkXAhNGXSlwo5naAg==} + engines: {node: '>=6.0.0'} + dev: false + /etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -12422,7 +12461,6 @@ packages: /fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - dev: true /from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} @@ -13235,9 +13273,28 @@ packages: domutils: 3.0.1 entities: 4.5.0 + /http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + dev: false + /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + /http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -13749,7 +13806,6 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 - dev: true /is-glob@3.1.0: resolution: {integrity: sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==} @@ -14583,6 +14639,10 @@ packages: '@sideway/pinpoint': 2.0.0 dev: true + /jose@4.14.0: + resolution: {integrity: sha512-LSA/XenLPwqk6e2L+PSUNuuY9G4NGsvjRWz6sJcUBmzTLEPJqQh46FHSUxnAQ64AWOkRO6bSXpy3yXuEKZkbIA==} + dev: false + /jpeg-js@0.3.7: resolution: {integrity: sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==} dev: false @@ -14734,6 +14794,12 @@ packages: hasBin: true dev: true + /jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + dev: false + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -14873,6 +14939,13 @@ packages: safe-buffer: 5.2.1 dev: false + /keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + dependencies: + tsscmp: 1.0.6 + dev: false + /keyv@4.5.2: resolution: {integrity: sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==} dependencies: @@ -14903,6 +14976,49 @@ packages: engines: {node: '>=6'} dev: true + /koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + dev: false + + /koa-convert@2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + dev: false + + /koa@2.14.2: + resolution: {integrity: sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.8.0 + debug: 4.3.4(supports-color@8.1.1) + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.8.1 + is-generator-function: 1.0.10 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /ky-universal@0.11.0(ky@0.33.3): resolution: {integrity: sha512-65KyweaWvk+uKKkCrfAf+xqN2/epw1IJDtlyCPxYffFCMR8u1sp2U65NtWpnozYfZxQ6IUzIlvUcw+hQ82U2Xw==} engines: {node: '>=14.16'} @@ -15377,7 +15493,6 @@ packages: /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - dev: true /meilisearch@0.33.0: resolution: {integrity: sha512-bYPb9WyITnJfzf92e7QFK8Rc50DmshFWxypXCs3ILlpNh8pT15A7KSu9Xgnnk/K3G/4vb3wkxxtFS4sxNkWB8w==} @@ -15425,7 +15540,6 @@ packages: /methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} - dev: true /mfm-js@0.23.3: resolution: {integrity: sha512-o8scYmbey6rMUmWAlT3k3ntt6khaCLdxlmHhAWV5wTTMj2OK1atQvZfRUq0SIVm1Jig08qlZg/ps71xUqrScNA==} @@ -15804,6 +15918,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + dev: false + /nanomatch@1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -16176,6 +16296,11 @@ packages: define-property: 0.2.5 kind-of: 3.2.2 + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: false + /object-inspect@1.12.2: resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} dev: true @@ -16260,6 +16385,31 @@ packages: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} dev: true + /oidc-provider@8.1.1: + resolution: {integrity: sha512-fu7nUNFpfzyAmvFmgVsFxRTKB3GoxKLKDcfLDeUgP03PLctsnKOzKY/ot4Pm2/ahPOGBNcpVQi670bPhD8X/NA==} + dependencies: + '@koa/cors': 4.0.0 + '@koa/router': 12.0.0 + debug: 4.3.4(supports-color@8.1.1) + eta: 2.0.1 + got: 12.6.0 + jose: 4.14.0 + jsesc: 3.0.2 + koa: 2.14.2 + nanoid: 4.0.2 + object-hash: 3.0.0 + oidc-token-hash: 5.0.2 + quick-lru: 6.1.1 + raw-body: 2.5.2 + transitivePeerDependencies: + - supports-color + dev: false + + /oidc-token-hash@5.0.2: + resolution: {integrity: sha512-U91Ba78GtVBxcExLI7U+hC2AwJQqXQEW/D3fjmJC4hhSVIgdl954KO4Gu95WqAlgDKJdLATxkmuxraWLT0fVRQ==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} dev: false @@ -16273,7 +16423,6 @@ packages: engines: {node: '>= 0.8'} dependencies: ee-first: 1.1.1 - dev: true /on-headers@1.0.2: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} @@ -16298,6 +16447,10 @@ packages: mimic-fn: 4.0.0 dev: true + /only@0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + dev: false + /open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -16583,7 +16736,6 @@ packages: /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - dev: true /pascalcase@0.1.1: resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} @@ -16666,7 +16818,6 @@ packages: /path-to-regexp@6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} - dev: true /path-type@1.1.0: resolution: {integrity: sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==} @@ -17617,6 +17768,11 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /quick-lru@6.1.1: + resolution: {integrity: sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q==} + engines: {node: '>=12'} + dev: false + /ramda@0.28.0: resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} dev: true @@ -17647,6 +17803,16 @@ packages: unpipe: 1.0.0 dev: true + /raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: false + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -19055,6 +19221,11 @@ packages: define-property: 0.2.5 object-copy: 0.1.0 + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: false + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -19802,6 +19973,11 @@ packages: /tslib@2.5.3: resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} + /tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + dev: false + /tsutils@3.21.0(typescript@5.1.3): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} @@ -19883,7 +20059,6 @@ packages: dependencies: media-typer: 0.3.0 mime-types: 2.1.35 - dev: true /type@1.2.0: resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} @@ -20163,7 +20338,6 @@ packages: /unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - dev: true /unplugin@0.10.2: resolution: {integrity: sha512-6rk7GUa4ICYjae5PrAllvcDeuT8pA9+j5J5EkxbMFaV+SalHhxZ7X2dohMzu6C3XzsMT+6jwR/+pwPNR3uK9MA==} @@ -21106,6 +21280,11 @@ packages: fd-slicer: 1.1.0 dev: true + /ylru@1.3.2: + resolution: {integrity: sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==} + engines: {node: '>= 4.0.0'} + dev: false + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} From a4fb17620c49a8efdcf07cc4fb73c62bfb1d3c92 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 19 Mar 2023 14:03:46 +0100 Subject: [PATCH 02/82] tmp --- .../src/server/oauth/OAuth2ProviderService.ts | 151 +++++++++++++++--- 1 file changed, 131 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 347b147534..d86eaa5915 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -11,11 +11,7 @@ import type { Config } from '@/config.js'; import { kinds } from '@/misc/api-permissions.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { FastifyInstance } from 'fastify'; - - -// TODO: For now let's focus on letting oidc-provider use the existing miauth infra. -// Supporting IndieAuth is a separate project. -// Allow client_id created by apps/create or not? It's already marked as old method. +import type Redis from 'ioredis'; // https://indieauth.spec.indieweb.org/#client-identifier function validateClientId(raw: string): URL { @@ -27,6 +23,7 @@ function validateClientId(raw: string): URL { })(); // Client identifier URLs MUST have either an https or http scheme + // XXX: but why allow http in 2023? if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('client_id must be either https or http URL'); } @@ -66,6 +63,33 @@ function validateClientId(raw: string): URL { return url; } +const grantable = new Set([ + 'AccessToken', + 'AuthorizationCode', + 'RefreshToken', + 'DeviceCode', + 'BackchannelAuthenticationRequest', +]); + +const consumable = new Set([ + 'AuthorizationCode', + 'RefreshToken', + 'DeviceCode', + 'BackchannelAuthenticationRequest', +]); + +function grantKeyFor(id: string): string { + return `grant:${id}`; +} + +function userCodeKeyFor(userCode: string): string { + return `userCode:${userCode}`; +} + +function uidKeyFor(uid: string): string { + return `uid:${uid}`; +} + async function fetchFromClientId(httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); @@ -74,8 +98,7 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str return new URL(redirectUri, res.url).toString(); } - const { window } = new JSDOM(await res.text()); - redirectUri = window.document.querySelector('link[rel=redirect_uri][href]')?.href; + redirectUri = JSDOM.fragment(await res.text()).querySelector('link[rel=redirect_uri][href]')?.href; if (redirectUri) { return new URL(redirectUri, res.url).toString(); } @@ -85,16 +108,66 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str } class MisskeyAdapter implements Adapter { - constructor(private httpRequestService: HttpRequestService) { } + name = 'oauth2'; - upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { - console.log('oauth upsert', id, payload, expiresIn); - throw new Error('Method not implemented.'); + constructor(private redisClient: Redis.Redis, private httpRequestService: HttpRequestService) { } + + key(id: string): string { + return `oauth2:${id}`; } - async find(id: string): Promise { - // Find client information from the remote. + async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { + console.log('oauth upsert', id, payload, expiresIn); + + const key = this.key(id); + + const multi = this.redisClient.multi(); + if (consumable.has(this.name)) { + multi.hset(key, { payload: JSON.stringify(payload) }); + } else { + multi.set(key, JSON.stringify(payload)); + } + + if (expiresIn) { + multi.expire(key, expiresIn); + } + + if (grantable.has(this.name) && payload.grantId) { + const grantKey = grantKeyFor(payload.grantId); + multi.rpush(grantKey, key); + // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM + // here to trim the list to an appropriate length + const ttl = await this.redisClient.ttl(grantKey); + if (expiresIn > ttl) { + multi.expire(grantKey, expiresIn); + } + } + + if (payload.userCode) { + const userCodeKey = userCodeKeyFor(payload.userCode); + multi.set(userCodeKey, id); + multi.expire(userCodeKey, expiresIn); + } + + if (payload.uid) { + const uidKey = uidKeyFor(payload.uid); + multi.set(uidKey, id); + multi.expire(uidKey, expiresIn); + } + + await multi.exec(); + } + + async find(id: string): Promise { console.log('oauth find', id); + + // XXX: really? + const fromRedis = await this.findRedis(id); + if (fromRedis) { + return fromRedis; + } + + // Find client information from the remote. const url = validateClientId(id); if (process.env.NODE_ENV !== 'test') { @@ -107,7 +180,7 @@ class MisskeyAdapter implements Adapter { const redirectUri = await fetchFromClientId(this.httpRequestService, id); if (!redirectUri) { // IndieAuth also implicitly allows any path under the same scheme+host, - // but oidc-provider does not have such option. + // but oidc-provider requires explicit list of uris. throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML element.'); } @@ -117,25 +190,60 @@ class MisskeyAdapter implements Adapter { redirect_uris: [redirectUri], }; } + + async findRedis(id: string | null): Promise { + if (!id) { + return; + } + + const data = consumable.has(this.name) + ? await this.redisClient.hgetall(this.key(id)) + : await this.redisClient.get(this.key(id)); + + if (!data || (typeof data === 'object' && !Object.entries(data).length)) { + return undefined; + } + + if (typeof data === 'string') { + return JSON.parse(data); + } + const { payload, ...rest } = data as any; + return { + ...rest, + ...JSON.parse(payload), + }; + } + async findByUserCode(userCode: string): Promise { console.log('oauth findByUserCode', userCode); - throw new Error('Method not implemented.'); + const id = await this.redisClient.get(userCodeKeyFor(userCode)); + return this.findRedis(id); } + async findByUid(uid: string): Promise { console.log('oauth findByUid', uid); - throw new Error('Method not implemented.'); + const id = await this.redisClient.get(uidKeyFor(uid)); + return this.findRedis(id); } + async consume(id: string): Promise { console.log('oauth consume', id); - throw new Error('Method not implemented.'); + await this.redisClient.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); } + async destroy(id: string): Promise { console.log('oauth destroy', id); - throw new Error('Method not implemented.'); + const key = this.key(id); + await this.redisClient.del(key); } + async revokeByGrantId(grantId: string): Promise { console.log('oauth revokeByGrandId', grantId); - throw new Error('Method not implemented.'); + const multi = this.redisClient.multi(); + const tokens = await this.redisClient.lrange(grantKeyFor(grantId), 0, -1); + tokens.forEach((token) => multi.del(token)); + multi.del(grantKeyFor(grantId)); + await multi.exec(); } } @@ -146,6 +254,7 @@ export class OAuth2ProviderService { constructor( @Inject(DI.config) private config: Config, + @Inject(DI.redis) redisClient: Redis.Redis, httpRequestService: HttpRequestService, ) { this.#provider = new Provider(config.url, { @@ -165,7 +274,7 @@ export class OAuth2ProviderService { return undefined; }, adapter(): MisskeyAdapter { - return new MisskeyAdapter(httpRequestService); + return new MisskeyAdapter(redisClient, httpRequestService); }, async renderError(ctx, out, error): Promise { console.log(error); @@ -209,6 +318,8 @@ export class OAuth2ProviderService { // this feature for some time, given that this is security related. fastify.get('/oauth/authorize', async () => { }); fastify.post('/oauth/token', async () => { }); + fastify.get('/oauth/interaction/:uid', async () => { }); + fastify.get('/oauth/interaction/:uid/login', async () => { }); await fastify.register(fastifyMiddie); fastify.use('/oauth', this.#provider.callback()); From f5a65096638137578d193237c447e1bb64f7ce13 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 19 Mar 2023 18:55:26 +0100 Subject: [PATCH 03/82] tmp --- packages/backend/package.json | 5 + .../src/server/oauth/OAuth2ProviderService.ts | 420 +++++++++++------- pnpm-lock.yaml | 79 +++- 3 files changed, 333 insertions(+), 171 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 34d4b206a0..f6912d0944 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -90,6 +90,7 @@ "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", + "express-session": "^1.17.3", "fastify": "4.18.0", "feed": "4.2.2", "file-type": "18.5.0", @@ -117,6 +118,8 @@ "nodemailer": "6.9.3", "nsfwjs": "2.4.2", "oauth": "0.10.0", + "oauth2orize": "^1.11.1", + "oauth2orize-pkce": "^0.1.2", "oidc-provider": "^8.1.1", "os-utils": "0.0.14", "otpauth": "9.1.2", @@ -171,6 +174,7 @@ "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", + "@types/express-session": "^1.17.6", "@types/fluent-ffmpeg": "2.1.21", "@types/jest": "29.5.2", "@types/js-yaml": "4.0.5", @@ -183,6 +187,7 @@ "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.8", "@types/oauth": "0.9.1", + "@types/oauth2orize": "^1.8.11", "@types/pg": "8.10.2", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d86eaa5915..e63bcf0252 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,17 +1,28 @@ import dns from 'node:dns/promises'; import { Inject, Injectable } from '@nestjs/common'; import Provider, { type Adapter, type Account, AdapterPayload } from 'oidc-provider'; -import fastifyMiddie from '@fastify/middie'; +import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; import parseLinkHeader from 'parse-link-header'; import ipaddr from 'ipaddr.js'; +import oauth2orize from 'oauth2orize'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { kinds } from '@/misc/api-permissions.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifySession from '@fastify/session'; import type Redis from 'ioredis'; +import oauth2Pkce from 'oauth2orize-pkce'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import expressSession from 'express-session'; +import http from 'node:http'; +import fastifyView from '@fastify/view'; +import pug from 'pug'; +import { fileURLToPath } from 'node:url'; +import { MetaService } from '@/core/MetaService.js'; // https://indieauth.spec.indieweb.org/#client-identifier function validateClientId(raw: string): URL { @@ -63,32 +74,32 @@ function validateClientId(raw: string): URL { return url; } -const grantable = new Set([ - 'AccessToken', - 'AuthorizationCode', - 'RefreshToken', - 'DeviceCode', - 'BackchannelAuthenticationRequest', -]); +// const grantable = new Set([ +// 'AccessToken', +// 'AuthorizationCode', +// 'RefreshToken', +// 'DeviceCode', +// 'BackchannelAuthenticationRequest', +// ]); -const consumable = new Set([ - 'AuthorizationCode', - 'RefreshToken', - 'DeviceCode', - 'BackchannelAuthenticationRequest', -]); +// const consumable = new Set([ +// 'AuthorizationCode', +// 'RefreshToken', +// 'DeviceCode', +// 'BackchannelAuthenticationRequest', +// ]); -function grantKeyFor(id: string): string { - return `grant:${id}`; -} +// function grantKeyFor(id: string): string { +// return `grant:${id}`; +// } -function userCodeKeyFor(userCode: string): string { - return `userCode:${userCode}`; -} +// function userCodeKeyFor(userCode: string): string { +// return `userCode:${userCode}`; +// } -function uidKeyFor(uid: string): string { - return `uid:${uid}`; -} +// function uidKeyFor(uid: string): string { +// return `uid:${uid}`; +// } async function fetchFromClientId(httpRequestService: HttpRequestService, id: string): Promise { try { @@ -107,179 +118,201 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str } } -class MisskeyAdapter implements Adapter { - name = 'oauth2'; +// class MisskeyAdapter implements Adapter { +// name = 'oauth2'; - constructor(private redisClient: Redis.Redis, private httpRequestService: HttpRequestService) { } +// constructor(private redisClient: Redis.Redis, private httpRequestService: HttpRequestService) { } - key(id: string): string { - return `oauth2:${id}`; - } +// key(id: string): string { +// return `oauth2:${id}`; +// } - async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { - console.log('oauth upsert', id, payload, expiresIn); +// async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { +// console.log('oauth upsert', id, payload, expiresIn); - const key = this.key(id); +// const key = this.key(id); - const multi = this.redisClient.multi(); - if (consumable.has(this.name)) { - multi.hset(key, { payload: JSON.stringify(payload) }); - } else { - multi.set(key, JSON.stringify(payload)); - } +// const multi = this.redisClient.multi(); +// if (consumable.has(this.name)) { +// multi.hset(key, { payload: JSON.stringify(payload) }); +// } else { +// multi.set(key, JSON.stringify(payload)); +// } - if (expiresIn) { - multi.expire(key, expiresIn); - } +// if (expiresIn) { +// multi.expire(key, expiresIn); +// } - if (grantable.has(this.name) && payload.grantId) { - const grantKey = grantKeyFor(payload.grantId); - multi.rpush(grantKey, key); - // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM - // here to trim the list to an appropriate length - const ttl = await this.redisClient.ttl(grantKey); - if (expiresIn > ttl) { - multi.expire(grantKey, expiresIn); - } - } +// if (grantable.has(this.name) && payload.grantId) { +// const grantKey = grantKeyFor(payload.grantId); +// multi.rpush(grantKey, key); +// // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM +// // here to trim the list to an appropriate length +// const ttl = await this.redisClient.ttl(grantKey); +// if (expiresIn > ttl) { +// multi.expire(grantKey, expiresIn); +// } +// } - if (payload.userCode) { - const userCodeKey = userCodeKeyFor(payload.userCode); - multi.set(userCodeKey, id); - multi.expire(userCodeKey, expiresIn); - } +// if (payload.userCode) { +// const userCodeKey = userCodeKeyFor(payload.userCode); +// multi.set(userCodeKey, id); +// multi.expire(userCodeKey, expiresIn); +// } - if (payload.uid) { - const uidKey = uidKeyFor(payload.uid); - multi.set(uidKey, id); - multi.expire(uidKey, expiresIn); - } +// if (payload.uid) { +// const uidKey = uidKeyFor(payload.uid); +// multi.set(uidKey, id); +// multi.expire(uidKey, expiresIn); +// } - await multi.exec(); - } +// await multi.exec(); +// } - async find(id: string): Promise { - console.log('oauth find', id); +// async find(id: string): Promise { +// console.log('oauth find', id); - // XXX: really? - const fromRedis = await this.findRedis(id); - if (fromRedis) { - return fromRedis; - } +// // XXX: really? +// const fromRedis = await this.findRedis(id); +// if (fromRedis) { +// return fromRedis; +// } - // Find client information from the remote. - const url = validateClientId(id); +// // Find client information from the remote. +// const url = validateClientId(id); - if (process.env.NODE_ENV !== 'test') { - const lookup = await dns.lookup(url.hostname); - if (ipaddr.parse(lookup.address).range() === 'loopback') { - throw new Error('client_id unexpectedly resolves to loopback IP.'); - } - } +// if (process.env.NODE_ENV !== 'test') { +// const lookup = await dns.lookup(url.hostname); +// if (ipaddr.parse(lookup.address).range() === 'loopback') { +// throw new Error('client_id unexpectedly resolves to loopback IP.'); +// } +// } - const redirectUri = await fetchFromClientId(this.httpRequestService, id); - if (!redirectUri) { - // IndieAuth also implicitly allows any path under the same scheme+host, - // but oidc-provider requires explicit list of uris. - throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML element.'); - } +// const redirectUri = await fetchFromClientId(this.httpRequestService, id); +// if (!redirectUri) { +// // IndieAuth also implicitly allows any path under the same scheme+host, +// // but oidc-provider requires explicit list of uris. +// throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML element.'); +// } - return { - client_id: id, - token_endpoint_auth_method: 'none', - redirect_uris: [redirectUri], - }; - } +// return { +// client_id: id, +// token_endpoint_auth_method: 'none', +// redirect_uris: [redirectUri], +// }; +// } - async findRedis(id: string | null): Promise { - if (!id) { - return; - } +// async findRedis(id: string | null): Promise { +// if (!id) { +// return; +// } - const data = consumable.has(this.name) - ? await this.redisClient.hgetall(this.key(id)) - : await this.redisClient.get(this.key(id)); +// const data = consumable.has(this.name) +// ? await this.redisClient.hgetall(this.key(id)) +// : await this.redisClient.get(this.key(id)); - if (!data || (typeof data === 'object' && !Object.entries(data).length)) { - return undefined; - } +// if (!data || (typeof data === 'object' && !Object.entries(data).length)) { +// return undefined; +// } - if (typeof data === 'string') { - return JSON.parse(data); - } - const { payload, ...rest } = data as any; - return { - ...rest, - ...JSON.parse(payload), - }; - } +// if (typeof data === 'string') { +// return JSON.parse(data); +// } +// const { payload, ...rest } = data as any; +// return { +// ...rest, +// ...JSON.parse(payload), +// }; +// } - async findByUserCode(userCode: string): Promise { - console.log('oauth findByUserCode', userCode); - const id = await this.redisClient.get(userCodeKeyFor(userCode)); - return this.findRedis(id); - } +// async findByUserCode(userCode: string): Promise { +// console.log('oauth findByUserCode', userCode); +// const id = await this.redisClient.get(userCodeKeyFor(userCode)); +// return this.findRedis(id); +// } - async findByUid(uid: string): Promise { - console.log('oauth findByUid', uid); - const id = await this.redisClient.get(uidKeyFor(uid)); - return this.findRedis(id); - } +// async findByUid(uid: string): Promise { +// console.log('oauth findByUid', uid); +// const id = await this.redisClient.get(uidKeyFor(uid)); +// return this.findRedis(id); +// } - async consume(id: string): Promise { - console.log('oauth consume', id); - await this.redisClient.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); - } +// async consume(id: string): Promise { +// console.log('oauth consume', id); +// await this.redisClient.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); +// } - async destroy(id: string): Promise { - console.log('oauth destroy', id); - const key = this.key(id); - await this.redisClient.del(key); - } +// async destroy(id: string): Promise { +// console.log('oauth destroy', id); +// const key = this.key(id); +// await this.redisClient.del(key); +// } - async revokeByGrantId(grantId: string): Promise { - console.log('oauth revokeByGrandId', grantId); - const multi = this.redisClient.multi(); - const tokens = await this.redisClient.lrange(grantKeyFor(grantId), 0, -1); - tokens.forEach((token) => multi.del(token)); - multi.del(grantKeyFor(grantId)); - await multi.exec(); - } -} +// async revokeByGrantId(grantId: string): Promise { +// console.log('oauth revokeByGrandId', grantId); +// const multi = this.redisClient.multi(); +// const tokens = await this.redisClient.lrange(grantKeyFor(grantId), 0, -1); +// tokens.forEach((token) => multi.del(token)); +// multi.del(grantKeyFor(grantId)); +// await multi.exec(); +// } +// } + +// function promisify(callback: T) { +// return (...args: Parameters) => { + +// args[args.length - 1](); +// }; +// } + +type OmitFirstElement = T extends [unknown, ...(infer R)] + ? R + : []; @Injectable() export class OAuth2ProviderService { - #provider: Provider; + // #provider: Provider; + #server = oauth2orize.createServer(); constructor( @Inject(DI.config) private config: Config, - @Inject(DI.redis) redisClient: Redis.Redis, - httpRequestService: HttpRequestService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private httpRequestService: HttpRequestService, + private metaService: MetaService, ) { - this.#provider = new Provider(config.url, { - clientAuthMethods: ['none'], - pkce: { - // This is the default, but be explicit here as we announce it below - methods: ['S256'], - }, - routes: { - // defaults to '/auth' but '/authorize' is more consistent with many - // other services eg. Mastodon/Twitter/Facebook/GitLab/GitHub/etc. - authorization: '/authorize', - }, - scopes: kinds, - async findAccount(ctx, id): Promise { - console.log(id); - return undefined; - }, - adapter(): MisskeyAdapter { - return new MisskeyAdapter(redisClient, httpRequestService); - }, - async renderError(ctx, out, error): Promise { - console.log(error); - }, - }); + // this.#provider = new Provider(config.url, { + // clientAuthMethods: ['none'], + // pkce: { + // // This is the default, but be explicit here as we announce it below + // methods: ['S256'], + // }, + // routes: { + // // defaults to '/auth' but '/authorize' is more consistent with many + // // other services eg. Mastodon/Twitter/Facebook/GitLab/GitHub/etc. + // authorization: '/authorize', + // }, + // scopes: kinds, + // async findAccount(ctx, id): Promise { + // console.log(id); + // return undefined; + // }, + // adapter(): MisskeyAdapter { + // return new MisskeyAdapter(redisClient, httpRequestService); + // }, + // async renderError(ctx, out, error): Promise { + // console.log(error); + // }, + // }); + this.#server.grant(oauth2Pkce.extensions()); + this.#server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => { + console.log(client, redirectUri, user, ares); + const code = secureRndstr(32, true); + done(null, code); + })); + this.#server.serializeClient((client, done) => done(null, client)); + this.#server.deserializeClient((id, done) => done(null, id)); } // Return 404 for any unknown paths under /oauth so that clients can know @@ -316,12 +349,65 @@ export class OAuth2ProviderService { // no way to turn it off. // For now only allow the basic OAuth endpoints, to start small and evaluate // this feature for some time, given that this is security related. - fastify.get('/oauth/authorize', async () => { }); + fastify.get<{ Querystring: { code_challenge?: string, code_challenge_method?: string } }>('/oauth/authorize', async (request, reply) => { + console.log('HIT /oauth/authorize', request.query); + if (typeof request.query.code_challenge !== 'string') { + throw new Error('`code_challenge` parameter is required'); + } + if (request.query.code_challenge_method !== 'S256') { + throw new Error('`code_challenge_method` parameter must be set as S256'); + } + + const meta = await this.metaService.fetch(); + return await reply.view('base', { + img: meta.bannerUrl, + title: meta.name ?? 'Misskey', + instanceName: meta.name ?? 'Misskey', + url: this.config.url, + desc: meta.description, + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + }); fastify.post('/oauth/token', async () => { }); - fastify.get('/oauth/interaction/:uid', async () => { }); - fastify.get('/oauth/interaction/:uid/login', async () => { }); + // fastify.get('/oauth/interaction/:uid', async () => { }); + // fastify.get('/oauth/interaction/:uid/login', async () => { }); + + fastify.register(fastifyView, { + root: fileURLToPath(new URL('../web/views', import.meta.url)), + engine: { pug }, + defaultContext: { + version: this.config.version, + config: this.config, + }, + }); await fastify.register(fastifyMiddie); - fastify.use('/oauth', this.#provider.callback()); + fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any); + fastify.use('/oauth/authorize', this.#server.authorization((clientId, redirectUri, done) => { + (async (): Promise>> => { + console.log('HIT /oauth/authorize validation middleware'); + + // Find client information from the remote. + const clientUrl = validateClientId(clientId); + const redirectUrl = new URL(redirectUri); + + if (process.env.NODE_ENV !== 'test') { + const lookup = await dns.lookup(clientUrl.hostname); + if (ipaddr.parse(lookup.address).range() === 'loopback') { + throw new Error('client_id unexpectedly resolves to loopback IP.'); + } + } + + if (redirectUrl.protocol !== clientUrl.protocol || redirectUrl.host !== clientUrl.host) { + // TODO: allow more redirect_uri by Client Information Discovery + throw new Error('cross-origin redirect_uri is not supported yet.'); + } + + return [clientId, redirectUri]; + })().then(args => done(null, ...args), err => done(err)); + })); + + // fastify.use('/oauth', this.#provider.callback()); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af9c08e95e..e52a4254cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: escape-regexp: specifier: 0.0.1 version: 0.0.1 + express-session: + specifier: ^1.17.3 + version: 1.17.3 fastify: specifier: 4.18.0 version: 4.18.0 @@ -266,6 +269,12 @@ importers: oauth: specifier: 0.10.0 version: 0.10.0 + oauth2orize: + specifier: ^1.11.1 + version: 1.11.1 + oauth2orize-pkce: + specifier: ^0.1.2 + version: 0.1.2 oidc-provider: specifier: ^8.1.1 version: 8.1.1 @@ -505,6 +514,9 @@ importers: '@types/escape-regexp': specifier: 0.0.1 version: 0.0.1 + '@types/express-session': + specifier: ^1.17.6 + version: 1.17.6 '@types/fluent-ffmpeg': specifier: 2.1.21 version: 2.1.21 @@ -541,6 +553,9 @@ importers: '@types/oauth': specifier: 0.9.1 version: 0.9.1 + '@types/oauth2orize': + specifier: ^1.8.11 + version: 1.8.11 '@types/pg': specifier: 8.10.2 version: 8.10.2 @@ -7562,6 +7577,12 @@ packages: '@types/range-parser': 1.2.4 dev: true + /@types/express-session@1.17.6: + resolution: {integrity: sha512-L6sB04HVA4HEZo1hDL65JXdZdBJtzZnCiw/P7MnO4w6746tJCNtXlHtzEASyI9ccn9zyOw6IbqQuhVa03VpO4w==} + dependencies: + '@types/express': 4.17.17 + dev: true + /@types/express@4.17.17: resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} dependencies: @@ -7782,6 +7803,13 @@ packages: resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} dev: true + /@types/oauth2orize@1.8.11: + resolution: {integrity: sha512-eir5IGegpcnPuhnx1Asdxj3kDWWP/Qr1qkERMlDASwlEJM6pppVBxkW7ZvAX2H8eBHE+FP7lhg/iNlRrtNGewQ==} + dependencies: + '@types/express': 4.17.17 + '@types/node': 20.3.1 + dev: true + /@types/oauth@0.9.1: resolution: {integrity: sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A==} dependencies: @@ -10434,12 +10462,10 @@ packages: /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - dev: true /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} - dev: true /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} @@ -11847,6 +11873,22 @@ packages: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} dev: false + /express-session@1.17.3: + resolution: {integrity: sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.2 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.0.2 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + dev: false + /express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -16276,6 +16318,21 @@ packages: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} dev: false + /oauth2orize-pkce@0.1.2: + resolution: {integrity: sha512-grto2UYhXHi9GLE3IBgBBbV87xci55+bCyjpVuxKyzol6I5Rg0K1MiTuXE+JZk54R86SG2wqXODMiZYHraPpxw==} + dev: false + + /oauth2orize@1.11.1: + resolution: {integrity: sha512-9dSx/Gwm0J2Rvj4RH9+h7iXVnRXZ6biwWRgb2dCeQhCosODS0nYdM9I/G7BUGsjbgn0pHjGcn1zcCRtzj2SlRA==} + engines: {node: '>= 0.4.0'} + dependencies: + debug: 2.6.9 + uid2: 0.0.4 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: false + /oauth@0.10.0: resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==} dev: false @@ -16427,7 +16484,6 @@ packages: /on-headers@1.0.2: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} - dev: true /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -17777,6 +17833,11 @@ packages: resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} dev: true + /random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + dev: false + /random-seed@0.3.0: resolution: {integrity: sha512-y13xtn3kcTlLub3HKWXxJNeC2qK4mB59evwZ5EkeRlolx+Bp2ztF7LbcZmyCnOqlHQrLnfuNbi1sVmm9lPDlDA==} engines: {node: '>= 0.6.0'} @@ -20173,6 +20234,17 @@ packages: dev: true optional: true + /uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + dependencies: + random-bytes: 1.0.0 + dev: false + + /uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + dev: false + /uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -20453,7 +20525,6 @@ packages: /utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - dev: true /uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} From a55d3f73829a3aff6a61559875232d9433849cc7 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 25 Mar 2023 17:34:36 +0100 Subject: [PATCH 04/82] tmp --- packages/backend/package.json | 3 + .../src/server/oauth/OAuth2ProviderService.ts | 47 ++++++------ .../backend/src/server/web/views/oauth.pug | 10 +++ packages/frontend/src/components/MkButton.vue | 2 + packages/frontend/src/pages/oauth.vue | 71 +++++++++++++++++++ packages/frontend/src/router.ts | 3 + pnpm-lock.yaml | 53 ++++++++++---- 7 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 packages/backend/src/server/web/views/oauth.pug create mode 100644 packages/frontend/src/pages/oauth.vue diff --git a/packages/backend/package.json b/packages/backend/package.json index f6912d0944..2e4f2b532c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -61,6 +61,7 @@ "@fastify/accepts": "4.2.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.3.0", + "@fastify/express": "^2.3.0", "@fastify/http-proxy": "9.2.1", "@fastify/multipart": "7.7.0", "@fastify/static": "6.10.2", @@ -78,6 +79,7 @@ "autwh": "0.1.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", + "body-parser": "^1.20.2", "bullmq": "4.1.0", "cacheable-lookup": "7.0.0", "cbor": "9.0.0", @@ -170,6 +172,7 @@ "@types/accepts": "1.3.5", "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", + "@types/body-parser": "^1.19.2", "@types/cbor": "6.0.0", "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index e63bcf0252..191f936098 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,7 +5,7 @@ import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; import parseLinkHeader from 'parse-link-header'; import ipaddr from 'ipaddr.js'; -import oauth2orize from 'oauth2orize'; +import oauth2orize, { OAuth2 } from 'oauth2orize'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -23,6 +23,9 @@ import fastifyView from '@fastify/view'; import pug from 'pug'; import { fileURLToPath } from 'node:url'; import { MetaService } from '@/core/MetaService.js'; +import fastifyFormbody from '@fastify/formbody'; +import bodyParser from 'body-parser'; +import fastifyExpress from '@fastify/express'; // https://indieauth.spec.indieweb.org/#client-identifier function validateClientId(raw: string): URL { @@ -58,16 +61,11 @@ function validateClientId(raw: string): URL { throw new Error('client_id must not contain a username or a password'); } - // MUST NOT contain a port - if (url.port) { - throw new Error('client_id must not contain a port'); - } + // (MAY contain a port) // host names MUST be domain names or a loopback interface and MUST NOT be // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]. - // (But in https://indieauth.spec.indieweb.org/#redirect-url we need to only - // fetch non-loopback URLs, so exclude them here.) - if (!url.hostname.match(/\.\w+$/)) { + if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { throw new Error('client_id must have a domain name as a host name'); } @@ -351,6 +349,9 @@ export class OAuth2ProviderService { // this feature for some time, given that this is security related. fastify.get<{ Querystring: { code_challenge?: string, code_challenge_method?: string } }>('/oauth/authorize', async (request, reply) => { console.log('HIT /oauth/authorize', request.query); + const oauth2 = (request.raw as any).oauth2 as (OAuth2 | undefined); + console.log(oauth2); + if (typeof request.query.code_challenge !== 'string') { throw new Error('`code_challenge` parameter is required'); } @@ -358,17 +359,11 @@ export class OAuth2ProviderService { throw new Error('`code_challenge_method` parameter must be set as S256'); } - const meta = await this.metaService.fetch(); - return await reply.view('base', { - img: meta.bannerUrl, - title: meta.name ?? 'Misskey', - instanceName: meta.name ?? 'Misskey', - url: this.config.url, - desc: meta.description, - icon: meta.iconUrl, - themeColor: meta.themeColor, + return await reply.view('oauth', { + transactionId: oauth2?.transactionID, }); }); + fastify.post('/oauth/decision', async (request, reply) => { }); fastify.post('/oauth/token', async () => { }); // fastify.get('/oauth/interaction/:uid', async () => { }); // fastify.get('/oauth/interaction/:uid/login', async () => { }); @@ -382,7 +377,7 @@ export class OAuth2ProviderService { }, }); - await fastify.register(fastifyMiddie); + await fastify.register(fastifyExpress); fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any); fastify.use('/oauth/authorize', this.#server.authorization((clientId, redirectUri, done) => { (async (): Promise>> => { @@ -392,13 +387,8 @@ export class OAuth2ProviderService { const clientUrl = validateClientId(clientId); const redirectUrl = new URL(redirectUri); - if (process.env.NODE_ENV !== 'test') { - const lookup = await dns.lookup(clientUrl.hostname); - if (ipaddr.parse(lookup.address).range() === 'loopback') { - throw new Error('client_id unexpectedly resolves to loopback IP.'); - } - } - + // https://indieauth.spec.indieweb.org/#authorization-request + // Allow same-origin redirection if (redirectUrl.protocol !== clientUrl.protocol || redirectUrl.host !== clientUrl.host) { // TODO: allow more redirect_uri by Client Information Discovery throw new Error('cross-origin redirect_uri is not supported yet.'); @@ -407,6 +397,13 @@ export class OAuth2ProviderService { return [clientId, redirectUri]; })().then(args => done(null, ...args), err => done(err)); })); + // for (const middleware of this.#server.decision()) { + + fastify.use('/oauth/decision', bodyParser.urlencoded({ + extend: false, + })); + fastify.use('/oauth/decision', this.#server.decision()); + // } // fastify.use('/oauth', this.#provider.callback()); } diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug new file mode 100644 index 0000000000..717336f5e1 --- /dev/null +++ b/packages/backend/src/server/web/views/oauth.pug @@ -0,0 +1,10 @@ +extends ./base + +block meta + //- Should be removed by the page when it loads, so that it won't leak when + //- user navigates away via the navigation bar + //- XXX: Remove navigation bar in auth page? + meta(name='misskey:oauth:transaction-id' content=transactionId) + meta(name='misskey:oauth:client-id' content=clientId) + meta(name='misskey:oauth:scope' content=scope) + meta(name='misskey:oauth:redirection-uri' content=redirectionUri) diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 16e44ec618..e40b655fb7 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -4,6 +4,7 @@ ref="el" class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :type="type" + :name="name" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -44,6 +45,7 @@ const props = defineProps<{ large?: boolean; transparent?: boolean; asLike?: boolean; + name?: string; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue new file mode 100644 index 0000000000..0e708df5df --- /dev/null +++ b/packages/frontend/src/pages/oauth.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index fe9bc5938e..c6d14d153f 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -254,6 +254,9 @@ export const routes = [{ icon: 'icon', permission: 'permission', }, +}, { + path: '/oauth/authorize', + component: page(() => import('./pages/oauth.vue')), }, { path: '/tags/:tag', component: page(() => import('./pages/tag.vue')), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e52a4254cb..2c21152d56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@fastify/cors': specifier: 8.3.0 version: 8.3.0 + '@fastify/express': + specifier: ^2.3.0 + version: 2.3.0 '@fastify/http-proxy': specifier: 9.2.1 version: 9.2.1(bufferutil@4.0.7)(utf-8-validate@6.0.3) @@ -149,6 +152,9 @@ importers: blurhash: specifier: 2.0.5 version: 2.0.5 + body-parser: + specifier: ^1.20.2 + version: 1.20.2 bullmq: specifier: 4.1.0 version: 4.1.0 @@ -502,6 +508,9 @@ importers: '@types/bcryptjs': specifier: 2.4.2 version: 2.4.2 + '@types/body-parser': + specifier: ^1.19.2 + version: 1.19.2 '@types/cbor': specifier: 6.0.0 version: 6.0.0 @@ -4820,6 +4829,15 @@ packages: resolution: {integrity: sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ==} dev: false + /@fastify/express@2.3.0: + resolution: {integrity: sha512-jvvjlPPCfJsSHfF6tQDyARJ3+c3xXiqcxVZu6bi3xMWCWB3fl07vrjFDeaqnwqKhLZ9+m6cog5dw7gIMKEsTnQ==} + dependencies: + express: 4.18.2 + fastify-plugin: 4.5.0 + transitivePeerDependencies: + - supports-color + dev: false + /@fastify/fast-json-stringify-compiler@4.3.0: resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} dependencies: @@ -8868,7 +8886,6 @@ packages: /array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - dev: true /array-includes@3.1.6: resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} @@ -9464,7 +9481,26 @@ packages: unpipe: 1.0.0 transitivePeerDependencies: - supports-color - dev: true + + /body-parser@1.20.2: + resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -11730,7 +11766,6 @@ packages: /etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - dev: true /event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} @@ -11926,7 +11961,6 @@ packages: vary: 1.1.2 transitivePeerDependencies: - supports-color - dev: true /ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} @@ -12262,7 +12296,6 @@ packages: unpipe: 1.0.0 transitivePeerDependencies: - supports-color - dev: true /find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} @@ -15570,7 +15603,6 @@ packages: /merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} - dev: true /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -15630,7 +15662,6 @@ packages: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} hasBin: true - dev: true /mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} @@ -16360,7 +16391,6 @@ packages: /object-inspect@1.12.2: resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} - dev: true /object-is@1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} @@ -16860,7 +16890,6 @@ packages: /path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} - dev: true /path-to-regexp@1.8.0: resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} @@ -17773,7 +17802,6 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.0.4 - dev: true /qs@6.11.1: resolution: {integrity: sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==} @@ -17848,7 +17876,6 @@ packages: /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - dev: true /ratelimiter@3.4.1: resolution: {integrity: sha512-5FJbRW/Jkkdk29ksedAfWFkQkhbUrMx3QJGwMKAypeIiQf4yrLW+gtPKZiaWt4zPrtw1uGufOjGO7UGM6VllsQ==} @@ -17862,7 +17889,6 @@ packages: http-errors: 2.0.0 iconv-lite: 0.4.24 unpipe: 1.0.0 - dev: true /raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} @@ -18710,7 +18736,6 @@ packages: statuses: 2.0.1 transitivePeerDependencies: - supports-color - dev: true /serve-favicon@2.5.0: resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} @@ -18733,7 +18758,6 @@ packages: send: 0.18.0 transitivePeerDependencies: - supports-color - dev: true /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -18839,7 +18863,6 @@ packages: call-bind: 1.0.2 get-intrinsic: 1.2.0 object-inspect: 1.12.2 - dev: true /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} From 8ea1288234cc0aa82582d1161122aaf2556c2391 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 25 Mar 2023 20:57:56 +0100 Subject: [PATCH 05/82] tmp --- .../src/server/oauth/OAuth2ProviderService.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 191f936098..6f3c66fa0b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -267,6 +267,17 @@ type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; +interface OAuthRequestQuery { + response_type: string; + client_id: string; + redirect_uri: string; + state: string; + code_challenge: string; + code_challenge_method: string; + scope?: string; + me?: string; +} + @Injectable() export class OAuth2ProviderService { // #provider: Provider; @@ -305,7 +316,7 @@ export class OAuth2ProviderService { // }); this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => { - console.log(client, redirectUri, user, ares); + console.log('HIT grant code:', client, redirectUri, user, ares); const code = secureRndstr(32, true); done(null, code); })); @@ -347,11 +358,14 @@ export class OAuth2ProviderService { // no way to turn it off. // For now only allow the basic OAuth endpoints, to start small and evaluate // this feature for some time, given that this is security related. - fastify.get<{ Querystring: { code_challenge?: string, code_challenge_method?: string } }>('/oauth/authorize', async (request, reply) => { + fastify.get<{ Querystring: OAuthRequestQuery }>('/oauth/authorize', async (request, reply) => { console.log('HIT /oauth/authorize', request.query); const oauth2 = (request.raw as any).oauth2 as (OAuth2 | undefined); console.log(oauth2); + if (request.query.response_type !== 'code') { + throw new Error('`response_type` parameter must be set as "code"'); + } if (typeof request.query.code_challenge !== 'string') { throw new Error('`code_challenge` parameter is required'); } @@ -363,7 +377,7 @@ export class OAuth2ProviderService { transactionId: oauth2?.transactionID, }); }); - fastify.post('/oauth/decision', async (request, reply) => { }); + fastify.post('/oauth/decision', async () => { }); fastify.post('/oauth/token', async () => { }); // fastify.get('/oauth/interaction/:uid', async () => { }); // fastify.get('/oauth/interaction/:uid/login', async () => { }); @@ -399,9 +413,7 @@ export class OAuth2ProviderService { })); // for (const middleware of this.#server.decision()) { - fastify.use('/oauth/decision', bodyParser.urlencoded({ - extend: false, - })); + fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision()); // } From 049dbfeb66848a957062764b436c718c3b4e02d9 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 26 Mar 2023 20:03:18 +0200 Subject: [PATCH 06/82] tmp --- .../src/server/oauth/OAuth2ProviderService.ts | 93 ++++++++++++++++++- packages/frontend/src/pages/oauth.vue | 1 + 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6f3c66fa0b..2b9954fe55 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -26,6 +26,11 @@ import { MetaService } from '@/core/MetaService.js'; import fastifyFormbody from '@fastify/formbody'; import bodyParser from 'body-parser'; import fastifyExpress from '@fastify/express'; +import crypto from 'node:crypto'; +import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { UserCacheService } from '@/core/UserCacheService.js'; +import type { LocalUser } from '@/models/entities/User.js'; // https://indieauth.spec.indieweb.org/#client-identifier function validateClientId(raw: string): URL { @@ -263,6 +268,12 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str // }; // } +function pkceS256(codeVerifier: string) { + return crypto.createHash('sha256') + .update(codeVerifier, 'ascii') + .digest('base64url'); +} + type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; @@ -290,6 +301,12 @@ export class OAuth2ProviderService { private redisClient: Redis.Redis, private httpRequestService: HttpRequestService, private metaService: MetaService, + @Inject(DI.accessTokensRepository) + accessTokensRepository: AccessTokensRepository, + idService: IdService, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private userCacheService: UserCacheService, ) { // this.#provider = new Provider(config.url, { // clientAuthMethods: ['none'], @@ -314,11 +331,69 @@ export class OAuth2ProviderService { // console.log(error); // }, // }); + + const TEMP_GRANT_CODES: Record = {}; + this.#server.grant(oauth2Pkce.extensions()); - this.#server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => { - console.log('HIT grant code:', client, redirectUri, user, ares); - const code = secureRndstr(32, true); - done(null, code); + this.#server.grant(oauth2orize.grant.code((client, redirectUri, token, ares, areq, done) => { + (async (): Promise>> => { + console.log('HIT grant code:', client, redirectUri, token, ares, areq); + const code = secureRndstr(32, true); + + const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + () => this.usersRepository.findOneBy({ token }) as Promise); + if (!user) { + throw new Error('No such user'); + } + + TEMP_GRANT_CODES[code] = { + clientId: client, + userId: user.id, + redirectUri, + codeChallenge: areq.codeChallenge, + scopes: areq.scope, + }; + return [code]; + })().then(args => done(null, ...args), err => done(err)); + })); + this.#server.exchange(oauth2orize.exchange.code((client, code, redirectUri, body, done) => { + (async (): Promise>> => { + const granted = TEMP_GRANT_CODES[code]; + console.log(granted, body, code, redirectUri); + if (!granted) { + return [false]; + } + delete TEMP_GRANT_CODES[code]; + if (!granted.scopes.length) return [false]; + if (body.client_id !== granted.clientId) return [false]; + if (redirectUri !== granted.redirectUri) return [false]; + if (!body.code_verifier || pkceS256(body.code_verifier) !== granted.codeChallenge) return [false]; + + const accessToken = secureRndstr(128, true); + const refreshToken = secureRndstr(128, true); + + const now = new Date(); + + // Insert access token doc + await accessTokensRepository.insert({ + id: idService.genId(), + createdAt: now, + lastUsedAt: now, + userId: granted.userId, + token: accessToken, + hash: accessToken, + name: granted.clientId, + permission: granted.scopes, + }); + + return [accessToken, refreshToken]; + })().then(args => done(null, ...args), err => done(err)); })); this.#server.serializeClient((client, done) => done(null, client)); this.#server.deserializeClient((id, done) => done(null, id)); @@ -373,6 +448,7 @@ export class OAuth2ProviderService { throw new Error('`code_challenge_method` parameter must be set as S256'); } + reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { transactionId: oauth2?.transactionID, }); @@ -414,7 +490,14 @@ export class OAuth2ProviderService { // for (const middleware of this.#server.decision()) { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); - fastify.use('/oauth/decision', this.#server.decision()); + fastify.use('/oauth/decision', this.#server.decision((req, done) => { + console.log('HIT decision:', req.oauth2, (req as any).body); + req.user = (req as any).body.login_token; + done(null, undefined); + })); + + fastify.use('/oauth/token', bodyParser.json({ strict: true })); + fastify.use('/oauth/token', this.#server.token()); // } // fastify.use('/oauth', this.#provider.callback()); diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 0e708df5df..29abef9893 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -13,6 +13,7 @@
{{ $t('_auth.shareAccess', { name }) }}
{{ i18n.ts._auth.shareAccessAsk }}
+ {{ i18n.ts.cancel }} {{ i18n.ts.accept }} From 39526d0225a076398b40a6dd84f33265cb5903f3 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 2 Apr 2023 10:41:43 +0200 Subject: [PATCH 07/82] tmp --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 5 ++--- packages/backend/src/server/web/views/oauth.pug | 1 - packages/frontend/src/components/MkButton.vue | 2 ++ packages/frontend/src/pages/oauth.vue | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 2b9954fe55..afd06721a2 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,6 +1,5 @@ import dns from 'node:dns/promises'; import { Inject, Injectable } from '@nestjs/common'; -import Provider, { type Adapter, type Account, AdapterPayload } from 'oidc-provider'; import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; import parseLinkHeader from 'parse-link-header'; @@ -429,8 +428,6 @@ export class OAuth2ProviderService { }); }); - // oidc-provider provides many more endpoints for OpenID support and there's - // no way to turn it off. // For now only allow the basic OAuth endpoints, to start small and evaluate // this feature for some time, given that this is security related. fastify.get<{ Querystring: OAuthRequestQuery }>('/oauth/authorize', async (request, reply) => { @@ -451,6 +448,8 @@ export class OAuth2ProviderService { reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { transactionId: oauth2?.transactionID, + clientId: oauth2?.client, + scope: oauth2?.req.scope.join(' '), }); }); fastify.post('/oauth/decision', async () => { }); diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug index 717336f5e1..c4731b8114 100644 --- a/packages/backend/src/server/web/views/oauth.pug +++ b/packages/backend/src/server/web/views/oauth.pug @@ -7,4 +7,3 @@ block meta meta(name='misskey:oauth:transaction-id' content=transactionId) meta(name='misskey:oauth:client-id' content=clientId) meta(name='misskey:oauth:scope' content=scope) - meta(name='misskey:oauth:redirection-uri' content=redirectionUri) diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index e40b655fb7..38c79e89d0 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -5,6 +5,7 @@ :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :type="type" :name="name" + :value="value" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -46,6 +47,7 @@ const props = defineProps<{ transparent?: boolean; asLike?: boolean; name?: string; + value?: string; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 29abef9893..77d64ef957 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -4,18 +4,18 @@
-

{{ $t('_auth.permission', { name }) }}

+

{{ i18n.t('_auth.permission', { name }) }}

{{ i18n.ts._auth.permissionAsk }}

    -
  • {{ $t(`_permissions.${p}`) }}
  • +
  • {{ i18n.t(`_permissions.${p}`) }}
-
{{ $t('_auth.shareAccess', { name }) }}
+
{{ i18n.t('_auth.shareAccess', { name }) }}
{{ i18n.ts._auth.shareAccessAsk }}
- {{ i18n.ts.cancel }} + {{ i18n.ts.cancel }} {{ i18n.ts.accept }}
From 82c9820ac89f0f81d3e8eee88756d719bac42843 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 2 Apr 2023 13:20:41 +0200 Subject: [PATCH 08/82] tmp --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index afd06721a2..4a40802958 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -361,7 +361,7 @@ export class OAuth2ProviderService { return [code]; })().then(args => done(null, ...args), err => done(err)); })); - this.#server.exchange(oauth2orize.exchange.code((client, code, redirectUri, body, done) => { + this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, done) => { (async (): Promise>> => { const granted = TEMP_GRANT_CODES[code]; console.log(granted, body, code, redirectUri); @@ -495,6 +495,8 @@ export class OAuth2ProviderService { done(null, undefined); })); + // 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()); // } From 71f62b9d895d5d146aba1fe5d86baefd9cf4bd2a Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 2 Apr 2023 21:59:38 +0200 Subject: [PATCH 09/82] tmp --- packages/backend/package.json | 5 +- .../src/server/oauth/OAuth2ProviderService.ts | 2 +- packages/backend/test/e2e/oauth.ts | 94 +++++++++++++++++++ pnpm-lock.yaml | 60 ++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 packages/backend/test/e2e/oauth.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 2e4f2b532c..584e57c233 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -127,6 +127,7 @@ "otpauth": "9.1.2", "parse5": "7.1.2", "pg": "8.11.0", + "pkce-challenge": "^3.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", @@ -202,6 +203,7 @@ "@types/sanitize-html": "2.9.0", "@types/semver": "7.5.0", "@types/sharp": "0.32.0", + "@types/simple-oauth2": "^5.0.4", "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", @@ -219,6 +221,7 @@ "eslint-plugin-import": "2.27.5", "execa": "6.1.0", "jest": "29.5.0", - "jest-mock": "29.5.0" + "jest-mock": "29.5.0", + "simple-oauth2": "^5.0.0" } } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 4a40802958..ffe2137cfe 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -433,7 +433,7 @@ export class OAuth2ProviderService { fastify.get<{ Querystring: OAuthRequestQuery }>('/oauth/authorize', async (request, reply) => { console.log('HIT /oauth/authorize', request.query); const oauth2 = (request.raw as any).oauth2 as (OAuth2 | undefined); - console.log(oauth2); + console.log(oauth2, request.raw.session); if (request.query.response_type !== 'code') { throw new Error('`response_type` parameter must be set as "code"'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts new file mode 100644 index 0000000000..9d7050c2c4 --- /dev/null +++ b/packages/backend/test/e2e/oauth.ts @@ -0,0 +1,94 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { port, signup, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import { AuthorizationCode } from 'simple-oauth2'; +import pkceChallenge from 'pkce-challenge'; +import { JSDOM } from 'jsdom'; + +describe('OAuth', () => { + let app: INestApplicationContext; + + let alice: any; + const clientPort = port + 1; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + // fastify = Fastify(); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('Full flow', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = new AuthorizationCode({ + client: { + id: `http://127.0.0.1:${clientPort}/`, + }, + auth: { + tokenHost: `http://127.0.0.1:${port}`, + tokenPath: '/oauth/token', + authorizePath: '/oauth/authorize', + }, + options: { + authorizationMethod: 'body', + }, + }); + + const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; + + const authEndpoint = client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + }); + const response = await fetch(authEndpoint); + assert.strictEqual(response.status, 200); + const cookie = response.headers.get('set-cookie'); + assert.ok(cookie?.startsWith('connect.sid=')); + + const fragment = JSDOM.fragment(await response.text()); + const transactionId = fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content; + assert.strictEqual(typeof transactionId, 'string'); + + const formData = new FormData(); + formData.append('transaction_id', transactionId!); + formData.append('login_token', alice.token); + const decisionResponse = await fetch(`http://127.0.0.1:${port}/oauth/decision`, { + method: 'post', + body: new URLSearchParams({ + transaction_id: transactionId!, + login_token: alice.token, + }), + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + cookie: cookie!, + }, + }); + assert.strictEqual(decisionResponse.status, 302); + assert.ok(decisionResponse.headers.has('location')); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.strictEqual(location.origin + location.pathname, redirect_uri); + + assert.ok(location.searchParams.has('code')); + assert.strictEqual(location.searchParams.get('state'), 'state'); + + const token = await client.getToken({ + code: location.searchParams.get('code')!, + redirect_uri, + code_verifier, + }); + assert.strictEqual(typeof token.token.access_token, 'string'); + assert.strictEqual(typeof token.token.refresh_token, 'string'); + assert.strictEqual(token.token.token_type, 'Bearer'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c21152d56..3ab71a3271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,9 @@ importers: pg: specifier: 8.11.0 version: 8.11.0 + pkce-challenge: + specifier: ^3.1.0 + version: 3.1.0 probe-image-size: specifier: 7.2.3 version: 7.2.3 @@ -598,6 +601,9 @@ importers: '@types/sharp': specifier: 0.32.0 version: 0.32.0 + '@types/simple-oauth2': + specifier: ^5.0.4 + version: 5.0.4 '@types/sinonjs__fake-timers': specifier: 8.1.2 version: 8.1.2 @@ -652,6 +658,9 @@ importers: jest-mock: specifier: 29.5.0 version: 29.5.0 + simple-oauth2: + specifier: ^5.0.0 + version: 5.0.0 packages/frontend: dependencies: @@ -4934,6 +4943,24 @@ packages: hashlru: 2.3.0 dev: false + /@hapi/boom@10.0.1: + resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} + dependencies: + '@hapi/hoek': 11.0.2 + dev: true + + /@hapi/bourne@3.0.0: + resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + dev: true + + /@hapi/hoek@10.0.1: + resolution: {integrity: sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw==} + dev: true + + /@hapi/hoek@11.0.2: + resolution: {integrity: sha512-aKmlCO57XFZ26wso4rJsW4oTUnrgTFw2jh3io7CAtO9w4UltBNwRXvXIVzzyfkaaLRo3nluP/19msA8vDUUuKw==} + dev: true + /@hapi/hoek@9.3.0: resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} dev: true @@ -4944,6 +4971,14 @@ packages: '@hapi/hoek': 9.3.0 dev: true + /@hapi/wreck@18.0.1: + resolution: {integrity: sha512-OLHER70+rZxvDl75xq3xXOfd3e8XIvz8fWY0dqg92UvhZ29zo24vQgfqgHSYhB5ZiuFpSLeriOisAlxAo/1jWg==} + dependencies: + '@hapi/boom': 10.0.1 + '@hapi/bourne': 3.0.0 + '@hapi/hoek': 11.0.2 + dev: true + /@humanwhocodes/config-array@0.11.10: resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} engines: {node: '>=10.10.0'} @@ -7964,6 +7999,10 @@ packages: sharp: 0.32.1 dev: true + /@types/simple-oauth2@5.0.4: + resolution: {integrity: sha512-4SvTfmAa1fGUa1d07j9vIiC4o92bGh0ihPXmtS05udMMmNwVIaU2nZ706cC4wI8cJxOlHD4P/d5tzqvWYd+KxA==} + dev: true + /@types/sinon@10.0.13: resolution: {integrity: sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==} dependencies: @@ -10609,6 +10648,10 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /crypto-js@4.1.1: + resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==} + dev: false + /crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -17096,6 +17139,12 @@ packages: engines: {node: '>= 6'} dev: true + /pkce-challenge@3.1.0: + resolution: {integrity: sha512-bQ/0XPZZ7eX+cdAkd61uYWpfMhakH3NeteUF1R8GNa+LMqX8QFAkbCLqq+AYAns1/ueACBu/BMWhrlKGrdvGZg==} + dependencies: + crypto-js: 4.1.1 + dev: false + /pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -18890,6 +18939,17 @@ packages: once: 1.4.0 simple-concat: 1.0.1 + /simple-oauth2@5.0.0: + resolution: {integrity: sha512-8291lo/z5ZdpmiOFzOs1kF3cxn22bMj5FFH+DNUppLJrpoIlM1QnFiE7KpshHu3J3i21TVcx4yW+gXYjdCKDLQ==} + dependencies: + '@hapi/hoek': 10.0.1 + '@hapi/wreck': 18.0.1 + debug: 4.3.4(supports-color@8.1.1) + joi: 17.7.0 + transitivePeerDependencies: + - supports-color + dev: true + /simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} dependencies: From 098d0670a314d6bc38de0f9710b97577a1b41537 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 3 Apr 2023 22:32:12 +0200 Subject: [PATCH 10/82] a bit more tests --- packages/backend/test/e2e/oauth.ts | 142 +++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 9d7050c2c4..28fe8cb95a 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -7,11 +7,57 @@ import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; +const clientPort = port + 1; +const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; + +function getClient(): AuthorizationCode<'client_id'> { + return new AuthorizationCode({ + client: { + id: `http://127.0.0.1:${clientPort}/`, + }, + auth: { + tokenHost: `http://127.0.0.1:${port}`, + tokenPath: '/oauth/token', + authorizePath: '/oauth/authorize', + }, + options: { + authorizationMethod: 'body', + }, + }); +} + +function getTransactionId(html: string): string | undefined { + const fragment = JSDOM.fragment(html); + return fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content; +} + +function fetchDecision(cookie: string, transactionId: string, user: any, { cancel }: { cancel?: boolean } = {}): Promise { + return fetch(`http://127.0.0.1:${port}/oauth/decision`, { + 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', + cookie, + }, + }); +} + +async function fetchDecisionFromResponse(response: Response, user: any, { cancel }: { cancel?: boolean } = {}): Promise { + const cookie = response.headers.get('set-cookie'); + const transactionId = getTransactionId(await response.text()); + + return await fetchDecision(cookie!, transactionId!, user, { cancel }); +} + describe('OAuth', () => { let app: INestApplicationContext; let alice: any; - const clientPort = port + 1; beforeAll(async () => { app = await startServer(); @@ -26,53 +72,23 @@ describe('OAuth', () => { test('Full flow', async () => { const { code_challenge, code_verifier } = pkceChallenge.default(128); - const client = new AuthorizationCode({ - client: { - id: `http://127.0.0.1:${clientPort}/`, - }, - auth: { - tokenHost: `http://127.0.0.1:${port}`, - tokenPath: '/oauth/token', - authorizePath: '/oauth/authorize', - }, - options: { - authorizationMethod: 'body', - }, - }); + const client = getClient(); - const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; - - const authEndpoint = client.authorizeURL({ + const response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', state: 'state', code_challenge, code_challenge_method: 'S256', - }); - const response = await fetch(authEndpoint); + })); assert.strictEqual(response.status, 200); const cookie = response.headers.get('set-cookie'); assert.ok(cookie?.startsWith('connect.sid=')); - const fragment = JSDOM.fragment(await response.text()); - const transactionId = fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content; + const transactionId = getTransactionId(await response.text()); assert.strictEqual(typeof transactionId, 'string'); - const formData = new FormData(); - formData.append('transaction_id', transactionId!); - formData.append('login_token', alice.token); - const decisionResponse = await fetch(`http://127.0.0.1:${port}/oauth/decision`, { - method: 'post', - body: new URLSearchParams({ - transaction_id: transactionId!, - login_token: alice.token, - }), - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - cookie: cookie!, - }, - }); + const decisionResponse = await fetchDecision(cookie!, transactionId!, alice); assert.strictEqual(decisionResponse.status, 302); assert.ok(decisionResponse.headers.has('location')); @@ -91,4 +107,58 @@ describe('OAuth', () => { assert.strictEqual(typeof token.token.refresh_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); }); + + test('Require PKCE', async () => { + const client = getClient(); + + let response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + })); + assert.ok(!response.ok); + + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + })); + assert.ok(!response.ok); + + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge_method: 'S256', + })); + assert.ok(!response.ok); + + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'SSSS', + })); + assert.ok(!response.ok); + }); + + test('Cancellation', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(!location.searchParams.has('code')); + assert.ok(location.searchParams.has('error')); + }); }); From 179640af30cf1f8d87001446fd165963afc7fb0f Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Wed, 5 Apr 2023 20:47:12 +0200 Subject: [PATCH 11/82] todos --- packages/backend/test/e2e/oauth.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 28fe8cb95a..ab85e2910b 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -161,4 +161,14 @@ describe('OAuth', () => { assert.ok(!location.searchParams.has('code')); assert.ok(location.searchParams.has('error')); }); + + // TODO: .well-known/oauth-authorization-server + + // TODO: scopes (totally missing / empty / exists but all invalid / exists but partially invalid / all valid) + + // TODO: PKCE verification failure + + // TODO: authorizing two users concurrently + + // TODO: invalid redirect_uri (at authorize / at token) }); From 2f566e4173b98e908fd5428831ab0faf7cec01eb Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 7 Apr 2023 10:06:07 +0200 Subject: [PATCH 12/82] resolve conflicts --- .../src/server/oauth/OAuth2ProviderService.ts | 7 +++---- packages/backend/test/e2e/oauth.ts | 18 +++++++++++++++++- packages/backend/test/utils.ts | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index ffe2137cfe..7175fb6680 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -12,7 +12,6 @@ import { kinds } from '@/misc/api-permissions.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; -import fastifySession from '@fastify/session'; import type Redis from 'ioredis'; import oauth2Pkce from 'oauth2orize-pkce'; import { secureRndstr } from '@/misc/secure-rndstr.js'; @@ -28,7 +27,7 @@ import fastifyExpress from '@fastify/express'; import crypto from 'node:crypto'; import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; // https://indieauth.spec.indieweb.org/#client-identifier @@ -305,7 +304,7 @@ export class OAuth2ProviderService { idService: IdService, @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, ) { // this.#provider = new Provider(config.url, { // clientAuthMethods: ['none'], @@ -345,7 +344,7 @@ export class OAuth2ProviderService { console.log('HIT grant code:', client, redirectUri, token, ares, areq); const code = secureRndstr(32, true); - const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (!user) { throw new Error('No such user'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index ab85e2910b..599190407e 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -1,11 +1,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { port, signup, startServer } from '../utils.js'; +import { port, relativeFetch, signup, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; +import { api } from '../utils.js'; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; @@ -106,6 +107,19 @@ describe('OAuth', () => { assert.strictEqual(typeof token.token.access_token, 'string'); assert.strictEqual(typeof token.token.refresh_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); + + const createResponse = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponse.status, 200); + + const createResponseBody: any = await createResponse.json(); + assert.strictEqual(createResponseBody.createdNote.text, 'test'); }); test('Require PKCE', async () => { @@ -171,4 +185,6 @@ describe('OAuth', () => { // TODO: authorizing two users concurrently // TODO: invalid redirect_uri (at authorize / at token) + + // TODO: Wrong Authorization header (Not starts with Bearer / token is wrong) }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 48947072e3..37c1474be4 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -90,7 +90,7 @@ const request = async (path: string, params: any, me?: UserToken): Promise<{ sta }; }; -const relativeFetch = async (path: string, init?: RequestInit | undefined) => { +export const relativeFetch = async (path: string, init?: RequestInit | undefined) => { return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); }; From 5034e6cd691acbe4a5c7c6c4d6d490c4122e41a6 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 8 Apr 2023 15:52:43 +0200 Subject: [PATCH 13/82] PKCE verification test --- packages/backend/test/e2e/oauth.ts | 115 +++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 599190407e..ad64963c6d 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -122,40 +122,91 @@ describe('OAuth', () => { assert.strictEqual(createResponseBody.createdNote.text, 'test'); }); - test('Require PKCE', async () => { - const client = getClient(); + describe('PKCE', () => { + test('Require PKCE', async () => { + const client = getClient(); - let response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - })); - assert.ok(!response.ok); + let response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + })); + assert.ok(!response.ok); - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - })); - assert.ok(!response.ok); + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + })); + assert.ok(!response.ok); - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge_method: 'S256', - })); - assert.ok(!response.ok); + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge_method: 'S256', + })); + assert.ok(!response.ok); - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'SSSS', - })); - assert.ok(!response.ok); + response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'SSSS', + })); + assert.ok(!response.ok); + }); + + test('Verify PKCE', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; + assert.ok(!!code); + + // Pattern 1: code followed by some junk code + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier: code_verifier + 'x', + })); + + // Pattern 2: clipped code + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier: code_verifier.slice(0, 80), + })); + + // Pattern 3: Some part of code is replaced + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier: code_verifier.slice(0, -10) + 'x'.repeat(10), + })); + + // And now the code is invalidated by the previous failures + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier, + })); + }); }); test('Cancellation', async () => { @@ -171,6 +222,8 @@ describe('OAuth', () => { assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); + assert.strictEqual(decisionResponse.status, 302); + const location = new URL(decisionResponse.headers.get('location')!); assert.ok(!location.searchParams.has('code')); assert.ok(location.searchParams.has('error')); @@ -180,8 +233,6 @@ describe('OAuth', () => { // TODO: scopes (totally missing / empty / exists but all invalid / exists but partially invalid / all valid) - // TODO: PKCE verification failure - // TODO: authorizing two users concurrently // TODO: invalid redirect_uri (at authorize / at token) From 88fd7f275897c9931e246baacb077cd72ab19efc Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 8 Apr 2023 16:03:20 +0200 Subject: [PATCH 14/82] test comment --- packages/backend/test/e2e/oauth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index ad64963c6d..7383442fed 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -126,6 +126,7 @@ describe('OAuth', () => { test('Require PKCE', async () => { const client = getClient(); + // Pattern 1: No PKCE fields at all let response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -133,6 +134,7 @@ describe('OAuth', () => { })); assert.ok(!response.ok); + // Pattern 2: Only code_challenge response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -141,6 +143,7 @@ describe('OAuth', () => { })); assert.ok(!response.ok); + // Pattern 2: Only code_challenge_method response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -149,6 +152,7 @@ describe('OAuth', () => { })); assert.ok(!response.ok); + // Pattern 3: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', From 401575a90394fb2bf54584369ba5312fe5da0983 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 8 Apr 2023 20:31:18 +0200 Subject: [PATCH 15/82] scope test --- .../src/server/oauth/OAuth2ProviderService.ts | 9 +- packages/backend/test/e2e/oauth.ts | 83 ++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 7175fb6680..b0fdc558e6 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -330,6 +330,7 @@ export class OAuth2ProviderService { // }, // }); + // TODO: store this in Redis const TEMP_GRANT_CODES: Record kinds.includes(s)); + if (!scopes.length) { + throw new Error('`scope` parameter has no known scope'); + } + reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { transactionId: oauth2?.transactionID, clientId: oauth2?.client, - scope: oauth2?.req.scope.join(' '), + scope: scopes.join(' '), }); }); fastify.post('/oauth/decision', async () => { }); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 7383442fed..92cb13437e 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -233,13 +233,92 @@ describe('OAuth', () => { assert.ok(location.searchParams.has('error')); }); - // TODO: .well-known/oauth-authorization-server + describe('Scope', () => { + test('Missing scope', async () => { + const client = getClient(); - // TODO: scopes (totally missing / empty / exists but all invalid / exists but partially invalid / all valid) + const response = await fetch(client.authorizeURL({ + redirect_uri, + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + + // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec + assert.strictEqual(response.status, 500); + }); + + test('Empty scope', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: '', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + + // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec + assert.strictEqual(response.status, 500); + }); + + test('Unknown scopes', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'test:unknown test:unknown2', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + + // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec + assert.strictEqual(response.status, 500); + }); + + test('Partially known scopes', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes test:unknown test:unknown2', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + + // Just get the known scope for this case for backward compatibility + assert.strictEqual(response.status, 200); + // TODO: OAuth2 requires returning `scope` in the token response in this case but oauth2orize seemingly doesn't support this + }); + + test('Known scopes', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes read:account', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + + // Just get the known scope for this case for backward compatibility + assert.strictEqual(response.status, 200); + }); + + // TODO: duplicate scopes test (currently token response doesn't return final scopes, although it must) + }); + + // TODO: .well-known/oauth-authorization-server // TODO: authorizing two users concurrently // TODO: invalid redirect_uri (at authorize / at token) // TODO: Wrong Authorization header (Not starts with Bearer / token is wrong) + + // TODO: Error format required by OAuth spec }); From 0cc9d5aa32b0dd0bb0a1516ce2f7ce38e986a318 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 9 Apr 2023 14:01:44 +0200 Subject: [PATCH 16/82] header test --- packages/backend/test/e2e/oauth.ts | 56 ++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 92cb13437e..d52853aa6b 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -310,6 +310,60 @@ describe('OAuth', () => { }); // TODO: duplicate scopes test (currently token response doesn't return final scopes, although it must) + + // TODO: write failure when no scope + }); + + test('Authorization header', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + const token = await client.getToken({ + code: location.searchParams.get('code')!, + redirect_uri, + code_verifier, + }); + + // Pattern 1: No preceding "Bearer " + let createResponse = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: token.token.access_token as string, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponse.status, 401); + + // Pattern 2: Incorrect token + createResponse = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${(token.token.access_token as string).slice(0, -1)}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + // RFC 6750 section 3.1 says 401 but it's SHOULD not MUST. 403 should be okay for now. + assert.strictEqual(createResponse.status, 403); + + // TODO: error code (invalid_token) }); // TODO: .well-known/oauth-authorization-server @@ -318,7 +372,5 @@ describe('OAuth', () => { // TODO: invalid redirect_uri (at authorize / at token) - // TODO: Wrong Authorization header (Not starts with Bearer / token is wrong) - // TODO: Error format required by OAuth spec }); From 515af3176ac0e256f1a8f4ff0101fbdbf4c577d5 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 9 Apr 2023 16:43:19 +0200 Subject: [PATCH 17/82] redirection test --- packages/backend/test/e2e/oauth.ts | 48 ++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index d52853aa6b..66c3a970bc 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -95,7 +95,6 @@ describe('OAuth', () => { const location = new URL(decisionResponse.headers.get('location')!); assert.strictEqual(location.origin + location.pathname, redirect_uri); - assert.ok(location.searchParams.has('code')); assert.strictEqual(location.searchParams.get('state'), 'state'); @@ -366,11 +365,54 @@ describe('OAuth', () => { // TODO: error code (invalid_token) }); + describe('Redirection', () => { + test('Invalid redirect_uri at authorization endpoint', async () => { + const client = getClient(); + + 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', + })); + // TODO: status code + assert.strictEqual(response.status, 500); + }); + + test('Invalid redirect_uri at token endpoint', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + await assert.rejects(client.getToken({ + code: location.searchParams.get('code')!, + redirect_uri: 'http://127.0.0.2/', + code_verifier, + })); + }); + + // TODO: disallow random same-origin URLs with strict redirect_uris with client information discovery + }); + // TODO: .well-known/oauth-authorization-server // TODO: authorizing two users concurrently - // TODO: invalid redirect_uri (at authorize / at token) - // TODO: Error format required by OAuth spec }); From 6385ca9b0d7ebe8c380885aecd732d4c143d1acc Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 9 Apr 2023 18:49:58 +0200 Subject: [PATCH 18/82] iss parameter test --- .../src/server/oauth/OAuth2ProviderService.ts | 16 +++++-- packages/backend/test/e2e/oauth.ts | 43 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index b0fdc558e6..677a85473f 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -4,7 +4,8 @@ import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; import parseLinkHeader from 'parse-link-header'; import ipaddr from 'ipaddr.js'; -import oauth2orize, { OAuth2 } from 'oauth2orize'; +import oauth2orize, { type OAuth2 } from 'oauth2orize'; +import * as oauth2Query from 'oauth2orize/lib/response/query.js'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -339,8 +340,17 @@ export class OAuth2ProviderService { scopes: string[], }> = {}; + const query = (txn, res, params) => { + // RFC 9207 + // TODO: Oh no, perhaps returning to oidc-provider is better. Hacks everywhere here. + params.iss = config.url; + oauth2Query.default(txn, res, params); + }; + this.#server.grant(oauth2Pkce.extensions()); - this.#server.grant(oauth2orize.grant.code((client, redirectUri, token, ares, areq, done) => { + this.#server.grant(oauth2orize.grant.code({ + modes: { query } + }, (client, redirectUri, token, ares, areq, done) => { (async (): Promise>> => { console.log('HIT grant code:', client, redirectUri, token, ares, areq); const code = secureRndstr(32, true); @@ -483,7 +493,7 @@ export class OAuth2ProviderService { // https://indieauth.spec.indieweb.org/#authorization-request // Allow same-origin redirection if (redirectUrl.protocol !== clientUrl.protocol || redirectUrl.host !== clientUrl.host) { - // TODO: allow more redirect_uri by Client Information Discovery + // TODO: allow only explicit redirect_uri by Client Information Discovery throw new Error('cross-origin redirect_uri is not supported yet.'); } diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 66c3a970bc..5dd0d7c39f 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -6,7 +6,6 @@ import type { INestApplicationContext } from '@nestjs/common'; import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; -import { api } from '../utils.js'; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; @@ -97,6 +96,7 @@ describe('OAuth', () => { assert.strictEqual(location.origin + location.pathname, redirect_uri); assert.ok(location.searchParams.has('code')); assert.strictEqual(location.searchParams.get('state'), 'state'); + assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); // RFC 9207 const token = await client.getToken({ code: location.searchParams.get('code')!, @@ -380,6 +380,19 @@ describe('OAuth', () => { assert.strictEqual(response.status, 500); }); + test('No redirect_uri at authorization endpoint', async () => { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + // TODO: status code + assert.strictEqual(response.status, 500); + }); + test('Invalid redirect_uri at token endpoint', async () => { const { code_challenge, code_verifier } = pkceChallenge.default(128); @@ -407,6 +420,32 @@ describe('OAuth', () => { })); }); + test('No redirect_uri at token endpoint', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + await assert.rejects(client.getToken({ + code: location.searchParams.get('code')!, + code_verifier, + })); + }); + // TODO: disallow random same-origin URLs with strict redirect_uris with client information discovery }); @@ -415,4 +454,6 @@ describe('OAuth', () => { // TODO: authorizing two users concurrently // TODO: Error format required by OAuth spec + + // TODO: Client Information Discovery }); From deb4429e3afacfb33292e935558d521a3957755a Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 9 Apr 2023 21:21:10 +0200 Subject: [PATCH 19/82] return scope in token response --- .../src/server/oauth/OAuth2ProviderService.ts | 12 +-- packages/backend/test/e2e/oauth.ts | 98 +++++++++++++++++-- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 677a85473f..a18b3519a5 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -384,7 +384,6 @@ export class OAuth2ProviderService { if (!body.code_verifier || pkceS256(body.code_verifier) !== granted.codeChallenge) return [false]; const accessToken = secureRndstr(128, true); - const refreshToken = secureRndstr(128, true); const now = new Date(); @@ -400,7 +399,7 @@ export class OAuth2ProviderService { permission: granted.scopes, }); - return [accessToken, refreshToken]; + return [accessToken, { scope: granted.scopes.join(' ') }]; })().then(args => done(null, ...args), err => done(err)); })); this.#server.serializeClient((client, done) => done(null, client)); @@ -441,7 +440,7 @@ export class OAuth2ProviderService { // this feature for some time, given that this is security related. fastify.get<{ Querystring: OAuthRequestQuery }>('/oauth/authorize', async (request, reply) => { console.log('HIT /oauth/authorize', request.query); - const oauth2 = (request.raw as any).oauth2 as (OAuth2 | undefined); + const oauth2 = (request.raw as any).oauth2 as OAuth2; console.log(oauth2, request.raw.session); if (request.query.response_type !== 'code') { @@ -454,15 +453,16 @@ export class OAuth2ProviderService { throw new Error('`code_challenge_method` parameter must be set as S256'); } - const scopes = [...new Set(oauth2?.req.scope)].filter(s => kinds.includes(s)); + const scopes = [...new Set(oauth2.req.scope)].filter(s => kinds.includes(s)); if (!scopes.length) { throw new Error('`scope` parameter has no known scope'); } + oauth2.req.scope = scopes; reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { - transactionId: oauth2?.transactionID, - clientId: oauth2?.client, + transactionId: oauth2.transactionID, + clientId: oauth2.client, scope: scopes.join(' '), }); }); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 5dd0d7c39f..17fcea9e9d 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -104,8 +104,8 @@ describe('OAuth', () => { code_verifier, }); assert.strictEqual(typeof token.token.access_token, 'string'); - assert.strictEqual(typeof token.token.refresh_token, 'string'); assert.strictEqual(token.token.token_type, 'Bearer'); + assert.strictEqual(token.token.scope, 'write:notes'); const createResponse = await relativeFetch('api/notes/create', { method: 'POST', @@ -278,19 +278,39 @@ describe('OAuth', () => { }); test('Partially known scopes', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + const client = getClient(); const response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes test:unknown test:unknown2', state: 'state', - code_challenge: 'code', + code_challenge, code_challenge_method: 'S256', })); // Just get the known scope for this case for backward compatibility assert.strictEqual(response.status, 200); - // TODO: OAuth2 requires returning `scope` in the token response in this case but oauth2orize seemingly doesn't support this + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; + assert.ok(!!code); + + const token = await client.getToken({ + code, + redirect_uri, + code_verifier, + }); + + // OAuth2 requires returning `scope` in the token response if the resulting scope is different than the requested one + // (Although Misskey always return scope, which is also fine) + assert.strictEqual(token.token.scope, 'write:notes'); }); test('Known scopes', async () => { @@ -304,13 +324,79 @@ describe('OAuth', () => { code_challenge_method: 'S256', })); - // Just get the known scope for this case for backward compatibility assert.strictEqual(response.status, 200); }); - // TODO: duplicate scopes test (currently token response doesn't return final scopes, although it must) + test('Duplicated scopes', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); - // TODO: write failure when no scope + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes write:notes read:account read:account', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; + assert.ok(!!code); + + const token = await client.getToken({ + code, + redirect_uri, + code_verifier, + }); + assert.strictEqual(token.token.scope, 'write:notes read:account'); + }); + + test('Scope check by API', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'read:account', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + const token = await client.getToken({ + code: location.searchParams.get('code')!, + redirect_uri, + code_verifier, + }); + assert.strictEqual(typeof token.token.access_token, 'string'); + + const createResponse = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + // XXX: PERMISSION_DENIED is not using kind: 'permission' and gives 400 instead of 403 + assert.strictEqual(createResponse.status, 400); + }); }); test('Authorization header', async () => { From 333d6a928386e2fe94db473f7e3e0a63f83561eb Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 10 Apr 2023 10:17:41 +0200 Subject: [PATCH 20/82] server metadata test --- .../src/server/oauth/OAuth2ProviderService.ts | 8 ++++++++ packages/backend/test/e2e/oauth.ts | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index a18b3519a5..d0cd211408 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -432,7 +432,15 @@ export class OAuth2ProviderService { issuer: this.config.url, authorization_endpoint: new URL('/oauth/authorize', this.config.url), token_endpoint: new URL('/oauth/token', this.config.url), + // TODO: support or not? + // introspection_endpoint: ... + // introspection_endpoint_auth_methods_supported: ... + 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, }); }); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 17fcea9e9d..69c5c869c8 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -7,6 +7,8 @@ import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; +const host = `http://127.0.0.1:${port}`; + const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; @@ -16,7 +18,7 @@ function getClient(): AuthorizationCode<'client_id'> { id: `http://127.0.0.1:${clientPort}/`, }, auth: { - tokenHost: `http://127.0.0.1:${port}`, + tokenHost: host, tokenPath: '/oauth/token', authorizePath: '/oauth/authorize', }, @@ -32,7 +34,7 @@ function getTransactionId(html: string): string | undefined { } function fetchDecision(cookie: string, transactionId: string, user: any, { cancel }: { cancel?: boolean } = {}): Promise { - return fetch(`http://127.0.0.1:${port}/oauth/decision`, { + return fetch(new URL('/oauth/decision', host), { method: 'post', body: new URLSearchParams({ transaction_id: transactionId!, @@ -535,7 +537,14 @@ describe('OAuth', () => { // TODO: disallow random same-origin URLs with strict redirect_uris with client information discovery }); - // TODO: .well-known/oauth-authorization-server + 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')); + }); // TODO: authorizing two users concurrently From f6d9cf1ef10dbd5d9a2396ff5c5e908c35967cc3 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 10 Apr 2023 14:49:18 +0200 Subject: [PATCH 21/82] strict redirection uri --- packages/backend/package.json | 2 + .../src/server/oauth/OAuth2ProviderService.ts | 59 +++++++++----- .../backend/src/server/web/views/oauth.pug | 2 +- packages/backend/test/e2e/oauth.ts | 79 ++++++++++++++++--- packages/frontend/src/pages/oauth.vue | 2 +- pnpm-lock.yaml | 17 ++++ 6 files changed, 127 insertions(+), 34 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 584e57c233..bf340925a6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -101,6 +101,7 @@ "got": "13.0.0", "happy-dom": "9.20.3", "hpagent": "1.2.0", + "http-link-header": "^1.1.0", "ioredis": "5.3.2", "ip-cidr": "3.1.0", "ipaddr.js": "2.1.0", @@ -180,6 +181,7 @@ "@types/escape-regexp": "0.0.1", "@types/express-session": "^1.17.6", "@types/fluent-ffmpeg": "2.1.21", + "@types/http-link-header": "^1.0.3", "@types/jest": "29.5.2", "@types/js-yaml": "4.0.5", "@types/jsdom": "21.1.1", diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d0cd211408..f6fbdbdd8b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -2,7 +2,7 @@ import dns from 'node:dns/promises'; import { Inject, Injectable } from '@nestjs/common'; import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; -import parseLinkHeader from 'parse-link-header'; +import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2 } from 'oauth2orize'; import * as oauth2Query from 'oauth2orize/lib/response/query.js'; @@ -103,18 +103,33 @@ function validateClientId(raw: string): URL { // return `uid:${uid}`; // } -async function fetchFromClientId(httpRequestService: HttpRequestService, id: string): Promise { +interface ClientInformation { + id: string; + redirectUris: string[]; + name: string; +} + +async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); - let redirectUri = parseLinkHeader(res.headers.get('link'))?.redirect_uri?.url; - if (redirectUri) { - return new URL(redirectUri, res.url).toString(); + const redirectUris: string[] = []; + + const linkHeader = res.headers.get('link'); + if (linkHeader) { + redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); } - redirectUri = JSDOM.fragment(await res.text()).querySelector('link[rel=redirect_uri][href]')?.href; - if (redirectUri) { - return new URL(redirectUri, res.url).toString(); - } + const fragment = JSDOM.fragment(await res.text()); + + redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); + + const name = fragment.querySelector('.h-app .p-name')?.textContent?.trim() ?? id; + + return { + id, + redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), + name, + }; } catch { throw new Error('Failed to fetch client information'); } @@ -267,7 +282,7 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str // }; // } -function pkceS256(codeVerifier: string) { +function pkceS256(codeVerifier: string): string { return crypto.createHash('sha256') .update(codeVerifier, 'ascii') .digest('base64url'); @@ -362,7 +377,7 @@ export class OAuth2ProviderService { } TEMP_GRANT_CODES[code] = { - clientId: client, + clientId: client.id, userId: user.id, redirectUri, codeChallenge: areq.codeChallenge, @@ -470,7 +485,7 @@ export class OAuth2ProviderService { reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { transactionId: oauth2.transactionID, - clientId: oauth2.client, + clientName: oauth2.client.name, scope: scopes.join(' '), }); }); @@ -494,18 +509,22 @@ export class OAuth2ProviderService { (async (): Promise>> => { console.log('HIT /oauth/authorize validation middleware'); - // Find client information from the remote. const clientUrl = validateClientId(clientId); - const redirectUrl = new URL(redirectUri); - // https://indieauth.spec.indieweb.org/#authorization-request - // Allow same-origin redirection - if (redirectUrl.protocol !== clientUrl.protocol || redirectUrl.host !== clientUrl.host) { - // TODO: allow only explicit redirect_uri by Client Information Discovery - throw new Error('cross-origin redirect_uri is not supported yet.'); + if (process.env.NODE_ENV !== 'test') { + const lookup = await dns.lookup(clientUrl.hostname); + if (ipaddr.parse(lookup.address).range() === 'loopback') { + throw new Error('client_id unexpectedly resolves to loopback IP.'); + } } - return [clientId, redirectUri]; + // Find client information from the remote. + const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); + if (!clientInfo.redirectUris.includes(redirectUri)) { + throw new Error('Invalid redirect_uri'); + } + + return [clientInfo, redirectUri]; })().then(args => done(null, ...args), err => done(err)); })); // for (const middleware of this.#server.decision()) { diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug index c4731b8114..94ef196b1d 100644 --- a/packages/backend/src/server/web/views/oauth.pug +++ b/packages/backend/src/server/web/views/oauth.pug @@ -5,5 +5,5 @@ block meta //- user navigates away via the navigation bar //- XXX: Remove navigation bar in auth page? meta(name='misskey:oauth:transaction-id' content=transactionId) - meta(name='misskey:oauth:client-id' content=clientId) + meta(name='misskey:oauth:client-name' content=clientName) meta(name='misskey:oauth:scope' content=scope) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 69c5c869c8..1a42d60cbc 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -1,11 +1,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { port, relativeFetch, signup, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; +import { port, relativeFetch, signup, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import Fastify, { type FastifyInstance } from 'fastify'; const host = `http://127.0.0.1:${port}`; @@ -28,9 +29,12 @@ function getClient(): AuthorizationCode<'client_id'> { }); } -function getTransactionId(html: string): string | undefined { +function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } | undefined { const fragment = JSDOM.fragment(html); - return fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content; + return { + transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, + clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, + }; } function fetchDecision(cookie: string, transactionId: string, user: any, { cancel }: { cancel?: boolean } = {}): Promise { @@ -51,24 +55,35 @@ function fetchDecision(cookie: string, transactionId: string, user: any, { cance async function fetchDecisionFromResponse(response: Response, user: any, { cancel }: { cancel?: boolean } = {}): Promise { const cookie = response.headers.get('set-cookie'); - const transactionId = getTransactionId(await response.text()); + const { transactionId } = getMeta(await response.text()); return await fetchDecision(cookie!, transactionId!, user, { cancel }); } describe('OAuth', () => { let app: INestApplicationContext; + let fastify: FastifyInstance; let alice: any; beforeAll(async () => { app = await startServer(); + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.send(` + + +
Misklient + `); + }); + fastify.listen({ port: clientPort, host: '0.0.0.0' }); + alice = await signup({ username: 'alice' }); - // fastify = Fastify(); }, 1000 * 60 * 2); afterAll(async () => { await app.close(); + await fastify.close(); }); test('Full flow', async () => { @@ -87,10 +102,11 @@ describe('OAuth', () => { const cookie = response.headers.get('set-cookie'); assert.ok(cookie?.startsWith('connect.sid=')); - const transactionId = getTransactionId(await response.text()); - assert.strictEqual(typeof transactionId, 'string'); + const meta = getMeta(await response.text()); + assert.strictEqual(typeof meta.transactionId, 'string'); + assert.strictEqual(meta?.clientName, 'Misklient'); - const decisionResponse = await fetchDecision(cookie!, transactionId!, alice); + const decisionResponse = await fetchDecision(cookie!, meta.transactionId!, alice); assert.strictEqual(decisionResponse.status, 302); assert.ok(decisionResponse.headers.has('location')); @@ -468,6 +484,20 @@ describe('OAuth', () => { assert.strictEqual(response.status, 500); }); + test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { + const client = getClient(); + + 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', + })); + // TODO: status code + assert.strictEqual(response.status, 500); + }); + test('No redirect_uri at authorization endpoint', async () => { const client = getClient(); @@ -508,6 +538,33 @@ describe('OAuth', () => { })); }); + test('Invalid redirect_uri including the valid one at token endpoint', async () => { + const { code_challenge, code_verifier } = pkceChallenge.default(128); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge, + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + await assert.rejects(client.getToken({ + code: location.searchParams.get('code')!, + redirect_uri: 'http://127.0.0.1/redirection', + code_verifier, + })); + }); + test('No redirect_uri at token endpoint', async () => { const { code_challenge, code_verifier } = pkceChallenge.default(128); @@ -533,8 +590,6 @@ describe('OAuth', () => { code_verifier, })); }); - - // TODO: disallow random same-origin URLs with strict redirect_uris with client information discovery }); test('Server metadata', async () => { @@ -550,5 +605,5 @@ describe('OAuth', () => { // TODO: Error format required by OAuth spec - // TODO: Client Information Discovery + // TODO: Client Information Discovery (use http header, loopback check, missing name or redirection uri) }); diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 77d64ef957..e0d126cb31 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -40,7 +40,7 @@ if (transactionIdMeta) { transactionIdMeta.remove(); } -const name = document.querySelector('meta[name="misskey:oauth:client-id"]')?.content; +const name = document.querySelector('meta[name="misskey:oauth:client-name"]')?.content; const _permissions = document.querySelector('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; function onLogin(res): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ab71a3271..d5be7fa4d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: hpagent: specifier: 1.2.0 version: 1.2.0 + http-link-header: + specifier: ^1.1.0 + version: 1.1.0 ioredis: specifier: 5.3.2 version: 5.3.2 @@ -532,6 +535,9 @@ importers: '@types/fluent-ffmpeg': specifier: 2.1.21 version: 2.1.21 + '@types/http-link-header': + specifier: ^1.0.3 + version: 1.0.3 '@types/jest': specifier: 29.5.2 version: 29.5.2 @@ -7713,6 +7719,12 @@ packages: /@types/http-cache-semantics@4.0.1: resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} + /@types/http-link-header@1.0.3: + resolution: {integrity: sha512-y8HkoD/vyid+5MrJ3aas0FvU3/BVBGcyG9kgxL0Zn4JwstA8CglFPnrR0RuzOjRCXwqzL5uxWC2IO7Ub0rMU2A==} + dependencies: + '@types/node': 20.2.5 + dev: true + /@types/istanbul-lib-coverage@2.0.4: resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} dev: true @@ -13423,6 +13435,11 @@ packages: statuses: 2.0.1 toidentifier: 1.0.1 + /http-link-header@1.1.0: + resolution: {integrity: sha512-pj6N1yxOz/ANO8HHsWGg/OoIL1kmRYvQnXQ7PIRpgp+15AnEsRH8fmIJE6D1OdWG2Bov+BJHVla1fFXxg1JbbA==} + engines: {node: '>=6.0.0'} + dev: false + /http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} From 87dbe5e9fb09cb20658a475fe9efaa28a5235682 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 10 Apr 2023 16:26:04 +0200 Subject: [PATCH 22/82] client info discovery test --- packages/backend/test/e2e/oauth.ts | 132 ++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 1a42d60cbc..f56846f300 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -76,7 +76,7 @@ describe('OAuth', () => {
Misklient `); }); - fastify.listen({ port: clientPort, host: '0.0.0.0' }); + await fastify.listen({ port: clientPort }); alice = await signup({ username: 'alice' }); }, 1000 * 60 * 2); @@ -601,6 +601,136 @@ describe('OAuth', () => { assert.ok(body.scopes_supported.includes('write:notes')); }); + describe('Client Information Discovery', () => { + test('Read HTTP header', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + }); + + test('Mixed links', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + + +
Misklient + `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + }); + + test('Multiple items in Link header', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + console.log(await response.text()); + assert.strictEqual(response.status, 200); + }); + + test('Multiple items in HTML', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.send(` + + + +
Misklient + `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + }); + + test('No item', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.send(` + +
Misklient + `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + // TODO: status code + assert.strictEqual(response.status, 500); + }); + }); + // TODO: authorizing two users concurrently // TODO: Error format required by OAuth spec From a688bd1061297fa51ae59ee7b0d5c50cb8bde284 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 10 Apr 2023 17:48:45 +0200 Subject: [PATCH 23/82] more discovery test --- .../src/server/oauth/OAuth2ProviderService.ts | 2 +- packages/backend/test/e2e/oauth.ts | 223 +++++++++++------- 2 files changed, 134 insertions(+), 91 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index f6fbdbdd8b..68056587e0 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -511,7 +511,7 @@ export class OAuth2ProviderService { const clientUrl = validateClientId(clientId); - if (process.env.NODE_ENV !== 'test') { + if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { const lookup = await dns.lookup(clientUrl.hostname); if (ipaddr.parse(lookup.address).range() === 'loopback') { throw new Error('client_id unexpectedly resolves to loopback IP.'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index f56846f300..bc8e62ec75 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -29,7 +29,7 @@ function getClient(): AuthorizationCode<'client_id'> { }); } -function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } | undefined { +function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } { const fragment = JSDOM.fragment(html); return { transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, @@ -68,6 +68,11 @@ describe('OAuth', () => { beforeAll(async () => { app = await startServer(); + alice = await signup({ username: 'alice' }); + }, 1000 * 60 * 2); + + beforeEach(async () => { + process.env.MISSKEY_TEST_DISALLOW_LOOPBACK = ''; fastify = Fastify(); fastify.get('/', async (request, reply) => { reply.send(` @@ -77,12 +82,13 @@ describe('OAuth', () => { `); }); await fastify.listen({ port: clientPort }); - - alice = await signup({ username: 'alice' }); - }, 1000 * 60 * 2); + }); afterAll(async () => { await app.close(); + }); + + afterEach(async () => { await fastify.close(); }); @@ -104,7 +110,7 @@ describe('OAuth', () => { const meta = getMeta(await response.text()); assert.strictEqual(typeof meta.transactionId, 'string'); - assert.strictEqual(meta?.clientName, 'Misklient'); + assert.strictEqual(meta.clientName, 'Misklient'); const decisionResponse = await fetchDecision(cookie!, meta.transactionId!, alice); assert.strictEqual(decisionResponse.status, 302); @@ -602,123 +608,139 @@ describe('OAuth', () => { }); describe('Client Information Discovery', () => { - test('Read HTTP header', async () => { - await fastify.close(); + describe('Redirection', () => { + test('Read HTTP header', async () => { + await fastify.close(); - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(`
Misklient `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); }); - await fastify.listen({ port: clientPort }); - const client = getClient(); + test('Mixed links', async () => { + await fastify.close(); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - })); - assert.strictEqual(response.status, 200); - }); - - test('Mixed links', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(`
Misklient `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); }); - await fastify.listen({ port: clientPort }); - const client = getClient(); + test('Multiple items in Link header', async () => { + await fastify.close(); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - })); - assert.strictEqual(response.status, 200); - }); - - test('Multiple items in Link header', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); - reply.send(` + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); + reply.send(`
Misklient `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); }); - await fastify.listen({ port: clientPort }); - const client = getClient(); + test('Multiple items in HTML', async () => { + await fastify.close(); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - })); - console.log(await response.text()); - assert.strictEqual(response.status, 200); - }); - - test('Multiple items in HTML', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - reply.send(` + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.send(`
Misklient `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); }); - await fastify.listen({ port: clientPort }); - const client = getClient(); + test('No item', async () => { + await fastify.close(); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - })); - assert.strictEqual(response.status, 200); - }); - - test('No item', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - reply.send(` + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.send(`
Misklient `); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + // TODO: status code + assert.strictEqual(response.status, 500); }); - await fastify.listen({ port: clientPort }); + }); + + test('Disallow loopback', async () => { + process.env.MISSKEY_TEST_DISALLOW_LOOPBACK = '1'; const client = getClient(); - const response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -729,11 +751,32 @@ describe('OAuth', () => { // TODO: status code assert.strictEqual(response.status, 500); }); + + test('Missing name', async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(); + }); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + })); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); }); // TODO: authorizing two users concurrently // TODO: Error format required by OAuth spec - - // TODO: Client Information Discovery (use http header, loopback check, missing name or redirection uri) }); From 027c5734a4aa6e45e212706a346ff7664db5d2ef Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 10 Apr 2023 20:29:11 +0200 Subject: [PATCH 24/82] concurrent flow test --- .../src/server/oauth/OAuth2ProviderService.ts | 27 ------ packages/backend/test/e2e/oauth.ts | 90 +++++++++++++++++-- packages/backend/test/utils.ts | 1 + 3 files changed, 85 insertions(+), 33 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 68056587e0..76b88e35cc 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -76,33 +76,6 @@ function validateClientId(raw: string): URL { return url; } -// const grantable = new Set([ -// 'AccessToken', -// 'AuthorizationCode', -// 'RefreshToken', -// 'DeviceCode', -// 'BackchannelAuthenticationRequest', -// ]); - -// const consumable = new Set([ -// 'AuthorizationCode', -// 'RefreshToken', -// 'DeviceCode', -// 'BackchannelAuthenticationRequest', -// ]); - -// function grantKeyFor(id: string): string { -// return `grant:${id}`; -// } - -// function userCodeKeyFor(userCode: string): string { -// return `userCode:${userCode}`; -// } - -// function uidKeyFor(uid: string): string { -// return `uid:${uid}`; -// } - interface ClientInformation { id: string; redirectUris: string[]; diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index bc8e62ec75..5670a5be43 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -4,9 +4,10 @@ import * as assert from 'assert'; import { AuthorizationCode } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; +import * as misskey from 'misskey-js'; +import Fastify, { type FastifyInstance } from 'fastify'; import { port, relativeFetch, signup, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; -import Fastify, { type FastifyInstance } from 'fastify'; const host = `http://127.0.0.1:${port}`; @@ -37,7 +38,7 @@ function getMeta(html: string): { transactionId: string | undefined, clientName: }; } -function fetchDecision(cookie: string, transactionId: string, user: any, { cancel }: { cancel?: boolean } = {}): Promise { +function fetchDecision(cookie: string, transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', body: new URLSearchParams({ @@ -53,7 +54,7 @@ function fetchDecision(cookie: string, transactionId: string, user: any, { cance }); } -async function fetchDecisionFromResponse(response: Response, user: any, { cancel }: { cancel?: boolean } = {}): Promise { +async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { const cookie = response.headers.get('set-cookie'); const { transactionId } = getMeta(await response.text()); @@ -64,11 +65,13 @@ describe('OAuth', () => { let app: INestApplicationContext; let fastify: FastifyInstance; - let alice: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); beforeEach(async () => { @@ -145,6 +148,81 @@ describe('OAuth', () => { assert.strictEqual(createResponseBody.createdNote.text, 'test'); }); + test('Two concurrent flows', async () => { + const client = getClient(); + + const pkceAlice = pkceChallenge.default(128); + const pkceBob = pkceChallenge.default(128); + + const responseAlice = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: pkceAlice.code_challenge, + code_challenge_method: 'S256', + })); + 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', + })); + 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 locationAlice = new URL(decisionResponseAlice.headers.get('location')!); + assert.ok(locationAlice.searchParams.has('code')); + + const locationBob = new URL(decisionResponseBob.headers.get('location')!); + assert.ok(locationBob.searchParams.has('code')); + + const tokenAlice = await client.getToken({ + code: locationAlice.searchParams.get('code')!, + redirect_uri, + code_verifier: pkceAlice.code_verifier, + }); + + const tokenBob = await client.getToken({ + code: locationBob.searchParams.get('code')!, + redirect_uri, + code_verifier: pkceBob.code_verifier, + }); + + const createResponseAlice = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenAlice.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponseAlice.status, 200); + + const createResponseBob = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenBob.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponseAlice.status, 200); + + const createResponseBodyAlice = await createResponseAlice.json() as { createdNote: misskey.entities.Note }; + assert.strictEqual(createResponseBodyAlice.createdNote.user.username, 'alice'); + + const createResponseBodyBob = await createResponseBob.json() as { createdNote: misskey.entities.Note }; + assert.strictEqual(createResponseBodyBob.createdNote.user.username, 'bob'); + }); + describe('PKCE', () => { test('Require PKCE', async () => { const client = getClient(); @@ -213,6 +291,8 @@ describe('OAuth', () => { code_verifier: code_verifier + 'x', })); + // TODO: The following patterns may fail only because of pattern 1's failure. Let's split them. + // Pattern 2: clipped code await assert.rejects(client.getToken({ code, @@ -776,7 +856,5 @@ describe('OAuth', () => { }); }); - // TODO: authorizing two users concurrently - // TODO: Error format required by OAuth spec }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 37c1474be4..8de6e4d7e5 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -10,6 +10,7 @@ import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; +import type { MeSignup } from 'misskey-js/built/entities.js'; export { server as startServer } from '@/boot/common.js'; From 937e9be34ef309015b94900a3643dbedaff19945 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 15 Apr 2023 21:30:41 +0200 Subject: [PATCH 25/82] fix import order --- .../src/server/oauth/OAuth2ProviderService.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 76b88e35cc..30062ef20b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,35 +1,31 @@ import dns from 'node:dns/promises'; +import { fileURLToPath } from 'node:url'; +import crypto from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2 } from 'oauth2orize'; import * as oauth2Query from 'oauth2orize/lib/response/query.js'; -import { bindThis } from '@/decorators.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { kinds } from '@/misc/api-permissions.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import type { FastifyInstance } from 'fastify'; -import fastifyCookie from '@fastify/cookie'; -import type Redis from 'ioredis'; import oauth2Pkce from 'oauth2orize-pkce'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; import expressSession from 'express-session'; -import http from 'node:http'; import fastifyView from '@fastify/view'; import pug from 'pug'; -import { fileURLToPath } from 'node:url'; -import { MetaService } from '@/core/MetaService.js'; -import fastifyFormbody from '@fastify/formbody'; import bodyParser from 'body-parser'; import fastifyExpress from '@fastify/express'; -import crypto from 'node:crypto'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { MetaService } from '@/core/MetaService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { kinds } from '@/misc/api-permissions.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; +import type * as Redis from 'ioredis'; +import type { FastifyInstance } from 'fastify'; // https://indieauth.spec.indieweb.org/#client-identifier function validateClientId(raw: string): URL { @@ -337,7 +333,7 @@ export class OAuth2ProviderService { this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ - modes: { query } + modes: { query }, }, (client, redirectUri, token, ares, areq, done) => { (async (): Promise>> => { console.log('HIT grant code:', client, redirectUri, token, ares, areq); From 8e7fc1ed981ea265e018138e2ad0227799396529 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 15 Apr 2023 23:15:37 +0200 Subject: [PATCH 26/82] use errorHandler() --- .../src/server/oauth/OAuth2ProviderService.ts | 57 +++++++++++-------- packages/backend/test/e2e/oauth.ts | 56 +++++++++++------- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 30062ef20b..c6ccf42467 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; -import oauth2orize, { type OAuth2 } from 'oauth2orize'; +import oauth2orize, { type OAuth2, AuthorizationError } from 'oauth2orize'; import * as oauth2Query from 'oauth2orize/lib/response/query.js'; import oauth2Pkce from 'oauth2orize-pkce'; import expressSession from 'express-session'; @@ -33,13 +33,13 @@ function validateClientId(raw: string): URL { const url = ((): URL => { try { return new URL(raw); - } catch { throw new Error('client_id must be a valid URL'); } + } catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); } })(); // Client identifier URLs MUST have either an https or http scheme // XXX: but why allow http in 2023? if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error('client_id must be either https or http URL'); + throw new AuthorizationError('client_id must be either https or http URL', 'invalid_request'); } // MUST contain a path component (new URL() implicitly adds one) @@ -48,17 +48,17 @@ function validateClientId(raw: string): URL { // url. const segments = url.pathname.split('/'); if (segments.includes('.') || segments.includes('..')) { - throw new Error('client_id must not contain dot path segments'); + throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); } // MUST NOT contain a fragment component if (url.hash) { - throw new Error('client_id must not contain a fragment component'); + throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); } // MUST NOT contain a username or password component if (url.username || url.password) { - throw new Error('client_id must not contain a username or a password'); + throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); } // (MAY contain a port) @@ -66,7 +66,7 @@ function validateClientId(raw: string): URL { // host names MUST be domain names or a loopback interface and MUST NOT be // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]. if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { - throw new Error('client_id must have a domain name as a host name'); + throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request'); } return url; @@ -100,7 +100,7 @@ async function discoverClientInformation(httpRequestService: HttpRequestService, name, }; } catch { - throw new Error('Failed to fetch client information'); + throw new AuthorizationError('Failed to fetch client information', 'server_error'); } } @@ -342,7 +342,7 @@ export class OAuth2ProviderService { const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (!user) { - throw new Error('No such user'); + throw new AuthorizationError('No such user', 'invalid_request'); } TEMP_GRANT_CODES[code] = { @@ -360,6 +360,7 @@ export class OAuth2ProviderService { const granted = TEMP_GRANT_CODES[code]; console.log(granted, body, code, redirectUri); if (!granted) { + // TODO: throw TokenError? return [false]; } delete TEMP_GRANT_CODES[code]; @@ -435,21 +436,26 @@ export class OAuth2ProviderService { const oauth2 = (request.raw as any).oauth2 as OAuth2; console.log(oauth2, request.raw.session); - if (request.query.response_type !== 'code') { - throw new Error('`response_type` parameter must be set as "code"'); - } - if (typeof request.query.code_challenge !== 'string') { - throw new Error('`code_challenge` parameter is required'); - } - if (request.query.code_challenge_method !== 'S256') { - throw new Error('`code_challenge_method` parameter must be set as S256'); - } - const scopes = [...new Set(oauth2.req.scope)].filter(s => kinds.includes(s)); - if (!scopes.length) { - throw new Error('`scope` parameter has no known scope'); + try { + if (!scopes.length) { + throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); + } + oauth2.req.scope = scopes; + + if (request.query.response_type !== 'code') { + throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); + } + if (typeof request.query.code_challenge !== 'string') { + throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); + } + if (request.query.code_challenge_method !== 'S256') { + throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); + } + } catch (err: any) { + this.#server.errorHandler()(err, request.raw, reply.raw, null as any); + return; } - oauth2.req.scope = scopes; reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { @@ -483,19 +489,20 @@ export class OAuth2ProviderService { if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { const lookup = await dns.lookup(clientUrl.hostname); if (ipaddr.parse(lookup.address).range() === 'loopback') { - throw new Error('client_id unexpectedly resolves to loopback IP.'); + throw new AuthorizationError('client_id unexpectedly resolves to loopback IP.', 'invalid_request'); } } // Find client information from the remote. const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); if (!clientInfo.redirectUris.includes(redirectUri)) { - throw new Error('Invalid redirect_uri'); + throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); } return [clientInfo, redirectUri]; })().then(args => done(null, ...args), err => done(err)); })); + fastify.use('/oauth/authorize', this.#server.errorHandler()); // TODO: use mode: indirect? // for (const middleware of this.#server.decision()) { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); @@ -504,11 +511,13 @@ export class OAuth2ProviderService { req.user = (req as any).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('/oauth', this.#provider.callback()); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 5670a5be43..906bb02fff 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -14,6 +14,11 @@ const host = `http://127.0.0.1:${port}`; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; +interface OAuthError { + error: string; + code: string; +} + function getClient(): AuthorizationCode<'client_id'> { return new AuthorizationCode({ client: { @@ -233,7 +238,8 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', })); - assert.ok(!response.ok); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); // Pattern 2: Only code_challenge response = await fetch(client.authorizeURL({ @@ -242,7 +248,8 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', })); - assert.ok(!response.ok); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); // Pattern 2: Only code_challenge_method response = await fetch(client.authorizeURL({ @@ -251,7 +258,8 @@ describe('OAuth', () => { state: 'state', code_challenge_method: 'S256', })); - assert.ok(!response.ok); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); // Pattern 3: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ @@ -261,7 +269,8 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'SSSS', })); - assert.ok(!response.ok); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); test('Verify PKCE', async () => { @@ -347,8 +356,8 @@ describe('OAuth', () => { code_challenge_method: 'S256', })); - // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec - assert.strictEqual(response.status, 500); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_scope'); }); test('Empty scope', async () => { @@ -362,8 +371,8 @@ describe('OAuth', () => { code_challenge_method: 'S256', })); - // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec - assert.strictEqual(response.status, 500); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_scope'); }); test('Unknown scopes', async () => { @@ -377,8 +386,8 @@ describe('OAuth', () => { code_challenge_method: 'S256', })); - // TODO: But 500 is not a valid code, should be 403 or such. Check the OAuth spec - assert.strictEqual(response.status, 500); + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_scope'); }); test('Partially known scopes', async () => { @@ -566,8 +575,9 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', })); - // TODO: status code - assert.strictEqual(response.status, 500); + + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { @@ -580,8 +590,9 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', })); - // TODO: status code - assert.strictEqual(response.status, 500); + + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); test('No redirect_uri at authorization endpoint', async () => { @@ -593,8 +604,9 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', })); - // TODO: status code - assert.strictEqual(response.status, 500); + + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); test('Invalid redirect_uri at token endpoint', async () => { @@ -812,8 +824,9 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', })); - // TODO: status code - assert.strictEqual(response.status, 500); + + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); }); @@ -828,8 +841,9 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', })); - // TODO: status code - assert.strictEqual(response.status, 500); + + assert.strictEqual(response.status, 400); + assert.strictEqual((await response.json() as any).error, 'invalid_request'); }); test('Missing name', async () => { @@ -856,5 +870,5 @@ describe('OAuth', () => { }); }); - // TODO: Error format required by OAuth spec + // TODO: Invalid decision endpoint parameters }); From 94ea15d2d7741fd0146deef01998f7b5016c3a7c Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 16 Apr 2023 15:43:32 +0200 Subject: [PATCH 27/82] merge authorization validation logic --- .../src/server/oauth/OAuth2ProviderService.ts | 77 +++++++++---------- packages/backend/test/e2e/oauth.ts | 2 + 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index c6ccf42467..739c910b0f 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -261,15 +261,14 @@ type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; -interface OAuthRequestQuery { - response_type: string; - client_id: string; - redirect_uri: string; +interface OAuthRequest { + type: string; + clientID: string; + redirectURI: string; state: string; - code_challenge: string; - code_challenge_method: string; - scope?: string; - me?: string; + codeChallenge: string; + codeChallengeMethod: string; + scope: string[]; } @Injectable() @@ -431,37 +430,15 @@ export class OAuth2ProviderService { // For now only allow the basic OAuth endpoints, to start small and evaluate // this feature for some time, given that this is security related. - fastify.get<{ Querystring: OAuthRequestQuery }>('/oauth/authorize', async (request, reply) => { - console.log('HIT /oauth/authorize', request.query); + fastify.get('/oauth/authorize', async (request, reply) => { const oauth2 = (request.raw as any).oauth2 as OAuth2; - console.log(oauth2, request.raw.session); - - const scopes = [...new Set(oauth2.req.scope)].filter(s => kinds.includes(s)); - try { - if (!scopes.length) { - throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); - } - oauth2.req.scope = scopes; - - if (request.query.response_type !== 'code') { - throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); - } - if (typeof request.query.code_challenge !== 'string') { - throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); - } - if (request.query.code_challenge_method !== 'S256') { - throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); - } - } catch (err: any) { - this.#server.errorHandler()(err, request.raw, reply.raw, null as any); - return; - } + console.log('HIT /oauth/authorize', request.query, oauth2, request.raw.session); reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { transactionId: oauth2.transactionID, clientName: oauth2.client.name, - scope: scopes.join(' '), + scope: (oauth2.req.scope as any as string[]).join(' '), }); }); fastify.post('/oauth/decision', async () => { }); @@ -479,12 +456,31 @@ export class OAuth2ProviderService { }); await fastify.register(fastifyExpress); + // TODO: use redis session store to prevent memory leak fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any); - fastify.use('/oauth/authorize', this.#server.authorization((clientId, redirectUri, done) => { + fastify.use('/oauth/authorize', this.#server.authorization((areq: OAuthRequest, done: (err: Error | null, client?: any, redirectURI?: string) => void) => { (async (): Promise>> => { - console.log('HIT /oauth/authorize validation middleware'); + console.log('HIT /oauth/authorize validation middleware', areq); - const clientUrl = validateClientId(clientId); + const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq; + + const scopes = [...new Set(scope)].filter(s => kinds.includes(s)); + if (!scopes.length) { + throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); + } + areq.scope = scopes; + + if (type !== 'code') { + throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); + } + if (typeof codeChallenge !== 'string') { + throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); + } + if (codeChallengeMethod !== 'S256') { + throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); + } + + const clientUrl = validateClientId(clientID); if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { const lookup = await dns.lookup(clientUrl.hostname); @@ -495,14 +491,17 @@ export class OAuth2ProviderService { // Find client information from the remote. const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); - if (!clientInfo.redirectUris.includes(redirectUri)) { + if (!clientInfo.redirectUris.includes(redirectURI)) { throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); } - return [clientInfo, redirectUri]; + return [clientInfo, redirectURI]; })().then(args => done(null, ...args), err => done(err)); })); - fastify.use('/oauth/authorize', this.#server.errorHandler()); // TODO: use mode: indirect? + // TODO: use mode: indirect + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 + // But make sure not to redirect to an invalid redirect_uri + fastify.use('/oauth/authorize', this.#server.errorHandler()); // for (const middleware of this.#server.decision()) { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 906bb02fff..fa52d80302 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -871,4 +871,6 @@ describe('OAuth', () => { }); // TODO: Invalid decision endpoint parameters + + // TODO: Unknown OAuth endpoint }); From 92f3ae2d9c7ab76e388a544be0aa457b8dd4167f Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 16 Apr 2023 15:51:52 +0200 Subject: [PATCH 28/82] reduce any using OAuthErrorResponse --- packages/backend/test/e2e/oauth.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index fa52d80302..a4c5fcbd7d 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -14,9 +14,9 @@ const host = `http://127.0.0.1:${port}`; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; -interface OAuthError { +interface OAuthErrorResponse { error: string; - code: string; + error_description: string; } function getClient(): AuthorizationCode<'client_id'> { @@ -239,7 +239,7 @@ describe('OAuth', () => { state: 'state', })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); // Pattern 2: Only code_challenge response = await fetch(client.authorizeURL({ @@ -249,7 +249,7 @@ describe('OAuth', () => { code_challenge: 'code', })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); // Pattern 2: Only code_challenge_method response = await fetch(client.authorizeURL({ @@ -259,7 +259,7 @@ describe('OAuth', () => { code_challenge_method: 'S256', })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); // Pattern 3: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ @@ -270,7 +270,7 @@ describe('OAuth', () => { code_challenge_method: 'SSSS', })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); test('Verify PKCE', async () => { @@ -357,7 +357,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_scope'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); }); test('Empty scope', async () => { @@ -372,7 +372,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_scope'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); }); test('Unknown scopes', async () => { @@ -387,7 +387,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_scope'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); }); test('Partially known scopes', async () => { @@ -577,7 +577,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { @@ -592,7 +592,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); test('No redirect_uri at authorization endpoint', async () => { @@ -606,7 +606,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); test('Invalid redirect_uri at token endpoint', async () => { @@ -826,7 +826,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); }); @@ -843,7 +843,7 @@ describe('OAuth', () => { })); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as any).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); test('Missing name', async () => { From 77ad8c0ac672202c7a80cb184f9fee05e2f7f021 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 16 Apr 2023 16:03:14 +0200 Subject: [PATCH 29/82] reduce type errors with pkce params --- packages/backend/test/e2e/oauth.ts | 100 ++++++++++++++++------------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index a4c5fcbd7d..b24716e772 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -1,7 +1,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { AuthorizationCode } from 'simple-oauth2'; +import { AuthorizationCode, type AuthorizationTokenConfig } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; import * as misskey from 'misskey-js'; @@ -19,6 +19,18 @@ interface OAuthErrorResponse { error_description: string; } +interface AuthorizationParamsExtended { + redirect_uri: string; + scope: string | string[]; + state: string; + code_challenge?: string; + code_challenge_method?: string; +} + +interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig { + code_verifier: string; +} + function getClient(): AuthorizationCode<'client_id'> { return new AuthorizationCode({ client: { @@ -111,7 +123,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const cookie = response.headers.get('set-cookie'); assert.ok(cookie?.startsWith('connect.sid=')); @@ -134,7 +146,7 @@ describe('OAuth', () => { code: location.searchParams.get('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'); @@ -165,7 +177,7 @@ describe('OAuth', () => { state: 'state', code_challenge: pkceAlice.code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(responseAlice.status, 200); const responseBob = await fetch(client.authorizeURL({ @@ -174,7 +186,7 @@ describe('OAuth', () => { state: 'state', code_challenge: pkceBob.code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(responseBob.status, 200); const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice); @@ -193,13 +205,13 @@ describe('OAuth', () => { code: locationAlice.searchParams.get('code')!, redirect_uri, code_verifier: pkceAlice.code_verifier, - }); + } as AuthorizationTokenConfigExtended); const tokenBob = await client.getToken({ code: locationBob.searchParams.get('code')!, redirect_uri, code_verifier: pkceBob.code_verifier, - }); + } as AuthorizationTokenConfigExtended); const createResponseAlice = await relativeFetch('api/notes/create', { method: 'POST', @@ -247,7 +259,7 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', code_challenge: 'code', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -257,7 +269,7 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -268,7 +280,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'SSSS', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); @@ -284,7 +296,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -298,7 +310,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier: code_verifier + 'x', - })); + } as AuthorizationTokenConfigExtended)); // TODO: The following patterns may fail only because of pattern 1's failure. Let's split them. @@ -307,21 +319,21 @@ describe('OAuth', () => { code, redirect_uri, code_verifier: code_verifier.slice(0, 80), - })); + } as AuthorizationTokenConfigExtended)); // Pattern 3: Some part of code is replaced await assert.rejects(client.getToken({ code, redirect_uri, code_verifier: code_verifier.slice(0, -10) + 'x'.repeat(10), - })); + } as AuthorizationTokenConfigExtended)); // And now the code is invalidated by the previous failures await assert.rejects(client.getToken({ code, redirect_uri, code_verifier, - })); + } as AuthorizationTokenConfigExtended)); }); }); @@ -334,7 +346,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); @@ -354,7 +366,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); @@ -369,7 +381,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); @@ -384,7 +396,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); @@ -401,7 +413,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); // Just get the known scope for this case for backward compatibility assert.strictEqual(response.status, 200); @@ -419,7 +431,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier, - }); + } as AuthorizationTokenConfigExtended); // OAuth2 requires returning `scope` in the token response if the resulting scope is different than the requested one // (Although Misskey always return scope, which is also fine) @@ -435,7 +447,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); }); @@ -451,7 +463,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); @@ -468,7 +480,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier, - }); + } as AuthorizationTokenConfigExtended); assert.strictEqual(token.token.scope, 'write:notes read:account'); }); @@ -483,7 +495,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -496,7 +508,7 @@ describe('OAuth', () => { code: location.searchParams.get('code')!, redirect_uri, code_verifier, - }); + } as AuthorizationTokenConfigExtended); assert.strictEqual(typeof token.token.access_token, 'string'); const createResponse = await relativeFetch('api/notes/create', { @@ -523,7 +535,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -536,7 +548,7 @@ describe('OAuth', () => { code: location.searchParams.get('code')!, redirect_uri, code_verifier, - }); + } as AuthorizationTokenConfigExtended); // Pattern 1: No preceding "Bearer " let createResponse = await relativeFetch('api/notes/create', { @@ -574,7 +586,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -589,7 +601,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -603,7 +615,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -620,7 +632,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -633,7 +645,7 @@ describe('OAuth', () => { code: location.searchParams.get('code')!, redirect_uri: 'http://127.0.0.2/', code_verifier, - })); + } as AuthorizationTokenConfigExtended)); }); test('Invalid redirect_uri including the valid one at token endpoint', async () => { @@ -647,7 +659,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -660,7 +672,7 @@ describe('OAuth', () => { code: location.searchParams.get('code')!, redirect_uri: 'http://127.0.0.1/redirection', code_verifier, - })); + } as AuthorizationTokenConfigExtended)); }); test('No redirect_uri at token endpoint', async () => { @@ -674,7 +686,7 @@ describe('OAuth', () => { state: 'state', code_challenge, code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); const decisionResponse = await fetchDecisionFromResponse(response, alice); @@ -686,7 +698,7 @@ describe('OAuth', () => { await assert.rejects(client.getToken({ code: location.searchParams.get('code')!, code_verifier, - })); + } as AuthorizationTokenConfigExtended)); }); }); @@ -722,7 +734,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); }); @@ -748,7 +760,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); }); @@ -773,7 +785,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); }); @@ -799,7 +811,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); }); @@ -823,7 +835,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -840,7 +852,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - })); + } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); @@ -864,7 +876,7 @@ describe('OAuth', () => { 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}/`); }); From b6f6819b7674eda968c0e53233b2a31e52eaebbc Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 17 Apr 2023 09:26:45 +0200 Subject: [PATCH 30/82] todo --- packages/backend/test/e2e/oauth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index b24716e772..c0efb73135 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -328,6 +328,8 @@ describe('OAuth', () => { code_verifier: code_verifier.slice(0, -10) + 'x'.repeat(10), } as AuthorizationTokenConfigExtended)); + // TODO: pattern 4: no code_verifier + // And now the code is invalidated by the previous failures await assert.rejects(client.getToken({ code, @@ -885,4 +887,6 @@ describe('OAuth', () => { // TODO: Invalid decision endpoint parameters // TODO: Unknown OAuth endpoint + + // TODO: successful token exchange should invalidate the grant token (spec?) }); From 2b23120664c16e6eac855c90b91ea5db34a69d72 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Thu, 11 May 2023 23:09:24 +0200 Subject: [PATCH 31/82] upgrade to pkce-challenge@4 --- packages/backend/package.json | 2 +- packages/backend/test/e2e/oauth.ts | 24 +++++++++++++----------- pnpm-lock.yaml | 17 ++++++----------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index bf340925a6..45704ecedc 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -128,7 +128,7 @@ "otpauth": "9.1.2", "parse5": "7.1.2", "pg": "8.11.0", - "pkce-challenge": "^3.1.0", + "pkce-challenge": "^4.0.1", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index c0efb73135..32060f3422 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -35,6 +35,7 @@ function getClient(): AuthorizationCode<'client_id'> { return new AuthorizationCode({ client: { id: `http://127.0.0.1:${clientPort}/`, + secret: '', }, auth: { tokenHost: host, @@ -113,7 +114,7 @@ describe('OAuth', () => { }); test('Full flow', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -168,8 +169,8 @@ describe('OAuth', () => { test('Two concurrent flows', async () => { const client = getClient(); - const pkceAlice = pkceChallenge.default(128); - const pkceBob = pkceChallenge.default(128); + const pkceAlice = await pkceChallenge(128); + const pkceBob = await pkceChallenge(128); const responseAlice = await fetch(client.authorizeURL({ redirect_uri, @@ -285,8 +286,9 @@ describe('OAuth', () => { assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); + // TODO: Use precomputed challenge/verifier set for this one for deterministic test test('Verify PKCE', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -405,7 +407,7 @@ describe('OAuth', () => { }); test('Partially known scopes', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -455,7 +457,7 @@ describe('OAuth', () => { }); test('Duplicated scopes', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -487,7 +489,7 @@ describe('OAuth', () => { }); test('Scope check by API', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -527,7 +529,7 @@ describe('OAuth', () => { }); test('Authorization header', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -624,7 +626,7 @@ describe('OAuth', () => { }); test('Invalid redirect_uri at token endpoint', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -651,7 +653,7 @@ describe('OAuth', () => { }); test('Invalid redirect_uri including the valid one at token endpoint', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); @@ -678,7 +680,7 @@ describe('OAuth', () => { }); test('No redirect_uri at token endpoint', async () => { - const { code_challenge, code_verifier } = pkceChallenge.default(128); + const { code_challenge, code_verifier } = await pkceChallenge(128); const client = getClient(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5be7fa4d7..2928b3c4ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,8 +300,8 @@ importers: specifier: 8.11.0 version: 8.11.0 pkce-challenge: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^4.0.1 + version: 4.0.1 probe-image-size: specifier: 7.2.3 version: 7.2.3 @@ -7722,7 +7722,7 @@ packages: /@types/http-link-header@1.0.3: resolution: {integrity: sha512-y8HkoD/vyid+5MrJ3aas0FvU3/BVBGcyG9kgxL0Zn4JwstA8CglFPnrR0RuzOjRCXwqzL5uxWC2IO7Ub0rMU2A==} dependencies: - '@types/node': 20.2.5 + '@types/node': 20.3.1 dev: true /@types/istanbul-lib-coverage@2.0.4: @@ -10660,10 +10660,6 @@ packages: shebang-command: 2.0.0 which: 2.0.2 - /crypto-js@4.1.1: - resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==} - dev: false - /crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -17156,10 +17152,9 @@ packages: engines: {node: '>= 6'} dev: true - /pkce-challenge@3.1.0: - resolution: {integrity: sha512-bQ/0XPZZ7eX+cdAkd61uYWpfMhakH3NeteUF1R8GNa+LMqX8QFAkbCLqq+AYAns1/ueACBu/BMWhrlKGrdvGZg==} - dependencies: - crypto-js: 4.1.1 + /pkce-challenge@4.0.1: + resolution: {integrity: sha512-WGmtS1stcStsvRwNXix3iR1ujFcDaJR+sEODRa2ZFruT0lM4lhPAFTL5SUpqD5vTJdRlgtuMQhcp1kIEJx4LUw==} + engines: {node: '>=16.20.0'} dev: false /pkg-dir@3.0.0: From 9c29880f8b5a6db19494f1ce99717158c04ba72b Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 27 May 2023 13:21:05 +0200 Subject: [PATCH 32/82] Update to @types/oauth2orize@1.11, fix type errors --- packages/backend/package.json | 2 +- .../backend/src/@types/oauth2orize-pkce.d.ts | 5 ++ .../src/server/oauth/OAuth2ProviderService.ts | 52 +++++++++---------- pnpm-lock.yaml | 8 +-- 4 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 packages/backend/src/@types/oauth2orize-pkce.d.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 45704ecedc..50e571bfd9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -193,7 +193,7 @@ "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.8", "@types/oauth": "0.9.1", - "@types/oauth2orize": "^1.8.11", + "@types/oauth2orize": "^1.11.0", "@types/pg": "8.10.2", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", diff --git a/packages/backend/src/@types/oauth2orize-pkce.d.ts b/packages/backend/src/@types/oauth2orize-pkce.d.ts new file mode 100644 index 0000000000..aa45ad2c04 --- /dev/null +++ b/packages/backend/src/@types/oauth2orize-pkce.d.ts @@ -0,0 +1,5 @@ +declare module 'oauth2orize-pkce' { + export default { + extensions(): any; + }; +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 739c910b0f..79422170f1 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,8 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; -import oauth2orize, { type OAuth2, AuthorizationError } from 'oauth2orize'; -import * as oauth2Query from 'oauth2orize/lib/response/query.js'; +import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req } from 'oauth2orize'; import oauth2Pkce from 'oauth2orize-pkce'; import expressSession from 'express-session'; import fastifyView from '@fastify/view'; @@ -45,12 +44,13 @@ function validateClientId(raw: string): URL { // MUST contain a path component (new URL() implicitly adds one) // MUST NOT contain single-dot or double-dot path segments, - // url. const segments = url.pathname.split('/'); if (segments.includes('.') || segments.includes('..')) { throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); } + // (MAY contain a query string component) + // MUST NOT contain a fragment component if (url.hash) { throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); @@ -261,14 +261,9 @@ type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; -interface OAuthRequest { - type: string; - clientID: string; - redirectURI: string; - state: string; +interface OAuthRequest extends OAuth2Req { codeChallenge: string; codeChallengeMethod: string; - scope: string[]; } @Injectable() @@ -323,17 +318,22 @@ export class OAuth2ProviderService { scopes: string[], }> = {}; - const query = (txn, res, params) => { - // RFC 9207 - // TODO: Oh no, perhaps returning to oidc-provider is better. Hacks everywhere here. - params.iss = config.url; - oauth2Query.default(txn, res, params); - }; - this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ - modes: { query }, - }, (client, redirectUri, token, ares, areq, done) => { + modes: { + query: (txn, res, params) => { + // RFC 9207 + params.iss = config.url; + + const parsed = new URL(txn.redirectURI); + for (const [key, value] of Object.entries(params)) { + parsed.searchParams.append(key, value as string); + } + + return (res as any).redirect(parsed.toString()); + }, + }, + }, (client, redirectUri, token, ares, areq, locals, done) => { (async (): Promise>> => { console.log('HIT grant code:', client, redirectUri, token, ares, areq); const code = secureRndstr(32, true); @@ -348,13 +348,13 @@ export class OAuth2ProviderService { clientId: client.id, userId: user.id, redirectUri, - codeChallenge: areq.codeChallenge, + codeChallenge: (areq as OAuthRequest).codeChallenge, scopes: areq.scope, }; return [code]; })().then(args => done(null, ...args), err => done(err)); })); - this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, done) => { + this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { (async (): Promise>> => { const granted = TEMP_GRANT_CODES[code]; console.log(granted, body, code, redirectUri); @@ -365,7 +365,7 @@ export class OAuth2ProviderService { delete TEMP_GRANT_CODES[code]; if (body.client_id !== granted.clientId) return [false]; if (redirectUri !== granted.redirectUri) return [false]; - if (!body.code_verifier || pkceS256(body.code_verifier) !== granted.codeChallenge) return [false]; + if (!body.code_verifier || pkceS256(body.code_verifier as string) !== granted.codeChallenge) return [false]; const accessToken = secureRndstr(128, true); @@ -383,7 +383,7 @@ export class OAuth2ProviderService { permission: granted.scopes, }); - return [accessToken, { scope: granted.scopes.join(' ') }]; + return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; })().then(args => done(null, ...args), err => done(err)); })); this.#server.serializeClient((client, done) => done(null, client)); @@ -432,7 +432,7 @@ export class OAuth2ProviderService { // this feature for some time, given that this is security related. fastify.get('/oauth/authorize', async (request, reply) => { const oauth2 = (request.raw as any).oauth2 as OAuth2; - console.log('HIT /oauth/authorize', request.query, oauth2, request.raw.session); + console.log('HIT /oauth/authorize', request.query, oauth2, (request.raw as any).session); reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { @@ -458,11 +458,11 @@ export class OAuth2ProviderService { await fastify.register(fastifyExpress); // TODO: use redis session store to prevent memory leak fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any); - fastify.use('/oauth/authorize', this.#server.authorization((areq: OAuthRequest, done: (err: Error | null, client?: any, redirectURI?: string) => void) => { + fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { (async (): Promise>> => { console.log('HIT /oauth/authorize validation middleware', areq); - const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq; + const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest; const scopes = [...new Set(scope)].filter(s => kinds.includes(s)); if (!scopes.length) { @@ -497,7 +497,7 @@ export class OAuth2ProviderService { return [clientInfo, redirectURI]; })().then(args => done(null, ...args), err => done(err)); - })); + }) as ValidateFunctionArity2)); // TODO: use mode: indirect // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 // But make sure not to redirect to an invalid redirect_uri diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2928b3c4ea..92271d5d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,8 +572,8 @@ importers: specifier: 0.9.1 version: 0.9.1 '@types/oauth2orize': - specifier: ^1.8.11 - version: 1.8.11 + specifier: ^1.11.0 + version: 1.11.0 '@types/pg': specifier: 8.10.2 version: 8.10.2 @@ -7868,8 +7868,8 @@ packages: resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} dev: true - /@types/oauth2orize@1.8.11: - resolution: {integrity: sha512-eir5IGegpcnPuhnx1Asdxj3kDWWP/Qr1qkERMlDASwlEJM6pppVBxkW7ZvAX2H8eBHE+FP7lhg/iNlRrtNGewQ==} + /@types/oauth2orize@1.11.0: + resolution: {integrity: sha512-jmnP/Ip36XBzs+nIn/I8wNBZkQcn/agmp8K9V81he+wOllLYMec8T8AqbRPJCFbnFwaL03bbR8gI3CknMCXohw==} dependencies: '@types/express': 4.17.17 '@types/node': 20.3.1 From c0f63234d7fa2599dd21575cbb9bd34ab7be6b42 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 27 May 2023 15:19:55 +0200 Subject: [PATCH 33/82] use verifyChallenge --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 79422170f1..d25f21ff5b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -12,6 +12,7 @@ import fastifyView from '@fastify/view'; import pug from 'pug'; import bodyParser from 'body-parser'; import fastifyExpress from '@fastify/express'; +import { verifyChallenge } from 'pkce-challenge'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -251,12 +252,6 @@ async function discoverClientInformation(httpRequestService: HttpRequestService, // }; // } -function pkceS256(codeVerifier: string): string { - return crypto.createHash('sha256') - .update(codeVerifier, 'ascii') - .digest('base64url'); -} - type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; @@ -365,7 +360,8 @@ export class OAuth2ProviderService { delete TEMP_GRANT_CODES[code]; if (body.client_id !== granted.clientId) return [false]; if (redirectUri !== granted.redirectUri) return [false]; - if (!body.code_verifier || pkceS256(body.code_verifier as string) !== granted.codeChallenge) return [false]; + if (!body.code_verifier) return [false]; + if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return [false]; const accessToken = secureRndstr(128, true); From 150a6f80d00bb99a3fdb2f104e8344486260877b Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 27 May 2023 20:52:48 +0200 Subject: [PATCH 34/82] Use MemoryKVCache --- .../src/server/oauth/OAuth2ProviderService.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d25f21ff5b..18189be830 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,6 +1,5 @@ import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; -import crypto from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; @@ -24,6 +23,7 @@ import type { AccessTokensRepository, UsersRepository } from '@/models/index.js' import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type * as Redis from 'ioredis'; import type { FastifyInstance } from 'fastify'; @@ -304,14 +304,14 @@ export class OAuth2ProviderService { // }, // }); - // TODO: store this in Redis - const TEMP_GRANT_CODES: Record = {}; + }>(1000 * 60 * 5); // 5m this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ @@ -339,25 +339,24 @@ export class OAuth2ProviderService { throw new AuthorizationError('No such user', 'invalid_request'); } - TEMP_GRANT_CODES[code] = { + grantCodeCache.set(code, { clientId: client.id, userId: user.id, redirectUri, codeChallenge: (areq as OAuthRequest).codeChallenge, scopes: areq.scope, - }; + }); return [code]; })().then(args => done(null, ...args), err => done(err)); })); this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { (async (): Promise>> => { - const granted = TEMP_GRANT_CODES[code]; + const granted = grantCodeCache.get(code); console.log(granted, body, code, redirectUri); if (!granted) { - // TODO: throw TokenError? return [false]; } - delete TEMP_GRANT_CODES[code]; + grantCodeCache.delete(code); if (body.client_id !== granted.clientId) return [false]; if (redirectUri !== granted.redirectUri) return [false]; if (!body.code_verifier) return [false]; From 2c6379649aa32f5008bbd0279fa1f0e53b29529f Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 27 May 2023 20:54:16 +0200 Subject: [PATCH 35/82] Update OAuth2ProviderService.ts --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 18189be830..e65676c31f 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -304,7 +304,7 @@ export class OAuth2ProviderService { // }, // }); - // XXX: But MemoryKVCache just grows forever without being cleared... + // XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused const grantCodeCache = new MemoryKVCache<{ clientId: string, userId: string, From cbaae2201f163a36eae430055e0082a19049a0b1 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 00:16:51 +0200 Subject: [PATCH 36/82] use MemoryKVCache for oauth store --- .../src/server/oauth/OAuth2ProviderService.ts | 219 +++--------------- packages/backend/test/e2e/oauth.ts | 10 +- 2 files changed, 36 insertions(+), 193 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index e65676c31f..c500632532 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -6,14 +6,12 @@ import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req } from 'oauth2orize'; import oauth2Pkce from 'oauth2orize-pkce'; -import expressSession from 'express-session'; import fastifyView from '@fastify/view'; import pug from 'pug'; import bodyParser from 'body-parser'; import fastifyExpress from '@fastify/express'; import { verifyChallenge } from 'pkce-challenge'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { kinds } from '@/misc/api-permissions.js'; import type { Config } from '@/config.js'; @@ -24,7 +22,6 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; import { MemoryKVCache } from '@/misc/cache.js'; -import type * as Redis from 'ioredis'; import type { FastifyInstance } from 'fastify'; // https://indieauth.spec.indieweb.org/#client-identifier @@ -105,153 +102,6 @@ async function discoverClientInformation(httpRequestService: HttpRequestService, } } -// class MisskeyAdapter implements Adapter { -// name = 'oauth2'; - -// constructor(private redisClient: Redis.Redis, private httpRequestService: HttpRequestService) { } - -// key(id: string): string { -// return `oauth2:${id}`; -// } - -// async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { -// console.log('oauth upsert', id, payload, expiresIn); - -// const key = this.key(id); - -// const multi = this.redisClient.multi(); -// if (consumable.has(this.name)) { -// multi.hset(key, { payload: JSON.stringify(payload) }); -// } else { -// multi.set(key, JSON.stringify(payload)); -// } - -// if (expiresIn) { -// multi.expire(key, expiresIn); -// } - -// if (grantable.has(this.name) && payload.grantId) { -// const grantKey = grantKeyFor(payload.grantId); -// multi.rpush(grantKey, key); -// // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM -// // here to trim the list to an appropriate length -// const ttl = await this.redisClient.ttl(grantKey); -// if (expiresIn > ttl) { -// multi.expire(grantKey, expiresIn); -// } -// } - -// if (payload.userCode) { -// const userCodeKey = userCodeKeyFor(payload.userCode); -// multi.set(userCodeKey, id); -// multi.expire(userCodeKey, expiresIn); -// } - -// if (payload.uid) { -// const uidKey = uidKeyFor(payload.uid); -// multi.set(uidKey, id); -// multi.expire(uidKey, expiresIn); -// } - -// await multi.exec(); -// } - -// async find(id: string): Promise { -// console.log('oauth find', id); - -// // XXX: really? -// const fromRedis = await this.findRedis(id); -// if (fromRedis) { -// return fromRedis; -// } - -// // Find client information from the remote. -// const url = validateClientId(id); - -// if (process.env.NODE_ENV !== 'test') { -// const lookup = await dns.lookup(url.hostname); -// if (ipaddr.parse(lookup.address).range() === 'loopback') { -// throw new Error('client_id unexpectedly resolves to loopback IP.'); -// } -// } - -// const redirectUri = await fetchFromClientId(this.httpRequestService, id); -// if (!redirectUri) { -// // IndieAuth also implicitly allows any path under the same scheme+host, -// // but oidc-provider requires explicit list of uris. -// throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML element.'); -// } - -// return { -// client_id: id, -// token_endpoint_auth_method: 'none', -// redirect_uris: [redirectUri], -// }; -// } - -// async findRedis(id: string | null): Promise { -// if (!id) { -// return; -// } - -// const data = consumable.has(this.name) -// ? await this.redisClient.hgetall(this.key(id)) -// : await this.redisClient.get(this.key(id)); - -// if (!data || (typeof data === 'object' && !Object.entries(data).length)) { -// return undefined; -// } - -// if (typeof data === 'string') { -// return JSON.parse(data); -// } -// const { payload, ...rest } = data as any; -// return { -// ...rest, -// ...JSON.parse(payload), -// }; -// } - -// async findByUserCode(userCode: string): Promise { -// console.log('oauth findByUserCode', userCode); -// const id = await this.redisClient.get(userCodeKeyFor(userCode)); -// return this.findRedis(id); -// } - -// async findByUid(uid: string): Promise { -// console.log('oauth findByUid', uid); -// const id = await this.redisClient.get(uidKeyFor(uid)); -// return this.findRedis(id); -// } - -// async consume(id: string): Promise { -// console.log('oauth consume', id); -// await this.redisClient.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); -// } - -// async destroy(id: string): Promise { -// console.log('oauth destroy', id); -// const key = this.key(id); -// await this.redisClient.del(key); -// } - -// async revokeByGrantId(grantId: string): Promise { -// console.log('oauth revokeByGrandId', grantId); -// const multi = this.redisClient.multi(); -// const tokens = await this.redisClient.lrange(grantKeyFor(grantId), 0, -1); -// tokens.forEach((token) => multi.del(token)); -// multi.del(grantKeyFor(grantId)); -// await multi.exec(); -// } -// } - -// function promisify(callback: T) { -// return (...args: Parameters) => { - -// args[args.length - 1](); -// }; -// } - type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; @@ -261,18 +111,47 @@ interface OAuthRequest extends OAuth2Req { codeChallengeMethod: string; } +class OAuth2Store { + #cache = new MemoryKVCache(1000 * 60 * 5); // 5min + + load(req: any, cb: (err: Error | null, txn?: OAuth2) => void): void { + console.log(req); + const { transaction_id } = req.body; + if (!transaction_id) { + cb(new Error('Missing transaction ID')); + return; + } + const loaded = this.#cache.get(transaction_id); + if (!loaded) { + cb(new Error('Failed to load transaction')); + return; + } + cb(null, loaded); + } + + store(req: any, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { + const transactionId = secureRndstr(128, true); + this.#cache.set(transactionId, oauth2); + cb(null, transactionId); + } + + remove(req: any, tid: string, cb: () => void): void { + this.#cache.delete(tid); + cb(); + } +} + @Injectable() export class OAuth2ProviderService { // #provider: Provider; - #server = oauth2orize.createServer(); + #server = oauth2orize.createServer({ + store: new OAuth2Store(), + }); constructor( @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, private httpRequestService: HttpRequestService, - private metaService: MetaService, @Inject(DI.accessTokensRepository) accessTokensRepository: AccessTokensRepository, idService: IdService, @@ -280,30 +159,6 @@ export class OAuth2ProviderService { private usersRepository: UsersRepository, private cacheService: CacheService, ) { - // this.#provider = new Provider(config.url, { - // clientAuthMethods: ['none'], - // pkce: { - // // This is the default, but be explicit here as we announce it below - // methods: ['S256'], - // }, - // routes: { - // // defaults to '/auth' but '/authorize' is more consistent with many - // // other services eg. Mastodon/Twitter/Facebook/GitLab/GitHub/etc. - // authorization: '/authorize', - // }, - // scopes: kinds, - // async findAccount(ctx, id): Promise { - // console.log(id); - // return undefined; - // }, - // adapter(): MisskeyAdapter { - // return new MisskeyAdapter(redisClient, httpRequestService); - // }, - // async renderError(ctx, out, error): Promise { - // console.log(error); - // }, - // }); - // XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused const grantCodeCache = new MemoryKVCache<{ clientId: string, @@ -438,8 +293,6 @@ export class OAuth2ProviderService { }); fastify.post('/oauth/decision', async () => { }); fastify.post('/oauth/token', async () => { }); - // fastify.get('/oauth/interaction/:uid', async () => { }); - // fastify.get('/oauth/interaction/:uid/login', async () => { }); fastify.register(fastifyView, { root: fileURLToPath(new URL('../web/views', import.meta.url)), @@ -451,8 +304,6 @@ export class OAuth2ProviderService { }); await fastify.register(fastifyExpress); - // TODO: use redis session store to prevent memory leak - fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any); fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { (async (): Promise>> => { console.log('HIT /oauth/authorize validation middleware', areq); @@ -497,7 +348,6 @@ export class OAuth2ProviderService { // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 // But make sure not to redirect to an invalid redirect_uri fastify.use('/oauth/authorize', this.#server.errorHandler()); - // for (const middleware of this.#server.decision()) { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision((req, done) => { @@ -512,8 +362,5 @@ export class OAuth2ProviderService { fastify.use('/oauth/token', bodyParser.json({ strict: true })); fastify.use('/oauth/token', this.#server.token()); fastify.use('/oauth/token', this.#server.errorHandler()); - // } - - // fastify.use('/oauth', this.#provider.callback()); } } diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 32060f3422..c6a5da9d4f 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -56,7 +56,7 @@ function getMeta(html: string): { transactionId: string | undefined, clientName: }; } -function fetchDecision(cookie: string, transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { +function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { return fetch(new URL('/oauth/decision', host), { method: 'post', body: new URLSearchParams({ @@ -67,16 +67,14 @@ function fetchDecision(cookie: string, transactionId: string, user: misskey.enti redirect: 'manual', headers: { 'content-type': 'application/x-www-form-urlencoded', - cookie, }, }); } async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { - const cookie = response.headers.get('set-cookie'); const { transactionId } = getMeta(await response.text()); - return await fetchDecision(cookie!, transactionId!, user, { cancel }); + return await fetchDecision(transactionId!, user, { cancel }); } describe('OAuth', () => { @@ -126,14 +124,12 @@ describe('OAuth', () => { code_challenge_method: 'S256', } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); - const cookie = response.headers.get('set-cookie'); - assert.ok(cookie?.startsWith('connect.sid=')); const meta = getMeta(await response.text()); assert.strictEqual(typeof meta.transactionId, 'string'); assert.strictEqual(meta.clientName, 'Misklient'); - const decisionResponse = await fetchDecision(cookie!, meta.transactionId!, alice); + const decisionResponse = await fetchDecision(meta.transactionId!, alice); assert.strictEqual(decisionResponse.status, 302); assert.ok(decisionResponse.headers.has('location')); From cb5cfd429668cad098f9ae1f685e2c678c09b006 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 00:54:55 +0200 Subject: [PATCH 37/82] remove express-session --- packages/backend/package.json | 2 -- pnpm-lock.yaml | 42 ++--------------------------------- 2 files changed, 2 insertions(+), 42 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 50e571bfd9..c20a05beed 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -92,7 +92,6 @@ "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "express-session": "^1.17.3", "fastify": "4.18.0", "feed": "4.2.2", "file-type": "18.5.0", @@ -179,7 +178,6 @@ "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", - "@types/express-session": "^1.17.6", "@types/fluent-ffmpeg": "2.1.21", "@types/http-link-header": "^1.0.3", "@types/jest": "29.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92271d5d49..ced936d148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,9 +191,6 @@ importers: escape-regexp: specifier: 0.0.1 version: 0.0.1 - express-session: - specifier: ^1.17.3 - version: 1.17.3 fastify: specifier: 4.18.0 version: 4.18.0 @@ -529,9 +526,6 @@ importers: '@types/escape-regexp': specifier: 0.0.1 version: 0.0.1 - '@types/express-session': - specifier: ^1.17.6 - version: 1.17.6 '@types/fluent-ffmpeg': specifier: 2.1.21 version: 2.1.21 @@ -7636,12 +7630,6 @@ packages: '@types/range-parser': 1.2.4 dev: true - /@types/express-session@1.17.6: - resolution: {integrity: sha512-L6sB04HVA4HEZo1hDL65JXdZdBJtzZnCiw/P7MnO4w6746tJCNtXlHtzEASyI9ccn9zyOw6IbqQuhVa03VpO4w==} - dependencies: - '@types/express': 4.17.17 - dev: true - /@types/express@4.17.17: resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} dependencies: @@ -10553,6 +10541,7 @@ packages: /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} + dev: true /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} @@ -11959,22 +11948,6 @@ packages: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} dev: false - /express-session@1.17.3: - resolution: {integrity: sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==} - engines: {node: '>= 0.8.0'} - dependencies: - cookie: 0.4.2 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - on-headers: 1.0.2 - parseurl: 1.3.3 - safe-buffer: 5.2.1 - uid-safe: 2.1.5 - transitivePeerDependencies: - - supports-color - dev: false - /express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -16570,6 +16543,7 @@ packages: /on-headers@1.0.2: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} + dev: true /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -17922,11 +17896,6 @@ packages: resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} dev: true - /random-bytes@1.0.0: - resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} - engines: {node: '>= 0.8'} - dev: false - /random-seed@0.3.0: resolution: {integrity: sha512-y13xtn3kcTlLub3HKWXxJNeC2qK4mB59evwZ5EkeRlolx+Bp2ztF7LbcZmyCnOqlHQrLnfuNbi1sVmm9lPDlDA==} engines: {node: '>= 0.6.0'} @@ -20329,13 +20298,6 @@ packages: dev: true optional: true - /uid-safe@2.1.5: - resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} - engines: {node: '>= 0.8'} - dependencies: - random-bytes: 1.0.0 - dev: false - /uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} dev: false From 9022971fb9fd09566a85aebb77fc3634f4fa0849 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 12:30:13 +0200 Subject: [PATCH 38/82] precomputed pkce test --- packages/backend/test/e2e/oauth.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index c6a5da9d4f..ec64014cb2 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -282,9 +282,10 @@ describe('OAuth', () => { assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); - // TODO: Use precomputed challenge/verifier set for this one for deterministic test test('Verify PKCE', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); + // Use precomputed challenge/verifier set for this one for deterministic test + const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs'; + const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8'; const client = getClient(); From c25836bc1a330b969e7f3502606f60a58f0c50d4 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 13:30:18 +0200 Subject: [PATCH 39/82] Split PKCE verification test --- .../src/server/oauth/OAuth2ProviderService.ts | 1 - packages/backend/test/e2e/oauth.ts | 92 +++++++++---------- 2 files changed, 45 insertions(+), 48 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index c500632532..01d4d5ea1b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -143,7 +143,6 @@ class OAuth2Store { @Injectable() export class OAuth2ProviderService { - // #provider: Provider; #server = oauth2orize.createServer({ store: new OAuth2Store(), }); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index ec64014cb2..7549e3e53e 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -1,3 +1,8 @@ +/** + * 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'; @@ -28,7 +33,7 @@ interface AuthorizationParamsExtended { } interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig { - code_verifier: string; + code_verifier: string | undefined; } function getClient(): AuthorizationCode<'client_id'> { @@ -282,59 +287,52 @@ describe('OAuth', () => { assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); }); - test('Verify PKCE', async () => { - // Use precomputed challenge/verifier set for this one for deterministic test - const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs'; - const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8'; + // 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 client = getClient(); + const tests: Record = { + '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, + }; - 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); + describe('Verify PKCE', () => { + for (const [title, code_verifier] of Object.entries(tests)) { + test(title, async () => { + const client = getClient(); - const decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); + 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 code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; - assert.ok(!!code); + // TODO: this fetch-decision-code checks are everywhere, maybe get a helper for this. + const decisionResponse = await fetchDecisionFromResponse(response, alice); + assert.strictEqual(decisionResponse.status, 302); - // Pattern 1: code followed by some junk code - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier: code_verifier + 'x', - } as AuthorizationTokenConfigExtended)); + const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; + assert.ok(!!code); - // TODO: The following patterns may fail only because of pattern 1's failure. Let's split them. + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier, + } as AuthorizationTokenConfigExtended)); - // Pattern 2: clipped code - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier: code_verifier.slice(0, 80), - } as AuthorizationTokenConfigExtended)); - - // Pattern 3: Some part of code is replaced - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier: code_verifier.slice(0, -10) + 'x'.repeat(10), - } as AuthorizationTokenConfigExtended)); - - // TODO: pattern 4: no code_verifier - - // And now the code is invalidated by the previous failures - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended)); + // And now the code is invalidated by the previous failure + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier, + } as AuthorizationTokenConfigExtended)); + }); + } }); }); From 967989c5f86146c8971cca45cf314e06ab7a8441 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 14:13:55 +0200 Subject: [PATCH 40/82] dedupe test logic --- packages/backend/test/e2e/oauth.ts | 186 +++++++---------------------- 1 file changed, 45 insertions(+), 141 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 7549e3e53e..fbdf5cfb81 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -82,6 +82,31 @@ async function fetchDecisionFromResponse(response: Response, user: misskey.entit return await fetchDecision(transactionId!, user, { cancel }); } +async function fetchAuthorizationCode(user: ImmediateSignup, scope: string, code_challenge: string) { + const client = getClient(); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope, + state: 'state', + code_challenge, + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + + // TODO: this fetch-decision-code checks are everywhere, maybe get a helper for this. + const decisionResponse = await fetchDecisionFromResponse(response, user); + assert.strictEqual(decisionResponse.status, 302); + + const location = new URL(decisionResponse.headers.get('location')!); + assert.ok(location.searchParams.has('code')); + + const code = new URL(location).searchParams.get('code')!; + assert.ok(!!code); + + return { client, code }; +} + describe('OAuth', () => { let app: INestApplicationContext; let fastify: FastifyInstance; @@ -301,23 +326,7 @@ describe('OAuth', () => { describe('Verify PKCE', () => { for (const [title, code_verifier] of Object.entries(tests)) { test(title, async () => { - const client = getClient(); - - 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); - - // TODO: this fetch-decision-code checks are everywhere, maybe get a helper for this. - const decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; - assert.ok(!!code); + const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); await assert.rejects(client.getToken({ code, @@ -404,27 +413,12 @@ describe('OAuth', () => { test('Partially known scopes', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes test:unknown test:unknown2', - state: 'state', - code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - // Just get the known scope for this case for backward compatibility - assert.strictEqual(response.status, 200); - - const decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); - - const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; - assert.ok(!!code); + const { client, code } = await fetchAuthorizationCode( + alice, + 'write:notes test:unknown test:unknown2', + code_challenge, + ); const token = await client.getToken({ code, @@ -454,26 +448,11 @@ describe('OAuth', () => { test('Duplicated scopes', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes write:notes read:account read:account', - state: 'state', + const { client, code } = await fetchAuthorizationCode( + alice, + 'write:notes write:notes read:account read:account', code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 200); - - const decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); - - const code = new URL(decisionResponse.headers.get('location')!).searchParams.get('code')!; - assert.ok(!!code); + ); const token = await client.getToken({ code, @@ -486,25 +465,10 @@ describe('OAuth', () => { test('Scope check by API', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'read:account', - state: 'state', - code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - - const decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); + const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge); const token = await client.getToken({ - code: location.searchParams.get('code')!, + code, redirect_uri, code_verifier, } as AuthorizationTokenConfigExtended); @@ -526,25 +490,10 @@ describe('OAuth', () => { test('Authorization header', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - 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 decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); + const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); const token = await client.getToken({ - code: location.searchParams.get('code')!, + code, redirect_uri, code_verifier, } as AuthorizationTokenConfigExtended); @@ -623,25 +572,10 @@ describe('OAuth', () => { test('Invalid redirect_uri at token endpoint', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - 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 decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); + const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); await assert.rejects(client.getToken({ - code: location.searchParams.get('code')!, + code, redirect_uri: 'http://127.0.0.2/', code_verifier, } as AuthorizationTokenConfigExtended)); @@ -650,25 +584,10 @@ describe('OAuth', () => { test('Invalid redirect_uri including the valid one at token endpoint', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - 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 decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); + const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); await assert.rejects(client.getToken({ - code: location.searchParams.get('code')!, + code, redirect_uri: 'http://127.0.0.1/redirection', code_verifier, } as AuthorizationTokenConfigExtended)); @@ -677,25 +596,10 @@ describe('OAuth', () => { test('No redirect_uri at token endpoint', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); - - 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 decisionResponse = await fetchDecisionFromResponse(response, alice); - assert.strictEqual(decisionResponse.status, 302); - - const location = new URL(decisionResponse.headers.get('location')!); - assert.ok(location.searchParams.has('code')); + const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); await assert.rejects(client.getToken({ - code: location.searchParams.get('code')!, + code, code_verifier, } as AuthorizationTokenConfigExtended)); }); From 9a5fa00f9a419d1810e180428645b8e4a417c318 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 14:20:52 +0200 Subject: [PATCH 41/82] reduce typescript warnings on tests --- packages/backend/test/e2e/oauth.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index fbdf5cfb81..168560fba0 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -65,7 +65,7 @@ function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { return fetch(new URL('/oauth/decision', host), { method: 'post', body: new URLSearchParams({ - transaction_id: transactionId!, + transaction_id: transactionId, login_token: user.token, cancel: cancel ? 'cancel' : '', }), @@ -78,11 +78,12 @@ function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { const { transactionId } = getMeta(await response.text()); + assert.ok(transactionId); - return await fetchDecision(transactionId!, user, { cancel }); + return await fetchDecision(transactionId, user, { cancel }); } -async function fetchAuthorizationCode(user: ImmediateSignup, scope: string, code_challenge: string) { +async function fetchAuthorizationCode(user: ImmediateSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { const client = getClient(); const response = await fetch(client.authorizeURL({ @@ -98,11 +99,14 @@ async function fetchAuthorizationCode(user: ImmediateSignup, scope: string, code const decisionResponse = await fetchDecisionFromResponse(response, user); assert.strictEqual(decisionResponse.status, 302); - const location = new URL(decisionResponse.headers.get('location')!); + 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); + const code = new URL(location).searchParams.get('code'); + assert.ok(code); return { client, code }; } @@ -157,20 +161,27 @@ describe('OAuth', () => { 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); + const decisionResponse = await fetchDecision(meta.transactionId, alice); assert.strictEqual(decisionResponse.status, 302); assert.ok(decisionResponse.headers.has('location')); - const location = new URL(decisionResponse.headers.get('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'); assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); // RFC 9207 + const code = new URL(location).searchParams.get('code'); + assert.ok(code); + const token = await client.getToken({ - code: location.searchParams.get('code')!, + code, redirect_uri, code_verifier, } as AuthorizationTokenConfigExtended); From 78c6bb1cc2b95e41af37af5912b4f7178539188e Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 14:50:30 +0200 Subject: [PATCH 42/82] dedupe CID test logic --- packages/backend/test/e2e/oauth.ts | 142 ++++++++++------------------- 1 file changed, 47 insertions(+), 95 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 168560fba0..8d6c9b42db 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -10,7 +10,7 @@ import { AuthorizationCode, type AuthorizationTokenConfig } from 'simple-oauth2' import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; import * as misskey from 'misskey-js'; -import Fastify, { type FastifyInstance } from 'fastify'; +import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify'; import { port, relativeFetch, signup, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; @@ -83,7 +83,7 @@ async function fetchDecisionFromResponse(response: Response, user: misskey.entit return await fetchDecision(transactionId, user, { cancel }); } -async function fetchAuthorizationCode(user: ImmediateSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { +async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { const client = getClient(); const response = await fetch(client.authorizeURL({ @@ -627,107 +627,59 @@ describe('OAuth', () => { describe('Client Information Discovery', () => { describe('Redirection', () => { - test('Read HTTP header', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { + const tests: Record void> = { + 'Read HTTP header': reply => { reply.header('Link', '; rel="redirect_uri"'); reply.send(` - -
Misklient - `); - }); - await fastify.listen({ port: clientPort }); - - const client = getClient(); - - 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('Mixed links', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { + +
Misklient + `); + }, + 'Mixed links': reply => { reply.header('Link', '; rel="redirect_uri"'); reply.send(` - - -
Misklient - `); - }); - await fastify.listen({ port: clientPort }); - - const client = getClient(); - - 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('Multiple items in Link header', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { + + +
Misklient + `); + }, + 'Multiple items in Link header': reply => { reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); reply.send(` - -
Misklient - `); - }); - await fastify.listen({ port: clientPort }); - - const client = getClient(); - - 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('Multiple items in HTML', async () => { - await fastify.close(); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { + +
Misklient + `); + }, + 'Multiple items in HTML': reply => { reply.send(` - - - -
Misklient - `); + + + +
Misklient + `); + }, + }; + + for (const [title, replyFunc] of Object.entries(tests)) { + test(title, async () => { + await fastify.close(); + + fastify = Fastify(); + fastify.get('/', async (request, reply) => replyFunc(reply)); + await fastify.listen({ port: clientPort }); + + const client = getClient(); + + 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); }); - await fastify.listen({ port: clientPort }); - - const client = getClient(); - - 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 () => { await fastify.close(); From bfe6e5abb8a5cd93ad0277226e2750f2090eb291 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 15:53:49 +0200 Subject: [PATCH 43/82] remove confusing `return [false];` --- .../src/server/oauth/OAuth2ProviderService.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 01d4d5ea1b..6461e02a31 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -204,17 +204,17 @@ export class OAuth2ProviderService { })().then(args => done(null, ...args), err => done(err)); })); this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { - (async (): Promise>> => { + (async (): Promise> | undefined> => { const granted = grantCodeCache.get(code); console.log(granted, body, code, redirectUri); if (!granted) { - return [false]; + return; } grantCodeCache.delete(code); - if (body.client_id !== granted.clientId) return [false]; - if (redirectUri !== granted.redirectUri) return [false]; - if (!body.code_verifier) return [false]; - if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return [false]; + if (body.client_id !== granted.clientId) return; + if (redirectUri !== granted.redirectUri) return; + if (!body.code_verifier) return; + if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; const accessToken = secureRndstr(128, true); @@ -233,7 +233,7 @@ export class OAuth2ProviderService { }); return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; - })().then(args => done(null, ...args), err => done(err)); + })().then(args => done(null, ...args ?? []), err => done(err)); })); this.#server.serializeClient((client, done) => done(null, client)); this.#server.deserializeClient((id, done) => done(null, id)); @@ -265,9 +265,6 @@ export class OAuth2ProviderService { issuer: this.config.url, authorization_endpoint: new URL('/oauth/authorize', this.config.url), token_endpoint: new URL('/oauth/token', this.config.url), - // TODO: support or not? - // introspection_endpoint: ... - // introspection_endpoint_auth_methods_supported: ... scopes_supported: kinds, response_types_supported: ['code'], grant_types_supported: ['authorization_code'], From 347a4a0b93e7115eedc2deb8afa9872478eab4b5 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 17:37:38 +0200 Subject: [PATCH 44/82] Decision endpoint tests --- .../src/server/oauth/OAuth2ProviderService.ts | 8 ++- packages/backend/test/e2e/oauth.ts | 72 +++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6461e02a31..591c55b337 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -115,15 +115,14 @@ class OAuth2Store { #cache = new MemoryKVCache(1000 * 60 * 5); // 5min load(req: any, cb: (err: Error | null, txn?: OAuth2) => void): void { - console.log(req); const { transaction_id } = req.body; if (!transaction_id) { - cb(new Error('Missing transaction ID')); + cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); return; } const loaded = this.#cache.get(transaction_id); if (!loaded) { - cb(new Error('Failed to load transaction')); + cb(new AuthorizationError('Failed to load transaction', 'access_denied')); return; } cb(null, loaded); @@ -187,6 +186,9 @@ export class OAuth2ProviderService { console.log('HIT grant code:', client, redirectUri, token, ares, areq); const code = secureRndstr(32, true); + if (!token) { + throw new AuthorizationError('No user', 'invalid_request'); + } const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (!user) { diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 8d6c9b42db..87e79c9072 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -19,6 +19,14 @@ 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 OAuthErrorResponse { error: string; error_description: string; @@ -95,7 +103,6 @@ async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: st } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 200); - // TODO: this fetch-decision-code checks are everywhere, maybe get a helper for this. const decisionResponse = await fetchDecisionFromResponse(response, user); assert.strictEqual(decisionResponse.status, 302); @@ -532,7 +539,7 @@ describe('OAuth', () => { // RFC 6750 section 3.1 says 401 but it's SHOULD not MUST. 403 should be okay for now. assert.strictEqual(createResponse.status, 403); - // TODO: error code (invalid_token) + // TODO: error code (wrong Authorization header should emit OAuth error instead of Misskey API error) }); describe('Redirection', () => { @@ -625,6 +632,65 @@ describe('OAuth', () => { assert.ok(body.scopes_supported.includes('write:notes')); }); + describe('Decision endpoint', () => { + test('No login token', async () => { + const client = getClient(); + + 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', + }, + }); + + assert.strictEqual(decisionResponse.status, 400); + assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, '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', + }, + }); + + assert.strictEqual(decisionResponse.status, 400); + assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, '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', + }, + }); + + assert.strictEqual(decisionResponse.status, 403); + assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, 'access_denied'); + }); + }); + describe('Client Information Discovery', () => { describe('Redirection', () => { const tests: Record void> = { @@ -748,8 +814,6 @@ describe('OAuth', () => { }); }); - // TODO: Invalid decision endpoint parameters - // TODO: Unknown OAuth endpoint // TODO: successful token exchange should invalidate the grant token (spec?) From 413fa63093e082f04ffc7a91c57a99d8e385976d Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 4 Jun 2023 18:03:42 +0200 Subject: [PATCH 45/82] remove needless `as any` --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 591c55b337..d6c4448935 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -286,7 +286,7 @@ export class OAuth2ProviderService { return await reply.view('oauth', { transactionId: oauth2.transactionID, clientName: oauth2.client.name, - scope: (oauth2.req.scope as any as string[]).join(' '), + scope: oauth2.req.scope.join(' '), }); }); fastify.post('/oauth/decision', async () => { }); From 3b8b9a658aa2f27e26103ca317e3a62a22684a60 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 5 Jun 2023 22:14:55 +0200 Subject: [PATCH 46/82] Add authorization code tests --- packages/backend/test/e2e/oauth.ts | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 87e79c9072..fd782d8475 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -342,27 +342,56 @@ describe('OAuth', () => { }; describe('Verify PKCE', () => { - for (const [title, code_verifier] of Object.entries(tests)) { + 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, - } as AuthorizationTokenConfigExtended)); - - // And now the code is invalidated by the previous failure - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier, + code_verifier: wrong_verifier, } as AuthorizationTokenConfigExtended)); }); } }); }); + // 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)); + }); + + 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 })); + + await assert.rejects(client.getToken({ + code, + redirect_uri, + code_verifier, + } as AuthorizationTokenConfigExtended)); + }); + }); + test('Cancellation', async () => { const client = getClient(); @@ -816,5 +845,5 @@ describe('OAuth', () => { // TODO: Unknown OAuth endpoint - // TODO: successful token exchange should invalidate the grant token (spec?) + // TODO: Add spec links to tests }); From b5df8ca0fde78c12ffc06f2b7d8e0cb806e4b793 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Tue, 6 Jun 2023 21:53:59 +0200 Subject: [PATCH 47/82] 404 test --- packages/backend/test/e2e/oauth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index fd782d8475..dae5f9c086 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -843,7 +843,10 @@ describe('OAuth', () => { }); }); - // TODO: Unknown OAuth endpoint + test('Unknown OAuth endpoint', async () => { + const response = await fetch(new URL('/oauth/foo', host)); + assert.strictEqual(response.status, 404); + }); // TODO: Add spec links to tests }); From 0d2041f5aadb1d25ae6d89a6de14d628df3c00a6 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 11 Jun 2023 20:32:58 +0200 Subject: [PATCH 48/82] mode: indirect --- .../src/server/oauth/OAuth2ProviderService.ts | 87 +++++++++++-------- packages/backend/test/e2e/oauth.ts | 82 +++++++++++------ 2 files changed, 104 insertions(+), 65 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d6c4448935..ded2b2756c 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -111,6 +111,22 @@ interface OAuthRequest extends OAuth2Req { codeChallengeMethod: string; } +function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { + return { + query: (txn, res, params): void => { + // RFC 9207 + params.iss = issuerUrl; + + const parsed = new URL(txn.redirectURI); + for (const [key, value] of Object.entries(params)) { + parsed.searchParams.append(key, value as string); + } + + return (res as any).redirect(parsed.toString()); + }, + }; +} + class OAuth2Store { #cache = new MemoryKVCache(1000 * 60 * 5); // 5min @@ -128,13 +144,13 @@ class OAuth2Store { cb(null, loaded); } - store(req: any, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { + store(req: unknown, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { const transactionId = secureRndstr(128, true); this.#cache.set(transactionId, oauth2); cb(null, transactionId); } - remove(req: any, tid: string, cb: () => void): void { + remove(req: unknown, tid: string, cb: () => void): void { this.#cache.delete(tid); cb(); } @@ -168,19 +184,7 @@ export class OAuth2ProviderService { this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ - modes: { - query: (txn, res, params) => { - // RFC 9207 - params.iss = config.url; - - const parsed = new URL(txn.redirectURI); - for (const [key, value] of Object.entries(params)) { - parsed.searchParams.append(key, value as string); - } - - return (res as any).redirect(parsed.toString()); - }, - }, + modes: getQueryMode(config.url), }, (client, redirectUri, token, ares, areq, locals, done) => { (async (): Promise>> => { console.log('HIT grant code:', client, redirectUri, token, ares, areq); @@ -303,27 +307,14 @@ export class OAuth2ProviderService { await fastify.register(fastifyExpress); fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { - (async (): Promise>> => { + (async (): Promise> => { console.log('HIT /oauth/authorize validation middleware', areq); + // This should return client/redirectURI AND the error, or + // the handler can't send error to the redirection URI + const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest; - const scopes = [...new Set(scope)].filter(s => kinds.includes(s)); - if (!scopes.length) { - throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); - } - areq.scope = scopes; - - if (type !== 'code') { - throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); - } - if (typeof codeChallenge !== 'string') { - throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); - } - if (codeChallengeMethod !== 'S256') { - throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); - } - const clientUrl = validateClientId(clientID); if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { @@ -339,13 +330,33 @@ export class OAuth2ProviderService { throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); } - return [clientInfo, redirectURI]; - })().then(args => done(null, ...args), err => done(err)); + try { + const scopes = [...new Set(scope)].filter(s => kinds.includes(s)); + if (!scopes.length) { + throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope'); + } + areq.scope = scopes; + + if (type !== 'code') { + throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); + } + if (typeof codeChallenge !== 'string') { + throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); + } + if (codeChallengeMethod !== 'S256') { + throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request'); + } + } catch (err) { + return [err as Error, clientInfo, redirectURI]; + } + + return [null, clientInfo, redirectURI]; + })().then(args => done(...args), err => done(err)); }) as ValidateFunctionArity2)); - // TODO: use mode: indirect - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 - // But make sure not to redirect to an invalid redirect_uri - fastify.use('/oauth/authorize', this.#server.errorHandler()); + fastify.use('/oauth/authorize', this.#server.errorHandler({ + mode: 'indirect', + modes: getQueryMode(this.config.url), + })); fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision((req, done) => { diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index dae5f9c086..1fc0b95b96 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -32,6 +32,10 @@ interface OAuthErrorResponse { error_description: string; } +interface OAuthErrorDirectResponse { + code: string; +} + interface AuthorizationParamsExtended { redirect_uri: string; scope: string | string[]; @@ -294,9 +298,12 @@ describe('OAuth', () => { redirect_uri, scope: 'write:notes', state: 'state', - })); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + }), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); + + let location = response.headers.get('location'); + assert.ok(location); + assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); // Pattern 2: Only code_challenge response = await fetch(client.authorizeURL({ @@ -304,9 +311,12 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', code_challenge: 'code', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); + + location = response.headers.get('location'); + assert.ok(location); + assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); // Pattern 2: Only code_challenge_method response = await fetch(client.authorizeURL({ @@ -314,9 +324,12 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); + + location = response.headers.get('location'); + assert.ok(location); + assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); // Pattern 3: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ @@ -325,9 +338,12 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'SSSS', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); + + location = response.headers.get('location'); + assert.ok(location); + assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); }); // Use precomputed challenge/verifier set here for deterministic test @@ -407,7 +423,10 @@ describe('OAuth', () => { const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); assert.strictEqual(decisionResponse.status, 302); - const location = new URL(decisionResponse.headers.get('location')!); + 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')); }); @@ -421,10 +440,13 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); + const locationHeader = response.headers.get('location'); + assert.ok(locationHeader); + + assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); }); test('Empty scope', async () => { @@ -436,10 +458,13 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); + const locationHeader = response.headers.get('location'); + assert.ok(locationHeader); + + assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); }); test('Unknown scopes', async () => { @@ -451,10 +476,13 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); + } as AuthorizationParamsExtended), { redirect: 'manual' }); + assert.strictEqual(response.status, 302); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope'); + const locationHeader = response.headers.get('location'); + assert.ok(locationHeader); + + assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); }); test('Partially known scopes', async () => { @@ -584,7 +612,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); }); test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { @@ -599,7 +627,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); }); test('No redirect_uri at authorization endpoint', async () => { @@ -613,7 +641,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); }); test('Invalid redirect_uri at token endpoint', async () => { @@ -799,7 +827,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); }); }); @@ -816,7 +844,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended)); assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request'); + assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); }); test('Missing name', async () => { From d245306d9009c4b8bce088f1058a9774cadb4b79 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 11 Jun 2023 20:58:28 +0200 Subject: [PATCH 49/82] helpers for error assertions --- packages/backend/test/e2e/oauth.ts | 109 +++++++++++------------------ 1 file changed, 39 insertions(+), 70 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 1fc0b95b96..8aeedd8f14 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -27,15 +27,6 @@ const basicAuthParams: AuthorizationParamsExtended = { code_challenge_method: 'S256', }; -interface OAuthErrorResponse { - error: string; - error_description: string; -} - -interface OAuthErrorDirectResponse { - code: string; -} - interface AuthorizationParamsExtended { redirect_uri: string; scope: string | string[]; @@ -122,6 +113,27 @@ async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: st return { client, code }; } +function assertIndirectError(response: Response, error: string): void { + assert.strictEqual(response.status, 302); + + const location = response.headers.get('location'); + assert.ok(location); + assert.strictEqual(new URL(location).searchParams.get('error'), error); +} + +async function assertDirectError(response: Response, status: number, error: string): Promise { + assert.strictEqual(response.status, status); + + const data = await response.json(); + // `mode: indirect` may throw a direct error with `code` while the default direct mode uses `error` + // For now this doesn't matter too much since direct errors are not intended to be sent to clients. + if ('code' in data) { + assert.strictEqual(data.code, error); + } else { + assert.strictEqual(data.error, error); + } +} + describe('OAuth', () => { let app: INestApplicationContext; let fastify: FastifyInstance; @@ -299,11 +311,7 @@ describe('OAuth', () => { scope: 'write:notes', state: 'state', }), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - let location = response.headers.get('location'); - assert.ok(location); - assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); + assertIndirectError(response, 'invalid_request'); // Pattern 2: Only code_challenge response = await fetch(client.authorizeURL({ @@ -312,11 +320,7 @@ describe('OAuth', () => { state: 'state', code_challenge: 'code', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - location = response.headers.get('location'); - assert.ok(location); - assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); + assertIndirectError(response, 'invalid_request'); // Pattern 2: Only code_challenge_method response = await fetch(client.authorizeURL({ @@ -325,11 +329,7 @@ describe('OAuth', () => { state: 'state', code_challenge_method: 'S256', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - location = response.headers.get('location'); - assert.ok(location); - assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); + assertIndirectError(response, 'invalid_request'); // Pattern 3: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ @@ -339,11 +339,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'SSSS', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - location = response.headers.get('location'); - assert.ok(location); - assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request'); + assertIndirectError(response, 'invalid_request'); }); // Use precomputed challenge/verifier set here for deterministic test @@ -441,12 +437,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - const locationHeader = response.headers.get('location'); - assert.ok(locationHeader); - - assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); + assertIndirectError(response, 'invalid_scope'); }); test('Empty scope', async () => { @@ -459,12 +450,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - const locationHeader = response.headers.get('location'); - assert.ok(locationHeader); - - assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); + assertIndirectError(response, 'invalid_scope'); }); test('Unknown scopes', async () => { @@ -477,12 +463,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended), { redirect: 'manual' }); - assert.strictEqual(response.status, 302); - - const locationHeader = response.headers.get('location'); - assert.ok(locationHeader); - - assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope'); + assertIndirectError(response, 'invalid_scope'); }); test('Partially known scopes', async () => { @@ -610,9 +591,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); + await assertDirectError(response, 400, 'invalid_request'); }); test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { @@ -625,9 +604,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); + await assertDirectError(response, 400, 'invalid_request'); }); test('No redirect_uri at authorization endpoint', async () => { @@ -639,9 +616,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); + await assertDirectError(response, 400, 'invalid_request'); }); test('Invalid redirect_uri at token endpoint', async () => { @@ -689,6 +664,8 @@ describe('OAuth', () => { 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 = getClient(); @@ -709,9 +686,7 @@ describe('OAuth', () => { 'content-type': 'application/x-www-form-urlencoded', }, }); - - assert.strictEqual(decisionResponse.status, 400); - assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, 'invalid_request'); + await assertDirectError(decisionResponse, 400, 'invalid_request'); }); test('No transaction ID', async () => { @@ -725,9 +700,7 @@ describe('OAuth', () => { 'content-type': 'application/x-www-form-urlencoded', }, }); - - assert.strictEqual(decisionResponse.status, 400); - assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, 'invalid_request'); + await assertDirectError(decisionResponse, 400, 'invalid_request'); }); test('Invalid transaction ID', async () => { @@ -742,9 +715,7 @@ describe('OAuth', () => { 'content-type': 'application/x-www-form-urlencoded', }, }); - - assert.strictEqual(decisionResponse.status, 403); - assert.strictEqual((await decisionResponse.json() as OAuthErrorResponse).error, 'access_denied'); + await assertDirectError(decisionResponse, 403, 'access_denied'); }); }); @@ -826,8 +797,8 @@ describe('OAuth', () => { code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); + // direct error because there's no redirect URI to ping + await assertDirectError(response, 400, 'invalid_request'); }); }); @@ -842,9 +813,7 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 400); - assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request'); + await assertDirectError(response, 400, 'invalid_request'); }); test('Missing name', async () => { From 4c12a9d882f98c375152ac1fd44a07e832234caf Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 11 Jun 2023 20:59:39 +0200 Subject: [PATCH 50/82] fix typo --- packages/backend/test/e2e/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 8aeedd8f14..d0d11b55cc 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -322,7 +322,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended), { redirect: 'manual' }); assertIndirectError(response, 'invalid_request'); - // Pattern 2: Only code_challenge_method + // Pattern 3: Only code_challenge_method response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -331,7 +331,7 @@ describe('OAuth', () => { } as AuthorizationParamsExtended), { redirect: 'manual' }); assertIndirectError(response, 'invalid_request'); - // Pattern 3: Unsupported code_challenge_method + // Pattern 4: Unsupported code_challenge_method response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', From d0245b59bcf837d0592eb3a4e3eaad8e6439a7c0 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 11 Jun 2023 21:14:08 +0200 Subject: [PATCH 51/82] add another error handler for non-indirect case --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 3 +++ packages/backend/test/e2e/oauth.ts | 8 +------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index ded2b2756c..4b24ea1139 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -357,6 +357,7 @@ export class OAuth2ProviderService { mode: 'indirect', modes: getQueryMode(this.config.url), })); + fastify.use('/oauth/authorize', this.#server.errorHandler()); fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision((req, done) => { @@ -373,3 +374,5 @@ export class OAuth2ProviderService { fastify.use('/oauth/token', this.#server.errorHandler()); } } + +// TODO: remove console.log and use proper logger diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index d0d11b55cc..c152c33ba4 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -125,13 +125,7 @@ async function assertDirectError(response: Response, status: number, error: stri assert.strictEqual(response.status, status); const data = await response.json(); - // `mode: indirect` may throw a direct error with `code` while the default direct mode uses `error` - // For now this doesn't matter too much since direct errors are not intended to be sent to clients. - if ('code' in data) { - assert.strictEqual(data.code, error); - } else { - assert.strictEqual(data.error, error); - } + assert.strictEqual(data.error, error); } describe('OAuth', () => { From c83628e5d0fa12458c05b58201ace93f3e6a08db Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Mon, 12 Jun 2023 23:04:35 +0200 Subject: [PATCH 52/82] use logger --- .../src/server/oauth/OAuth2ProviderService.ts | 26 ++++++++++++------- packages/backend/test/e2e/oauth.ts | 2 ++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 4b24ea1139..e6a69f41a0 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -22,6 +22,8 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; import { MemoryKVCache } from '@/misc/cache.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; import type { FastifyInstance } from 'fastify'; // https://indieauth.spec.indieweb.org/#client-identifier @@ -161,6 +163,7 @@ export class OAuth2ProviderService { #server = oauth2orize.createServer({ store: new OAuth2Store(), }); + #logger: Logger; constructor( @Inject(DI.config) @@ -172,7 +175,10 @@ export class OAuth2ProviderService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, private cacheService: CacheService, + loggerService: LoggerService, ) { + this.#logger = loggerService.getLogger('oauth'); + // XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused const grantCodeCache = new MemoryKVCache<{ clientId: string, @@ -187,7 +193,7 @@ export class OAuth2ProviderService { modes: getQueryMode(config.url), }, (client, redirectUri, token, ares, areq, locals, done) => { (async (): Promise>> => { - console.log('HIT grant code:', client, redirectUri, token, ares, areq); + this.#logger.info(`Checking the user before sending authorization code to ${client.id}`); const code = secureRndstr(32, true); if (!token) { @@ -199,6 +205,7 @@ export class OAuth2ProviderService { throw new AuthorizationError('No such user', 'invalid_request'); } + this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); grantCodeCache.set(code, { clientId: client.id, userId: user.id, @@ -211,8 +218,8 @@ export class OAuth2ProviderService { })); this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { (async (): Promise> | undefined> => { + this.#logger.info('Checking the received authorization code for the exchange'); const granted = grantCodeCache.get(code); - console.log(granted, body, code, redirectUri); if (!granted) { return; } @@ -238,6 +245,8 @@ export class OAuth2ProviderService { permission: granted.scopes, }); + this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); + return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; })().then(args => done(null, ...args ?? []), err => done(err)); })); @@ -246,9 +255,10 @@ export class OAuth2ProviderService { } // Return 404 for any unknown paths under /oauth so that clients can know - // certain endpoints are unsupported. + // whether a certain endpoint is supported or not. // Registering separately because otherwise fastify.use() will match the // wildcard too. + // TODO: is this separation still needed? @bindThis public async createServerWildcard(fastify: FastifyInstance): Promise { fastify.all('/oauth/*', async (_request, reply) => { @@ -284,7 +294,7 @@ export class OAuth2ProviderService { // this feature for some time, given that this is security related. fastify.get('/oauth/authorize', async (request, reply) => { const oauth2 = (request.raw as any).oauth2 as OAuth2; - console.log('HIT /oauth/authorize', request.query, oauth2, (request.raw as any).session); + this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); reply.header('Cache-Control', 'no-store'); return await reply.view('oauth', { @@ -308,13 +318,13 @@ export class OAuth2ProviderService { await fastify.register(fastifyExpress); fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => { (async (): Promise> => { - console.log('HIT /oauth/authorize validation middleware', areq); - // This should return client/redirectURI AND the error, or // the handler can't send error to the redirection URI const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest; + this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); + const clientUrl = validateClientId(clientID); if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { @@ -361,7 +371,7 @@ export class OAuth2ProviderService { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision((req, done) => { - console.log('HIT decision:', req.oauth2, (req as any).body); + this.#logger.info(`Received the decision. Cancel: ${!!(req as any).body.cancel}`); req.user = (req as any).body.login_token; done(null, undefined); })); @@ -374,5 +384,3 @@ export class OAuth2ProviderService { fastify.use('/oauth/token', this.#server.errorHandler()); } } - -// TODO: remove console.log and use proper logger diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index c152c33ba4..3f75569a84 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -840,4 +840,6 @@ describe('OAuth', () => { }); // TODO: Add spec links to tests + + // TODO: Check whether indirect errors have state and iss }); From 95dd66a0bac6a322874234bac5ab6b2ced921450 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Tue, 13 Jun 2023 22:55:23 +0200 Subject: [PATCH 53/82] more assertions for indirect errors --- packages/backend/test/e2e/oauth.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 3f75569a84..fe52f68882 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -116,9 +116,13 @@ async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: st function assertIndirectError(response: Response, error: string): void { assert.strictEqual(response.status, 302); - const location = response.headers.get('location'); - assert.ok(location); - assert.strictEqual(new URL(location).searchParams.get('error'), error); + const locationHeader = response.headers.get('location'); + assert.ok(locationHeader); + + const location = new URL(locationHeader); + assert.strictEqual(location.searchParams.get('error'), error); + assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); + assert.ok(location.searchParams.has('state')); } async function assertDirectError(response: Response, status: number, error: string): Promise { @@ -840,6 +844,4 @@ describe('OAuth', () => { }); // TODO: Add spec links to tests - - // TODO: Check whether indirect errors have state and iss }); From aa87fb2f5084cb9373f18448619e051910904a83 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Tue, 13 Jun 2023 23:06:48 +0200 Subject: [PATCH 54/82] merge wildcard binder to createServer --- packages/backend/src/server/ServerService.ts | 1 - .../src/server/oauth/OAuth2ProviderService.ts | 34 ++++++++----------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 2ef074cb56..a98df389e1 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -92,7 +92,6 @@ export class ServerService implements OnApplicationShutdown { fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); - fastify.register(this.oauth2ProviderService.createServerWildcard); fastify.register(this.oauth2ProviderService.createServer); fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index e6a69f41a0..75c5c54c1e 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -254,26 +254,6 @@ export class OAuth2ProviderService { this.#server.deserializeClient((id, done) => done(null, id)); } - // Return 404 for any unknown paths under /oauth so that clients can know - // whether a certain endpoint is supported or not. - // Registering separately because otherwise fastify.use() will match the - // wildcard too. - // TODO: is this separation still needed? - @bindThis - public async createServerWildcard(fastify: FastifyInstance): Promise { - fastify.all('/oauth/*', async (_request, reply) => { - reply.code(404); - reply.send({ - error: { - message: 'Unknown OAuth endpoint.', - code: 'UNKNOWN_OAUTH_ENDPOINT', - id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', - kind: 'client', - }, - }); - }); - } - @bindThis public async createServer(fastify: FastifyInstance): Promise { fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { @@ -382,5 +362,19 @@ export class OAuth2ProviderService { fastify.use('/oauth/token', bodyParser.json({ strict: true })); fastify.use('/oauth/token', this.#server.token()); fastify.use('/oauth/token', 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) => { + reply.code(404); + reply.send({ + error: { + message: 'Unknown OAuth endpoint.', + code: 'UNKNOWN_OAUTH_ENDPOINT', + id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', + kind: 'client', + }, + }); + }); } } From 20efdc78e2634b82756e4b73baee3f4051c2cc13 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Wed, 14 Jun 2023 00:22:39 +0200 Subject: [PATCH 55/82] add more comments --- .../src/server/oauth/OAuth2ProviderService.ts | 26 ++++++++---- packages/backend/test/e2e/oauth.ts | 42 ++++++++++++++++--- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 75c5c54c1e..4a07758796 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -36,9 +36,13 @@ function validateClientId(raw: string): URL { })(); // Client identifier URLs MUST have either an https or http scheme - // XXX: but why allow http in 2023? - if (!['http:', 'https:'].includes(url.protocol)) { - throw new AuthorizationError('client_id must be either https or http URL', 'invalid_request'); + // But then again: + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1 + // 'The redirection endpoint SHOULD require the use of TLS as described + // in Section 1.6 when the requested response type is "code" or "token"' + const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:']; + if (!allowedProtocols.includes(url.protocol)) { + throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); } // MUST contain a path component (new URL() implicitly adds one) @@ -116,7 +120,10 @@ interface OAuthRequest extends OAuth2Req { function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { return { query: (txn, res, params): void => { - // RFC 9207 + // https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss + // "In authorization responses to the client, including error responses, + // an authorization server supporting this specification MUST indicate its + // identity by including the iss parameter in the response." params.iss = issuerUrl; const parsed = new URL(txn.redirectURI); @@ -188,6 +195,7 @@ export class OAuth2ProviderService { scopes: string[], }>(1000 * 60 * 5); // 5m + // https://datatracker.ietf.org/doc/html/rfc7636.html this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ modes: getQueryMode(config.url), @@ -307,10 +315,14 @@ export class OAuth2ProviderService { const clientUrl = validateClientId(clientID); - if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') { + // TODO: Consider allowing this for native apps (RFC 8252) + // The current setup requires an explicit list of redirect_uris per + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 + // which blocks the support. But we could loose the rule in this case. + if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { const lookup = await dns.lookup(clientUrl.hostname); - if (ipaddr.parse(lookup.address).range() === 'loopback') { - throw new AuthorizationError('client_id unexpectedly resolves to loopback IP.', 'invalid_request'); + if (ipaddr.parse(lookup.address).range() !== 'unicast') { + throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request'); } } diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index fe52f68882..a9bdfae770 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -121,7 +121,10 @@ function assertIndirectError(response: Response, error: string): void { 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')); } @@ -146,7 +149,7 @@ describe('OAuth', () => { }, 1000 * 60 * 2); beforeEach(async () => { - process.env.MISSKEY_TEST_DISALLOW_LOOPBACK = ''; + process.env.MISSKEY_TEST_CHECK_IP_RANGE = ''; fastify = Fastify(); fastify.get('/', async (request, reply) => { reply.send(` @@ -196,7 +199,8 @@ describe('OAuth', () => { assert.strictEqual(location.origin + location.pathname, redirect_uri); assert.ok(location.searchParams.has('code')); assert.strictEqual(location.searchParams.get('state'), 'state'); - assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); // RFC 9207 + // 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); @@ -299,7 +303,11 @@ describe('OAuth', () => { assert.strictEqual(createResponseBodyBob.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 = getClient(); @@ -425,7 +433,13 @@ describe('OAuth', () => { 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 = getClient(); @@ -464,6 +478,11 @@ describe('OAuth', () => { 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); @@ -480,8 +499,6 @@ describe('OAuth', () => { code_verifier, } as AuthorizationTokenConfigExtended); - // OAuth2 requires returning `scope` in the token response if the resulting scope is different than the requested one - // (Although Misskey always return scope, which is also fine) assert.strictEqual(token.token.scope, 'write:notes'); }); @@ -541,6 +558,7 @@ describe('OAuth', () => { }); }); + // https://datatracker.ietf.org/doc/html/rfc6750.html test('Authorization header', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); @@ -572,12 +590,22 @@ describe('OAuth', () => { }, body: JSON.stringify({ text: 'test' }), }); - // RFC 6750 section 3.1 says 401 but it's SHOULD not MUST. 403 should be okay for now. + + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-3.1 + // "The access token provided is expired, revoked, malformed, or + // invalid for other reasons. The resource SHOULD respond with + // the HTTP 401 (Unauthorized) status code." + // (but it's SHOULD not MUST. 403 should be okay for now.) assert.strictEqual(createResponse.status, 403); // TODO: error code (wrong Authorization header should emit OAuth error instead of Misskey API error) }); + // 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 = getClient(); @@ -653,6 +681,7 @@ describe('OAuth', () => { }); }); + // 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); @@ -717,6 +746,7 @@ describe('OAuth', () => { }); }); + // https://indieauth.spec.indieweb.org/#client-information-discovery describe('Client Information Discovery', () => { describe('Redirection', () => { const tests: Record void> = { @@ -801,7 +831,7 @@ describe('OAuth', () => { }); test('Disallow loopback', async () => { - process.env.MISSKEY_TEST_DISALLOW_LOOPBACK = '1'; + process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1'; const client = getClient(); const response = await fetch(client.authorizeURL({ From b938bc7c526da6c28356eb774f74f9c3f0aee674 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Wed, 14 Jun 2023 23:43:15 +0200 Subject: [PATCH 56/82] more description about client id validation --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 4a07758796..c2a57adb3c 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -26,7 +26,9 @@ import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; import type { FastifyInstance } from 'fastify'; -// https://indieauth.spec.indieweb.org/#client-identifier +// Follows https://indieauth.spec.indieweb.org/#client-identifier +// This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation +// although Google has stricter rule. function validateClientId(raw: string): URL { // Clients are identified by a [URL]. const url = ((): URL => { From 15f859d562c599b9e52fdfceeb4e29d803fa1b84 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Thu, 15 Jun 2023 22:06:19 +0200 Subject: [PATCH 57/82] Return 403 from permission error --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 1 + packages/backend/test/e2e/oauth.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index c2a57adb3c..58b2c9afa2 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -84,6 +84,7 @@ interface ClientInformation { name: string; } +// https://indieauth.spec.indieweb.org/#client-information-discovery async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index a9bdfae770..5cd4135fca 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -553,8 +553,7 @@ describe('OAuth', () => { }, body: JSON.stringify({ text: 'test' }), }); - // XXX: PERMISSION_DENIED is not using kind: 'permission' and gives 400 instead of 403 - assert.strictEqual(createResponse.status, 400); + assert.strictEqual(createResponse.status, 403); }); }); From b81e6eeff9ec9529287633f573b89689ff702558 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Thu, 15 Jun 2023 22:19:05 +0200 Subject: [PATCH 58/82] rfc 8252 --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 58b2c9afa2..76786829a0 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -42,6 +42,7 @@ function validateClientId(raw: string): URL { // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1 // 'The redirection endpoint SHOULD require the use of TLS as described // in Section 1.6 when the requested response type is "code" or "token"' + // TODO: Consider allowing custom URIs per RFC 8252. const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:']; if (!allowedProtocols.includes(url.protocol)) { throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); @@ -318,7 +319,7 @@ export class OAuth2ProviderService { const clientUrl = validateClientId(clientID); - // TODO: Consider allowing this for native apps (RFC 8252) + // TODO: Consider allowing localhost for native apps (RFC 8252) // The current setup requires an explicit list of redirect_uris per // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 // which blocks the support. But we could loose the rule in this case. From 260ac0ecfc5716937510fb14e8e3596574548d98 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 16 Jun 2023 22:24:03 +0200 Subject: [PATCH 59/82] solve typescript warnings --- .../src/server/oauth/OAuth2ProviderService.ts | 40 ++++++++++++++----- packages/backend/test/e2e/oauth.ts | 21 ++++++---- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 76786829a0..127d730762 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,10 +1,11 @@ import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; +import { ServerResponse } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; -import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req } from 'oauth2orize'; +import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; import oauth2Pkce from 'oauth2orize-pkce'; import fastifyView from '@fastify/view'; import pug from 'pug'; @@ -116,11 +117,23 @@ type OmitFirstElement = T extends [unknown, ...(infer R)] ? R : []; -interface OAuthRequest extends OAuth2Req { +interface OAuthParsedRequest extends OAuth2Req { codeChallenge: string; codeChallengeMethod: string; } +interface OAuthHttpResponse extends ServerResponse { + redirect(location: string): void; +} + +interface OAuth2DecisionRequest extends MiddlewareRequest { + body: { + transaction_id: string; + cancel: boolean; + login_token: string; + } +} + function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { return { query: (txn, res, params): void => { @@ -135,7 +148,7 @@ function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { parsed.searchParams.append(key, value as string); } - return (res as any).redirect(parsed.toString()); + return (res as OAuthHttpResponse).redirect(parsed.toString()); }, }; } @@ -143,7 +156,7 @@ function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { class OAuth2Store { #cache = new MemoryKVCache(1000 * 60 * 5); // 5min - load(req: any, cb: (err: Error | null, txn?: OAuth2) => void): void { + load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void { const { transaction_id } = req.body; if (!transaction_id) { cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); @@ -157,13 +170,13 @@ class OAuth2Store { cb(null, loaded); } - store(req: unknown, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { + store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { const transactionId = secureRndstr(128, true); this.#cache.set(transactionId, oauth2); cb(null, transactionId); } - remove(req: unknown, tid: string, cb: () => void): void { + remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { this.#cache.delete(tid); cb(); } @@ -222,7 +235,7 @@ export class OAuth2ProviderService { clientId: client.id, userId: user.id, redirectUri, - codeChallenge: (areq as OAuthRequest).codeChallenge, + codeChallenge: (areq as OAuthParsedRequest).codeChallenge, scopes: areq.scope, }); return [code]; @@ -285,7 +298,11 @@ export class OAuth2ProviderService { // For now only allow the basic OAuth endpoints, to start small and evaluate // this feature for some time, given that this is security related. fastify.get('/oauth/authorize', async (request, reply) => { - const oauth2 = (request.raw as any).oauth2 as OAuth2; + const oauth2 = (request.raw as MiddlewareRequest).oauth2; + if (!oauth2) { + throw new Error('Unexpected lack of authorization information'); + } + this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); reply.header('Cache-Control', 'no-store'); @@ -313,7 +330,7 @@ export class OAuth2ProviderService { // This should return client/redirectURI AND the error, or // the handler can't send error to the redirection URI - const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest; + const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthParsedRequest; this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); @@ -367,8 +384,9 @@ export class OAuth2ProviderService { fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false })); fastify.use('/oauth/decision', this.#server.decision((req, done) => { - this.#logger.info(`Received the decision. Cancel: ${!!(req as any).body.cancel}`); - req.user = (req as any).body.login_token; + 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()); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 5cd4135fca..8a11d1ff76 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -224,7 +224,7 @@ describe('OAuth', () => { }); assert.strictEqual(createResponse.status, 200); - const createResponseBody: any = await createResponse.json(); + const createResponseBody = await createResponse.json() as { createdNote: Note }; assert.strictEqual(createResponseBody.createdNote.text, 'test'); }); @@ -258,20 +258,27 @@ describe('OAuth', () => { const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob); assert.strictEqual(decisionResponseBob.status, 302); - const locationAlice = new URL(decisionResponseAlice.headers.get('location')!); - assert.ok(locationAlice.searchParams.has('code')); + const locationHeaderAlice = decisionResponseAlice.headers.get('location'); + assert.ok(locationHeaderAlice); + const locationAlice = new URL(locationHeaderAlice); - const locationBob = new URL(decisionResponseBob.headers.get('location')!); - assert.ok(locationBob.searchParams.has('code')); + 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: locationAlice.searchParams.get('code')!, + code: codeAlice, redirect_uri, code_verifier: pkceAlice.code_verifier, } as AuthorizationTokenConfigExtended); const tokenBob = await client.getToken({ - code: locationBob.searchParams.get('code')!, + code: codeBob, redirect_uri, code_verifier: pkceBob.code_verifier, } as AuthorizationTokenConfigExtended); From 52e7bdd8179d9582007bdb2383e9c62d3ea3b356 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 16 Jun 2023 22:42:11 +0200 Subject: [PATCH 60/82] import changes --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 8 ++++---- packages/backend/test/e2e/oauth.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 127d730762..ded2786a26 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -1,6 +1,5 @@ import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; -import { ServerResponse } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import httpLinkHeader from 'http-link-header'; @@ -19,12 +18,13 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; +import type { IdService } from '@/core/IdService.js'; +import type { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; import { MemoryKVCache } from '@/misc/cache.js'; -import { LoggerService } from '@/core/LoggerService.js'; +import type { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; +import type { ServerResponse } from 'node:http'; import type { FastifyInstance } from 'fastify'; // Follows https://indieauth.spec.indieweb.org/#client-identifier diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 8a11d1ff76..dbef0e457a 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -9,7 +9,7 @@ import * as assert from 'assert'; import { AuthorizationCode, type AuthorizationTokenConfig } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; -import * as misskey from 'misskey-js'; +import type * as misskey from 'misskey-js'; import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify'; import { port, relativeFetch, signup, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; @@ -224,7 +224,7 @@ describe('OAuth', () => { }); assert.strictEqual(createResponse.status, 200); - const createResponseBody = await createResponse.json() as { createdNote: Note }; + const createResponseBody = await createResponse.json() as misskey.Endpoints['notes/create']['res']; assert.strictEqual(createResponseBody.createdNote.text, 'test'); }); @@ -303,10 +303,10 @@ describe('OAuth', () => { }); assert.strictEqual(createResponseAlice.status, 200); - const createResponseBodyAlice = await createResponseAlice.json() as { createdNote: misskey.entities.Note }; + const createResponseBodyAlice = await createResponseAlice.json() as misskey.Endpoints['notes/create']['res']; assert.strictEqual(createResponseBodyAlice.createdNote.user.username, 'alice'); - const createResponseBodyBob = await createResponseBob.json() as { createdNote: misskey.entities.Note }; + const createResponseBodyBob = await createResponseBob.json() as misskey.Endpoints['notes/create']['res']; assert.strictEqual(createResponseBodyBob.createdNote.user.username, 'bob'); }); From c55d9784fe2a5c31b6f8faeb7b0a0e13c6e3629a Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 16 Jun 2023 22:54:39 +0200 Subject: [PATCH 61/82] migration todo --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index ded2786a26..8d55929ac4 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -27,6 +27,10 @@ import Logger from '@/logger.js'; import type { ServerResponse } from 'node:http'; import type { FastifyInstance } from 'fastify'; +// TODO: Consider migrating to @node-oauth/oauth2-server once +// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. +// Upstream the redirection URI validation below and RFC9207 implementation in that case. + // Follows https://indieauth.spec.indieweb.org/#client-identifier // This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation // although Google has stricter rule. From 1755c7564754f0770a6f5304cf54da89dfbdf753 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 16 Jun 2023 23:07:02 +0200 Subject: [PATCH 62/82] some edits for comments --- .../src/server/oauth/OAuth2ProviderService.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 8d55929ac4..2f6e2a06fc 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -35,14 +35,14 @@ import type { FastifyInstance } from 'fastify'; // This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation // although Google has stricter rule. function validateClientId(raw: string): URL { - // Clients are identified by a [URL]. + // "Clients are identified by a [URL]." const url = ((): URL => { try { return new URL(raw); } catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); } })(); - // Client identifier URLs MUST have either an https or http scheme + // "Client identifier URLs MUST have either an https or http scheme" // But then again: // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1 // 'The redirection endpoint SHOULD require the use of TLS as described @@ -53,30 +53,30 @@ function validateClientId(raw: string): URL { throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request'); } - // MUST contain a path component (new URL() implicitly adds one) + // "MUST contain a path component (new URL() implicitly adds one)" - // MUST NOT contain single-dot or double-dot path segments, + // "MUST NOT contain single-dot or double-dot path segments," const segments = url.pathname.split('/'); if (segments.includes('.') || segments.includes('..')) { throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request'); } - // (MAY contain a query string component) + // ("MAY contain a query string component") - // MUST NOT contain a fragment component + // "MUST NOT contain a fragment component" if (url.hash) { throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request'); } - // MUST NOT contain a username or password component + // "MUST NOT contain a username or password component" if (url.username || url.password) { throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request'); } - // (MAY contain a port) + // ("MAY contain a port") - // host names MUST be domain names or a loopback interface and MUST NOT be - // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]. + // "host names MUST be domain names or a loopback interface and MUST NOT be + // IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]." if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request'); } @@ -91,6 +91,16 @@ interface ClientInformation { } // https://indieauth.spec.indieweb.org/#client-information-discovery +// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, +// and if there is an [h-app] with a url property matching the client_id URL, +// then it should use the name and icon and display them on the authorization prompt." +// (But we don't display any icon for now) +// https://indieauth.spec.indieweb.org/#redirect-url +// "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute +// of redirect_uri at the client_id URL. +// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST +// look for an exact match of the given redirect_uri in the request against the list of +// redirect_uris discovered after resolving any relative URLs." async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); @@ -341,9 +351,8 @@ export class OAuth2ProviderService { const clientUrl = validateClientId(clientID); // TODO: Consider allowing localhost for native apps (RFC 8252) - // The current setup requires an explicit list of redirect_uris per - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 - // which blocks the support. But we could loose the rule in this case. + // This is currently blocked by the redirect_uri check below, but we can theoretically + // loosen the rule for localhost as the data never leaves the client machine. if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { const lookup = await dns.lookup(clientUrl.hostname); if (ipaddr.parse(lookup.address).range() !== 'unicast') { @@ -353,6 +362,9 @@ export class OAuth2ProviderService { // Find client information from the remote. const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); + + // Requires an explicit list of redirect_uris per + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 if (!clientInfo.redirectUris.includes(redirectURI)) { throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); } From b57d40ed099161a419006c11a03634e337b28307 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 16 Jun 2023 23:07:21 +0200 Subject: [PATCH 63/82] typo --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 2f6e2a06fc..da84d876ae 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -363,7 +363,7 @@ export class OAuth2ProviderService { // Find client information from the remote. const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); - // Requires an explicit list of redirect_uris per + // Require an explicit list of redirect_uris per // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3 if (!clientInfo.redirectUris.includes(redirectURI)) { throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); From 628377187a7896a844b3589127b5a5c6599bac24 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 17 Jun 2023 00:22:19 +0200 Subject: [PATCH 64/82] grant type tests --- .../src/server/oauth/OAuth2ProviderService.ts | 12 +-- packages/backend/test/e2e/oauth.ts | 92 +++++++++++++++++-- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index da84d876ae..5b954cbf62 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -18,11 +18,11 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { AccessTokensRepository, UsersRepository } from '@/models/index.js'; -import type { IdService } from '@/core/IdService.js'; -import type { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { LocalUser } from '@/models/entities/User.js'; import { MemoryKVCache } from '@/misc/cache.js'; -import type { LoggerService } from '@/core/LoggerService.js'; +import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; import type { ServerResponse } from 'node:http'; import type { FastifyInstance } from 'fastify'; @@ -376,9 +376,9 @@ export class OAuth2ProviderService { } areq.scope = scopes; - if (type !== 'code') { - throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request'); - } + // Require PKCE parameters. + // Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack: + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack if (typeof codeChallenge !== 'string') { throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request'); } diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index dbef0e457a..b515eabaca 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { AuthorizationCode, type AuthorizationTokenConfig } from 'simple-oauth2'; +import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; import type * as misskey from 'misskey-js'; @@ -375,7 +375,10 @@ describe('OAuth', () => { code, redirect_uri, code_verifier: wrong_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); }); } }); @@ -400,20 +403,29 @@ describe('OAuth', () => { code, redirect_uri, code_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + 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 })); + await assert.rejects(client.getToken({ code, redirect_uri }), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); await assert.rejects(client.getToken({ code, redirect_uri, code_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); }); }); @@ -660,7 +672,10 @@ describe('OAuth', () => { code, redirect_uri: 'http://127.0.0.2/', code_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); }); test('Invalid redirect_uri including the valid one at token endpoint', async () => { @@ -672,7 +687,10 @@ describe('OAuth', () => { code, redirect_uri: 'http://127.0.0.1/redirection', code_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); }); test('No redirect_uri at token endpoint', async () => { @@ -683,7 +701,10 @@ describe('OAuth', () => { await assert.rejects(client.getToken({ code, code_verifier, - } as AuthorizationTokenConfigExtended)); + } as AuthorizationTokenConfigExtended), (err: any) => { + assert.strictEqual(err.data.payload.error, 'invalid_grant'); + return true; + }); }); }); @@ -752,6 +773,61 @@ describe('OAuth', () => { }); }); + // 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({ + client: { + id: `http://127.0.0.1:${clientPort}/`, + secret: '', + }, + auth: { + tokenHost: host, + tokenPath: '/oauth/token', + }, + options: { + authorizationMethod: 'body', + }, + }); + + await assert.rejects(client.getToken({ + username: 'alice', + password: 'test', + }), (err: any) => { + assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); + return true; + }); + }); + + test('Client credential grant is not supported', async () => { + const client = new ClientCredentials({ + client: { + id: `http://127.0.0.1:${clientPort}/`, + secret: '', + }, + auth: { + tokenHost: host, + tokenPath: '/oauth/token', + }, + options: { + authorizationMethod: 'body', + }, + }); + + await assert.rejects(client.getToken({}), (err: any) => { + 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', () => { From 5db1126db6dc68dffd74612c55dd227dc7b06260 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 17 Jun 2023 00:28:03 +0200 Subject: [PATCH 65/82] clientConfig --- packages/backend/test/e2e/oauth.ts | 82 +++++++++++++----------------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index b515eabaca..8e4694faaf 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials } from 'simple-oauth2'; +import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; import type * as misskey from 'misskey-js'; @@ -39,22 +39,20 @@ interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig { code_verifier: string | undefined; } -function getClient(): AuthorizationCode<'client_id'> { - return new AuthorizationCode({ - client: { - id: `http://127.0.0.1:${clientPort}/`, - secret: '', - }, - auth: { - tokenHost: host, - tokenPath: '/oauth/token', - authorizePath: '/oauth/authorize', - }, - options: { - authorizationMethod: 'body', - }, - }); -} +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); @@ -87,7 +85,7 @@ async function fetchDecisionFromResponse(response: Response, user: misskey.entit } async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -172,7 +170,7 @@ describe('OAuth', () => { test('Full flow', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -229,7 +227,7 @@ describe('OAuth', () => { }); test('Two concurrent flows', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const pkceAlice = await pkceChallenge(128); const pkceBob = await pkceChallenge(128); @@ -316,7 +314,7 @@ describe('OAuth', () => { // '... the authorization endpoint MUST return the authorization // error response with the "error" value set to "invalid_request".' test('Require PKCE', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); // Pattern 1: No PKCE fields at all let response = await fetch(client.authorizeURL({ @@ -430,7 +428,7 @@ describe('OAuth', () => { }); test('Cancellation', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -460,7 +458,7 @@ describe('OAuth', () => { // indicating an invalid scope." // (And Misskey does the latter) test('Missing scope', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -472,7 +470,7 @@ describe('OAuth', () => { }); test('Empty scope', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -485,7 +483,7 @@ describe('OAuth', () => { }); test('Unknown scopes', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -522,7 +520,7 @@ describe('OAuth', () => { }); test('Known scopes', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -626,7 +624,7 @@ describe('OAuth', () => { // automatically redirect the user-agent to the invalid redirection URI." describe('Redirection', () => { test('Invalid redirect_uri at authorization endpoint', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri: 'http://127.0.0.2/', @@ -639,7 +637,7 @@ describe('OAuth', () => { }); test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri: 'http://127.0.0.1/redirection', @@ -652,7 +650,7 @@ describe('OAuth', () => { }); test('No redirect_uri at authorization endpoint', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ scope: 'write:notes', @@ -722,7 +720,7 @@ describe('OAuth', () => { // Do not use indirect error here. describe('Decision endpoint', () => { test('No login token', async () => { - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL(basicAuthParams)); assert.strictEqual(response.status, 200); @@ -784,17 +782,11 @@ describe('OAuth', () => { test('Resource owner grant is not supported', async () => { const client = new ResourceOwnerPassword({ - client: { - id: `http://127.0.0.1:${clientPort}/`, - secret: '', - }, + ...clientConfig, auth: { tokenHost: host, tokenPath: '/oauth/token', }, - options: { - authorizationMethod: 'body', - }, }); await assert.rejects(client.getToken({ @@ -808,17 +800,11 @@ describe('OAuth', () => { test('Client credential grant is not supported', async () => { const client = new ClientCredentials({ - client: { - id: `http://127.0.0.1:${clientPort}/`, - secret: '', - }, + ...clientConfig, auth: { tokenHost: host, tokenPath: '/oauth/token', }, - options: { - authorizationMethod: 'body', - }, }); await assert.rejects(client.getToken({}), (err: any) => { @@ -872,7 +858,7 @@ describe('OAuth', () => { fastify.get('/', async (request, reply) => replyFunc(reply)); await fastify.listen({ port: clientPort }); - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -897,7 +883,7 @@ describe('OAuth', () => { }); await fastify.listen({ port: clientPort }); - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, @@ -915,7 +901,7 @@ describe('OAuth', () => { test('Disallow loopback', async () => { process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1'; - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -936,7 +922,7 @@ describe('OAuth', () => { }); await fastify.listen({ port: clientPort }); - const client = getClient(); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, From 7ed8fbbba3eeb8819d84539eff5218f5f6f50579 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 17 Jun 2023 00:29:33 +0200 Subject: [PATCH 66/82] GetTokenError --- packages/backend/test/e2e/oauth.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 8e4694faaf..151d26f0e9 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -39,6 +39,14 @@ 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}/`, @@ -373,7 +381,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier: wrong_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -401,7 +409,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -411,7 +419,7 @@ describe('OAuth', () => { 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: any) => { + await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -420,7 +428,7 @@ describe('OAuth', () => { code, redirect_uri, code_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -670,7 +678,7 @@ describe('OAuth', () => { code, redirect_uri: 'http://127.0.0.2/', code_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -685,7 +693,7 @@ describe('OAuth', () => { code, redirect_uri: 'http://127.0.0.1/redirection', code_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -699,7 +707,7 @@ describe('OAuth', () => { await assert.rejects(client.getToken({ code, code_verifier, - } as AuthorizationTokenConfigExtended), (err: any) => { + } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'invalid_grant'); return true; }); @@ -792,7 +800,7 @@ describe('OAuth', () => { await assert.rejects(client.getToken({ username: 'alice', password: 'test', - }), (err: any) => { + }), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); return true; }); @@ -807,7 +815,7 @@ describe('OAuth', () => { }, }); - await assert.rejects(client.getToken({}), (err: any) => { + await assert.rejects(client.getToken({}), (err: GetTokenError) => { assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); return true; }); From d7e0e9feca1486970c9a6d8b2f3bd7dcd455f326 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 17 Jun 2023 16:07:16 +0200 Subject: [PATCH 67/82] todo: revoke all tokens --- .../backend/src/server/oauth/OAuth2ProviderService.ts | 8 ++++++-- packages/backend/test/e2e/oauth.ts | 3 +-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 5b954cbf62..7a5c90d87b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -29,7 +29,7 @@ import type { FastifyInstance } from 'fastify'; // TODO: Consider migrating to @node-oauth/oauth2-server once // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. -// Upstream the redirection URI validation below and RFC9207 implementation in that case. +// Upstream the various validations and RFC9207 implementation in that case. // Follows https://indieauth.spec.indieweb.org/#client-identifier // This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation @@ -263,8 +263,12 @@ export class OAuth2ProviderService { return; } grantCodeCache.delete(code); + + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 if (body.client_id !== granted.clientId) return; if (redirectUri !== granted.redirectUri) return; + + // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 if (!body.code_verifier) return; if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; @@ -344,7 +348,7 @@ export class OAuth2ProviderService { // This should return client/redirectURI AND the error, or // the handler can't send error to the redirection URI - const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthParsedRequest; + const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest; this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 151d26f0e9..776f00c723 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -394,6 +394,7 @@ describe('OAuth', () => { // "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." + // TODO: implement the "revoke all tokens" part, since we currently only deny the request. describe('Revoking authorization code', () => { test('On success', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); @@ -948,6 +949,4 @@ describe('OAuth', () => { const response = await fetch(new URL('/oauth/foo', host)); assert.strictEqual(response.status, 404); }); - - // TODO: Add spec links to tests }); From ecdd1c115a9dfbab3a86fdb04aa5161f39f75eb3 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sat, 17 Jun 2023 22:03:03 +0200 Subject: [PATCH 68/82] Revoke access token if the code is reused --- .../src/server/oauth/OAuth2ProviderService.ts | 30 ++++++++++++-- packages/backend/test/e2e/oauth.ts | 41 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 7a5c90d87b..4c639c7fa6 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -224,6 +224,11 @@ export class OAuth2ProviderService { redirectUri: string, codeChallenge: string, scopes: string[], + + // fields to prevent multiple code use + grantedToken?: string, + revoked?: boolean, + used?: boolean, }>(1000 * 60 * 5); // 5m // https://datatracker.ietf.org/doc/html/rfc7636.html @@ -262,7 +267,21 @@ export class OAuth2ProviderService { if (!granted) { return; } - grantCodeCache.delete(code); + + // 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." + if (granted.used) { + this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`); + grantCodeCache.delete(code); + granted.revoked = true; + if (granted.grantedToken) { + await accessTokensRepository.delete({ token: granted.grantedToken }); + } + return; + } + granted.used = true; // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3 if (body.client_id !== granted.clientId) return; @@ -273,10 +292,8 @@ export class OAuth2ProviderService { if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; const accessToken = secureRndstr(128, true); - const now = new Date(); - // Insert access token doc await accessTokensRepository.insert({ id: idService.genId(), createdAt: now, @@ -288,6 +305,13 @@ export class OAuth2ProviderService { permission: granted.scopes, }); + if (granted.revoked) { + this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.'); + await accessTokensRepository.delete({ token: accessToken }); + return; + } + + granted.grantedToken = accessToken; this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 776f00c723..921b140448 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -394,7 +394,6 @@ describe('OAuth', () => { // "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." - // TODO: implement the "revoke all tokens" part, since we currently only deny the request. describe('Revoking authorization code', () => { test('On success', async () => { const { code_challenge, code_verifier } = await pkceChallenge(128); @@ -434,6 +433,46 @@ describe('OAuth', () => { 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 createResponse = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponse.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 createResponse2 = await relativeFetch('api/notes/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token.token.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text: 'test' }), + }); + assert.strictEqual(createResponse2.status, 403); + }); }); test('Cancellation', async () => { From 1567a2ea3efd9d06b2c771f3bc3d1a5739e7ccd5 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Thu, 22 Jun 2023 01:25:40 +0200 Subject: [PATCH 69/82] error in rfc6750 --- .../src/server/oauth/OAuth2ProviderService.ts | 17 ++++++++++++----- packages/backend/test/e2e/oauth.ts | 7 ++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 4c639c7fa6..1ec9553860 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -167,8 +167,16 @@ function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { }; } +/** + * Maps the transaction ID and the oauth/authorize parameters. + * + * Flow: + * 1. oauth/authorize endpoint will call store() to store the parameters + * and puts the generated transaction ID to the dialog page + * 2. oauth/decision will call load() to retrieve the parameters and then remove() + */ class OAuth2Store { - #cache = new MemoryKVCache(1000 * 60 * 5); // 5min + #cache = new MemoryKVCache(1000 * 60 * 5); // expires after 5min load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void { const { transaction_id } = req.body; @@ -178,7 +186,7 @@ class OAuth2Store { } const loaded = this.#cache.get(transaction_id); if (!loaded) { - cb(new AuthorizationError('Failed to load transaction', 'access_denied')); + cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied')); return; } cb(null, loaded); @@ -217,7 +225,6 @@ export class OAuth2ProviderService { ) { this.#logger = loggerService.getLogger('oauth'); - // XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused const grantCodeCache = new MemoryKVCache<{ clientId: string, userId: string, @@ -229,7 +236,7 @@ export class OAuth2ProviderService { grantedToken?: string, revoked?: boolean, used?: boolean, - }>(1000 * 60 * 5); // 5m + }>(1000 * 60 * 5); // expires after 5m // https://datatracker.ietf.org/doc/html/rfc7636.html this.#server.grant(oauth2Pkce.extensions()); @@ -238,7 +245,7 @@ export class OAuth2ProviderService { }, (client, redirectUri, token, ares, areq, locals, done) => { (async (): Promise>> => { this.#logger.info(`Checking the user before sending authorization code to ${client.id}`); - const code = secureRndstr(32, true); + const code = secureRndstr(128, true); if (!token) { throw new AuthorizationError('No user', 'invalid_request'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 921b140448..f5b87b1b2a 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -471,7 +471,7 @@ describe('OAuth', () => { }, body: JSON.stringify({ text: 'test' }), }); - assert.strictEqual(createResponse2.status, 403); + assert.strictEqual(createResponse2.status, 401); }); }); @@ -659,10 +659,7 @@ describe('OAuth', () => { // "The access token provided is expired, revoked, malformed, or // invalid for other reasons. The resource SHOULD respond with // the HTTP 401 (Unauthorized) status code." - // (but it's SHOULD not MUST. 403 should be okay for now.) - assert.strictEqual(createResponse.status, 403); - - // TODO: error code (wrong Authorization header should emit OAuth error instead of Misskey API error) + await assertDirectError(createResponse as Response, 401, 'invalid_token'); }); // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4 From 0b3fd09bb0574191b4dc98fc89c4d1a08db6355b Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Thu, 22 Jun 2023 01:52:13 +0200 Subject: [PATCH 70/82] no token expiration? --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 1ec9553860..fa5299dbc9 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -301,6 +301,7 @@ export class OAuth2ProviderService { const accessToken = secureRndstr(128, true); const now = new Date(); + // NOTE: we don't have a setup for automatic token expiration await accessTokensRepository.insert({ id: idService.genId(), createdAt: now, From daa18efc99b5293187eb0427a11d39ecb6d53c02 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Fri, 23 Jun 2023 01:53:27 +0200 Subject: [PATCH 71/82] generate the code later --- packages/backend/src/server/oauth/OAuth2ProviderService.ts | 6 ++++-- packages/frontend/src/pages/oauth.vue | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index fa5299dbc9..8bbbfa5d6c 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -238,14 +238,14 @@ export class OAuth2ProviderService { used?: boolean, }>(1000 * 60 * 5); // expires after 5m - // https://datatracker.ietf.org/doc/html/rfc7636.html + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics + // Authorization servers MUST support PKCE [RFC7636]. this.#server.grant(oauth2Pkce.extensions()); this.#server.grant(oauth2orize.grant.code({ modes: getQueryMode(config.url), }, (client, redirectUri, token, ares, areq, locals, done) => { (async (): Promise>> => { this.#logger.info(`Checking the user before sending authorization code to ${client.id}`); - const code = secureRndstr(128, true); if (!token) { throw new AuthorizationError('No user', 'invalid_request'); @@ -257,6 +257,8 @@ export class OAuth2ProviderService { } this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); + + const code = secureRndstr(128, true); grantCodeCache.set(code, { clientId: client.id, userId: user.id, diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index e0d126cb31..94ad8e6d3e 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -1,7 +1,7 @@