mirror of
https://github.com/NLnetLabs/krill.git
synced 2026-08-23 16:12:33 +02:00
634 lines
35 KiB
Markdown
634 lines
35 KiB
Markdown
# Multi-User: Authentication
|
|
|
|
> Authentication is the process of proving that you are who you say you are.<br/>
|
|
> _Source: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-vs-authorization_
|
|
|
|
Authentication in the context of Krill is the act of determining whether a client of the REST API that claims to be a
|
|
particular identity possesses the details needed to confirm that identity. It doesn't actually tell you that the client
|
|
**IS** that identity, they could for example be using borrowed or stolen proof to verify their claim.
|
|
|
|
**Contents:**
|
|
|
|
<!-- generated using gh-md-toc -->
|
|
|
|
* [Abstract 'plugin' interface](#abstract-plugin-interface)
|
|
* [Expected call sequence](#expected-call-sequence)
|
|
* [Raising errors](#raising-errors)
|
|
* [Impact on Lagosta](#impact-on-lagosta)
|
|
* [Interface with Lagosta](#interface-with-lagosta)
|
|
* [Stateless providers](#stateless-providers)
|
|
* [AdminTokenAuthProvider](#AdminTokenAuthProvider)
|
|
* [Stateful providers](#stateful-providers)
|
|
* [Storing session state](#storing-session-state)
|
|
* [Protecting sensitive details](#protecting-sensitive-details)
|
|
* [Session caching](#session-caching)
|
|
* [ConfigFileAuthProvider](#configfileauthprovider)
|
|
* [Password management](#password-management)
|
|
* [Modified login form](#modified-login-form)
|
|
* [AuthProvider implementation](#authprovider-implementation)
|
|
* [OpenIDConnectAuthProvider](#openidconnectauthprovider)
|
|
* [Rust crate dependencies](#rust-crate-dependencies)
|
|
* [Security](#security)
|
|
* [Standards](#standards)
|
|
* [Interoperability](#interoperability)
|
|
* [Terminology](#terminology)
|
|
* [Code smell](#code-smell)
|
|
* [Testing](#testing)
|
|
* [Flow](#flow)
|
|
* [Modified login form](#modified-login-form-1)
|
|
* [Architecture](#architecture)
|
|
* [Known issues](#known-issues)
|
|
|
|
## Abstract 'plugin' interface
|
|
|
|
The `AuthProvider` Rust trait enables Krill to support different authentication providers without Lagosta needing to
|
|
know much about it. We say "much" because if a particular provider implementation requires its own login form and that
|
|
login form must be part of Lagosta then clearly the form has to be added to Lagosta. When a login form is part of
|
|
Lagosta, submitting the form has to invoke the Krill login REST API, but the form is otherwise self-contained and
|
|
nothing else in Lagosta needs to be changed to support a new provider. If the login form is hosted by some external
|
|
service such as an OpenID Connect provider then no changes are needed in Lagosta at all to support the new provider!
|
|
|
|
`AuthProvider` is not named `AuthenticationProvider` because its function overlaps with that of authorization too. The
|
|
`AuthProvider` does not make a determination about whether or not a given client is authorized to do something, but it
|
|
does supply requested metadata about the authenticated client (when available) which is then used by the
|
|
[authorization policy engine](./authorization) to make an authorization determination.
|
|
|
|
The `AuthProvider` defines four functions which every provider must implement:
|
|
|
|
```rust
|
|
pub trait AuthProvider: Send + Sync {
|
|
/// Given a HTTP request, determine whether it is unauthenticated (Ok(None)), correctly authenticated (Ok(Some)), or
|
|
/// incorrectly authenticated (Err). If it is correctly authenticated the Some value will be an ActorDef stating the
|
|
/// ID and metadata for the end user or client represented by the credentials. Typically authentication is performed
|
|
/// by verifying the HTTP `Authorization` header.
|
|
fn authenticate(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<Option<ActorDef>>;
|
|
|
|
/// Tell the caller where an end user should login. The response body should consist of a URL that the caller should
|
|
/// be (re)directed to (the manner of how the user is sent to the login URL is determined by Lagosta). If the
|
|
/// response URL is relative, Lagosta will treat it as a Vue router path to "redirect" to, otherwise it will direct
|
|
/// the browser to navigate to the specified URL. The AuthProvider can set HTTP response headers if needed, e.g. to
|
|
/// set HTTP cookies.
|
|
fn get_login_url(&self) -> KrillResult<HttpResponse>;
|
|
|
|
/// Given a HTTP request, attempt to log the end user in to Krill. On success the result is a representation of
|
|
/// the user to login including a token that when later passed back in to `authenticate()`will be considered valid
|
|
/// by the same AuthProvider that issued it. The result is not an ActorDef but rather only those details that should
|
|
/// be serialized and passed back to Lagosta.
|
|
fn login(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<LoggedInUser>;
|
|
|
|
/// Given a HTTP request, attempt to log the end user out of Krill AND, if possible, out of the provider. The
|
|
/// response body should consist of a URL that the caller should be (re)directed to (the manner of how the user is
|
|
/// sent to the login URL is determined by Lagosta). By returning a HTTP response rather than just a URL, the
|
|
/// AuthProvider implementation has more control without requiring logic specific to the AuthProvider to exist
|
|
/// higher up the call chain, for example to instruct the user agent to delete provider specific cookies.
|
|
fn logout(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<HttpResponse>;
|
|
}
|
|
```
|
|
|
|
## Expected call sequence
|
|
|
|
The expected flow through the `AuthProvider` functions is as follows:
|
|
|
|
1. (optional) A client requests via `get_login_url()` the URL at which the end user can submit their login
|
|
credentials. This URL may contain query parameters that differ from one login attempt to the next and so must not
|
|
be fetched once and cached at the caller.
|
|
|
|
2. (optional) A client presents the credentials of an end user to `login()` which on success results in `LoggedInUser`
|
|
details being returned. The `LoggedInUser` includes a token to be used provided by the client on subsequent calls
|
|
to the Krill REST API and which will be validated by `authenticate()`. It also contains details about the logged in
|
|
user for display purposes (these details are NOT used for any authentication or authorization, they are purely
|
|
for display purposes).
|
|
|
|
3. (required) A client presents its authentication token (either already known to the client or previously obtained by
|
|
a call to `login()`) for verification. On success an `ActorDef` representing the clients identity and associated
|
|
metadata is returned to the caller. These details can then be used by Krill to make an authorization determination.
|
|
|
|
4. (optional) A client invokes `logout()` to terminate the provider login session, if any such session state is being
|
|
managed by Krill.
|
|
|
|
Some `AuthProvider` implementations will use more of/do more in the optional function calls in this flow, others less.
|
|
|
|
|
|
## Raising errors
|
|
|
|
Krill exposes errors to REST API clients in a structured JSON format which can be used by the client to select the
|
|
appropriate language specific template string and to populate it where relevant with values specific to the issue.
|
|
|
|
The multi-user feature builds on this ability by adding a few new variants of the Krill Error type which are mapped to
|
|
the following HTTP status codes:
|
|
|
|
Error Variant | HTTP Status Code
|
|
--------------|-----------------
|
|
`ApiInsufficientRights(String)` | 403 Forbidden **(see note below)**
|
|
`ApiInvalidCredentials(String)` | 401 Unauthorized
|
|
`ApiAuthPermanentError(String)` | 401 Unauthorized
|
|
`ApiAuthSessionExpired(String)` | 401 Unauthorized
|
|
`ApiAuthTransientError(String)` | 401 Unauthorized
|
|
`ApiLoginError(String)` | 401 Unauthorized
|
|
|
|
**Note:** `ApiInsufficientRights` is included here for completeness but is never raised by an `AuthProvider` as they
|
|
do not handle authorization.
|
|
|
|
## Impact on Lagosta
|
|
|
|
Prior to multi-user support the Lagosta web user interface only needed to check on Vue "view" activation if the current
|
|
user has a valid bearer token and after that could assume that calls to the API would not fail for authentication
|
|
reasons. With multi-user support any call to the Krill REST API can fail because the Krill server can reject the request
|
|
due to insufficient user rights, or the session can expire or be terminated, while previously only Lagosta could terminate
|
|
a login session.
|
|
|
|
Lagosta has not been redesigned for multi-user support, instead it has been grafted on top with as few changes as
|
|
possible. One consequence of this is that Lagosta still checks if the user is "authorized" on every Vue "view"
|
|
activation which in turn causes an authentication failure when the UI is initially browsed to by an end user. Rather
|
|
than pollute the Krill log with a warning every time a user loads the UI we treat these failures when invoking the
|
|
`/api/authorized` endpoint as benign and deliberately do not log them as warnings.
|
|
|
|
## Interface with Lagosta
|
|
|
|
If we look at Krill up to and including v0.8.2, the interface with Lagosta was extremely simple:
|
|
- The Lagosta login form would store the given API token in browser storage and then attempt to visit the welcome page
|
|
of the UI.
|
|
- Every Vue "view" load would make a call to the Krill `GET /api/v1/authorized` endpoint passing the stored API token
|
|
in the `Authorize: bearer xxx` HTTP request header.
|
|
- Krill would then compare the API token string to the one it was configured with and respond with success or failure.
|
|
|
|
That was it. No login, no logout, no login form discovery, no OpenID Connect callback handler and no receipt, use or
|
|
storage of user identity or metadata.
|
|
|
|
The API that Lagosta now uses to login and logout of Krill is not part of the publicly documented Krill API. The
|
|
essence of it (taken from `daemon/http/auth.rs`) is:
|
|
|
|
```rust
|
|
pub async fn auth(req: Request) -> RoutingResult {
|
|
match req.path.full() {
|
|
#[cfg(feature = "multi-user")]
|
|
"/auth/callback" if *req.method() == Method::GET => {
|
|
req.login()
|
|
.await
|
|
.and_then(|user| {
|
|
Ok(build_auth_redirect_location(user).map_err(|err| {
|
|
Error::custom(format!(
|
|
"Unable to build redirect with logged in user details: {:?}",
|
|
err
|
|
))
|
|
})?)
|
|
})
|
|
.map(|location| HttpResponse::found(&location))
|
|
.or_else(render_error_redirect)
|
|
}
|
|
"/auth/login" if *req.method() == Method::GET => req.get_login_url().await.or_else(render_error),
|
|
"/auth/login" if *req.method() == Method::POST => match req.login().await {
|
|
Ok(logged_in_user) => Ok(HttpResponse::json(&logged_in_user)),
|
|
Err(err) => render_error(err),
|
|
},
|
|
"/auth/logout" if *req.method() == Method::POST => req.logout().await.or_else(render_error),
|
|
_ => Err(req),
|
|
}
|
|
}
|
|
```
|
|
|
|
These endpoints map on to and are routed to the four functions each `AuthProvider` has to implement.
|
|
|
|
Firstly, notice that the "GET /auth/callback" endpoint is not handled unless the multi-user feature is enabled. This is
|
|
because this endpoint is only needed by the OpenID Connect provider.
|
|
|
|
The "POST /auth/login" endpoint is roughly equivalent to what was done in Krill v0.8.2 and earlier. It is a POST
|
|
endpoint now rather than GET because login can have side-effects, e.g. it could cause Krill to make changes to its
|
|
internal state _(in actual fact all of the `AuthProvider` implementations are currently stateless on the Krill
|
|
server-side)_.
|
|
|
|
## Stateless providers
|
|
|
|
### `AdminTokenAuthProvider`
|
|
|
|
This is the default provider which is backward compatible with earlier versions of Krill. It is an extremely simple
|
|
provider. The essence of this provider implementation can be reduced to something like the following (based on
|
|
`daemon/auth/providers/admin_token.rs`):
|
|
|
|
Login and post-logout-redirect URLs are hard-coded, and logout doesn't actually do anything.
|
|
|
|
```rust
|
|
impl AuthProvider for AdminTokenAuthProvider {
|
|
fn get_login_url(&self) -> KrillResult<HttpResponse> {
|
|
Ok(HttpResponse::text_no_cache("/login"))
|
|
}
|
|
|
|
fn logout(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<HttpResponse> {
|
|
Ok(HttpResponse::text_no_cache("/"))
|
|
}
|
|
}
|
|
```
|
|
|
|
Authenticating a request simply checks if it the given bearer token matches the admin API token Krill has been
|
|
configured with:
|
|
|
|
```rust
|
|
impl AuthProvider for AdminTokenAuthProvider {
|
|
fn authenticate(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<Option<ActorDef>> {
|
|
match self.get_bearer_token(request) {
|
|
Some(token) if token == self.required_token => Ok(Some(ACTOR_DEF_ADMIN_TOKEN)),
|
|
Some(_) => Err(Error::ApiInvalidCredentials("Invalid bearer token".to_string())),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
```
|
|
|
|
And as there are no other credentials such as a password to check, login verification is the same as authentication. The
|
|
only extra piece is that login is for the UI and the UI wants to know the users ID and any attributes they have so these
|
|
are packaged up and returned to the caller:
|
|
|
|
```rust
|
|
impl AuthProvider for AdminTokenAuthProvider {
|
|
fn login(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<LoggedInUser> {
|
|
match self.authenticate(request)? {
|
|
Some(actor_def) => Ok(LoggedInUser {
|
|
token: self.required_token.clone(),
|
|
id: actor_def.name.as_str().to_string(),
|
|
attributes: actor_def.attributes.as_map(),
|
|
}),
|
|
None => Err(Error::ApiInvalidCredentials("Missing bearer token".to_string())),
|
|
}
|
|
}
|
|
```
|
|
|
|
Note that the hard-coded URL responses are marked as uncacheable. This is important because if we later reconfigure this
|
|
Krill instance to use a different auth provider, the same requests will be made by Lagosta and thus if the responses
|
|
were cached the users browser might used cached responses from the previous provider instead of contacting Krill again
|
|
to get responses from the new provider.
|
|
|
|
## Stateful providers
|
|
|
|
|
|
### Storing session state
|
|
|
|
Unlike with the `AdminTokenAuthProvider` where the user identity and attributes are implicitly "admin-token" and
|
|
"role=admin" respectively, the `ConfigFileAuthProvider` and `OpenIDConnectAuthProvider` cannot know the identity and
|
|
user attributes from an arbitrary bearer token. They need therefore to store these details somewhere.
|
|
|
|
The details could be kept in an in-memory mapping of issued tokens to user details but this wouldn't work in future if
|
|
we want to support distributed Krill deployment scenarios. We could store the data in a key value store on disk and
|
|
cache it in memory as Krill does with other data. Any changes required later to support distributing the current key
|
|
value store would also then work for the login session state as well.
|
|
|
|
Instead the current approach avoids the distributed Krill deployment scenario problems almost entirely by storing the
|
|
session state in the client browser. This is done by creating an en/decryption key on startup and storing it on disk and
|
|
using this key to en/decrypt a structured bearer token that contains the session details. The only thing clustered Krill
|
|
servers would need to share then would be the en/decryption key file.
|
|
|
|
The encryption uses the AEAD AES 256 GCM cipher.
|
|
|
|
_**NOTE:** Currently the encryption nonce/IV and AAD/extra data values are hard-coded. This needs to be reviewed and/or
|
|
changed._
|
|
|
|
### Protecting sensitive details
|
|
|
|
Why encrypt the data when the connection to the client browser should already be TLS encrypted? The data we send inside
|
|
the bearer token is stored by the browser in local storage which is vulnerable, especially on a shared computer. The
|
|
data is opaque to the Krill web user interface, it does not read or interpret it, it only sends it back as a bearer
|
|
token to Krill on subsequent requests to the Krill REST API.
|
|
|
|
In the OpenIDConnectAuthProvider case we also need to remember a sensitive access token issued by the provider. That
|
|
token [must not be leaked to unauthorized parties](https://openid.net/specs/openid-connect-core-1_0.html#AccessTokenDisclosure).
|
|
User attributes that are used for authorization but marked as "hidden" so that they are not displayed by the Krill web
|
|
user interface in the client browser are also part of the encrypted structured bearer token and could potentially contain sensitive information.
|
|
|
|
As such we use the encrypted structured bearer token approach for both the `ConfigFileAuthProvider` and the
|
|
`OpenIDConnectAuthProvider`.
|
|
|
|
Encryption is done using a higher level AEAD algorithm (rather than compose lower level cryptographic primitives together ourselves). Initially the AES-GCM 256 algorithm was used as it is supported by the OpenSSL crate that Krill
|
|
already depends on. However, The ChaCha20-Poly1305 algorithm was instead chosen due to AES-GCM performance being
|
|
dependent on hardware acceleration, not being constant time, potentially leaking keys via cache timing attacks, and
|
|
being harder to select a secure nonce with AES-GCM than with ChaCha20-Poly1305. See [Krill issue #382](https://github.com/NLnetLabs/krill/issues/382) and links from there for more context, e.g. [this article](https://soatok.blog/2020/07/12/comparison-of-symmetric-encryption-methods/#aes-gcm-vs-chacha20poly1305) and [this article](https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html).
|
|
|
|
### Session caching
|
|
|
|
As browsers can make multiple requests in parallel or in short succession (e.g. for static assets) and every HTTP
|
|
request is checked for the authentication, it could be wasteful and possibly impacting if Krill has to repeatedly base64
|
|
decode, decrypt and JSON deserialize the same bearer token over and over again. The results of this process are
|
|
therefore stored in an in-memory "cache" in the Krill server. As the content of the bearer token may contain sensitive
|
|
details and thus should not be stored for longer than necessary, and as the cache is only intended to assist with short
|
|
bursts of activity, the cache is therefore very short lived. The cache is implemented by
|
|
`daemon::auth::common::session::LoginSessionCache`. A Krill scheduled job sweeps the cache periodically to evict expired
|
|
entries.
|
|
|
|
### `ConfigFileAuthProvider`
|
|
|
|
This provider is instantiated if `krill.conf` contains `auth_type = "config-file"`. This `AuthProvider` supports the
|
|
definition of arbitrary user identities each with their own metadata by adding TOML keys to an `[auth_users]` section in
|
|
`krill.conf`.
|
|
|
|
#### Password management
|
|
|
|
As storing passwords is a security risk we instead store password hashes.
|
|
|
|
We considered using the popular Apache `.htpasswd` format but unfortunately it either uses insecure SHA1, or
|
|
[non-standard MD5](https://httpd.apache.org/docs/current/misc/password_encryptions.html) or would require additional
|
|
crate dependencies to support bcrypt or Linux crypt, and even then would only be useful if the operator already had the
|
|
Apache tooling installed to be able to work with `.htpasswd` files. Also any hash also needs to be computable by the
|
|
Lagosta web user interface client code from a password entered by the user as avoiding transmitting passwords also
|
|
reduces the attack surface.
|
|
|
|
Instead `krillc` has been extended with a `config user` subcommand to generate password hashes, and 3rd party
|
|
dependencies have been added to Krill and Lagosta to hash using the `scrypt` algorithm (see
|
|
[RFC 7914](https://tools.ietf.org/html/rfc7914)). See [Krill issue #382](https://github.com/NLnetLabs/krill/issues/382)
|
|
for more information about why `scrypt` was chosen.
|
|
|
|
The password hashing is actually done in four places:
|
|
|
|
1. When `krillc config user` is executed the given password is hashed using a "shared salt" known to both Krill and
|
|
Lagosta.
|
|
|
|
2. Next `krillc config user` hashes the hash from step 1 using a randomly generated salt known only to Krill. This
|
|
random hash is output by `krillc` along with the final password hash so that both can be stored by the operator in the
|
|
`krill.conf` file.
|
|
|
|
3. When a user submits their password to Lagosta in the browser it will be hashed using the "shared secret" before being
|
|
sent to Krill as a HTTPS Authorization request header.
|
|
|
|
4. Krill then uses the user-specific random salt from `krill.conf` to hash the given hash and compares that to the hash
|
|
from `krill.conf` for the user. If the user id is unknown, the hashing is done anyway using a fake salt.
|
|
|
|
The "shared salt" ensures that Lagosta generated password hashes are different to those stored in `krill.conf` and
|
|
thus prevents replay of `krill.conf` password hashes as valid login tokens if `krill.conf` is stolen.
|
|
|
|
The hashing of password hashes even for unknown user ids limits the ability of attackers to use timing differences to
|
|
determine if a username is known to Krill or not. The use of a strong hashing algorithm and per user random salts limit the ability of attackers to brute force passwords by slowing down login attempts and by preventing the use of stock
|
|
rainbow tables. The amount of slow down caused by using a strong hashing algorithm is a choice with consequences, too slow and the Krill login experience becomes annoying and can possibly be used as a Dos vector.
|
|
|
|
#### Modified login form
|
|
|
|
The standard login view built-in to Lagosta at `/login` only has a single input field for the admin API token. This has
|
|
been extended so that when invoked as `/login?withId=true` it will instead show username and password input fields. When
|
|
the `GET /auth/login` Krill endpoint is queried by Lagosta to determine the login URL to use,
|
|
`ConfigFileAuthProvider::get_login_url()` responds with `/login?withId=true` to cause this modified login form to be
|
|
shown to the user.
|
|
|
|
#### AuthProvider implementation
|
|
|
|
The actual implementation is quite simple and similar to that of the `AdminTokenAuthProvider`. The essence of this
|
|
provider implementation can be reduced to something like the following (based on
|
|
`daemon/auth/providers/config_file/provider.rs`):
|
|
|
|
Login and post-logout-redirect URLs are hard-coded. Logout evicts the session from the cache, though it would quickly
|
|
expire anyway:
|
|
|
|
```rust
|
|
impl AuthProvider for ConfigFileAuthProvider {
|
|
fn get_login_url(&self) -> KrillResult<HttpResponse> {
|
|
Ok(HttpResponse::text_no_cache("/login?withId=true"))
|
|
}
|
|
|
|
fn logout(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<HttpResponse> {
|
|
if let Some(token) = self.get_bearer_token(request) {
|
|
self.session_cache.remove(&token);
|
|
}
|
|
|
|
Ok(HttpResponse::text_no_cache("/"))
|
|
}
|
|
}
|
|
```
|
|
Authenticating a request checks if the given bearer token can be fetched from the cache, or otherwise can be decoded,
|
|
decrypted and deserialized and stored in the cache:
|
|
|
|
```rust
|
|
impl AuthProvider for ConfigFileAuthProvider {
|
|
fn authenticate(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<Option<ActorDef>> {
|
|
match self.get_bearer_token(request) {
|
|
Some(token) => {
|
|
let session = self.session_cache.decode(token, &self.key, true)?;
|
|
Ok(Some(ActorDef::user(session.id, session.attributes, None)))
|
|
}
|
|
_None_ => Ok(None),
|
|
}
|
|
}
|
|
```
|
|
|
|
Login checks for the required id and password hash query parameters, looks up the user in the users that were loaded on
|
|
startup from `krill.conf` and creates and caches a session object based on the users details and returns the generated
|
|
token and user details to the `Authorizer` for eventual transmission back to Lagosta as JSON:
|
|
|
|
```rust
|
|
impl AuthProvider for ConfigFileAuthProvider {
|
|
fn login(&self, request: &hyper::Request<hyper::Body>) -> KrillResult<LoggedInUser> {
|
|
if let Some(Auth::IdAndPasswordHash { id, password_hash }) = self.get_auth(request) {
|
|
if let Some(user) = self.users.get(&id) {
|
|
if user.password_hash == password_hash {
|
|
let api_token =
|
|
self.session_cache
|
|
.encode(&id, &user.attributes, HashMap::new(), &self.key, None)?;
|
|
|
|
Ok(LoggedInUser {
|
|
token: api_token,
|
|
id: id.to_string(),
|
|
attributes: user.attributes.clone(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### `OpenIDConnectAuthProvider`
|
|
|
|
This provider is instantiated if `krill.conf` contains `auth_type = "openid-connect"`. This `AuthProvider` supports
|
|
connecting to an external OpenID Connect Core 1.0 compliant identity provider to authenticate users and provide user
|
|
metadata on our behalf.
|
|
|
|
#### Rust crate dependencies
|
|
|
|
The core client implementation is provided by the
|
|
[openidconnect v2](https://crates.io/crates/openidconnect/2.0.0-beta.1) Rust crate which in turn builds on the
|
|
[oauth2 v4](https://crates.io/crates/oauth2/4.0.0-beta.1) crate (both by the same author). We use these newest (and at
|
|
the time of writing not yet finally released) versions as they are based on newer dependencies, contain support for
|
|
OAuth 2.0 Token Revocation (which I contributed) and better error reporting, and these versions despite being new have
|
|
been stable for quite some time.
|
|
|
|
Additional dependencies are added for HTTP cookie parsing, JMESPath, regular expressions, URL parsing, etc. See also
|
|
[Krill issue #428](https://github.com/NLnetLabs/krill/issues/428) concerning a second `reqwest` dependency.
|
|
|
|
#### Security
|
|
|
|
There are LOTS of documents about OAuth 2.0, bearer token and OpenID Connect security and things you should or shouldn't
|
|
do. Reviewing the current implementation against these is yet to be done.
|
|
|
|
_**Note:** Encrypted payload based communication with the OP is not currently supported._
|
|
|
|
#### Standards
|
|
|
|
- OAuth 2.0 standardizes authentication but says nothing about identity.
|
|
- OpenID Connect Core 1.0 standardizes identity on top of OAuth 2.0 but requires a lot of client side configuration and
|
|
says nothing about logout.
|
|
- OpenID Connect Discovery 1.0 solves the configuration problem by standardizing a provider endpoint which can be used
|
|
to greatly reduce the amount of client side configuration required.
|
|
- Various OpenID Connect drafts attempt to standardize login sessions and various logout mechanisms.
|
|
- Some providers lack support for a standardized logout mechanism but do support the OAuth 2.0 Token Revocation
|
|
standard.
|
|
|
|
This provider implements the following standards:
|
|
|
|
- [RFC 6749 The OAuth 2.0 Authorization Framework](https://tools.ietf.org/html/rfc6749)
|
|
- [RFC 7009 OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)
|
|
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
- [OpenID Connect RP-Initiated Logout 1.0 (DRAFT)](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)
|
|
|
|
Where the standards define optional elements, only support for those needed thus far have been implemented.
|
|
|
|
#### Interoperability
|
|
|
|
This implementation has been seen to work without any known issues with Microsoft Azure Active Directory, AWS Cognito,
|
|
RedHat KeyCloak, Google Cloud Platform and Micro Focus NetIQ Access Manager 4.5.
|
|
|
|
#### Terminology
|
|
|
|
The OAuth 2.0 and OpenID Connect Core specifications define terms which have the following meaning in the context of
|
|
Krill:
|
|
|
|
| OAuth 2.0 Term | OpenID Connect Term | Meaning in Krill |
|
|
|---|---|---|
|
|
| Authorization Server | OpenID Provider (OP) | Remote OpenID Connect Core 1.0 compliant identity provider service. |
|
|
| Client | Relying Party (RP) | The Krill server. |
|
|
| Resource Owner | End-User | End-user interacting with the Lagosta web user interface. |
|
|
| Resource Server | N/A | We do not access resources of the provider, we only use it for authentication & identity. |
|
|
| User-Agent | User-Agent | The browser running the Lagosta web user interface. |
|
|
|
|
#### Code smell
|
|
|
|
In no particular order:
|
|
|
|
- The terminology used by the specifications is **NOT** used, or not used consistently, in the Rust code
|
|
implementation in Krill.
|
|
- There are lots of possibly out-dated comments in the code which need reviewing and updating or removing.
|
|
- The core `provider.rs` source code file is too large.
|
|
- There are likely opportunities to simplify and make the code more Rust idiomatic.
|
|
- There are very few comments on the structs and functions.
|
|
- There are no unit tests (there are however LOTS of 'integration' tests).
|
|
|
|
#### Testing
|
|
|
|
Testing of the UI interaction is handled by dedicated tests in the UI repository. Further tests are implemented
|
|
inline in the Oso rule files, with a few explicit Oso tests also invoked by Krill immediately after Oso is
|
|
initialized and rule files have been loaded.
|
|
|
|
#### Flow
|
|
|
|
This implementation supports the [OpenID Connect Authorization Code Flow](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)
|
|
which builds on the [OAuth 2.0 Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1) flow.
|
|
|
|
Before any of that can happen however three things must first happen:
|
|
|
|
1. The instance of Krill must be registered with the OP, resulting in client credentials and an issuer URL.
|
|
2. The discovery issuer URL of the OP and the issued client credentials must be configured in the `krill.conf` file.
|
|
3. The `krill.conf` file and the OP must be suitably configured to permit users access to and grant them a role in
|
|
Krill.
|
|
|
|
Once these have been properly setup the "login" flow according to RFC 6749 looks like this (with Krill specific
|
|
annotations added in parentheses):
|
|
|
|
```
|
|
4.1. Authorization Code Grant
|
|
|
|
The authorization code grant type is used to obtain both access
|
|
tokens and refresh tokens and is optimized for confidential clients.
|
|
Since this is a redirection-based flow, the client must be capable of
|
|
interacting with the resource owner's user-agent (typically a web
|
|
browser) and capable of receiving incoming requests (via redirection)
|
|
from the authorization server.
|
|
|
|
+----------+
|
|
| Resource |
|
|
| Owner |
|
|
|(End-User)|
|
|
+----------+
|
|
^
|
|
|
|
|
(B)
|
|
+----|-----+ Client Identifier +---------------+
|
|
| -+----(A)-- & Redirection URI ---->| |
|
|
| User- | | Authorization |
|
|
| Agent -+----(B)-- User authenticates --->| Server |
|
|
| (Lagosta)| | (OpenID |
|
|
| -+----(C)-- Authorization Code ---<| Provider) |
|
|
+-|----|---+ +---------------+
|
|
| | ^ v
|
|
(A) (C) | |
|
|
| | | |
|
|
^ v | |
|
|
+---------+ | |
|
|
| |>---(D)-- Authorization Code ---------' |
|
|
| Client | & Redirection URI |
|
|
| (Krill) | |
|
|
| |<---(E)----- Access Token -------------------'
|
|
+---------+ (w/ Optional Refresh Token)
|
|
|
|
Note: The lines illustrating steps (A), (B), and (C) are broken into
|
|
two parts as they pass through the user-agent.
|
|
|
|
Figure 3: Authorization Code Flow
|
|
```
|
|
|
|
[RFC 6749 section 4.1](https://tools.ietf.org/html/rfc6749#section-4.1) goes into a lot of detail about what happens at
|
|
each step. The OpenID Connect Core 1.0 specification summarizes this more succinctly (and more relevant to us) as (with
|
|
letters referencing the diagram above added by me in parentheses)
|
|
|
|
> 3.1.1. Authorization Code Flow Steps
|
|
>
|
|
> The Authorization Code Flow goes through the following steps.
|
|
>
|
|
> 1. Client prepares an Authentication Request containing the desired request parameters. (A)
|
|
> 2. Client sends the request to the Authorization Server. (A)
|
|
> 3. Authorization Server Authenticates the End-User. (B)
|
|
> 4. Authorization Server obtains End-User Consent/Authorization. (B)
|
|
> 5. Authorization Server sends the End-User back to the Client with an Authorization Code. (C)
|
|
> 6. Client requests a response using the Authorization Code at the Token Endpoint. (D)
|
|
> 7. Client receives a response that contains an ID Token and Access Token in the response body. (E)
|
|
> 8. Client validates the ID token and retrieves the End-User's Subject Identifier.
|
|
|
|
#### Modified login form
|
|
|
|
The Lagosta login form is NOT modified for the OpenID Connect provider, in fact it isn't used at all. Instead the
|
|
`GET /auth/login` endpoint is responded to by `AuthProvider::get_login_url()` with a URL that directs the user to a
|
|
login page location determined by OpenID Connect Discovery, and including per login attempt unique query parameter
|
|
values.
|
|
#### Architecture
|
|
|
|
The current implementation handles concurrent requests by making onward requests to the OP in the same thread as the
|
|
caller. There is no centralized management or queueing of requests and thus not rate limiting or deduplication of
|
|
requests (e.g. multiple requests for static assets from the user-agent causing multiple concurrent requests to the OP to
|
|
refresh an expiring or expired access token).
|
|
|
|
Diagnosing problems and handling errors from the provider may involve logging sensitive or complex details such as
|
|
access tokens or entire request/response exchanges with the OP. The implementation endeavours to hide this complexity
|
|
from the end user while still giving them meaningful errors in the Lagosta web user interface.
|
|
|
|
This provider uses the same approach as the `ConfigFileAuthProvider` to create and cache encoded encrypted structured
|
|
bearer tokens. See above for more details.
|
|
|
|
#### Known issues
|
|
|
|
Unfortunately the provider is implemented using synchronous Rust code while the `openidconnect` crate uses a version of
|
|
`reqwest` which uses its own async runtime around an asynchronous implementation. This caused failures reportedly to do
|
|
with multiple async runtimes when used in Krill, perhaps some interaction with the existing Tokio runtime that Krill
|
|
uses. Switching the provider over to be asynchronous is also non-trivial due to the lack of stable Rust support for
|
|
async traits. The end result is that currently the OpenID Connect support in Krill brings in a second older copy of the
|
|
`reqwest` dependency that is synchronous internally rather than the asynchronous version used by the rest of Krill.
|
|
|
|
#### Infinite flexibility
|
|
|
|
There is a lot of room in the specifications for supporting additional features and OPs can structure claim responses
|
|
seemingly however they like. The incomplete final standardization of logout and varied state of deployment of certain
|
|
optional features further complicates interoperation with actual OPs.
|
|
|
|
This implementation tries to flexible where it seems to be needed to enable a reasonable quality of integration with the
|
|
OP and to enable a reasonable end-user experience:
|
|
|
|
- Support for various logout behaviors (e.g. AWS Cognito only supports a non-standard logout endpoint, one
|
|
potential customer wanted control over where users were redirected to after logout, and one tested OP deployment
|
|
claimed support for OAuth 2.0 Token Revocation but was not standards compliant meaning we couldn't automatically use
|
|
that approach but need to be able to turn that feature off).
|
|
- Support for refresh tokens and extending the login session with the OP (but not all OPs support or permit refresh
|
|
tokens).
|
|
- JMESPath and regular expression based support for arbitrary parsing and extraction of claim values from OP token and
|
|
user info endpoint responses.
|
|
- Limited support for passing custom login related parameters such as scopes and other parameters.
|