Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
* `digest::Qop` now implements `PartialEq` and `Eq`.
* `digest::QopSet` constructors: `From<Qop>`, `BitOr`, `contains`,
`is_empty`.
* `bearer::ChallengeBuilder`, `bearer::ErrorCode`, and `bearer::Credentials`
(server side).

### Changed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
42 changes: 42 additions & 0 deletions design/20261023-bearer.md
Original file line number Diff line number Diff line change
@@ -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 <token>`
([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.
12 changes: 12 additions & 0 deletions src/bearer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (C) 2026 Scott Lamb <slamb@slamb.org>
// 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};
213 changes: 213 additions & 0 deletions src/bearer/server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Copyright (C) 2026 Scott Lamb <slamb@slamb.org>
// 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<String>,
scope: Option<String>,
error: Option<ErrorCode>,
error_description: Option<String>,
error_uri: Option<String>,
resource_metadata: Option<String>,
}

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<String>) -> Self {
self.realm = Some(realm.into());
self
}

/// Sets the `scope`.
pub fn scope(mut self, scope: impl Into<String>) -> 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<String>) -> Self {
self.error_description = Some(error_description.into());
self
}

/// Sets the `error_uri`.
pub fn error_uri(mut self, error_uri: impl Into<String>) -> Self {
self.error_uri = Some(error_uri.into());
self
}

/// Sets the `resource_metadata`.
pub fn resource_metadata(mut self, resource_metadata: impl Into<String>) -> Self {
self.resource_metadata = Some(resource_metadata.into());
self
}

/// Returns the challenge, or [`ConfigError`] for an invalid value.
pub fn build(self) -> Result<Challenge, ConfigError> {
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<Result<Self, ServerError>> {
source.parse_scheme("Bearer", Self::from_body)
}

fn from_body(body: &AuthBodyRef<'_>) -> Result<Self, ServerError> {
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", &"<redacted>")
.finish()
}
}
23 changes: 11 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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};

Expand Down
8 changes: 8 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
}
Loading
Loading