mizzkey/packages/backend/src/server/oauth/OAuth2ProviderService.ts

381 lines
13 KiB
TypeScript
Raw Normal View History

2023-03-18 16:07:12 +01:00
import dns from 'node:dns/promises';
2023-04-15 21:30:41 +02:00
import { fileURLToPath } from 'node:url';
2023-03-18 16:07:12 +01:00
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
2023-04-10 14:49:18 +02:00
import httpLinkHeader from 'http-link-header';
2023-03-18 16:07:12 +01:00
import ipaddr from 'ipaddr.js';
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req } from 'oauth2orize';
2023-03-19 18:55:26 +01:00
import oauth2Pkce from 'oauth2orize-pkce';
import fastifyView from '@fastify/view';
import pug from 'pug';
2023-03-25 17:34:36 +01:00
import bodyParser from 'body-parser';
import fastifyExpress from '@fastify/express';
2023-05-27 15:19:55 +02:00
import { verifyChallenge } from 'pkce-challenge';
2023-04-15 21:30:41 +02:00
import { secureRndstr } from '@/misc/secure-rndstr.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';
2023-03-26 20:03:18 +02:00
import type { AccessTokensRepository, UsersRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
2023-04-07 10:06:07 +02:00
import { CacheService } from '@/core/CacheService.js';
2023-03-26 20:03:18 +02:00
import type { LocalUser } from '@/models/entities/User.js';
2023-05-27 20:52:48 +02:00
import { MemoryKVCache } from '@/misc/cache.js';
2023-06-12 23:04:35 +02:00
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
2023-04-15 21:30:41 +02:00
import type { FastifyInstance } from 'fastify';
2023-03-18 16:07:12 +01:00
// 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);
2023-04-15 23:15:37 +02:00
} catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); }
2023-03-18 16:07:12 +01:00
})();
// Client identifier URLs MUST have either an https or http scheme
2023-03-19 14:03:46 +01:00
// XXX: but why allow http in 2023?
2023-03-18 16:07:12 +01:00
if (!['http:', 'https:'].includes(url.protocol)) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id must be either https or http URL', 'invalid_request');
2023-03-18 16:07:12 +01:00
}
// MUST contain a path component (new URL() implicitly adds one)
// MUST NOT contain single-dot or double-dot path segments,
const segments = url.pathname.split('/');
if (segments.includes('.') || segments.includes('..')) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
2023-03-18 16:07:12 +01:00
}
// (MAY contain a query string component)
2023-03-18 16:07:12 +01:00
// MUST NOT contain a fragment component
if (url.hash) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
2023-03-18 16:07:12 +01:00
}
// MUST NOT contain a username or password component
if (url.username || url.password) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
2023-03-18 16:07:12 +01:00
}
2023-03-25 17:34:36 +01:00
// (MAY contain a port)
2023-03-18 16:07:12 +01:00
// 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].
2023-03-25 17:34:36 +01:00
if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request');
2023-03-18 16:07:12 +01:00
}
return url;
}
2023-04-10 14:49:18 +02:00
interface ClientInformation {
id: string;
redirectUris: string[];
name: string;
}
async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
2023-03-18 16:07:12 +01:00
try {
const res = await httpRequestService.send(id);
2023-04-10 14:49:18 +02:00
const redirectUris: string[] = [];
2023-03-18 16:07:12 +01:00
2023-04-10 14:49:18 +02:00
const linkHeader = res.headers.get('link');
if (linkHeader) {
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
2023-03-18 16:07:12 +01:00
}
2023-04-10 14:49:18 +02:00
const fragment = JSDOM.fragment(await res.text());
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
const name = fragment.querySelector<HTMLElement>('.h-app .p-name')?.textContent?.trim() ?? id;
return {
id,
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
name,
};
2023-03-18 16:07:12 +01:00
} catch {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('Failed to fetch client information', 'server_error');
2023-03-18 16:07:12 +01:00
}
}
2023-03-19 18:55:26 +01:00
type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)]
? R
: [];
2023-03-18 16:07:12 +01:00
interface OAuthRequest extends OAuth2Req {
2023-04-16 15:43:32 +02:00
codeChallenge: string;
codeChallengeMethod: string;
2023-03-25 20:57:56 +01:00
}
2023-06-11 20:32:58 +02:00
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());
},
};
}
2023-06-04 00:16:51 +02:00
class OAuth2Store {
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // 5min
load(req: any, cb: (err: Error | null, txn?: OAuth2) => void): void {
const { transaction_id } = req.body;
if (!transaction_id) {
2023-06-04 17:37:38 +02:00
cb(new AuthorizationError('Missing transaction ID', 'invalid_request'));
2023-06-04 00:16:51 +02:00
return;
}
const loaded = this.#cache.get(transaction_id);
if (!loaded) {
2023-06-04 17:37:38 +02:00
cb(new AuthorizationError('Failed to load transaction', 'access_denied'));
2023-06-04 00:16:51 +02:00
return;
}
cb(null, loaded);
}
2023-06-11 20:32:58 +02:00
store(req: unknown, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
2023-06-04 00:16:51 +02:00
const transactionId = secureRndstr(128, true);
this.#cache.set(transactionId, oauth2);
cb(null, transactionId);
}
2023-06-11 20:32:58 +02:00
remove(req: unknown, tid: string, cb: () => void): void {
2023-06-04 00:16:51 +02:00
this.#cache.delete(tid);
cb();
}
}
2023-03-18 16:07:12 +01:00
@Injectable()
export class OAuth2ProviderService {
2023-06-04 00:16:51 +02:00
#server = oauth2orize.createServer({
store: new OAuth2Store(),
});
2023-06-12 23:04:35 +02:00
#logger: Logger;
2023-03-18 16:07:12 +01:00
constructor(
@Inject(DI.config)
private config: Config,
2023-03-19 18:55:26 +01:00
private httpRequestService: HttpRequestService,
2023-03-26 20:03:18 +02:00
@Inject(DI.accessTokensRepository)
accessTokensRepository: AccessTokensRepository,
idService: IdService,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
2023-04-07 10:06:07 +02:00
private cacheService: CacheService,
2023-06-12 23:04:35 +02:00
loggerService: LoggerService,
2023-03-18 16:07:12 +01:00
) {
2023-06-12 23:04:35 +02:00
this.#logger = loggerService.getLogger('oauth');
2023-05-27 20:54:16 +02:00
// XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused
2023-05-27 20:52:48 +02:00
const grantCodeCache = new MemoryKVCache<{
2023-03-26 20:03:18 +02:00
clientId: string,
userId: string,
redirectUri: string,
codeChallenge: string,
scopes: string[],
2023-05-27 20:52:48 +02:00
}>(1000 * 60 * 5); // 5m
2023-03-26 20:03:18 +02:00
2023-03-19 18:55:26 +01:00
this.#server.grant(oauth2Pkce.extensions());
2023-04-09 18:49:58 +02:00
this.#server.grant(oauth2orize.grant.code({
2023-06-11 20:32:58 +02:00
modes: getQueryMode(config.url),
}, (client, redirectUri, token, ares, areq, locals, done) => {
2023-03-26 20:03:18 +02:00
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
2023-06-12 23:04:35 +02:00
this.#logger.info(`Checking the user before sending authorization code to ${client.id}`);
2023-03-26 20:03:18 +02:00
const code = secureRndstr(32, true);
2023-06-04 17:37:38 +02:00
if (!token) {
throw new AuthorizationError('No user', 'invalid_request');
}
2023-04-07 10:06:07 +02:00
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
2023-03-26 20:03:18 +02:00
() => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>);
if (!user) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('No such user', 'invalid_request');
2023-03-26 20:03:18 +02:00
}
2023-06-12 23:04:35 +02:00
this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`);
2023-05-27 20:52:48 +02:00
grantCodeCache.set(code, {
2023-04-10 14:49:18 +02:00
clientId: client.id,
2023-03-26 20:03:18 +02:00
userId: user.id,
redirectUri,
codeChallenge: (areq as OAuthRequest).codeChallenge,
2023-03-26 20:03:18 +02:00
scopes: areq.scope,
2023-05-27 20:52:48 +02:00
});
2023-03-26 20:03:18 +02:00
return [code];
})().then(args => done(null, ...args), err => done(err));
}));
this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => {
2023-06-04 15:53:49 +02:00
(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => {
2023-06-12 23:04:35 +02:00
this.#logger.info('Checking the received authorization code for the exchange');
2023-05-27 20:52:48 +02:00
const granted = grantCodeCache.get(code);
2023-03-26 20:03:18 +02:00
if (!granted) {
2023-06-04 15:53:49 +02:00
return;
2023-03-26 20:03:18 +02:00
}
2023-05-27 20:52:48 +02:00
grantCodeCache.delete(code);
2023-06-04 15:53:49 +02:00
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;
2023-03-26 20:03:18 +02:00
const accessToken = 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,
});
2023-06-12 23:04:35 +02:00
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
2023-06-04 15:53:49 +02:00
})().then(args => done(null, ...args ?? []), err => done(err));
2023-03-19 18:55:26 +01:00
}));
this.#server.serializeClient((client, done) => done(null, client));
this.#server.deserializeClient((id, done) => done(null, id));
2023-03-18 16:07:12 +01:00
}
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
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),
2023-04-10 10:17:41 +02:00
scopes_supported: kinds,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
service_documentation: 'https://misskey-hub.net',
2023-03-18 16:07:12 +01:00
code_challenge_methods_supported: ['S256'],
2023-04-10 10:17:41 +02:00
authorization_response_iss_parameter_supported: true,
2023-03-18 16:07:12 +01:00
});
});
// For now only allow the basic OAuth endpoints, to start small and evaluate
// this feature for some time, given that this is security related.
2023-04-16 15:43:32 +02:00
fastify.get('/oauth/authorize', async (request, reply) => {
2023-04-09 21:21:10 +02:00
const oauth2 = (request.raw as any).oauth2 as OAuth2;
2023-06-12 23:04:35 +02:00
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
2023-04-08 20:31:18 +02:00
2023-03-26 20:03:18 +02:00
reply.header('Cache-Control', 'no-store');
2023-03-25 17:34:36 +01:00
return await reply.view('oauth', {
2023-04-09 21:21:10 +02:00
transactionId: oauth2.transactionID,
2023-04-10 14:49:18 +02:00
clientName: oauth2.client.name,
2023-06-04 18:03:42 +02:00
scope: oauth2.req.scope.join(' '),
2023-03-19 18:55:26 +01:00
});
});
2023-03-25 20:57:56 +01:00
fastify.post('/oauth/decision', async () => { });
2023-03-18 16:07:12 +01:00
fastify.post('/oauth/token', async () => { });
2023-03-19 18:55:26 +01:00
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
2023-03-18 16:07:12 +01:00
2023-03-25 17:34:36 +01:00
await fastify.register(fastifyExpress);
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
2023-06-11 20:32:58 +02:00
(async (): Promise<Parameters<typeof done>> => {
// This should return client/redirectURI AND the error, or
// the handler can't send error to the redirection URI
2023-04-16 15:43:32 +02:00
2023-06-11 20:32:58 +02:00
const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest;
2023-03-19 18:55:26 +01:00
2023-06-12 23:04:35 +02:00
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
2023-04-16 15:43:32 +02:00
const clientUrl = validateClientId(clientID);
2023-03-19 18:55:26 +01:00
2023-04-10 17:48:45 +02:00
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') {
2023-04-10 14:49:18 +02:00
const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() === 'loopback') {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('client_id unexpectedly resolves to loopback IP.', 'invalid_request');
2023-04-10 14:49:18 +02:00
}
}
// Find client information from the remote.
const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href);
2023-04-16 15:43:32 +02:00
if (!clientInfo.redirectUris.includes(redirectURI)) {
2023-04-15 23:15:37 +02:00
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
2023-03-19 18:55:26 +01:00
}
2023-06-11 20:32:58 +02:00
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));
2023-06-11 20:32:58 +02:00
fastify.use('/oauth/authorize', this.#server.errorHandler({
mode: 'indirect',
modes: getQueryMode(this.config.url),
}));
fastify.use('/oauth/authorize', this.#server.errorHandler());
2023-03-25 17:34:36 +01:00
2023-03-25 20:57:56 +01:00
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
2023-03-26 20:03:18 +02:00
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
2023-06-12 23:04:35 +02:00
this.#logger.info(`Received the decision. Cancel: ${!!(req as any).body.cancel}`);
2023-03-26 20:03:18 +02:00
req.user = (req as any).body.login_token;
done(null, undefined);
}));
2023-04-15 23:15:37 +02:00
fastify.use('/oauth/decision', this.#server.errorHandler());
2023-03-26 20:03:18 +02:00
2023-04-02 13:20:41 +02:00
// Clients may use JSON or urlencoded
fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false }));
2023-03-26 20:03:18 +02:00
fastify.use('/oauth/token', bodyParser.json({ strict: true }));
fastify.use('/oauth/token', this.#server.token());
2023-04-15 23:15:37 +02:00
fastify.use('/oauth/token', this.#server.errorHandler());
2023-06-13 23:06:48 +02:00
// 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',
},
});
});
2023-03-18 16:07:12 +01:00
}
}