This commit is contained in:
Kagami Sascha Rosylight
2023-03-18 16:07:12 +01:00
parent 1b1f82a2e2
commit 0621e94c7d
5 changed files with 418 additions and 16 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<void> {
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;

View File

@@ -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<string | void> {
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<HTMLLinkElement>('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<void> {
console.log('oauth upsert', id, payload, expiresIn);
throw new Error('Method not implemented.');
}
async find(id: string): Promise<void | AdapterPayload> {
// 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 <link> element.');
}
return {
client_id: id,
token_endpoint_auth_method: 'none',
redirect_uris: [redirectUri],
};
}
async findByUserCode(userCode: string): Promise<void | AdapterPayload> {
console.log('oauth findByUserCode', userCode);
throw new Error('Method not implemented.');
}
async findByUid(uid: string): Promise<void | AdapterPayload> {
console.log('oauth findByUid', uid);
throw new Error('Method not implemented.');
}
async consume(id: string): Promise<void> {
console.log('oauth consume', id);
throw new Error('Method not implemented.');
}
async destroy(id: string): Promise<void | undefined> {
console.log('oauth destroy', id);
throw new Error('Method not implemented.');
}
async revokeByGrantId(grantId: string): Promise<void | undefined> {
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<Account | undefined> {
console.log(id);
return undefined;
},
adapter(): MisskeyAdapter {
return new MisskeyAdapter(httpRequestService);
},
async renderError(ctx, out, error): Promise<void> {
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<void> {
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<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),
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());
}
}