mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 17:57:06 +02:00
fix(core): Add response validation for requests during DCR (#22076)
This commit is contained in:
parent
d485fc9f6f
commit
9ab9d1c8f1
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user