-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Switch Kerberos authentication provider to a dedicated _kerberos
grant. Introduce Tokens
for common access/refresh token tasks.
#39366
Conversation
Pinging @elastic/kibana-security |
…ant. Introduce `Tokens` for common access/refresh token tasks.
cb217e7
to
5fdd55b
Compare
basePath: config.get<string>('server.basePath'), | ||
tokens: new Tokens({ client, log }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: basic
is the only provider that doesn't use tokens and it's pretty cheap to instantiate it even if basic
provider is used, so I decided to have it as provider option instead of re-creating it for every provider that uses tokens.
import { AuthenticationProviderOptions } from './base'; | ||
|
||
export function mockAuthenticationProviderOptions( | ||
providerOptions: Partial<AuthenticationProviderOptions> = {} | ||
providerOptions: Partial<Pick<AuthenticationProviderOptions, 'basePath'>> = {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: we don't need anything except for basePath
as an argument here, but I decided to keep "args as an object" pattern here for now, to have less updates and account for the future options we may want to tweak in tests.
@@ -23,6 +24,7 @@ export interface AuthenticationProviderOptions { | |||
basePath: string; | |||
client: Legacy.Plugins.elasticsearch.Cluster; | |||
log: (tags: string[], message: string) => void; | |||
tokens: PublicMethodsOf<Tokens>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: relying on PublicMethodsOf
makes it super simple to mock tokens
in TS tests. I hate that tests dictate how we should write code, but here it's more or less logically acceptable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels similar to what you have to do in Java/C# when you extract an interface that only has the public properties for the sake of testing. What you have here seems less intrusive, and makes me wonder if it's a pattern that we should embrace more widely?
I have a bad habit of pretending that TS is more similar to the type systems in Java/C# than it really is, so it's great learning techniques like these from you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What you have here seems less intrusive, and makes me wonder if it's a pattern that we should embrace more widely?
In a NP world we won't have too many of these cases (contracts are usually plain objects with public only stuff on them, that's is trivial to mock and work with without additional TS voodoo), hopefully, but in places where we still want something like this I believe we embrace this approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the core has the same problems. We pass around ES6 classes and later in tests have type errors passing POJO with the same interface. We haven't decided on the official solution in the core #33396
but in some places already started using a separate interface for the contracts https://github.com/skaapgif/kibana/blob/a52d038cbaf605c29a94a3872d4eb70fb60c3fd2/src/legacy/server/saved_objects/service/saved_objects_client.ts#L132
Probably declaring TokenContract
is more self-describing than PublicMethodsOf<Tokens>
this.debug(`Trying to deauthenticate user via ${request.url.path}.`); | ||
|
||
if (!state || !state.accessToken) { | ||
if (!state) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: if we have a state that doesn't have an accessToken
in it - it's a bug, and I'd rather notice it earlier.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property | ||
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if | ||
// it's called with this request once again down the line (e.g. in the next authentication provider). | ||
delete request.headers.authorization; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: I was about to delete all these delete request.headers.authorization;
in catch
and mutate request only if _autenticate
succeeds using this new headers
argument:
const user = await this.options.client.callWithRequest(request, 'shield.authenticate', {
headers: { authorization }
});
But it doesn't allow to override header that is already in request, it's a bummer, we'll discuss this in the upcoming NP context-bound services RFC.
@@ -8,9 +8,9 @@ import Boom from 'boom'; | |||
import type from 'type-detect'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: OIDC integration tests are disabled :/ And we should enable them as soon as possible #36959. But for now I tweaked kbn-es locally and run the tests - they passed with the latest version of this PR.
@@ -47,15 +50,6 @@ describe('TokenAuthenticationProvider', () => { | |||
); | |||
}); | |||
|
|||
it('does not handle authentication if state exists, but accessToken property is missing.', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: same here, if we have a state that doesn't have an accessToken
in it - it's a bug, and I'd rather notice it earlier.
|
||
let invalidationError; | ||
let invalidatedTokensCount; | ||
if (refreshToken) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: I know it's kind of stupid since invalidation will be super quick, but I feel bit safer if I invalidate refresh token first as having it active feels more dangerous 🙈
Another change in behavior here is that I'll try to invalidate access token even if invalidation of refresh token failed previously.
💚 Build Succeeded |
* @param message Message to log. | ||
*/ | ||
private debug(message: string) { | ||
this.options.log(['debug', 'security', 'tokens'], message); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we worried about losing the context of which auth provider is performing these token operations? Perhaps if we added a providerName
constructor argument to Token
and instantiate an instance of providerOptions
for each actual provider, it'd let us do this transparently to the consumer? If you don't foresee this being an issue when diagnosing issues using the logs, we don't have to do this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we worried about losing the context of which auth provider is performing these token operations?
Yeah, I thought about this, but I'm not really worried: when we see logs, these will be surrounded by something like this, so it's pretty easy to derive the correct context:
[debug, security, saml] authenticating ......
[debug, security, tokens] refreshing tokens.....
[debug, security, saml] authentication completed.....
Having said that, our logs need some love and unifying :) Different providers were written by different people, and since there were no guidelines logs don't look as consistent as they should be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In NP Logger
may provide factory method to extend parent context #39695
// class SamlProvider
// saml.ts
const log = log.get('saml');
new Tokens({log})
// token.ts
class Token {
constructor({log: Logger}){
this.log = log.get('tokens');
this.log.debug('....'); // ] ['security', 'saml', 'tokens'] ....
}
@@ -23,6 +24,7 @@ export interface AuthenticationProviderOptions { | |||
basePath: string; | |||
client: Legacy.Plugins.elasticsearch.Cluster; | |||
log: (tags: string[], message: string) => void; | |||
tokens: PublicMethodsOf<Tokens>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels similar to what you have to do in Java/C# when you extract an interface that only has the public properties for the sake of testing. What you have here seems less intrusive, and makes me wonder if it's a pattern that we should embrace more widely?
I have a bad habit of pretending that TS is more similar to the type systems in Java/C# than it really is, so it's great learning techniques like these from you.
this.debug('Refresh token has been successfully invalidated.'); | ||
} else { | ||
this.debug( | ||
`${invalidatedTokensCount} refresh tokens were invalidated, this is unexpected.` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the call on line 99 fails, won't invalidatedTokensCount
be undefined here and hit this branch?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, good catch! Previously we were immediately throwing if access token invalidation failed, sooo bad copy-pasting 🙈 Will fix.
|
||
if (accessToken) { | ||
try { | ||
invalidatedTokensCount = (await this.options.client.callWithInternalUser( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-using the invalidatedTokensCount
variable seems potentially problematic. If this call fails, we potentially see the value that was set by a successful call to invalidate the refresh token.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I probably should just throw directly from second catch
since I re-throw only the latest error anyway, wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That seems fine to me. The other thing I thought of was to just declare let invalidatedTokensCount
right inside the if (accessToken)
check. I'm fine with either though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other thing I thought of was to just declare let invalidatedTokensCount right inside the if (accessToken) check.
I like it better, thanks!
} else if (invalidatedTokensCount === 1) { | ||
this.debug('Access token has been successfully invalidated.'); | ||
} else { | ||
this.debug(`${invalidatedTokensCount} access tokens were invalidated, this is unexpected.`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're only invalidating an access token, and the call on line 122 fails won't invalidatedTokensCount
be undefined as well.
)).invalidated_tokens; | ||
} catch (err) { | ||
this.debug(`Failed to invalidate access token: ${err.message}`); | ||
invalidationError = err; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're potentially overwriting the error from invalidating the refresh token, is there a way to combine these errors?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's a bit clumsy, I decided to just throw the latest error, but log both. Frankly speaking I can't see any good way of merging these since they can even have different status codes that we return to the client, but I'm all ears if you have any suggestion!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I don't have any real suggestions and I'm fine with the current approach.
return AuthenticationResult.failed(err); | ||
} | ||
|
||
// If refresh token is no longer valid, then we should clear session and redirect user to the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit:
// If refresh token is no longer valid, then we should clear session and redirect user to the | |
// If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO |
} | ||
|
||
// If refresh token is no longer valid, then we should clear session and redirect user to the | ||
// login page to re-authenticate, or fail if redirect isn't possible. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// login page to re-authenticate, or fail if redirect isn't possible. |
// that has been used already. | ||
// | ||
// When user has neither valid access nor refresh token, the only way to resolve this issue is to get new | ||
// SAML LoginResponse and exchange it for a new access/refresh token pair. To do that we initiate a new SAML |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit: these docs talk specifically about the SAML flow, when this is now used by both the token and kerberos providers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch!
await getService('esSupertest') | ||
.post('/_security/role_mapping/krb5') | ||
.send({ | ||
roles: ['krb5-user'], | ||
roles: ['kibana_user'], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🍾 🎆
💔 Build Failed |
retest |
💔 Build Failed |
retest |
💚 Build Succeeded |
💚 Build Succeeded |
…ant. Introduce `Tokens` for common access/refresh token tasks. (#39366) (#40101) * Switch Kerberos authentication provider to a dedicated `_kerberos` grant. Introduce `Tokens` for common access/refresh token tasks. * Review#1: improve/fix code comments, properly log the case when token invalidation failed.
7.x/7.3.0: 9d86c55 |
Since elastic/elasticsearch#42847 we should be using a dedicated
_kerberos
grant to exchange SPNEGO token to a pair of access and refresh tokens. Previously withclient_credentials
grant we were getting only access token.Since this change would have required yet another copy-paste of token refresh/invalidation code, I decided to move this code to a separate class, instance of which is available to every authentication provider.
Related to: elastic/elasticsearch#42847