use crate::client::Client;
use crate::endpoints::params::Params;
use crate::server::AuthorizationServer;
use rustlavel_auth::base64;
use rustlavel_http::Request;
use rustlavel_oauth::{OAuthError, OAuthErrorCode, url};
pub struct Credentials {
pub id: String,
pub secret: Option<String>,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("id", &self.id)
.field("secret", &self.secret.as_ref().map(|_| "<redacted>"))
.finish()
}
}
pub fn presented(request: &Request, params: &Params) -> Result<Credentials, OAuthError> {
let basic = basic_auth(request)?;
let body_id = params.get("client_id")?.map(str::to_string);
let body_secret = params.get("client_secret")?.map(str::to_string);
match (basic, body_id, body_secret) {
(Some(_), _, Some(_)) => Err(OAuthError::because(
OAuthErrorCode::InvalidClient,
"credentials were sent both in the Authorization header and in the body. RFC 6749 \
§2.3 allows exactly one method per request; send only the header.",
)),
(Some(basic), Some(id), None) if basic.id != id => Err(OAuthError::because(
OAuthErrorCode::InvalidClient,
"the `client_id` in the body does not match the one in the Authorization header",
)),
(Some(basic), _, None) => Ok(basic),
(None, Some(id), secret) => Ok(Credentials { id, secret }),
(None, None, _) => Err(OAuthError::because(
OAuthErrorCode::InvalidClient,
"no client credentials were presented: send HTTP Basic, or `client_id` in the body",
)),
}
}
fn basic_auth(request: &Request) -> Result<Option<Credentials>, OAuthError> {
let Some(header) = request.header("authorization") else { return Ok(None) };
let Some(encoded) = header.strip_prefix("Basic ").or_else(|| header.strip_prefix("basic ")) else {
return Ok(None);
};
let malformed = || {
OAuthError::because(
OAuthErrorCode::InvalidClient,
"the Authorization header is not a readable HTTP Basic credential",
)
};
let decoded = base64::decode(encoded.trim()).ok_or_else(malformed)?;
let decoded = String::from_utf8(decoded).map_err(|_| malformed())?;
let (id, secret) = decoded.split_once(':').ok_or_else(malformed)?;
Ok(Some(Credentials { id: url::decode(id), secret: Some(url::decode(secret)) }))
}
thread_local! {
static DECOY: Client = Client::confidential("", &crate::client::generate_secret());
}
pub async fn authenticate(
server: &AuthorizationServer,
credentials: &Credentials,
) -> Result<Client, OAuthError> {
let refused = || {
OAuthError::because(
OAuthErrorCode::InvalidClient,
"client authentication failed: unknown client, or the wrong secret",
)
};
let Some(client) = server.client(&credentials.id).await else {
if let Some(secret) = &credentials.secret {
DECOY.with(|decoy| decoy.verify_secret(secret));
}
return Err(refused());
};
match (&credentials.secret, client.is_confidential()) {
(Some(secret), true) if client.verify_secret(secret) => Ok(client),
(_, true) => Err(refused()),
(None, false) => Ok(client),
(Some(_), false) => Err(OAuthError::because(
OAuthErrorCode::InvalidClient,
"this client is registered as public and has no secret; do not send `client_secret`",
)),
}
}
pub async fn authenticate_confidential(
server: &AuthorizationServer,
credentials: &Credentials,
) -> Result<Client, OAuthError> {
let client = authenticate(server, credentials).await?;
if client.is_confidential() {
Ok(client)
} else {
Err(OAuthError::because(
OAuthErrorCode::InvalidClient,
"this endpoint requires a client that can authenticate. A public client's only \
credential is its id, which is public by construction.",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Client, MemoryClientStore};
use rustlavel_http::Method;
fn basic_header(id: &str, secret: &str) -> String {
let raw = format!("{}:{}", url::encode(id), url::encode(secret));
format!("Basic {}", base64::encode(raw.as_bytes()))
}
fn read(request: &mut Request) -> Result<Credentials, OAuthError> {
let params = Params::from_body(request);
presented(request, ¶ms)
}
fn server() -> AuthorizationServer {
AuthorizationServer::new(
MemoryClientStore::new()
.with(Client::confidential("web", "s3cr+t/val=ue"))
.with(Client::public("spa")),
)
}
#[test]
fn reads_http_basic_credentials() {
let mut request = Request::new(Method::Post, "/token")
.with_header("authorization", basic_header("web", "s3cret"))
.with_form(&[("grant_type", "client_credentials")]);
let credentials = read(&mut request).expect("read");
assert_eq!(credentials.id, "web");
assert_eq!(credentials.secret.as_deref(), Some("s3cret"));
}
#[test]
fn a_basic_secret_containing_plus_or_slash_survives() {
let mut request = Request::new(Method::Post, "/token")
.with_header("authorization", basic_header("web", "s3cr+t/val=ue"))
.with_form(&[]);
let credentials = read(&mut request).expect("read");
assert_eq!(credentials.secret.as_deref(), Some("s3cr+t/val=ue"));
}
#[test]
fn reads_form_body_credentials() {
let mut request = Request::new(Method::Post, "/token")
.with_form(&[("client_id", "web"), ("client_secret", "s3cret")]);
let credentials = read(&mut request).expect("read");
assert_eq!(credentials.id, "web");
assert_eq!(credentials.secret.as_deref(), Some("s3cret"));
}
#[test]
fn a_public_client_presents_only_its_id() {
let mut request = Request::new(Method::Post, "/token").with_form(&[("client_id", "spa")]);
let credentials = read(&mut request).expect("read");
assert_eq!(credentials.id, "spa");
assert!(credentials.secret.is_none());
}
#[test]
fn presenting_two_sets_of_credentials_is_refused() {
let mut request = Request::new(Method::Post, "/token")
.with_header("authorization", basic_header("web", "s3cret"))
.with_form(&[("client_id", "web"), ("client_secret", "other")]);
let error = read(&mut request).unwrap_err();
assert_eq!(error.code, OAuthErrorCode::InvalidClient);
assert!(error.to_string().contains("exactly one method"));
}
#[test]
fn a_body_client_id_that_disagrees_with_the_header_is_refused() {
let mut request = Request::new(Method::Post, "/token")
.with_header("authorization", basic_header("web", "s3cret"))
.with_form(&[("client_id", "spa")]);
assert!(read(&mut request).is_err());
}
#[test]
fn a_request_with_no_credentials_at_all_is_refused() {
let mut request = Request::new(Method::Post, "/token").with_form(&[("token", "abc")]);
let error = read(&mut request).unwrap_err();
assert!(error.to_string().contains("no client credentials"));
}
#[test]
fn a_malformed_basic_header_is_refused_rather_than_ignored() {
let mut request = Request::new(Method::Post, "/token")
.with_header("authorization", "Basic ????")
.with_form(&[]);
assert!(read(&mut request).is_err());
}
#[test]
fn a_bearer_authorization_header_is_not_read_as_client_credentials() {
let mut request = Request::new(Method::Post, "/revoke")
.with_header("authorization", "Bearer abc")
.with_form(&[("client_id", "spa")]);
assert_eq!(read(&mut request).expect("read").id, "spa");
}
#[tokio::test]
async fn a_confidential_client_authenticates_with_its_secret_and_not_without() {
let server = server();
let good = Credentials { id: "web".into(), secret: Some("s3cr+t/val=ue".into()) };
assert!(authenticate(&server, &good).await.is_ok());
let wrong = Credentials { id: "web".into(), secret: Some("nope".into()) };
assert!(authenticate(&server, &wrong).await.is_err());
let none = Credentials { id: "web".into(), secret: None };
assert!(authenticate(&server, &none).await.is_err());
}
#[tokio::test]
async fn an_unknown_client_fails_exactly_like_a_wrong_secret() {
let server = server();
let unknown = authenticate(&server, &Credentials { id: "ghost".into(), secret: Some("x".into()) })
.await
.unwrap_err();
let wrong = authenticate(&server, &Credentials { id: "web".into(), secret: Some("x".into()) })
.await
.unwrap_err();
assert_eq!(unknown.code, wrong.code);
assert_eq!(unknown.description, wrong.description);
}
#[tokio::test]
async fn a_public_client_authenticates_by_id_alone_but_may_not_send_a_secret() {
let server = server();
assert!(authenticate(&server, &Credentials { id: "spa".into(), secret: None }).await.is_ok());
let error =
authenticate(&server, &Credentials { id: "spa".into(), secret: Some("x".into()) })
.await
.unwrap_err();
assert!(error.to_string().contains("registered as public"));
}
#[tokio::test]
async fn endpoints_that_need_real_authentication_refuse_a_public_client() {
let server = server();
let error =
authenticate_confidential(&server, &Credentials { id: "spa".into(), secret: None })
.await
.unwrap_err();
assert!(error.to_string().contains("public by construction"));
let good = Credentials { id: "web".into(), secret: Some("s3cr+t/val=ue".into()) };
assert!(authenticate_confidential(&server, &good).await.is_ok());
}
}