diff --git a/packages/backend/package.json b/packages/backend/package.json
index b82bd73a0c..3bdf1b5b46 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -167,6 +167,7 @@
"typeorm": "0.3.17",
"typescript": "5.2.2",
"ulid": "2.3.0",
+ "uuid": "^9.0.1",
"vary": "1.1.2",
"web-push": "3.6.6",
"ws": "8.14.2",
@@ -212,6 +213,7 @@
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.4",
"@types/tmp": "0.2.4",
+ "@types/uuid": "^9.0.4",
"@types/vary": "1.1.0",
"@types/web-push": "3.6.0",
"@types/ws": "8.5.5",
diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
index cd96cda125..a7e2ea50b1 100644
--- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts
+++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
@@ -3,359 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import dns from 'node:dns/promises';
-import { fileURLToPath } from 'node:url';
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, MiddlewareRequest } from 'oauth2orize';
-import oauth2Pkce from 'oauth2orize-pkce';
-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 { mf2 } from 'microformats-parser';
-import { secureRndstr } from '@/misc/secure-rndstr.js';
-import { HttpRequestService } from '@/core/HttpRequestService.js';
-import { kinds } from '@/misc/api-permissions.js';
+import megalodon, { MegalodonInterface } from 'megalodon';
+import { v4 as uuid } from 'uuid';
+/* import { kinds } from '@/misc/api-permissions.js';
import type { Config } from '@/config.js';
-import { DI } from '@/di-symbols.js';
+import { DI } from '@/di-symbols.js'; */
import { bindThis } from '@/decorators.js';
-import type { AccessTokensRepository, UsersRepository } from '@/models/_.js';
-import { IdService } from '@/core/IdService.js';
-import { CacheService } from '@/core/CacheService.js';
-import type { MiLocalUser } from '@/models/User.js';
-import { MemoryKVCache } from '@/misc/cache.js';
-import { LoggerService } from '@/core/LoggerService.js';
-import Logger from '@/logger.js';
-import { StatusError } from '@/misc/status-error.js';
-import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify';
-const base64regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
-
-// TODO: Consider migrating to @node-oauth/oauth2-server once
-// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
-// 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
-// although Google has stricter rule.
-function validateClientId(raw: string): URL {
- // "Clients are identified by a [URL]."
- const url = ((): URL => {
- try {
- if (base64regex.test(raw)) return new URL(atob(raw));
- 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"
- // 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)"
-
- // "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")
-
- // "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"
- if (url.username || url.password) {
- throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
- }
-
- // ("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]."
- 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');
- }
-
- return url;
-}
-
-interface ClientInformation {
- id: string;
- redirectUris: string[];
- name: string;
-}
-
-// 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(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise {
- try {
- const res = await httpRequestService.send(id);
- const redirectUris: string[] = [];
-
- const linkHeader = res.headers.get('link');
- if (linkHeader) {
- redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
- }
-
- const text = await res.text();
- const fragment = JSDOM.fragment(text);
-
- redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href));
-
- let name = id;
- if (text) {
- const microformats = mf2(text, { baseUrl: res.url });
- const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0];
- if (typeof nameProperty === 'string') {
- name = nameProperty;
- }
- }
-
- return {
- id,
- redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
- name: typeof name === 'string' ? name : id,
- };
- } catch (err) {
- console.error(err);
- logger.error('Error while fetching client information', { err });
- if (err instanceof StatusError) {
- throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
- } else {
- throw new AuthorizationError('Failed to parse client information', 'server_error');
- }
- }
-}
-
-type OmitFirstElement = T extends [unknown, ...(infer R)]
- ? R
- : [];
-
-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 => {
- // 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);
- for (const [key, value] of Object.entries(params)) {
- parsed.searchParams.append(key, value as string);
- }
-
- return (res as OAuthHttpResponse).redirect(parsed.toString());
- },
- };
-}
-
-/**
- * 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); // expires after 5min
-
- 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'));
- return;
- }
- const loaded = this.#cache.get(transaction_id);
- if (!loaded) {
- cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied'));
- return;
- }
- cb(null, loaded);
- }
-
- store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
- const transactionId = secureRndstr(128);
- this.#cache.set(transactionId, oauth2);
- cb(null, transactionId);
- }
-
- remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void {
- this.#cache.delete(tid);
- cb();
- }
-}
@Injectable()
export class OAuth2ProviderService {
- #server = oauth2orize.createServer({
- store: new OAuth2Store(),
- });
- #logger: Logger;
constructor(
- @Inject(DI.config)
- private config: Config,
- private httpRequestService: HttpRequestService,
- @Inject(DI.accessTokensRepository)
- accessTokensRepository: AccessTokensRepository,
- idService: IdService,
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
- private cacheService: CacheService,
- loggerService: LoggerService,
- ) {
- this.#logger = loggerService.getLogger('oauth');
-
- const grantCodeCache = new MemoryKVCache<{
- clientId: string,
- userId: string,
- redirectUri: string,
- codeChallenge: string,
- scopes: string[],
-
- // fields to prevent multiple code use
- grantedToken?: string,
- revoked?: boolean,
- used?: boolean,
- }>(1000 * 60 * 5); // expires after 5m
-
- // 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}`);
-
- 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) {
- 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}]`);
-
- const code = secureRndstr(128);
- grantCodeCache.set(code, {
- clientId: client.id,
- userId: user.id,
- redirectUri,
- codeChallenge: (areq as OAuthParsedRequest).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> | undefined> => {
- this.#logger.info('Checking the received authorization code for the exchange');
- const granted = grantCodeCache.get(code);
- if (!granted) {
- return;
- }
-
- // 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;
- 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;
-
- const accessToken = secureRndstr(128);
- const now = new Date();
-
- // NOTE: we don't have a setup for automatic token expiration
- await accessTokensRepository.insert({
- id: idService.genId(),
- createdAt: now,
- lastUsedAt: now,
- userId: granted.userId,
- token: accessToken,
- hash: accessToken,
- name: granted.clientId,
- 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(' ') }];
- })().then(args => done(null, ...args ?? []), err => done(err));
- }));
- }
+ /* @Inject(DI.config)
+ private config: Config, */
+ ) { }
@bindThis
public async createServer(fastify: FastifyInstance): Promise {
// https://datatracker.ietf.org/doc/html/rfc8414.html
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
- fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => {
+ /* 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),
@@ -367,123 +36,68 @@ export class OAuth2ProviderService {
code_challenge_methods_supported: ['S256'],
authorization_response_iss_parameter_supported: true,
});
- });
+ }); */
fastify.get('/oauth/authorize', async (request, reply) => {
- const oauth2 = (request.raw as MiddlewareRequest).oauth2;
- if (!oauth2) {
- throw new Error('Unexpected lack of authorization information');
+ const query: any = request.query;
+ let param = "mastodon=true";
+ if (query.state) param += `&state=${query.state}`;
+ if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
+ const client = query.client_id ? query.client_id : "";
+ reply.redirect(
+ `${atob(client)}?${param}`,
+ );
+ });
+
+ fastify.post('/oauth/token', async (request, reply) => {
+ const body: any = request.body || request.query;
+ if (body.grant_type === "client_credentials") {
+ const ret = {
+ access_token: uuid(),
+ token_type: "Bearer",
+ scope: "read",
+ created_at: Math.floor(new Date().getTime() / 1000),
+ };
+ reply.send(ret);
+ }
+ let client_id: any = body.client_id;
+ const BASE_URL = `${request.protocol}://${request.hostname}`;
+ const generator = (megalodon as any).default;
+ const client = generator(BASE_URL, null) as MegalodonInterface;
+ let m = null;
+ let token = null;
+ if (body.code) {
+ //m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/);
+ //if (!m.length) {
+ // ctx.body = { error: "Invalid code" };
+ // return;
+ //}
+ //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}`
+ //console.log(body.code, token);
+ token = body.code;
+ }
+ if (client_id instanceof Array) {
+ client_id = client_id.toString();
+ } else if (!client_id) {
+ client_id = null;
+ }
+ try {
+ const atData = await client.fetchAccessToken(
+ client_id,
+ body.client_secret,
+ token ? token : "",
+ );
+ const ret = {
+ access_token: atData.accessToken,
+ token_type: "Bearer",
+ scope: body.scope || "read write follow push",
+ created_at: Math.floor(new Date().getTime() / 1000),
+ };
+ reply.send(ret);
+ } catch (err: any) {
+ /* console.error(err); */
+ reply.code(401).send(err.response);
}
-
- this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
-
- reply.header('Cache-Control', 'no-store');
- return await reply.view('oauth', {
- transactionId: oauth2.transactionID,
- clientName: oauth2.client.name,
- scope: oauth2.req.scope.join(' '),
- });
- });
- fastify.post('/oauth/decision', async () => { });
- fastify.post('/oauth/token', 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(fastifyExpress);
- fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
- (async (): Promise> => {
- // This should return client/redirectURI AND the error, or
- // the handler can't send error to the redirection URI
-
- const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest;
-
- this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
-
- const clientUrl = validateClientId(clientID);
-
- // https://indieauth.spec.indieweb.org/#client-information-discovery
- // "the server may want to resolve the domain name first and avoid fetching the document
- // if the IP address is within the loopback range defined by [RFC5735]
- // or any other implementation-specific internal IP address."
- 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') {
- throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
- }
- }
-
- // Find client information from the remote.
- const clientInfo = await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
-
- // Require the redirect URI to be included in an explicit list, 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');
- } */
-
- 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;
-
- // 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');
- }
- 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));
- fastify.use('/oauth/authorize', this.#server.errorHandler({
- 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) => {
- const { body } = req as OAuth2DecisionRequest;
- this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
- req.user = body.login_token;
- done(null, undefined);
- }));
- fastify.use('/oauth/decision', this.#server.errorHandler());
-
- // Clients may use JSON or urlencoded
- fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false }));
- fastify.use('/oauth/token', bodyParser.json({ strict: true }));
- fastify.use('/oauth/token', this.#server.token());
- fastify.use('/oauth/token', this.#server.errorHandler());
-
- // 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',
- },
- });
});
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c7bbc9531b..c870285af2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -389,6 +389,9 @@ importers:
ulid:
specifier: 2.3.0
version: 2.3.0
+ uuid:
+ specifier: ^9.0.1
+ version: 9.0.1
vary:
specifier: 1.1.2
version: 1.1.2
@@ -607,6 +610,9 @@ importers:
'@types/tmp':
specifier: 0.2.4
version: 0.2.4
+ '@types/uuid':
+ specifier: ^9.0.4
+ version: 9.0.4
'@types/vary':
specifier: 1.1.0
version: 1.1.0
@@ -19184,7 +19190,7 @@ packages:
reflect-metadata: 0.1.13
sha.js: 2.4.11
tslib: 2.5.3
- uuid: 9.0.0
+ uuid: 9.0.1
yargs: 17.6.2
transitivePeerDependencies:
- supports-color
@@ -19453,10 +19459,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
- /uuid@9.0.0:
- resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
- dev: false
-
/uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true