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
Support multiple jwt keys for authentication #1374
base: main
Are you sure you want to change the base?
Support multiple jwt keys for authentication #1374
Conversation
jwt_keys: &Vec<jsonwebtoken::DecodingKey>, | ||
jwt: &str, | ||
) -> Result<Authenticated, AuthError> { | ||
for (index, jwt_key) in jwt_keys.iter().enumerate() { |
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.
Should this be limited to two keys so that no iteration needs to be done and encapsulate the concept some how.
Pseudo Code:
validate_jwt(&primary_jwt_key, jwt) || validate_jwt(&secondary_jwt_key, jwt);
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.
It's a good question. Here was my thinking:
- For the (99%) key rotation use case it should only be in this state for a relatively short period of time (maybe a few hours or a day tops). Otherwise it'll be in the singleton case where we use the
DecodingKeyContainer::Single
to avoid iteration per Turso's suggestion. - There may be use cases that I'm not thinking about where 3 or more keys are actually desired. I couldn't come up with a good reason to artificially block this.
@@ -7,8 +7,13 @@ use crate::{ | |||
|
|||
use super::{UserAuthContext, UserAuthStrategy}; | |||
|
|||
pub enum DecodingKeyContainer { |
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.
It won't be long until we forget why we implemented this single/multiple distinction
it is a seed of complexity -> it requires forked logic to handle
any chance we could have one more round of conversations about how much real overhead a Vec<> introduces?
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'm okay removing the complexity if everyone prefers that after looking at the enum version. Could be a classic case of premature optimization. Fwiw, I do think it should remaining somewhat intuitive that the vast majority of the time there should only be a single key and hence a motivation for this bit of weirdness.
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.
let's let the others chime in then
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 second @shopifyski here, I'd rather have a single code path, the overhead of allocating a vector once in a while is negligible
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.
As an extra data point, I was exploring today what it would look like with just Vec<jsonwebtoken::DecodingKey>
everywhere except for the DecodingKeyContainer
enum. The immediate instinct was to rename all jwt_key
to jwt_keys
since it felt weird to do things like pub fn parse_jwt_key(data: &str) -> Result<Vec<DecodingKey>>
or let key: Vec<DecodingKey> = ...
. However, as the thread kept on pulling I started to run into some backwards-compatibility issues, specifically with the jwt_key
(singular) request variable in libsql-server/src/http/admin/mod.rs. We can certainly halt the cascade of singular-to-plural changes anywhere we like, but at some point we may have something gross like config.jwt_keys = req.jwt_key
.
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 could extend the config with jwt_keys
, and merge jwt_key
and jwt_keys
as late as possible when building the decoding object. We would mark the jwt_key
field as deprecated, and cleanup sometime in the future, would that contain the changes?
) -> Result<Authenticated, AuthError> { | ||
for (index, jwt_key) in jwt_keys.iter().enumerate() { | ||
let result = validate_jwt(&jwt_key, jwt); | ||
if result.is_ok() || index == jwt_keys.len() - 1 { |
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 swallowing individual errors
we should at least log a warning if result is 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.
The problem is that those errors are expected. If I have two different keys, only one of them can decrypt the JWT, so the other one will error out. If we log them it will get very noisy without adding value.
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.
maybe debug log then? to turn it on only when troubleshooting?
} | ||
} | ||
|
||
Err(AuthError::Other) |
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 case validation fails, we might be interested in individual errors for each jwt
I suggest that we collect all errors an fold them into a single one, something like https://stackoverflow.com/a/61171459
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.
When validation fails, we'll return the last error. The only reason we would ever get this generic one is when there are no JWT keys - which really shouldn't happen.
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.
Another thought here is do we want to expose the fact that there are multiple keys to clients? That feels like an internal piece of data that really shouldn't be leaked.
@@ -138,6 +164,23 @@ mod tests { | |||
(encoding_key, decoding_key) | |||
} | |||
|
|||
fn key_pairs( |
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.
maybe rename to generate_key_pairs
verb_object() naming convention is preferred for functions
key_pair() function should also be renamed
object() only names are best left for getters
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'm okay with renaming both, but as you suggest we should keep them consistent. It would be weird to have one be key_pair
and the other generate_key_pairs
.
04a8422
to
b54dd01
Compare
b54dd01
to
52e2a39
Compare
Modify the auth strategy for libsql-server to support multiple JWT keys. This will allow server administrators to more gracefully rotate keys by adding new keys without having to remove old ones simultaneously. Once all clients have been migrated to using new keys, the old one can be safely removed.
The modified auth strategy will accept a JWT if any of the public keys can successfully decrypt it. However, if none can (or all error), the last error will be returned.
As defined in RFC 1422 a *.PEM file by definition can contain multiple items, so to provide multiple JWT keys one needs only to concatenate them together. For example:
This should work whether the multiple certificates are passed via the
SQLD_AUTH_JWT_KEY
environment variable or the--auth-jwt-key-file
argument.