fixed Oauth2 provider

This commit is contained in:
fly_mc 2024-11-20 14:21:48 +08:00
parent 03d734aac7
commit df0f5505e5

View file

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import dns from 'node:dns/promises';
import { fileURLToPath } from 'node:url';
@ -66,29 +61,23 @@ const kinds = [
function validateClientId(raw: string): URL {
try {
const url = new URL(raw);
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');
}
const segments = url.pathname.split('/');
if (segments.includes('.') || segments.includes('..')) {
throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
}
if (url.hash) {
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
}
if (url.username || url.password) {
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
}
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;
} catch (e) {
throw new AuthorizationError('client_id must be a valid URL', 'invalid_request');
@ -130,19 +119,18 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
}
}
function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
function getClient(BASE_URL: string, authorization: string | undefined, type: string = 'misskey'): MegalodonInterface {
const accessTokenArr = authorization?.split(' ') ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
return client;
return generator(type, BASE_URL, accessToken) as MegalodonInterface;
}
@Injectable()
export class OAuth2ProviderService {
#logger: Logger;
#server = oauth2orize.createServer();
#clientCache = new MemoryKVCache<ClientInformation>(60 * 60 * 1000); // 1 hour
#clientCache = new MemoryKVCache<ClientInformation>(60 * 60 * 1000);
constructor(
@Inject(DI.config)
@ -154,8 +142,6 @@ export class OAuth2ProviderService {
this.#server.grant(oauth2Pkce.extensions());
}
// https://datatracker.ietf.org/doc/html/rfc8414.html
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
public generateRFC8414() {
return {
issuer: this.config.url,
@ -181,7 +167,6 @@ export class OAuth2ProviderService {
});
fastify.register(fastifyCors);
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
@ -214,7 +199,6 @@ export class OAuth2ProviderService {
fastify.register(multer.contentParser);
// Handle both Misskey and Mastodon OAuth flows
fastify.get('/authorize', async (request, reply) => {
const query: any = request.query;
let param = "mastodon=true";
@ -222,7 +206,6 @@ export class OAuth2ProviderService {
if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
const client = query.client_id ? query.client_id : "";
// Misskey OAuth validation
if (client) {
try {
const clientUrl = validateClientId(Buffer.from(client.toString(), 'base64').toString());
@ -232,14 +215,10 @@ export class OAuth2ProviderService {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
}
}
// Cache client information
const clientInfo = await this.#clientCache.fetch(
clientUrl.href,
() => discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href),
);
// Store client info for token endpoint
await this.#clientCache.set(`client:${query.code_challenge}`, clientInfo);
} catch (err) {
reply.code(400).send({ error: err.message });
@ -247,9 +226,7 @@ export class OAuth2ProviderService {
}
}
reply.redirect(
`${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
);
reply.redirect(`${Buffer.from(client.toString(), 'base64').toString()}?${param}`);
});
fastify.get('/authorize/', (request, reply) => {
@ -258,8 +235,8 @@ export class OAuth2ProviderService {
fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => {
const body: any = request.body || request.query;
const isMastodon = body.client_id?.includes('mastodon');
// Handle client_credentials grant type
if (body.grant_type === "client_credentials") {
const ret = {
access_token: uuid(),
@ -273,52 +250,56 @@ export class OAuth2ProviderService {
let client_id: any = body.client_id;
const BASE_URL = `${request.protocol}://${request.hostname}`;
const client = getClient(BASE_URL, '');
let token = body.code || null;
// Normalize client_id
if (client_id instanceof Array) {
client_id = client_id.toString();
} else if (!client_id) {
client_id = null;
}
// PKCE Validation for authorization_code grant
if (body.grant_type === 'authorization_code') {
if (!body.code_verifier) {
reply.code(400).send({
error: 'invalid_request',
error_description: 'code_verifier is required for authorization_code grant type'
});
return;
}
// Verify the stored client information matches
const storedClientInfo = await this.#clientCache.get(`client:${body.code_verifier}`);
if (!storedClientInfo) {
reply.code(400).send({
error: 'invalid_grant',
error_description: 'Invalid authorization code'
});
return;
}
}
try {
let finalToken = token;
if (finalToken && finalToken.includes('-')) {
finalToken = token;
} else if (finalToken) {
const matches = finalToken.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 (matches) {
finalToken = `${matches[1]}-${matches[2]}-${matches[3]}-${matches[4]}-${matches[5]}`;
}
}
const client = getClient(BASE_URL, '', isMastodon ? 'mastodon' : 'misskey');
const atData = await client.fetchAccessToken(
client_id,
body.client_secret,
token || "",
finalToken || "",
{
scope: body.scope || "read write follow push",
grant_type: body.grant_type,
redirect_uri: body.redirect_uri,
code_verifier: body.code_verifier,
}
);
const ret = {
reply.send({
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) {
reply.code(401).send(err.response.data);
this.#logger.error('Token exchange failed', {
error: err,
clientId: client_id,
code: token
});
reply.code(401).send(err.response?.data || {
error: 'invalid_grant',
error_description: 'The provided authorization grant is invalid'
});
}
});
@ -340,8 +321,6 @@ export class OAuth2ProviderService {
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(
`${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
);
reply.redirect(`${Buffer.from(client.toString(), 'base64').toString()}?${param}`);
}
}