fix(core): Add response validation for requests during DCR (#22076)

This commit is contained in:
RomanDavydchuk 2025-11-27 09:44:55 +02:00 committed by GitHub
parent d485fc9f6f
commit 9ab9d1c8f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 117 additions and 45 deletions

View File

@ -33,33 +33,3 @@ export interface OAuth2AccessTokenErrorResponse extends Record<string, unknown>
error_description?: string;
error_uri?: string;
}
/**
* OAuth 2.0 Authorization Server Metadata
* Based on RFC 8414: https://www.rfc-editor.org/rfc/rfc8414.html
*/
export interface OAuthAuthorizationServerMetadata {
/** The authorization server's identifier */
issuer: string;
/** URL of the authorization server's authorization endpoint */
authorization_endpoint: string;
/** URL of the authorization server's token endpoint */
token_endpoint: string;
/** URL of the authorization server's dynamic client registration endpoint */
registration_endpoint: string;
/** Array of OAuth 2.0 response_type values supported */
response_types_supported: string[];
/** Array of OAuth 2.0 grant type values supported */
grant_types_supported: string[];
/** Array of client authentication methods supported by the token endpoint */
token_endpoint_auth_methods_supported: string[];
/** Array of PKCE code challenge methods supported */
code_challenge_methods_supported: string[];
}

View File

@ -246,6 +246,81 @@ describe('OAuth2CredentialController', () => {
});
},
);
it.each([
[
{
authorization_endpoint: 'invalid',
token_endpoint: 'https://example.com/token',
registration_endpoint: 'https://example.com/registration',
},
],
[
{
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'invalid',
registration_endpoint: 'https://example.com/registration',
},
],
[
{
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'https://example.com/token',
registration_endpoint: 'invalid',
},
],
])(
'should throw a BadRequestError when OAuth2 server metadata is invalid',
async (response) => {
credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({
useDynamicClientRegistration: true,
serverUrl: 'https://example.com',
});
nock('https://example.com')
.get('/.well-known/oauth-authorization-server')
.reply(200, response);
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
/Invalid OAuth2 server metadata/,
);
},
);
it('should throw a BadRequestError when the registration response is invalid', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({
useDynamicClientRegistration: true,
serverUrl: 'https://example.com',
});
nock('https://example.com')
.get('/.well-known/oauth-authorization-server')
.reply(200, {
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'https://example.com/token',
registration_endpoint: 'https://example.com/registration',
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
code_challenge_methods_supported: ['S256'],
})
.post('/registration', {
redirect_uris: ['http://localhost:5678/rest/oauth2-credential/callback'],
token_endpoint_auth_method: 'client_secret_basic',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: 'n8n',
client_uri: 'https://n8n.io/',
})
.reply(200, { invalid: 'invalid' });
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
/Invalid client registration response/,
);
});
});
describe('handleCallback', () => {

View File

@ -3,7 +3,6 @@ import type {
OAuth2AuthenticationMethod,
OAuth2CredentialData,
OAuth2GrantType,
OAuthAuthorizationServerMetadata,
} from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import { Get, RestController } from '@n8n/decorators';
@ -21,12 +20,16 @@ import {
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller';
import {
oAuthAuthorizationServerMetadataSchema,
dynamicClientRegistrationResponseSchema,
} from './oauth2-dynamic-client-registration.schema';
import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { OAuthRequest } from '@/requests';
import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller';
@RestController('/oauth2-credential')
export class OAuth2CredentialController extends AbstractOAuthController {
override oauthVersion = 2;
@ -60,25 +63,27 @@ export class OAuth2CredentialController extends AbstractOAuthController {
if (oauthCredentials.useDynamicClientRegistration && oauthCredentials.serverUrl) {
const serverUrl = new URL(oauthCredentials.serverUrl);
const { data } = await axios.get<OAuthAuthorizationServerMetadata>(
const { data } = await axios.get<unknown>(
`${serverUrl.origin}/.well-known/oauth-authorization-server`,
);
const { authorization_endpoint, token_endpoint, registration_endpoint } = data;
if (!authorization_endpoint || !token_endpoint || !registration_endpoint) {
const metadataValidation = oAuthAuthorizationServerMetadataSchema.safeParse(data);
if (!metadataValidation.success) {
throw new BadRequestError(
'The OAuth2 server does not support dynamic client registration. Missing endpoints in metadata.',
`Invalid OAuth2 server metadata: ${metadataValidation.error.issues.map((e) => e.message).join(', ')}`,
);
}
const { authorization_endpoint, token_endpoint, registration_endpoint } =
metadataValidation.data;
oauthCredentials.authUrl = authorization_endpoint;
oauthCredentials.accessTokenUrl = token_endpoint;
toUpdate.authUrl = authorization_endpoint;
toUpdate.accessTokenUrl = token_endpoint;
const { grantType, authentication } = this.selectGrantTypeAndAuthenticationMethod(
data.grant_types_supported,
data.token_endpoint_auth_methods_supported,
data.code_challenge_methods_supported,
metadataValidation.data.grant_types_supported ?? ['authorization_code', 'implicit'],
metadataValidation.data.token_endpoint_auth_methods_supported ?? ['client_secret_basic'],
metadataValidation.data.code_challenge_methods_supported ?? [],
);
oauthCredentials.grantType = grantType;
toUpdate.grantType = grantType;
@ -102,12 +107,19 @@ export class OAuth2CredentialController extends AbstractOAuthController {
await this.externalHooks.run('oauth2.dynamicClientRegistration', [registerPayload]);
const { data: registerResult } = await axios.post<{
client_id: string;
client_secret?: string;
}>(registration_endpoint, registerPayload);
const { data: registerResult } = await axios.post<unknown>(
registration_endpoint,
registerPayload,
);
const registrationValidation =
dynamicClientRegistrationResponseSchema.safeParse(registerResult);
if (!registrationValidation.success) {
throw new BadRequestError(
`Invalid client registration response: ${registrationValidation.error.issues.map((e) => e.message).join(', ')}`,
);
}
const { client_id, client_secret } = registerResult;
const { client_id, client_secret } = registrationValidation.data;
oauthCredentials.clientId = client_id;
toUpdate.clientId = client_id;
if (client_secret) {

View File

@ -0,0 +1,15 @@
import { z } from 'zod/v4';
export const oAuthAuthorizationServerMetadataSchema = z.object({
authorization_endpoint: z.url({ protocol: /^https?$/ }),
token_endpoint: z.url({ protocol: /^https?$/ }),
registration_endpoint: z.url({ protocol: /^https?$/ }),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
code_challenge_methods_supported: z.array(z.string()).optional(),
});
export const dynamicClientRegistrationResponseSchema = z.object({
client_id: z.string(),
client_secret: z.string().optional(),
});