diff --git a/packages/@n8n/client-oauth2/src/types.ts b/packages/@n8n/client-oauth2/src/types.ts index 54d920c7f6d..2772c1e69c2 100644 --- a/packages/@n8n/client-oauth2/src/types.ts +++ b/packages/@n8n/client-oauth2/src/types.ts @@ -33,33 +33,3 @@ export interface OAuth2AccessTokenErrorResponse extends Record 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[]; -} diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 934de34ad2b..7df56fa5c4c 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -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({ 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({ user, query: { id: '1' } }); + await expect(controller.getAuthUri(req)).rejects.toThrowError( + /Invalid client registration response/, + ); + }); }); describe('handleCallback', () => { diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index d53a90b20cc..c879dab7239 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -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( + const { data } = await axios.get( `${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( + 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) { diff --git a/packages/cli/src/controllers/oauth/oauth2-dynamic-client-registration.schema.ts b/packages/cli/src/controllers/oauth/oauth2-dynamic-client-registration.schema.ts new file mode 100644 index 00000000000..f327cf542ec --- /dev/null +++ b/packages/cli/src/controllers/oauth/oauth2-dynamic-client-registration.schema.ts @@ -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(), +});