Skip to content

Commit dd3af77

Browse files
authored
Merge branch 'connectors-auth-code-grant' into issue-250979-user-token-so
2 parents 7cd83db + fe99023 commit dd3af77

5 files changed

Lines changed: 1501 additions & 0 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { OAuthAuthorizationService } from './oauth_authorization_service';
9+
import { actionsClientMock } from '../actions_client/actions_client.mock';
10+
import { createMockConnector } from '../application/connector/mocks';
11+
12+
const mockActionsClient = actionsClientMock.create();
13+
14+
const mockEncryptedSavedObjectsClient = {
15+
getDecryptedAsInternalUser: jest.fn(),
16+
};
17+
18+
const createService = () =>
19+
new OAuthAuthorizationService({
20+
actionsClient: mockActionsClient as never,
21+
encryptedSavedObjectsClient: mockEncryptedSavedObjectsClient as never,
22+
});
23+
24+
describe('OAuthAuthorizationService', () => {
25+
beforeEach(() => {
26+
jest.resetAllMocks();
27+
});
28+
29+
describe('getOAuthConfig', () => {
30+
it('returns OAuth config from decrypted secrets', async () => {
31+
const service = createService();
32+
const getResult = createMockConnector({
33+
id: 'connector-1',
34+
config: { authType: 'oauth_authorization_code' },
35+
});
36+
mockActionsClient.get.mockResolvedValue(getResult);
37+
mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
38+
attributes: {
39+
secrets: {
40+
authorizationUrl: 'https://provider.example.com/authorize',
41+
clientId: 'secret-client-id',
42+
scope: 'openid email',
43+
},
44+
config: {},
45+
},
46+
});
47+
48+
const result = await service.getOAuthConfig('connector-1', undefined);
49+
50+
expect(result).toEqual({
51+
authorizationUrl: 'https://provider.example.com/authorize',
52+
clientId: 'secret-client-id',
53+
scope: 'openid email',
54+
});
55+
expect(mockActionsClient.get).toHaveBeenCalledWith({ id: 'connector-1' });
56+
expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith(
57+
'action',
58+
'connector-1',
59+
{ namespace: undefined }
60+
);
61+
});
62+
63+
it('falls back to config when secrets are missing fields', async () => {
64+
const service = createService();
65+
const getResult = createMockConnector({
66+
id: 'connector-1',
67+
config: {
68+
authType: 'oauth_authorization_code',
69+
authorizationUrl: 'https://config-provider.example.com/authorize',
70+
clientId: 'config-client-id',
71+
scope: 'profile',
72+
},
73+
});
74+
mockActionsClient.get.mockResolvedValue(getResult);
75+
mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
76+
attributes: {
77+
secrets: {},
78+
config: {
79+
authorizationUrl: 'https://config-provider.example.com/authorize',
80+
clientId: 'config-client-id',
81+
scope: 'profile',
82+
},
83+
},
84+
});
85+
86+
const result = await service.getOAuthConfig('connector-1', undefined);
87+
88+
expect(result).toEqual({
89+
authorizationUrl: 'https://config-provider.example.com/authorize',
90+
clientId: 'config-client-id',
91+
scope: 'profile',
92+
});
93+
});
94+
95+
it('supports auth.type for OAuth validation', async () => {
96+
const service = createService();
97+
const getResult = createMockConnector({
98+
id: 'connector-1',
99+
config: { auth: { type: 'oauth_authorization_code' } },
100+
});
101+
mockActionsClient.get.mockResolvedValue(getResult);
102+
mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
103+
attributes: {
104+
secrets: {
105+
authorizationUrl: 'https://provider.example.com/authorize',
106+
clientId: 'client-id',
107+
},
108+
config: {},
109+
},
110+
});
111+
112+
const result = await service.getOAuthConfig('connector-1', undefined);
113+
114+
expect(result).toEqual({
115+
authorizationUrl: 'https://provider.example.com/authorize',
116+
clientId: 'client-id',
117+
scope: undefined,
118+
});
119+
});
120+
121+
it('passes namespace when provided', async () => {
122+
const service = createService();
123+
const getResult = createMockConnector({
124+
id: 'connector-1',
125+
config: { authType: 'oauth_authorization_code' },
126+
});
127+
mockActionsClient.get.mockResolvedValue(getResult);
128+
mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
129+
attributes: {
130+
secrets: {
131+
authorizationUrl: 'https://provider.example.com/authorize',
132+
clientId: 'client-id',
133+
},
134+
config: {},
135+
},
136+
});
137+
138+
await service.getOAuthConfig('connector-1', 'custom-namespace');
139+
140+
expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith(
141+
'action',
142+
'connector-1',
143+
{ namespace: 'custom-namespace' }
144+
);
145+
});
146+
147+
it('throws when connector does not use OAuth Authorization Code flow', async () => {
148+
const service = createService();
149+
const getResult = createMockConnector({
150+
id: 'connector-1',
151+
config: { authType: 'basic' },
152+
});
153+
mockActionsClient.get.mockResolvedValue(getResult);
154+
155+
await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow(
156+
'Connector does not use OAuth Authorization Code flow'
157+
);
158+
});
159+
160+
it.each([
161+
['authorizationUrl', { clientId: 'client-id' }],
162+
['clientId', { authorizationUrl: 'https://provider.example.com/authorize' }],
163+
])('throws when missing required OAuth config (%s)', async (_, secrets) => {
164+
const service = createService();
165+
const getResult = createMockConnector({
166+
id: 'connector-1',
167+
config: { authType: 'oauth_authorization_code' },
168+
});
169+
mockActionsClient.get.mockResolvedValue(getResult);
170+
mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
171+
attributes: {
172+
secrets,
173+
config: {},
174+
},
175+
});
176+
177+
await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow(
178+
'Connector missing required OAuth configuration (authorizationUrl, clientId)'
179+
);
180+
});
181+
});
182+
183+
describe('getRedirectUri', () => {
184+
it('returns the correct redirect URI', () => {
185+
expect(OAuthAuthorizationService.getRedirectUri('https://kibana.example.com')).toBe(
186+
'https://kibana.example.com/api/actions/connector/_oauth_callback'
187+
);
188+
});
189+
190+
it.each([[''], [undefined]])('throws when publicBaseUrl is %j', (publicBaseUrl) => {
191+
expect(() => OAuthAuthorizationService.getRedirectUri(publicBaseUrl)).toThrow(
192+
'Kibana public URL not configured. Please set server.publicBaseUrl in kibana.yml'
193+
);
194+
});
195+
});
196+
197+
describe('buildAuthorizationUrl', () => {
198+
it('builds URL with all required parameters', () => {
199+
const service = createService();
200+
201+
const url = service.buildAuthorizationUrl({
202+
baseAuthorizationUrl: 'https://provider.example.com/authorize',
203+
clientId: 'my-client-id',
204+
scope: 'openid email profile',
205+
redirectUri: 'https://kibana.example.com/api/actions/connector/_oauth_callback',
206+
state: 'random-state-value',
207+
codeChallenge: 'code-challenge-value',
208+
});
209+
210+
const parsed = new URL(url);
211+
expect(parsed.origin).toBe('https://provider.example.com');
212+
expect(parsed.pathname).toBe('/authorize');
213+
expect(parsed.searchParams.get('client_id')).toBe('my-client-id');
214+
expect(parsed.searchParams.get('response_type')).toBe('code');
215+
expect(parsed.searchParams.get('redirect_uri')).toBe(
216+
'https://kibana.example.com/api/actions/connector/_oauth_callback'
217+
);
218+
expect(parsed.searchParams.get('state')).toBe('random-state-value');
219+
expect(parsed.searchParams.get('code_challenge')).toBe('code-challenge-value');
220+
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
221+
expect(parsed.searchParams.get('scope')).toBe('openid email profile');
222+
});
223+
224+
it('excludes scope when not provided', () => {
225+
const service = createService();
226+
227+
const url = service.buildAuthorizationUrl({
228+
baseAuthorizationUrl: 'https://provider.example.com/authorize',
229+
clientId: 'my-client-id',
230+
redirectUri: 'https://kibana.example.com/callback',
231+
state: 'state-value',
232+
codeChallenge: 'challenge-value',
233+
});
234+
235+
const parsed = new URL(url);
236+
expect(parsed.searchParams.has('scope')).toBe(false);
237+
expect(parsed.searchParams.get('client_id')).toBe('my-client-id');
238+
});
239+
});
240+
});

0 commit comments

Comments
 (0)