SQL-81: Improve OIDC error messages#34891
Conversation
6ccc11c to
0cda15c
Compare
ad906bf to
a681dd4
Compare
Before we'd assume `issuer` ends with '/', but this is actually incorrect given JWTs return an audience with no trailing '/' and validate_aud would fail later.
4a44537 to
3e4247e
Compare
The idea is propagate these errors back up to the user such that they can debug more easily. Currently we only surface the "Invalid password" error for pgwire clients.
3e4247e to
32edf9e
Compare
src/authenticator/src/oidc.rs
Outdated
| JwksFetchFailed(String), | ||
| InvalidIssuerUrl( | ||
| /// Issuer URL. | ||
| String, |
There was a problem hiding this comment.
Once you start documenting the fields, this seems like a sign that their meaning isn't obvious and you should perhaps use a struct variant rather than a tuple variant, i.e.:
InvalidIssuerUrl {
url: String,
}
Although... in this case I'd actually say it is pretty obvious. The string is just the invalid issuer URL, which is exactly the variant name. So maybe just remove the comment here. But some of the below variants should be struct variants I think.
src/authenticator/src/oidc.rs
Outdated
| pub fn detail(&self) -> Option<String> { | ||
| match self { | ||
| OidcError::InvalidIssuerUrl(issuer) => { | ||
| Some(format!("Could not parse \"{}\" as a URL.", issuer)) |
There was a problem hiding this comment.
Nit: Consider inlining for better readability
| Some(format!("Could not parse \"{}\" as a URL.", issuer)) | |
| Some(format!("Could not parse \"{issuer}\" as a URL.")) |
src/authenticator/src/oidc.rs
Outdated
| impl GenericOidcAuthenticatorInner { | ||
| async fn fetch_jwks_uri(&self, issuer: &str) -> Result<String, OidcError> { | ||
| let openid_config_url = Url::parse(issuer) | ||
| let openid_config_url = Url::parse(&format!("{}/", issuer)) |
There was a problem hiding this comment.
Why is this done? It seems like appending a / either doesn't change the semantics of a URL (e.g. https://foo.com -> https://foo.com/) or changes the semantics in unexpected ways (e.g. https://foo.com/hello.html -> https://foo.com/hello.html/).
There was a problem hiding this comment.
It changes the semantics of the URL! We need to append an / to issuer such that url.join below appends .well-known/openid-configuration. If we don't have the trailing slash, we replace the last path segment instead of append onto it.
For example, the URL we want to create is https://accounts.google.com/.well-known/openid-configuration where the issuer URL is https://accounts.google.com.
We also want to make sure the issuer URL is https://accounts.google.com and not https://accounts.google.com/ otherwise the issuer validation later on fails
There was a problem hiding this comment.
I was thinking we could all change it to this:
let mut openid_config_url =
Url::parse(issuer).map_err(|_| OidcError::InvalidIssuerUrl(issuer.to_string()))?;
{
let mut segments = openid_config_url
.path_segments_mut()
.map_err(|_| OidcError::InvalidIssuerUrl(issuer.to_string()))?;
segments.push(".well-known");
segments.push("openid-configuration");
}
There was a problem hiding this comment.
Hm, at least for you example, this works fine without appending a /:
println!("{}",
url::Url::parse("https://accounts.google.com")
.unwrap()
.join(".well-known/openid-configuration")
.unwrap()
);... prints https://accounts.google.com/.well-known/openid-configuration.
Appending the / makes a difference when the issuer URL has a path. However, it's not clear to me what the correct behavior is if somebody supplies an issuer URL like https://accounts.google.com/index.html. Just converting that to https://accounts.google.com/index.html/.well-known/openid-configuration seems guaranteed to be wrong at least?
Do you know if path's are valid in issuer URLs? If not, we could just return an error if one was supplied.
There was a problem hiding this comment.
Appending the / makes a difference when the issuer URL has a path.
Ah I think the issuer URL can have a path. The motivation for this fix is when testing via Okta, https://dev-123456.okta.com/oauth2/default was turning into https://dev-123456.okta.com/oauth2/.well-known/openid-configuration.
Do you know if path's are valid in issuer URLs?
Paths are valid! Through discussion, I'm thinking we should handle the case where we get https://accounts.google.com or https://accounts.google.com/ as the issuer. #34891 (comment) should achieve this
There was a problem hiding this comment.
Through discussion, I'm thinking we should handle the case where we get
https://accounts.google.comorhttps://accounts.google.com/as the issuer.
Those seem like easy cases though. Just calling Url::parse on the given string directly works for both.
The hard case is https://dev-123456.okta.com/oauth2/default because you can't know whether default is a file or a directory. The solution the url crate takes is to require a trailing / to denote a directory, but seems like auth providers don't follow that convention?
Looking at the OpenID spec, it has this:
OpenID Providers supporting Discovery MUST make a JSON document available at the path formed by concatenating the string
/.well-known/openid-configurationto the Issuer.
Which I think means your current implementation is correct! If you put in https://accounts.google.com/index.html, that's an invalid issuer URL and we will give you an error about not being able to check the discovery file, which seems fine.
One suggestion I have would be to simplify by just following the above spec to a tee:
Url::parse(&format!("{issuer}/.well-known/openid-configuration"))
src/authenticator/src/oidc.rs
Outdated
| FetchFromProviderFailed( | ||
| /// Fetch URL. | ||
| String, | ||
| // HTTP status code. | ||
| Option<StatusCode>, | ||
| ), |
There was a problem hiding this comment.
Istm like this error is not really useful if we don't have a status code. It will just say "failed to fetch from {url}", but I think we should say at least something like "timed out" or "failed to decode". So maybe make the second field an error string instead?
There was a problem hiding this comment.
Seems like here we should also test the DETAIL, given that this contains most of the interesting information about the error?
src/authenticator/src/oidc.rs
Outdated
| if let Some(audience) = &audience { | ||
| OidcError::InvalidAudience(audience.clone()) | ||
| } else { | ||
| debug_assert!( |
There was a problem hiding this comment.
Use soft_panic_or_log instead!
- No need to put in an extra
falseto make it panic - Will make CI fail, if hit
- Will log an error in production
|
@teskje Addressed all your feedback. TFTR! |
| warn!( | ||
| "Audience validation skipped. It is discouraged | ||
| to skip audience validation since it allows | ||
| anyone with a JWT issued by the same issuer | ||
| to authenticate." | ||
| "{}", | ||
| concat!( | ||
| "Audience validation skipped. It is discouraged ", | ||
| "to skip audience validation since it allows anyone ", | ||
| "with a JWT issued by the same issuer to authenticate." | ||
| ) | ||
| ); |
There was a problem hiding this comment.
This should work:
warn!(
"Audience validation skipped. It is discouraged \
to skip audience validation since it allows \
anyone with a JWT issued by the same issuer \
to authenticate."
);- inline errors - Add test for fetch error - simplify url parsing - Replace assert with soft panic - Structify error enum bodies - Test on error detail
58262cf to
98de882
Compare
see commit messages for details.
Motivation
Fixes https://linear.app/materializeinc/issue/SQL-81/create-better-error-messages-on-authentication-error
Tips for reviewer
Checklist
$T ⇔ Proto$Tmapping (possibly in a backwards-incompatible way), then it is tagged with aT-protolabel.