Skip to content

Commit 5286e34

Browse files
authored
feat(security, authc): implement optional "minimal" authentication mode (elastic#251119)
## Summary This PR introduces a **"minimal" authentication mode** for Kibana HTTP routes and updates the authentication provider interfaces to pass full session objects instead of raw provider state. ### Minimal authentication mode Adds a new `'minimal'` option for `security.authc.enabled` on route definitions. When a route opts into minimal authentication, Kibana **skips the Elasticsearch `_authenticate` API call** and instead returns a lightweight user proxy constructed from session data already stored in the Kibana session document. This is useful for high-frequency or performance-sensitive endpoints where: - The session has already been fully authenticated on login - Credential validation will happen naturally when the request reaches Elasticsearch - The overhead of an extra `_authenticate` round-trip is unnecessary The minimal user proxy provides `username`, `authentication_provider`, `profile_uid`, and `enabled`, but deliberately **throws** if code tries to access properties that require a real ES authenticate call (`authentication_realm`, `lookup_realm`, `authentication_type`, `elastic_cloud_user`). ### Route type system changes The `RouteAuthc` type is expanded from `AuthcEnabled | AuthcDisabled` to `AuthcEnabled | AuthcMinimal | AuthcOptional | AuthcDisabled`: - **`AuthcEnabled`** (`enabled: true`) - full authentication (default, unchanged) - **`AuthcMinimal`** (`enabled: 'minimal'`) - new, session-only authentication, no ES call, requires `reason` - **`AuthcOptional`** (`enabled: 'optional'`) - existing behavior, now requires an explicit `reason` - **`AuthcDisabled`** (`enabled: false`) - no authentication (unchanged) Both `'minimal'` and `'optional'` now require a `reason` string explaining why the route deviates from the default. Existing `'optional'` routes are migrated to the new shape with explicit reasons. ### Provider interface refactoring All authentication provider methods (`login`, `authenticate`, `logout`) now receive the full `SessionValue<TState>` object instead of raw `state`: - `BaseAuthenticationProvider` becomes generic: `BaseAuthenticationProvider<TState>` - `logout(request, state?)` → `logout(request, session?)` - Providers extract `state` from `session?.state` internally when needed - The `Authenticator` passes `sessionValue` (not `sessionValue.state`) to providers - This gives providers access to session metadata (`username`, `provider`, `userProfileId`) needed for minimal auth ### Route migrations Existing routes using `options.authRequired: 'optional'` are migrated to the new `security.authc` config: - `GET /api/status` - status endpoint (k8s probes) - `GET /api/banners/info` - banner info on login page - `GET /api/custom_branding/info` - custom branding on login page - `GET /bootstrap-anonymous.js` - anonymous bootstrap script - `POST /internal/security/analytics/_record_violations` - CSP violation reports - `POST /login` - login page view route (reason added) - Mock IDP plugin routes (testing) ### Integration tests Every authentication provider (anonymous, basic/token, SAML, OIDC, PKI, Kerberos) gets a `'should support minimal authentication'` integration test that: 1. Authenticates and obtains a session cookie via the provider's normal flow 2. Hits **both** `/authentication/fast/me` (minimal) and `/internal/security/me` (default) 3. Asserts that `username` and `authentication_provider` match between both responses 4. Asserts the key behavioral difference: minimal mode does **not** return `authentication_realm` (since ES `_authenticate` is skipped), while default mode does __Assisted by:__ Claude Opus 4.6 (via OpenCode and GitHub Copilot). Closes elastic#244928
1 parent 5fbc247 commit 5286e34

48 files changed

Lines changed: 1651 additions & 640 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/kbn-mock-idp-plugin/server/plugin.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = as
262262
}),
263263
},
264264
security: {
265-
authc: { enabled: 'optional' },
265+
authc: { enabled: 'optional', reason: 'Mock IDP plugin for testing' },
266266
authz: { enabled: false, reason: 'Mock IDP plugin for testing' },
267267
},
268268
},
@@ -312,8 +312,13 @@ export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = as
312312
credential: schema.string(),
313313
}),
314314
},
315-
options: { authRequired: 'optional' },
316-
security: { authz: { enabled: false, reason: 'Mock IDP plugin for testing' } },
315+
security: {
316+
authc: {
317+
enabled: 'optional',
318+
reason: 'Mock IDP plugin for testing UIAM operations',
319+
},
320+
authz: { enabled: false, reason: 'Mock IDP plugin for testing' },
321+
},
317322
},
318323
async (context, request, response) => {
319324
try {
@@ -366,8 +371,13 @@ export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = as
366371
keys: schema.arrayOf(schema.string(), { minSize: 1 }),
367372
}),
368373
},
369-
options: { authRequired: 'optional' },
370-
security: { authz: { enabled: false, reason: 'Mock IDP plugin for testing' } },
374+
security: {
375+
authc: {
376+
enabled: 'optional',
377+
reason: 'Mock IDP plugin for testing UIAM operations',
378+
},
379+
authz: { enabled: false, reason: 'Mock IDP plugin for testing' },
380+
},
371381
},
372382
async (context, request, response) => {
373383
try {

src/core/packages/capabilities/server-internal/src/capabilities_service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe('CapabilitiesService', () => {
5959
},
6060
authc: {
6161
enabled: 'optional',
62+
reason: expect.any(String),
6263
},
6364
},
6465
}),

src/core/packages/capabilities/server-internal/src/routes/resolve_capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function registerCapabilitiesRoutes(router: IRouter, resolver: Capabiliti
2424
},
2525
authc: {
2626
enabled: 'optional',
27+
reason: 'This route can be accessed by both authenticated and unauthenticated users',
2728
},
2829
},
2930
validate: {

src/core/packages/http/router-server-internal/src/security_route_config_validator.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
*/
99

1010
import { validRouteSecurity } from './security_route_config_validator';
11-
import { ReservedPrivilegesSet } from '@kbn/core-http-server';
11+
import { ReservedPrivilegesSet, type RouteSecurity } from '@kbn/core-http-server';
12+
import type { DeepPartial } from '@kbn/utility-types';
1213

1314
describe('RouteSecurity validation', () => {
1415
it('should pass validation for valid route security with authz enabled and valid required privileges', () => {
@@ -19,6 +20,7 @@ describe('RouteSecurity validation', () => {
1920
},
2021
authc: {
2122
enabled: 'optional',
23+
reason: 'some reason',
2224
},
2325
})
2426
).not.toThrow();
@@ -161,6 +163,40 @@ describe('RouteSecurity validation', () => {
161163
);
162164
});
163165

166+
it('should fail validation when authc is minimal but reason is missing', () => {
167+
const routeSecurity = {
168+
authz: {
169+
requiredPrivileges: ['read'],
170+
},
171+
authc: {
172+
enabled: 'minimal',
173+
},
174+
};
175+
176+
expect(() =>
177+
validRouteSecurity(routeSecurity as DeepPartial<RouteSecurity>)
178+
).toThrowErrorMatchingInlineSnapshot(
179+
`"[authc.reason]: expected value of type [string] but got [undefined]"`
180+
);
181+
});
182+
183+
it('should fail validation when authc is optional but reason is missing', () => {
184+
const routeSecurity = {
185+
authz: {
186+
requiredPrivileges: ['read'],
187+
},
188+
authc: {
189+
enabled: 'optional',
190+
},
191+
};
192+
193+
expect(() =>
194+
validRouteSecurity(routeSecurity as DeepPartial<RouteSecurity>)
195+
).toThrowErrorMatchingInlineSnapshot(
196+
`"[authc.reason]: expected value of type [string] but got [undefined]"`
197+
);
198+
});
199+
164200
it('should fail validation when authc is disabled but reason is missing', () => {
165201
const routeSecurity = {
166202
authz: {
@@ -193,6 +229,20 @@ describe('RouteSecurity validation', () => {
193229
);
194230
});
195231

232+
it('should pass validation when authc is minimal', () => {
233+
expect(() =>
234+
validRouteSecurity({
235+
authz: {
236+
requiredPrivileges: ['read'],
237+
},
238+
authc: {
239+
enabled: 'minimal',
240+
reason: 'some reason',
241+
},
242+
})
243+
).not.toThrow();
244+
});
245+
196246
it('should pass validation when authc is optional', () => {
197247
expect(() =>
198248
validRouteSecurity({
@@ -201,6 +251,7 @@ describe('RouteSecurity validation', () => {
201251
},
202252
authc: {
203253
enabled: 'optional',
254+
reason: 'some reason',
204255
},
205256
})
206257
).not.toThrow();

src/core/packages/http/router-server-internal/src/security_route_config_validator.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,17 @@ const authzSchema = schema.object({
149149
});
150150

151151
const authcSchema = schema.object({
152-
enabled: schema.oneOf([schema.literal(true), schema.literal('optional'), schema.literal(false)]),
152+
enabled: schema.oneOf([
153+
schema.literal(true),
154+
schema.literal('optional'),
155+
schema.literal('minimal'),
156+
schema.literal(false),
157+
]),
153158
reason: schema.conditional(
154159
schema.siblingRef('enabled'),
155-
schema.literal(false),
156-
schema.string(),
157-
schema.never()
160+
schema.literal(true),
161+
schema.never(),
162+
schema.string()
158163
),
159164
});
160165

src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ describe('Versioned route', () => {
667667
},
668668
authc: {
669669
enabled: 'optional',
670+
reason: 'some reason',
670671
},
671672
};
672673
const securityConfig2: RouteSecurity = {
@@ -813,6 +814,7 @@ describe('Versioned route', () => {
813814
},
814815
authc: {
815816
enabled: 'optional',
817+
reason: 'some reason',
816818
},
817819
};
818820

@@ -863,6 +865,7 @@ describe('Versioned route', () => {
863865
},
864866
authc: {
865867
enabled: 'optional',
868+
reason: 'some reason',
866869
},
867870
};
868871

@@ -888,7 +891,7 @@ describe('Versioned route', () => {
888891
// @ts-expect-error for test purpose
889892
const security = route.getSecurity({ headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1' } });
890893

891-
expect(security.authc).toEqual({ enabled: 'optional' });
894+
expect(security.authc).toEqual({ enabled: 'optional', reason: 'some reason' });
892895

893896
expect(security.authz).toEqual({ requiredPrivileges: ['foo', 'bar'] });
894897
});

src/core/packages/http/server-internal/src/http_server.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,7 +2139,7 @@ test('exposes authentication details of incoming request to a route handler', as
21392139
path: '/foo',
21402140
validate: false,
21412141
security: {
2142-
authc: { enabled: 'optional' },
2142+
authc: { enabled: 'optional', reason: 'test' },
21432143
authz: { enabled: false, reason: 'test' },
21442144
},
21452145
},
@@ -2181,7 +2181,48 @@ test('exposes authentication details of incoming request to a route handler', as
21812181
tags: [],
21822182
timeout: {},
21832183
security: {
2184-
authc: { enabled: 'optional' },
2184+
authc: { enabled: 'optional', reason: 'test' },
2185+
authz: { enabled: false, reason: 'test' },
2186+
},
2187+
},
2188+
});
2189+
});
2190+
2191+
test('properly treats minimal authentication as required', async () => {
2192+
const { registerRouter, registerAuth, server: innerServer } = await server.setup({ config$ });
2193+
2194+
const router = new Router('', logger, enhanceWithContext, routerOptions);
2195+
router.get(
2196+
{
2197+
path: '/',
2198+
validate: false,
2199+
security: {
2200+
authc: { enabled: 'minimal', reason: 'test' },
2201+
authz: { enabled: false, reason: 'test' },
2202+
},
2203+
},
2204+
(context, req, res) => res.ok({ body: req.route })
2205+
);
2206+
2207+
// mocking to have `authRegistered` filed set to true
2208+
registerAuth((req, res, auth) => auth.authenticated({ state: { alpha: 'beta' } }));
2209+
registerRouter(router);
2210+
2211+
await server.start();
2212+
await supertest(innerServer.listener)
2213+
.get('/')
2214+
.expect(200, {
2215+
method: 'get',
2216+
path: '/',
2217+
routePath: '/',
2218+
options: {
2219+
authRequired: true,
2220+
xsrfRequired: false,
2221+
access: 'internal',
2222+
tags: [],
2223+
timeout: {},
2224+
security: {
2225+
authc: { enabled: 'minimal', reason: 'test' },
21852226
authz: { enabled: false, reason: 'test' },
21862227
},
21872228
},

src/core/packages/http/server-internal/src/http_server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,12 @@ export class HttpServer {
396396
}
397397

398398
private getAuthOption(
399-
authRequired: RouteConfigOptions<any>['authRequired'] = true
399+
authRequired: RouteConfigOptions<any>['authRequired'] | 'minimal' = true
400400
): undefined | false | { mode: 'required' | 'try' } {
401401
if (this.authRegistered === false) return undefined;
402402

403-
if (authRequired === true) {
403+
// Minimal authentication still should go through the authentication handler.
404+
if (authRequired === true || authRequired === 'minimal') {
404405
return { mode: 'required' };
405406
}
406407
if (authRequired === 'optional') {

src/core/packages/http/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ export type {
117117
RouteAuthc,
118118
AuthcDisabled,
119119
AuthcEnabled,
120+
AuthcMinimal,
121+
AuthcOptional,
120122
Privilege,
121123
PrivilegeSet,
122124
AllRequiredCondition,

src/core/packages/http/server/src/router/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export type {
6262
RouteAuthc,
6363
AuthcDisabled,
6464
AuthcEnabled,
65+
AuthcMinimal,
66+
AuthcOptional,
6567
RouteSecurity,
6668
AllRequiredCondition,
6769
AnyRequiredCondition,

0 commit comments

Comments
 (0)