Resurrect deprecated and removed authentication settings. (#110835)

This commit is contained in:
Aleh Zasypkin 2021-09-03 11:42:12 +02:00 committed by GitHub
parent 23fa1b4c07
commit c42391ed3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 436 additions and 89 deletions

View file

@ -318,6 +318,52 @@ describe('AuthenticationService', () => {
});
});
describe('getServerBaseURL()', () => {
let getServerBaseURL: () => string;
beforeEach(() => {
mockStartAuthenticationParams.http.getServerInfo.mockReturnValue({
name: 'some-name',
protocol: 'socket',
hostname: 'test-hostname',
port: 1234,
});
service.setup(mockSetupAuthenticationParams);
service.start(mockStartAuthenticationParams);
getServerBaseURL = jest.requireMock('./authenticator').Authenticator.mock.calls[0][0]
.getServerBaseURL;
});
it('falls back to legacy server config if `public` config is not specified', async () => {
expect(getServerBaseURL()).toBe('socket://test-hostname:1234');
});
it('respects `public` config if it is specified', async () => {
mockStartAuthenticationParams.config.public = {
protocol: 'https',
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('https://test-hostname:1234');
mockStartAuthenticationParams.config.public = {
hostname: 'elastic.co',
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('socket://elastic.co:1234');
mockStartAuthenticationParams.config.public = {
port: 4321,
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('socket://test-hostname:4321');
mockStartAuthenticationParams.config.public = {
protocol: 'https',
hostname: 'elastic.co',
port: 4321,
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('https://elastic.co:4321');
});
});
describe('getCurrentUser()', () => {
let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null;
beforeEach(async () => {

View file

@ -41,7 +41,7 @@ interface AuthenticationServiceSetupParams {
}
interface AuthenticationServiceStartParams {
http: Pick<HttpServiceStart, 'auth' | 'basePath'>;
http: Pick<HttpServiceStart, 'auth' | 'basePath' | 'getServerInfo'>;
config: ConfigType;
clusterClient: IClusterClient;
legacyAuditLogger: SecurityAuditLogger;
@ -234,6 +234,17 @@ export class AuthenticationService {
license: this.license,
});
/**
* Retrieves server protocol name/host name/port and merges it with `xpack.security.public` config
* to construct a server base URL (deprecated, used by the SAML provider only).
*/
const getServerBaseURL = () => {
const { protocol, hostname, port } = http.getServerInfo();
const serverConfig = { protocol, hostname, port, ...config.public };
return `${serverConfig.protocol}://${serverConfig.hostname}:${serverConfig.port}`;
};
const getCurrentUser = (request: KibanaRequest) =>
http.auth.get<AuthenticatedUser>(request).state ?? null;
@ -247,6 +258,7 @@ export class AuthenticationService {
config: { authc: config.authc },
getCurrentUser,
featureUsageService,
getServerBaseURL,
license: this.license,
session,
});

View file

@ -55,6 +55,7 @@ function getMockOptions({
basePath: httpServiceMock.createSetupContract().basePath,
license: licenseMock.create(),
loggers: loggingSystemMock.create(),
getServerBaseURL: jest.fn(),
config: createConfig(
ConfigSchema.validate({ authc: { selector, providers, http } }),
loggingSystemMock.create().get(),

View file

@ -87,6 +87,7 @@ export interface AuthenticatorOptions {
loggers: LoggerFactory;
clusterClient: IClusterClient;
session: PublicMethodsOf<Session>;
getServerBaseURL: () => string;
}
// Mapping between provider key defined in the config and authentication
@ -216,6 +217,7 @@ export class Authenticator {
client: this.options.clusterClient.asInternalUser,
logger: this.options.loggers.get('tokens'),
}),
getServerBaseURL: this.options.getServerBaseURL,
};
this.providers = new Map(

View file

@ -17,6 +17,7 @@ export type MockAuthenticationProviderOptions = ReturnType<
export function mockAuthenticationProviderOptions(options?: { name: string }) {
return {
getServerBaseURL: () => 'test-protocol://test-hostname:1234',
client: elasticsearchServiceMock.createClusterClient(),
logger: loggingSystemMock.create().get(),
basePath: httpServiceMock.createBasePath(),

View file

@ -26,6 +26,7 @@ import type { Tokens } from '../tokens';
*/
export interface AuthenticationProviderOptions {
name: string;
getServerBaseURL: () => string;
basePath: HttpServiceSetup['basePath'];
getRequestOriginalURL: (
request: KibanaRequest,

View file

@ -39,23 +39,7 @@ describe('SAMLAuthenticationProvider', () => {
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
});
});
it('throws if `realm` option is not specified', () => {
const providerOptions = mockAuthenticationProviderOptions();
expect(() => new SAMLAuthenticationProvider(providerOptions)).toThrowError(
'Realm name must be specified'
);
expect(() => new SAMLAuthenticationProvider(providerOptions, {})).toThrowError(
'Realm name must be specified'
);
expect(() => new SAMLAuthenticationProvider(providerOptions, { realm: '' })).toThrowError(
'Realm name must be specified'
);
provider = new SAMLAuthenticationProvider(mockOptions);
});
describe('`login` method', () => {
@ -67,6 +51,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'some-token',
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
@ -108,13 +93,13 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'some-token',
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
useRelayStateDeepLink: true,
});
await expect(
@ -169,6 +154,10 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if realm from state is different from the realm provider is configured with.', async () => {
const request = httpServerMock.createKibanaRequest();
const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
provider = new SAMLAuthenticationProvider(customMockOptions, {
realm: 'test-realm',
});
await expect(
provider.login(
@ -184,7 +173,7 @@ describe('SAMLAuthenticationProvider', () => {
)
);
expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
expect(customMockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
});
it('redirects to the default location if state contains empty redirect URL.', async () => {
@ -195,6 +184,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'user-initiated-login-token',
refresh_token: 'user-initiated-login-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
@ -232,13 +222,13 @@ describe('SAMLAuthenticationProvider', () => {
body: {
access_token: 'user-initiated-login-token',
refresh_token: 'user-initiated-login-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
useRelayStateDeepLink: true,
});
await expect(
@ -275,6 +265,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.asInternalUser.transport.request.mockResolvedValue(
securityMock.createApiResponse({
body: {
realm: 'test-realm',
access_token: 'idp-initiated-login-token',
refresh_token: 'idp-initiated-login-refresh-token',
authentication: mockUser,
@ -301,7 +292,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
body: { ids: [], content: 'saml-response-xml' },
});
});
@ -342,20 +333,19 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'valid-token',
refresh_token: 'valid-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
);
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
useRelayStateDeepLink: true,
});
});
it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
useRelayStateDeepLink: false,
});
@ -454,10 +444,39 @@ describe('SAMLAuthenticationProvider', () => {
)
);
});
it('uses `realm` name instead of `acs` if it is specified for SAML authenticate request.', async () => {
// Create new provider instance with additional `realm` option.
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
});
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
})
).resolves.toEqual(
AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
state: {
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
user: mockUser,
})
);
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
});
});
});
describe('IdP initiated login with existing session', () => {
it('returns `notHandled` if new SAML Response is rejected.', async () => {
it('fails if new SAML Response is rejected and provider is not configured with specific realm.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const authorization = 'Bearer some-valid-token';
@ -466,6 +485,39 @@ describe('SAMLAuthenticationProvider', () => {
);
mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
realm: 'test-realm',
}
)
).resolves.toEqual(AuthenticationResult.failed(failureReason));
expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } });
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml' },
});
});
it('returns `notHandled` if new SAML Response is rejected and provider is configured with specific realm.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const authorization = 'Bearer some-valid-token';
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
});
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 503, body: {} })
);
mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
await expect(
provider.login(
request,
@ -521,7 +573,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@ -543,7 +595,7 @@ describe('SAMLAuthenticationProvider', () => {
),
],
[
'current session is is expired',
'current session is expired',
Promise.reject(
new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} }))
),
@ -568,6 +620,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
@ -595,7 +648,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@ -624,6 +677,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
realm: 'test-realm',
authentication: mockUser,
},
})
@ -632,7 +686,6 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
useRelayStateDeepLink: true,
});
@ -661,7 +714,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
body: { ids: [], content: 'saml-response-xml' },
});
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
@ -699,19 +752,16 @@ describe('SAMLAuthenticationProvider', () => {
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
realm: 'test-realm',
},
})
);
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/test-base-path/some-path#some-fragment',
},
{ realm: 'test-realm' }
)
provider.login(request, {
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/test-base-path/some-path#some-fragment',
})
).resolves.toEqual(
AuthenticationResult.redirectTo(
'https://idp-host/path/login?SAMLRequest=some%20request%20',
@ -728,7 +778,9 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
body: {
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
@ -742,6 +794,7 @@ describe('SAMLAuthenticationProvider', () => {
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
realm: 'test-realm',
},
})
);
@ -771,12 +824,62 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
body: {
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
});
it('uses `realm` name instead of `acs` if it is specified for SAML prepare request.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
// Create new provider instance with additional `realm` option.
const customMockOptions = mockAuthenticationProviderOptions();
provider = new SAMLAuthenticationProvider(customMockOptions, {
realm: 'test-realm',
});
customMockOptions.client.asInternalUser.transport.request.mockResolvedValue(
securityMock.createApiResponse({
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
realm: 'test-realm',
},
})
);
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/test-base-path/some-path#some-fragment',
},
{ realm: 'test-realm' }
)
).resolves.toEqual(
AuthenticationResult.redirectTo(
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-fragment',
realm: 'test-realm',
},
}
)
);
expect(customMockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
});
});
it('fails if SAML request preparation fails.', async () => {
const request = httpServerMock.createKibanaRequest();
@ -786,20 +889,18 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason);
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/test-base-path/some-path#some-fragment',
},
{ realm: 'test-realm' }
)
provider.login(request, {
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/test-base-path/some-path#some-fragment',
})
).resolves.toEqual(AuthenticationResult.failed(failureReason));
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
body: {
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});
});
@ -893,7 +994,6 @@ describe('SAMLAuthenticationProvider', () => {
state: {
requestId: 'some-request-id',
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
realm: 'test-realm',
},
}
)
@ -905,7 +1005,9 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
body: {
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});
@ -1112,6 +1214,13 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if realm from state is different from the realm provider is configured with.', async () => {
const request = httpServerMock.createKibanaRequest();
// Create new provider instance with additional `realm` option.
const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
provider = new SAMLAuthenticationProvider(customMockOptions, {
realm: 'test-realm',
});
await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual(
AuthenticationResult.failed(
Boom.unauthorized(
@ -1186,7 +1295,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
body: {
query_string: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});
@ -1305,7 +1417,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
body: {
query_string: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});
@ -1324,7 +1439,10 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/invalidate',
body: { query_string: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
body: {
query_string: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});

View file

@ -42,9 +42,10 @@ interface ProviderState extends Partial<TokenPair> {
redirectURL?: string;
/**
* The name of the SAML realm that was used to establish session.
* The name of the SAML realm that was used to establish session (may not be known during URL
* fragment capturing stage).
*/
realm: string;
realm?: string;
}
/**
@ -105,9 +106,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
static readonly type = 'saml';
/**
* Specifies Elasticsearch SAML realm name that Kibana should use.
* Optionally specifies Elasticsearch SAML realm name that Kibana should use. If not specified
* Kibana ACS URL is used for realm matching instead.
*/
private readonly realm: string;
private readonly realm?: string;
/**
* Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect
@ -121,12 +123,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
) {
super(options);
if (!samlOptions || !samlOptions.realm) {
throw new Error('Realm name must be specified');
}
this.realm = samlOptions.realm;
this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false;
this.realm = samlOptions?.realm;
this.useRelayStateDeepLink = samlOptions?.useRelayStateDeepLink ?? false;
}
/**
@ -144,7 +142,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
if (state?.realm && state.realm !== this.realm) {
if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.debug(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
@ -215,7 +213,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
if (state?.realm && state.realm !== this.realm) {
if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.debug(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
@ -274,7 +272,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// and state !== undefined). In this case case it'd be safer to trigger SP initiated logout
// for the new session as well.
const redirect = isIdPInitiatedSLORequest
? await this.performIdPInitiatedSingleLogout(request)
? await this.performIdPInitiatedSingleLogout(request, this.realm || state?.realm)
: state
? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!)
: // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901
@ -331,9 +329,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information,
// then something unexpected happened and we should fail.
const { requestId: stateRequestId, redirectURL: stateRedirectURL } = state || {
const {
requestId: stateRequestId,
redirectURL: stateRedirectURL,
realm: stateRealm,
} = state || {
requestId: '',
redirectURL: '',
realm: '',
};
if (state && !stateRequestId) {
const message = 'SAML response state does not have corresponding request id.';
@ -349,7 +352,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
: 'Login has been initiated by Identity Provider.'
);
let result: { access_token: string; refresh_token: string; authentication: AuthenticationInfo };
const providerRealm = this.realm || stateRealm;
let result: {
access_token: string;
refresh_token: string;
realm: string;
authentication: AuthenticationInfo;
};
try {
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`.
@ -362,7 +372,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
body: {
ids: !isIdPInitiatedLogin ? [stateRequestId] : [],
content: samlResponse,
realm: this.realm,
...(providerRealm ? { realm: providerRealm } : {}),
},
})
).body as any;
@ -372,7 +382,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Since we don't know upfront what realm is targeted by the Identity Provider initiated login
// there is a chance that it failed because of realm mismatch and hence we should return
// `notHandled` and give other SAML providers a chance to properly handle it instead.
return isIdPInitiatedLogin
return isIdPInitiatedLogin && providerRealm
? AuthenticationResult.notHandled()
: AuthenticationResult.failed(err);
}
@ -404,7 +414,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
state: {
accessToken: result.access_token,
refreshToken: result.refresh_token,
realm: this.realm,
realm: result.realm,
},
user: this.authenticationInfoToAuthenticatedUser(result.authentication),
}
@ -545,7 +555,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
authHeaders: {
authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(),
},
state: { accessToken, refreshToken, realm: this.realm },
state: { accessToken, refreshToken, realm: this.realm || state.realm },
}
);
}
@ -559,15 +569,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Trying to initiate SAML handshake.');
try {
// Prefer realm name if it's specified, otherwise fallback to ACS.
const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() };
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/prepare`.
// We can replace generic `transport.request` with a dedicated API method call once
// https://github.com/elastic/elasticsearch/issues/67189 is resolved.
const { id: requestId, redirect } = (
const { id: requestId, redirect, realm } = (
await this.options.client.asInternalUser.transport.request({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: this.realm },
body: preparePayload,
})
).body as any;
@ -575,7 +588,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Store request id in the state so that we can reuse it once we receive `SAMLResponse`.
return AuthenticationResult.redirectTo(redirect, {
state: { requestId, redirectURL, realm: this.realm },
state: { requestId, redirectURL, realm },
});
} catch (err) {
this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`);
@ -612,10 +625,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Calls `saml/invalidate` with the `SAMLRequest` query string parameter received from the Identity
* Provider and redirects user back to the Identity Provider if needed.
* @param request Request instance.
* @param realm Configured SAML realm name.
*/
private async performIdPInitiatedSingleLogout(request: KibanaRequest) {
private async performIdPInitiatedSingleLogout(request: KibanaRequest, realm?: string) {
this.logger.debug('Single logout has been initiated by the Identity Provider.');
// Prefer realm name if it's specified, otherwise fallback to ACS.
const invalidatePayload = realm ? { realm } : { acs: this.getACS() };
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`.
// We can replace generic `transport.request` with a dedicated API method call once
@ -627,7 +644,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Elasticsearch expects `query_string` without leading `?`, so we should strip it with `slice`.
body: {
query_string: request.url.search ? request.url.search.slice(1) : '',
realm: this.realm,
...invalidatePayload,
},
})
).body as any;
@ -637,6 +654,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return redirect;
}
/**
* Constructs and returns Kibana's Assertion consumer service URL.
*/
private getACS() {
return `${this.options.getServerBaseURL()}${
this.options.basePath.serverBasePath
}/api/security/v1/saml`;
}
/**
* Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will
* initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment

View file

@ -58,6 +58,7 @@ describe('config schema', () => {
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
"public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@ -109,6 +110,7 @@ describe('config schema', () => {
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
"public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@ -159,6 +161,7 @@ describe('config schema', () => {
"cookieName": "sid",
"enabled": true,
"loginAssistanceMessage": "",
"public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
@ -179,6 +182,109 @@ describe('config schema', () => {
);
});
describe('public', () => {
it('properly validates `protocol`', async () => {
expect(ConfigSchema.validate({ public: { protocol: 'http' } }).public).toMatchInlineSnapshot(`
Object {
"protocol": "http",
}
`);
expect(ConfigSchema.validate({ public: { protocol: 'https' } }).public)
.toMatchInlineSnapshot(`
Object {
"protocol": "https",
}
`);
expect(() => ConfigSchema.validate({ public: { protocol: 'ftp' } }))
.toThrowErrorMatchingInlineSnapshot(`
"[public.protocol]: types that failed validation:
- [public.protocol.0]: expected value to equal [http]
- [public.protocol.1]: expected value to equal [https]"
`);
expect(() => ConfigSchema.validate({ public: { protocol: 'some-protocol' } }))
.toThrowErrorMatchingInlineSnapshot(`
"[public.protocol]: types that failed validation:
- [public.protocol.0]: expected value to equal [http]
- [public.protocol.1]: expected value to equal [https]"
`);
});
it('properly validates `hostname`', async () => {
expect(ConfigSchema.validate({ public: { hostname: 'elastic.co' } }).public)
.toMatchInlineSnapshot(`
Object {
"hostname": "elastic.co",
}
`);
expect(ConfigSchema.validate({ public: { hostname: '192.168.1.1' } }).public)
.toMatchInlineSnapshot(`
Object {
"hostname": "192.168.1.1",
}
`);
expect(ConfigSchema.validate({ public: { hostname: '::1' } }).public).toMatchInlineSnapshot(`
Object {
"hostname": "::1",
}
`);
expect(() =>
ConfigSchema.validate({ public: { hostname: 'http://elastic.co' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.hostname]: value must be a valid hostname (see RFC 1123)."`
);
expect(() =>
ConfigSchema.validate({ public: { hostname: 'localhost:5601' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.hostname]: value must be a valid hostname (see RFC 1123)."`
);
});
it('properly validates `port`', async () => {
expect(ConfigSchema.validate({ public: { port: 1234 } }).public).toMatchInlineSnapshot(`
Object {
"port": 1234,
}
`);
expect(ConfigSchema.validate({ public: { port: 0 } }).public).toMatchInlineSnapshot(`
Object {
"port": 0,
}
`);
expect(ConfigSchema.validate({ public: { port: 65535 } }).public).toMatchInlineSnapshot(`
Object {
"port": 65535,
}
`);
expect(() =>
ConfigSchema.validate({ public: { port: -1 } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: Value must be equal to or greater than [0]."`
);
expect(() =>
ConfigSchema.validate({ public: { port: 65536 } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: Value must be equal to or lower than [65535]."`
);
expect(() =>
ConfigSchema.validate({ public: { port: '56x1' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: expected value of type [number] but got [string]"`
);
});
});
describe('authc.oidc', () => {
it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => {
expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'] } })).toThrow(
@ -255,14 +361,42 @@ describe('config schema', () => {
});
describe('authc.saml', () => {
it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
expect(() => ConfigSchema.validate({ authc: { providers: ['saml'] } })).toThrow(
'[authc.saml.realm]: expected value of type [string] but got [undefined]'
);
it('does not fail if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
expect(ConfigSchema.validate({ authc: { providers: ['saml'] } }).authc)
.toMatchInlineSnapshot(`
Object {
"http": Object {
"autoSchemesEnabled": true,
"enabled": true,
"schemes": Array [
"apikey",
],
},
"providers": Array [
"saml",
],
"saml": Object {},
"selector": Object {},
}
`);
expect(() => ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } })).toThrow(
'[authc.saml.realm]: expected value of type [string] but got [undefined]'
);
expect(ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }).authc)
.toMatchInlineSnapshot(`
Object {
"http": Object {
"autoSchemesEnabled": true,
"enabled": true,
"schemes": Array [
"apikey",
],
},
"providers": Array [
"saml",
],
"saml": Object {},
"selector": Object {},
}
`);
expect(
ConfigSchema.validate({

View file

@ -228,6 +228,11 @@ export const ConfigSchema = schema.object({
sameSiteCookies: schema.maybe(
schema.oneOf([schema.literal('Strict'), schema.literal('Lax'), schema.literal('None')])
),
public: schema.object({
protocol: schema.maybe(schema.oneOf([schema.literal('http'), schema.literal('https')])),
hostname: schema.maybe(schema.string({ hostname: true })),
port: schema.maybe(schema.number({ min: 0, max: 65535 })),
}),
authc: schema.object({
selector: schema.object({ enabled: schema.maybe(schema.boolean()) }),
providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], {
@ -256,7 +261,7 @@ export const ConfigSchema = schema.object({
saml: providerOptionsSchema(
'saml',
schema.object({
realm: schema.string(),
realm: schema.maybe(schema.string()),
maxRedirectURLSize: schema.maybe(schema.byteSize()),
})
),

View file

@ -286,7 +286,7 @@ describe('Config Deprecations', () => {
const { messages } = applyConfigDeprecations(cloneDeep(config));
expect(messages).toMatchInlineSnapshot(`
Array [
"\\"xpack.security.authc.providers.saml.<provider-name>.maxRedirectURLSize\\" is is no longer used.",
"\\"xpack.security.authc.providers.saml.<provider-name>.maxRedirectURLSize\\" is no longer used.",
]
`);
});

View file

@ -13,6 +13,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
unused,
}) => [
rename('sessionTimeout', 'session.idleTimeout'),
rename('authProviders', 'authc.providers'),
rename('audit.appender.kind', 'audit.appender.type'),
rename('audit.appender.layout.kind', 'audit.appender.layout.type'),
@ -121,7 +122,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
}),
message: i18n.translate('xpack.security.deprecations.maxRedirectURLSizeMessage', {
defaultMessage:
'"xpack.security.authc.providers.saml.<provider-name>.maxRedirectURLSize" is is no longer used.',
'"xpack.security.authc.providers.saml.<provider-name>.maxRedirectURLSize" is no longer used.',
}),
correctiveActions: {
manualSteps: [

View file

@ -50,7 +50,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${plugin}`,
`--xpack.security.authc.providers=${JSON.stringify(['oidc', 'basic'])}`,
`--xpack.security.authProviders=${JSON.stringify(['oidc', 'basic'])}`,
'--xpack.security.authc.oidc.realm="oidc1"',
],
},