diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9f160..ebd40f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ * `digest::Qop` now implements `PartialEq` and `Eq`. * `digest::QopSet` constructors: `From`, `BitOr`, `contains`, `is_empty`. +* `bearer::ChallengeBuilder`, `bearer::ErrorCode`, and `bearer::Credentials` + (server side). ### Changed diff --git a/README.md b/README.md index 403ad9c..6a260a3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Rust library for HTTP authentication. Parses challenge lists and responds to `Basic` and `Digest` challenges (client side), and issues challenges and -verifies `Basic` and `Digest` credentials (server side, `server` feature). +verifies `Basic`, `Digest`, and `Bearer` credentials (server side, `server` feature). HTTP authentication is described in the following documents and specifications: diff --git a/design/20261023-bearer.md b/design/20261023-bearer.md new file mode 100644 index 0000000..250df68 --- /dev/null +++ b/design/20261023-bearer.md @@ -0,0 +1,42 @@ +# Server-side `Bearer` challenges and credentials + +Date: 2026-10-23 + +# Problem statement + +A resource server accepting OAuth 2.0 access tokens sends `Bearer` challenges +([RFC 6750 section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3)), +optionally pointing to its metadata with `resource_metadata` +([RFC 9728 section 5.1](https://datatracker.ietf.org/doc/html/rfc9728#section-5.1)), +and reads the token from `Authorization: Bearer ` +([RFC 6750 section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)). + +Non-goals: token validation, metadata documents, grants, form-encoded or +query-string tokens. + +# Decision outcome + +Module `bearer`, behind `server`; no new dependencies. + +* `bearer::ChallengeBuilder::new()` with optional `realm`, `scope`, + `error`, `error_description`, `error_uri`, `resource_metadata`; `build()` + returns a `Challenge`, written in that order. +* `bearer::ErrorCode`: `InvalidRequest` (400), `InvalidToken` (401), + `InsufficientScope` (403), via `status()`. +* `bearer::Credentials::parse(source)` returns the `token68`, like + `basic::Credentials::parse`. + +## Validation rules + +| Rule | Error | +|---|---| +| `realm` quotable | `ConfigError` | +| `scope`: `scope-token`s (`%x21 / %x23-5B / %x5D-7E`) separated by single spaces | `ConfigError` | +| `error_description`, `error_uri`, `resource_metadata`: `%x20-21 / %x23-5B / %x5D-7E` | `ConfigError` | +| Credentials: body is a `token68` | `ServerError::Malformed` | + +# Testing + +* RFC 6750 section 3 and RFC 9728 section 5.1 examples. +* Every challenge parameter parsed back with `parse_challenges`. +* One negative test per validation rule. diff --git a/src/bearer.rs b/src/bearer.rs new file mode 100644 index 0000000..10b65dd --- /dev/null +++ b/src/bearer.rs @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Bearer` authentication scheme, as in +//! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). + +#[cfg(feature = "server")] +mod server; + +#[cfg(feature = "server")] +#[cfg_attr(docsrs, doc(cfg(feature = "server")))] +pub use self::server::{ChallengeBuilder, Credentials, ErrorCode}; diff --git a/src/bearer/server.rs b/src/bearer/server.rs new file mode 100644 index 0000000..1df2678 --- /dev/null +++ b/src/bearer/server.rs @@ -0,0 +1,213 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Server side of the `Bearer` scheme, as in +//! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). + +use crate::render::HeaderWriter; +use crate::server::{check_realm, Challenge, ConfigError, CredentialsSource, ServerError}; +use crate::table::{char_classes, C_SCOPE_TOKEN}; +use crate::AuthBodyRef; + +/// An `error` value for a `Bearer` challenge, as in +/// [RFC 6750 section 3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ErrorCode { + /// The request is missing a required parameter or is otherwise malformed. + InvalidRequest, + /// The access token is expired, revoked, or otherwise invalid. + InvalidToken, + /// The token doesn't grant the requested scope. + InsufficientScope, +} + +impl ErrorCode { + /// The HTTP status code to respond with. + pub fn status(&self) -> u16 { + match self { + ErrorCode::InvalidRequest => 400, + ErrorCode::InvalidToken => 401, + ErrorCode::InsufficientScope => 403, + } + } + + /// The wire form of this error code. + pub fn as_str(&self) -> &'static str { + match self { + ErrorCode::InvalidRequest => "invalid_request", + ErrorCode::InvalidToken => "invalid_token", + ErrorCode::InsufficientScope => "insufficient_scope", + } + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Builds a `Bearer` challenge. +/// +/// ```rust +/// use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; +/// let challenge = ChallengeBuilder::new().realm("example").build()?; +/// assert_eq!(challenge.as_str(), r#"Bearer realm="example""#); +/// let creds = Credentials::parse("Bearer mF_9.B5f-4.1JqM").unwrap().unwrap(); +/// assert_eq!(creds.token(), "mF_9.B5f-4.1JqM"); +/// # Ok::<(), http_auth::ConfigError>(()) +/// ``` +#[derive(Clone, Debug)] +pub struct ChallengeBuilder { + realm: Option, + scope: Option, + error: Option, + error_description: Option, + error_uri: Option, + resource_metadata: Option, +} + +impl ChallengeBuilder { + /// Starts building a challenge with no parameters. + pub fn new() -> Self { + Self { + realm: None, + scope: None, + error: None, + error_description: None, + error_uri: None, + resource_metadata: None, + } + } + + /// Sets the `realm`. + pub fn realm(mut self, realm: impl Into) -> Self { + self.realm = Some(realm.into()); + self + } + + /// Sets the `scope`. + pub fn scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } + + /// Sets the `error`. + pub fn error(mut self, error: ErrorCode) -> Self { + self.error = Some(error); + self + } + + /// Sets the `error_description`. + pub fn error_description(mut self, error_description: impl Into) -> Self { + self.error_description = Some(error_description.into()); + self + } + + /// Sets the `error_uri`. + pub fn error_uri(mut self, error_uri: impl Into) -> Self { + self.error_uri = Some(error_uri.into()); + self + } + + /// Sets the `resource_metadata`. + pub fn resource_metadata(mut self, resource_metadata: impl Into) -> Self { + self.resource_metadata = Some(resource_metadata.into()); + self + } + + /// Returns the challenge, or [`ConfigError`] for an invalid value. + pub fn build(self) -> Result { + if let Some(realm) = &self.realm { + check_realm(realm)?; + } + if let Some(scope) = &self.scope { + if !valid_scope(scope) { + return Err(ConfigError("scope must be space-separated scope-tokens")); + } + } + let values = [ + self.error_description.as_deref(), + self.error_uri.as_deref(), + self.resource_metadata.as_deref(), + ]; + for value in values.iter().filter_map(|v| *v) { + if !crate::render::is_bearer_param(value) { + return Err(ConfigError("must be printable ASCII without \" or \\")); + } + } + + let mut w = HeaderWriter::new("Bearer"); + if let Some(realm) = &self.realm { + w.quoted("realm", realm); + } + if let Some(scope) = &self.scope { + w.quoted("scope", scope); + } + if let Some(error) = &self.error { + w.quoted("error", error.as_str()); + } + if let Some(error_description) = &self.error_description { + w.quoted("error_description", error_description); + } + if let Some(error_uri) = &self.error_uri { + w.quoted("error_uri", error_uri); + } + if let Some(resource_metadata) = &self.resource_metadata { + w.quoted("resource_metadata", resource_metadata); + } + Ok(Challenge(w.finish())) + } +} + +impl Default for ChallengeBuilder { + fn default() -> Self { + Self::new() + } +} + +fn valid_scope(s: &str) -> bool { + !s.is_empty() + && s.split(' ').all(|part| { + !part.is_empty() && part.bytes().all(|b| (char_classes(b) & C_SCOPE_TOKEN) != 0) + }) +} + +/// Credentials from a `Bearer` `Authorization` or `Proxy-Authorization` header. +#[derive(Clone)] +pub struct Credentials { + token: String, +} + +impl Credentials { + /// Parses a header value as in [RFC 6750 section + /// 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1). + /// + /// Returns `None` if `source` uses another scheme. See + /// [`CredentialsSource`] for accepted sources. + pub fn parse<'i, S: CredentialsSource<'i>>(source: S) -> Option> { + source.parse_scheme("Bearer", Self::from_body) + } + + fn from_body(body: &AuthBodyRef<'_>) -> Result { + let &AuthBodyRef::Token68(token) = body else { + return Err(ServerError::Malformed("expected token68")); + }; + Ok(Self { + token: token.to_owned(), + }) + } + + /// Returns the access token. + pub fn token(&self) -> &str { + &self.token + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("token", &"") + .finish() + } +} diff --git a/src/lib.rs b/src/lib.rs index 8a2dadb..ae74ac0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,10 @@ //! The 'Basic' HTTP Authentication Scheme //! * [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616): //! HTTP Digest Access Authentication +//! * [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750): +//! The OAuth 2.0 Authorization Framework: Bearer Token Usage +//! * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728): +//! OAuth 2.0 Protected Resource Metadata //! //! This framework is primarily used with HTTP, as suggested by the name. It is //! also used by some other protocols such as RTSP. @@ -89,26 +93,21 @@ pub mod basic; #[cfg_attr(docsrs, doc(cfg(feature = "digest-scheme")))] pub mod digest; +#[cfg(feature = "server")] +#[cfg_attr(docsrs, doc(cfg(feature = "server")))] +pub mod bearer; + mod table; -#[cfg(all( - feature = "server", - any(feature = "basic-scheme", feature = "digest-scheme") -))] +#[cfg(feature = "server")] mod server; -#[cfg(any( - feature = "digest-scheme", - all(feature = "server", feature = "basic-scheme") -))] +#[cfg(any(feature = "digest-scheme", feature = "server"))] mod render; pub use parser::{parse_credentials, ChallengeParser}; -#[cfg(all( - feature = "server", - any(feature = "basic-scheme", feature = "digest-scheme") -))] +#[cfg(feature = "server")] #[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use server::{Challenge, ConfigError, CredentialsSource, ServerError}; diff --git a/src/render.rs b/src/render.rs index 74b237e..b3163ef 100644 --- a/src/render.rs +++ b/src/render.rs @@ -14,6 +14,14 @@ pub(crate) fn is_quotable(s: &str) -> bool { .all(|b| (char_classes(b) & (C_QDTEXT | C_ESCAPABLE)) != 0) } +/// Returns true if `s` consists only of bytes allowed in `Bearer` +/// `error_description`, `error_uri`, and `resource_metadata` values. +#[cfg(feature = "server")] +pub(crate) fn is_bearer_param(s: &str) -> bool { + s.bytes() + .all(|b| b == b' ' || (char_classes(b) & crate::table::C_SCOPE_TOKEN) != 0) +} + /// Writes a challenge or credentials header value. Callers validate values. pub(crate) struct HeaderWriter { out: String, diff --git a/src/table.rs b/src/table.rs index 9746127..0515b17 100644 --- a/src/table.rs +++ b/src/table.rs @@ -10,6 +10,7 @@ pub(crate) const C_QDTEXT: u8 = 2; pub(crate) const C_ESCAPABLE: u8 = 4; pub(crate) const C_OWS: u8 = 8; pub(crate) const C_ATTR: u8 = 16; +pub(crate) const C_SCOPE_TOKEN: u8 = 32; static TABLE: [u8; 128] = build_table(); @@ -40,6 +41,9 @@ const fn build_table() -> [u8; 128] { if is_attr(b) { classes |= C_ATTR; } + if is_scope_token(b) { + classes |= C_SCOPE_TOKEN; + } table[i] = classes; i += 1; } @@ -108,3 +112,9 @@ const fn is_attr(b: u8) -> bool { const fn is_ows(b: u8) -> bool { matches!(b, b' ' | b'\t') } + +/// Returns true if the byte is a `scope-token` character as in +/// [RFC 6750 section 1.1](https://datatracker.ietf.org/doc/html/rfc6750#section-1.1). +const fn is_scope_token(b: u8) -> bool { + matches!(b, 0x21 | 0x23..=0x5B | 0x5D..=0x7E) +} diff --git a/tests/bearer.rs b/tests/bearer.rs new file mode 100644 index 0000000..43a16f4 --- /dev/null +++ b/tests/bearer.rs @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Bearer` challenges and credentials against RFC 6750 and RFC 9728. + +#![cfg(feature = "server")] + +use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; + +fn params(header: &str) -> Vec<(String, String)> { + let c = http_auth::parse_challenges(header).unwrap(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].scheme(), "Bearer"); + match c[0].body() { + http_auth::AuthBodyRef::Params(p) => p + .iter() + .map(|(k, v)| (k.to_string(), v.to_unescaped().into_owned())) + .collect(), + http_auth::AuthBodyRef::Token68(_) => panic!("token68 body"), + } +} + +#[test] +fn rfc6750_examples() { + let c = ChallengeBuilder::new().realm("example").build().unwrap(); + assert_eq!(c.as_str(), r#"Bearer realm="example""#); + let c = ChallengeBuilder::new() + .realm("example") + .error(ErrorCode::InvalidToken) + .error_description("The access token expired") + .build() + .unwrap(); + assert_eq!( + c.as_str(), + r#"Bearer realm="example", error="invalid_token", error_description="The access token expired""# + ); +} + +#[test] +fn rfc9728_example() { + let c = ChallengeBuilder::new() + .resource_metadata("https://resource.example.com/.well-known/oauth-protected-resource") + .build() + .unwrap(); + assert_eq!( + c.as_str(), + r#"Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource""# + ); +} + +#[test] +fn round_trip() { + let c = ChallengeBuilder::new() + .realm("zen \"ith\" \\") + .scope("live:read vod:read") + .error(ErrorCode::InsufficientScope) + .error_description("need vod:read") + .error_uri("https://example.com/e") + .resource_metadata("https://example.com/.well-known/oauth-protected-resource") + .build() + .unwrap(); + assert_eq!( + params(c.as_str()), + vec![ + ("realm".to_owned(), "zen \"ith\" \\".to_owned()), + ("scope".to_owned(), "live:read vod:read".to_owned()), + ("error".to_owned(), "insufficient_scope".to_owned()), + ("error_description".to_owned(), "need vod:read".to_owned()), + ("error_uri".to_owned(), "https://example.com/e".to_owned()), + ( + "resource_metadata".to_owned(), + "https://example.com/.well-known/oauth-protected-resource".to_owned() + ), + ] + ); + let c = ChallengeBuilder::new() + .error(ErrorCode::InvalidRequest) + .build() + .unwrap(); + assert_eq!( + params(c.as_str()), + vec![("error".to_owned(), "invalid_request".to_owned())] + ); + assert_eq!(ChallengeBuilder::new().build().unwrap().as_str(), "Bearer"); +} + +#[test] +fn rejects_invalid_values() { + assert!(ChallengeBuilder::new().realm("a\nb").build().is_err()); + assert!(ChallengeBuilder::new() + .error_description("say \"hi\"") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_description("back\\slash") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_description("tab\there") + .build() + .is_err()); + assert!(ChallengeBuilder::new().error_uri("a\"b").build().is_err()); + assert!(ChallengeBuilder::new().scope("a b").build().is_err()); + assert!(ChallengeBuilder::new().scope(" a").build().is_err()); + assert!(ChallengeBuilder::new().scope("a ").build().is_err()); + assert!(ChallengeBuilder::new().scope("").build().is_err()); + assert!(ChallengeBuilder::new().scope("a\"b").build().is_err()); + assert!(ChallengeBuilder::new().scope("caf\u{e9}").build().is_err()); + assert!(ChallengeBuilder::new() + .resource_metadata("x\"y") + .build() + .is_err()); + assert!(ChallengeBuilder::new().scope("a b").build().is_ok()); +} + +#[test] +fn error_status() { + assert_eq!(ErrorCode::InvalidRequest.status(), 400); + assert_eq!(ErrorCode::InvalidToken.status(), 401); + assert_eq!(ErrorCode::InsufficientScope.status(), 403); +} + +#[test] +fn credentials() { + let c = Credentials::parse("Bearer mF_9.B5f-4.1JqM") + .unwrap() + .unwrap(); + assert_eq!(c.token(), "mF_9.B5f-4.1JqM"); + let c = Credentials::parse("bearer abc+/=").unwrap().unwrap(); + assert_eq!(c.token(), "abc+/="); + let parsed = http_auth::parse_credentials("Bearer xyz").unwrap(); + assert_eq!(Credentials::parse(&parsed).unwrap().unwrap().token(), "xyz"); + + assert!(Credentials::parse("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==").is_none()); + assert!(matches!( + Credentials::parse("Bearer a=\"b\""), + Some(Err(http_auth::ServerError::Malformed(_))) + )); + assert!(matches!( + Credentials::parse("Bearer"), + Some(Err(http_auth::ServerError::Malformed(_))) + )); + assert!(matches!( + Credentials::parse("Bearer a b"), + Some(Err(http_auth::ServerError::Malformed(_))) + )); +}