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'; import type Redis from 'ioredis'; // 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 // 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'); } // 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; } 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); let redirectUri = parseLinkHeader(res.headers.get('link'))?.redirect_uri?.url; if (redirectUri) { return new URL(redirectUri, res.url).toString(); } redirectUri = JSDOM.fragment(await res.text()).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 { 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(); } } @Injectable() export class OAuth2ProviderService { #provider: Provider; constructor( @Inject(DI.config) private config: Config, @Inject(DI.redis) redisClient: Redis.Redis, 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(redisClient, 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 () => { }); fastify.get('/oauth/interaction/:uid', async () => { }); fastify.get('/oauth/interaction/:uid/login', async () => { }); await fastify.register(fastifyMiddie); fastify.use('/oauth', this.#provider.callback()); } }