diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 960489f..baa94e7 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -38,6 +38,8 @@ jobs: seeds: challenges - target: parse_credentials seeds: credentials + - target: bearer_uri + seeds: uris - target: digest_response seeds: credentials env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9f160..fdbb9a8 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::Client`, `bearer::encode_credentials`, `bearer::ChallengeBuilder`, + `bearer::ErrorCode`, and `bearer::Credentials`. ### 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-rfc.md b/design/20261023-bearer-rfc.md new file mode 100644 index 0000000..5f489f9 --- /dev/null +++ b/design/20261023-bearer-rfc.md @@ -0,0 +1,365 @@ +# RFC excerpts for `bearer` + +Verbatim from the RFCs the `bearer` module implements; page headers removed. +See [20261023-bearer.md](20261023-bearer.md). + +## RFC 6750 section 2.1: Authorization Request Header Field + +```text +2.1. Authorization Request Header Field + + When sending the access token in the "Authorization" request header + field defined by HTTP/1.1 [RFC2617], the client uses the "Bearer" + authentication scheme to transmit the access token. + + For example: + + GET /resource HTTP/1.1 + Host: server.example.com + Authorization: Bearer mF_9.B5f-4.1JqM + + The syntax of the "Authorization" header field for this scheme + follows the usage of the Basic scheme defined in Section 2 of + [RFC2617]. Note that, as with Basic, it does not conform to the + generic syntax defined in Section 1.2 of [RFC2617] but is compatible + with the general authentication framework being developed for + HTTP 1.1 [HTTP-AUTH], although it does not follow the preferred + practice outlined therein in order to reflect existing deployments. + The syntax for Bearer credentials is as follows: + + b64token = 1*( ALPHA / DIGIT / + "-" / "." / "_" / "~" / "+" / "/" ) *"=" + credentials = "Bearer" 1*SP b64token + + Clients SHOULD make authenticated requests with a bearer token using + the "Authorization" request header field with the "Bearer" HTTP + authorization scheme. Resource servers MUST support this method. +``` + +## RFC 6750 section 3: The WWW-Authenticate Response Header Field + +```text +3. The WWW-Authenticate Response Header Field + + If the protected resource request does not include authentication + credentials or does not contain an access token that enables access + to the protected resource, the resource server MUST include the HTTP + "WWW-Authenticate" response header field; it MAY include it in + response to other conditions as well. The "WWW-Authenticate" header + field uses the framework defined by HTTP/1.1 [RFC2617]. + + All challenges defined by this specification MUST use the auth-scheme + value "Bearer". This scheme MUST be followed by one or more + auth-param values. The auth-param attributes used or defined by this + specification are as follows. Other auth-param attributes MAY be + used as well. + + A "realm" attribute MAY be included to indicate the scope of + protection in the manner described in HTTP/1.1 [RFC2617]. The + "realm" attribute MUST NOT appear more than once. + + The "scope" attribute is defined in Section 3.3 of [RFC6749]. The + "scope" attribute is a space-delimited list of case-sensitive scope + values indicating the required scope of the access token for + accessing the requested resource. "scope" values are implementation + defined; there is no centralized registry for them; allowed values + are defined by the authorization server. The order of "scope" values + is not significant. In some cases, the "scope" value will be used + + when requesting a new access token with sufficient scope of access to + utilize the protected resource. Use of the "scope" attribute is + OPTIONAL. The "scope" attribute MUST NOT appear more than once. The + "scope" value is intended for programmatic use and is not meant to be + displayed to end-users. + + Two example scope values follow; these are taken from the OpenID + Connect [OpenID.Messages] and the Open Authentication Technology + Committee (OATC) Online Multimedia Authorization Protocol [OMAP] + OAuth 2.0 use cases, respectively: + + scope="openid profile email" + scope="urn:example:channel=HBO&urn:example:rating=G,PG-13" + + If the protected resource request included an access token and failed + authentication, the resource server SHOULD include the "error" + attribute to provide the client with the reason why the access + request was declined. The parameter value is described in + Section 3.1. In addition, the resource server MAY include the + "error_description" attribute to provide developers a human-readable + explanation that is not meant to be displayed to end-users. It also + MAY include the "error_uri" attribute with an absolute URI + identifying a human-readable web page explaining the error. The + "error", "error_description", and "error_uri" attributes MUST NOT + appear more than once. + + Values for the "scope" attribute (specified in Appendix A.4 of + [RFC6749]) MUST NOT include characters outside the set %x21 / %x23-5B + / %x5D-7E for representing scope values and %x20 for delimiters + between scope values. Values for the "error" and "error_description" + attributes (specified in Appendixes A.7 and A.8 of [RFC6749]) MUST + NOT include characters outside the set %x20-21 / %x23-5B / %x5D-7E. + Values for the "error_uri" attribute (specified in Appendix A.9 of + [RFC6749]) MUST conform to the URI-reference syntax and thus MUST NOT + include characters outside the set %x21 / %x23-5B / %x5D-7E. + + For example, in response to a protected resource request without + authentication: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example" + + And in response to a protected resource request with an + authentication attempt using an expired access token: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" + +3.1. Error Codes + + When a request fails, the resource server responds using the + appropriate HTTP status code (typically, 400, 401, 403, or 405) and + includes one of the following error codes in the response: + + invalid_request + The request is missing a required parameter, includes an + unsupported parameter or parameter value, repeats the same + parameter, uses more than one method for including an access + token, or is otherwise malformed. The resource server SHOULD + respond with the HTTP 400 (Bad Request) status code. + + invalid_token + The access token provided is expired, revoked, malformed, or + invalid for other reasons. The resource SHOULD respond with + the HTTP 401 (Unauthorized) status code. The client MAY + request a new access token and retry the protected resource + request. + + insufficient_scope + The request requires higher privileges than provided by the + access token. The resource server SHOULD respond with the HTTP + 403 (Forbidden) status code and MAY include the "scope" + attribute with the scope necessary to access the protected + resource. + + If the request lacks any authentication information (e.g., the client + was unaware that authentication is necessary or attempted using an + unsupported authentication method), the resource server SHOULD NOT + include an error code or other error information. + + For example: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example" +``` + +## RFC 6749 appendix A.4, A.7-A.9: `scope`, `error`, `error_description`, `error_uri` syntax + +```text +Appendix A. Augmented Backus-Naur Form (ABNF) Syntax + + This section provides Augmented Backus-Naur Form (ABNF) syntax + descriptions for the elements defined in this specification using the + notation of [RFC5234]. The ABNF below is defined in terms of Unicode + code points [W3C.REC-xml-20081126]; these characters are typically + encoded in UTF-8. Elements are presented in the order first defined. + + Some of the definitions that follow use the "URI-reference" + definition from [RFC3986]. + + Some of the definitions that follow use these common definitions: + + VSCHAR = %x20-7E + NQCHAR = %x21 / %x23-5B / %x5D-7E + NQSCHAR = %x20-21 / %x23-5B / %x5D-7E + UNICODECHARNOCRLF = %x09 /%x20-7E / %x80-D7FF / + %xE000-FFFD / %x10000-10FFFF + + (The UNICODECHARNOCRLF definition is based upon the Char definition + in Section 2.2 of [W3C.REC-xml-20081126], but omitting the Carriage + Return and Linefeed characters.) + +A.4. "scope" Syntax + + The "scope" element is defined in Section 3.3: + + scope = scope-token *( SP scope-token ) + scope-token = 1*NQCHAR + +A.7. "error" Syntax + + The "error" element is defined in Sections 4.1.2.1, 4.2.2.1, 5.2, + 7.2, and 8.5: + + error = 1*NQSCHAR + +A.8. "error_description" Syntax + + The "error_description" element is defined in Sections 4.1.2.1, + 4.2.2.1, 5.2, and 7.2: + + error-description = 1*NQSCHAR + +A.9. "error_uri" Syntax + + The "error_uri" element is defined in Sections 4.1.2.1, 4.2.2.1, 5.2, + and 7.2: + + error-uri = URI-reference +``` + +## RFC 9728 section 1.2: Terminology (resource identifier) + +```text +1.2. Terminology + + This specification uses the terms "access token", "authorization + code", "authorization server", "client", "client authentication", + "client identifier", "protected resource", and "resource server" + defined by OAuth 2.0 [RFC6749], and the terms "Claim Name" and "JSON + Web Token (JWT)" defined by "JSON Web Token (JWT)" [JWT]. + + This specification defines the following term: + + Resource Identifier: + The protected resource's resource identifier, which is a URL that + uses the https scheme and has no fragment component. As specified + in Section 2 of [RFC8707], it also SHOULD NOT include a query + component, but it is recognized that there are cases that make a + query component a useful and necessary part of a resource + identifier. Protected resource metadata is published at a .well- + known location [RFC8615] derived from this resource identifier, as + described in Section 3. +``` + +## RFC 9728 sections 5.1-5.2: WWW-Authenticate Response + +```text +5.1. WWW-Authenticate Response + + This specification introduces a new parameter in the WWW-Authenticate + HTTP response header field to indicate the protected resource + metadata URL: + + resource_metadata: + The URL of the protected resource metadata. + + The response below is an example of a WWW-Authenticate header that + includes the resource identifier. + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer resource_metadata= + "https://resource.example.com/.well-known/oauth-protected-resource" + + The HTTP status code in the example response above is defined by + [RFC6750]. + + This parameter MAY also be used in WWW-Authenticate responses using + authorization schemes other than "Bearer" [RFC6750], such as the DPoP + scheme defined by [RFC9449]. + + The resource_metadata parameter MAY be combined with other parameters + defined in other extensions, such as the max_age parameter defined by + [RFC9470]. + +5.2. Changes to Resource Metadata + + At any point, for any reason determined by the resource server, the + protected resource MAY respond with a new WWW-Authenticate challenge + that includes a value for the protected resource metadata URL to + indicate that its metadata may have changed. If the client receives + such a WWW-Authenticate response, it SHOULD retrieve the updated + protected resource metadata and use the new metadata values obtained, + after validating them as described in Section 3.3. Among other + things, this enables a resource server to change which authorization + servers it uses without any other coordination with clients. +``` + +## RFC 3986 appendix A: Collected ABNF for URI + +```text +Appendix A. Collected ABNF for URI + + URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + + hier-part = "//" authority path-abempty + / path-absolute + / path-rootless + / path-empty + + URI-reference = URI / relative-ref + + absolute-URI = scheme ":" hier-part [ "?" query ] + + relative-ref = relative-part [ "?" query ] [ "#" fragment ] + + relative-part = "//" authority path-abempty + / path-absolute + / path-noscheme + / path-empty + + scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + + authority = [ userinfo "@" ] host [ ":" port ] + userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) + host = IP-literal / IPv4address / reg-name + port = *DIGIT + + IP-literal = "[" ( IPv6address / IPvFuture ) "]" + + IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) + + IPv6address = 6( h16 ":" ) ls32 + / "::" 5( h16 ":" ) ls32 + / [ h16 ] "::" 4( h16 ":" ) ls32 + / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + / [ *4( h16 ":" ) h16 ] "::" ls32 + / [ *5( h16 ":" ) h16 ] "::" h16 + / [ *6( h16 ":" ) h16 ] "::" + + h16 = 1*4HEXDIG + ls32 = ( h16 ":" h16 ) / IPv4address + IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet + + dec-octet = DIGIT ; 0-9 + / %x31-39 DIGIT ; 10-99 + / "1" 2DIGIT ; 100-199 + / "2" %x30-34 DIGIT ; 200-249 + / "25" %x30-35 ; 250-255 + + reg-name = *( unreserved / pct-encoded / sub-delims ) + + path = path-abempty ; begins with "/" or is empty + / path-absolute ; begins with "/" but not "//" + / path-noscheme ; begins with a non-colon segment + / path-rootless ; begins with a segment + / path-empty ; zero characters + + path-abempty = *( "/" segment ) + path-absolute = "/" [ segment-nz *( "/" segment ) ] + path-noscheme = segment-nz-nc *( "/" segment ) + path-rootless = segment-nz *( "/" segment ) + path-empty = 0 + + segment = *pchar + segment-nz = 1*pchar + segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) + ; non-zero-length segment without any colon ":" + + pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + + query = *( pchar / "/" / "?" ) + + fragment = *( pchar / "/" / "?" ) + + pct-encoded = "%" HEXDIG HEXDIG + + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + reserved = gen-delims / sub-delims + gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" +``` diff --git a/design/20261023-bearer.md b/design/20261023-bearer.md new file mode 100644 index 0000000..e2742af --- /dev/null +++ b/design/20261023-bearer.md @@ -0,0 +1,68 @@ +# `Bearer` challenges and credentials + +Date: 2026-10-23 + +# Problem statement + +OAuth 2.0 resource servers send `Bearer` challenges +([RFC 6750 section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3)), +optionally with `resource_metadata` +([RFC 9728 section 5.1](https://datatracker.ietf.org/doc/html/rfc9728#section-5.1)), +and read the token from `Authorization: Bearer ` +([RFC 6750 section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)). +Clients read those challenges and send the token. + +The RFC text is in [20261023-bearer-rfc.md](20261023-bearer-rfc.md). + +Non-goals: the optional form-body and query methods (RFC 6750 sections 2.2, +2.3); token validation; fetching or validating metadata (RFC 9728 section +3.3); transport security (RFC 6750 section 5). + +# Decision outcome + +Module `bearer`. The client half has no feature gate and no dependencies; the +server half is behind `server`. + +* `bearer::Client`, from a `Bearer` `ChallengeRef`: `realm()`, `scope()`, + `error()`, `error_description()`, `error_uri()`, `resource_metadata()`, + and `respond(token)`. +* `bearer::encode_credentials(token)`, like `basic::encode_credentials`. +* `bearer::ChallengeBuilder`, `bearer::ErrorCode` (`status()` 400 / 401 / + 403) and `bearer::Credentials::parse`, behind `server`. + +The client and server check challenge values with the same functions, so a +challenge the builder writes is one the client accepts, and the reverse. + +## Validation rules + +| Rule | Source | Builder | Client | +|---|---|---|---| +| At least one auth-param | 6750 §3 | `ConfigError` | `ClientError` | +| `realm`, `scope`, `error`, `error_description`, `error_uri` at most once | 6750 §3 | by construction | `ClientError` | +| `resource_metadata` at most once | 9728 §5.1 | by construction | `ClientError` | +| `realm` is a quoted-string | 6750 §3 | `ConfigError` | parser | +| `scope` = `scope-token *( SP scope-token )`, `scope-token = 1*NQCHAR` | 6749 A.4 | `ConfigError` | `ClientError` | +| `error` = `1*NQSCHAR` | 6749 A.7 | `ErrorCode` only | `ClientError` | +| `error_description` = `1*NQSCHAR` | 6749 A.8 | `ConfigError` | `ClientError` | +| `error_uri` is an absolute `URI` | 6750 §3, 6749 A.9, 3986 §3 | `ConfigError` | `ClientError` | +| `resource_metadata` is an `https` `URI` with a host and no fragment | 9728 §1.2, §3, §5.1 | `ConfigError` | `ClientError` | +| Token is a `b64token` | 6750 §2.1 | — | `ClientError` | +| Credentials are `"Bearer" 1*SP b64token` | 6750 §2.1 | `ServerError::Malformed` | — | + +Unknown auth-params are ignored (RFC 6750 section 3). + +## Caller duties + +* Send a `Bearer` challenge on every 401 for a protected resource (RFC 6750 + section 3). +* Leave out `error` when the request had no credentials (RFC 6750 section + 3.1). +* Respond with `ErrorCode::status()`. + +# Testing + +* RFC 6750 section 3 and RFC 9728 section 5.1 examples, on both sides. +* Builder output parsed back with `parse_challenges` and `Client`. +* `respond` output parsed back with `Credentials::parse`. +* One negative test per rule, on each side that checks it. +* Fuzzing: `bearer_uri` compares the URI check with `iri-string`. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 80d8806..144d65a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -176,6 +176,7 @@ version = "0.0.0" dependencies = [ "env_logger", "http-auth", + "iri-string", "libfuzzer-sys", "log", "nom", @@ -187,6 +188,16 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iri-string" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1663ee7d8cf2900cc1414b1e1eec9f348d6eaa3bcab07579f4726a4b8499f447" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -343,6 +354,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.8" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1783f8b..d37faed 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -14,6 +14,7 @@ env_logger = "0.9" libfuzzer-sys = "0.4" log = "0.4" nom = "7.0" +iri-string = "0.7" [dependencies.http-auth] path = ".." @@ -35,6 +36,12 @@ path = "fuzz_targets/parse_credentials.rs" test = false doc = false +[[bin]] +name = "bearer_uri" +path = "fuzz_targets/bearer_uri.rs" +test = false +doc = false + [[bin]] name = "render_challenges" path = "fuzz_targets/render_challenges.rs" diff --git a/fuzz/fuzz_targets/bearer_uri.rs b/fuzz/fuzz_targets/bearer_uri.rs new file mode 100644 index 0000000..be08d7d --- /dev/null +++ b/fuzz/fuzz_targets/bearer_uri.rs @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Differential: http-auth's URI validator vs iri-string (RFC 3986 `URI`): +// $ cargo +nightly fuzz run bearer_uri + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &str| { + let _ = env_logger::builder().try_init(); + let ours = http_auth::bearer::ChallengeBuilder::new() + .error_uri(data) + .build() + .is_ok(); + let oracle = iri_string::types::UriStr::new(data).is_ok(); + if ours != oracle { + panic!("MISMATCH {:?} ours={} oracle={}", data, ours, oracle); + } +}); diff --git a/fuzz/seeds/uris/ipv4-fallback b/fuzz/seeds/uris/ipv4-fallback new file mode 100644 index 0000000..1fc7780 --- /dev/null +++ b/fuzz/seeds/uris/ipv4-fallback @@ -0,0 +1 @@ +https://1.2.3.4abc/ \ No newline at end of file diff --git a/fuzz/seeds/uris/ipv4-mapped b/fuzz/seeds/uris/ipv4-mapped new file mode 100644 index 0000000..bb6189a --- /dev/null +++ b/fuzz/seeds/uris/ipv4-mapped @@ -0,0 +1 @@ +http://[::ffff:192.0.2.1]/ \ No newline at end of file diff --git a/fuzz/seeds/uris/ipv6 b/fuzz/seeds/uris/ipv6 new file mode 100644 index 0000000..d403375 --- /dev/null +++ b/fuzz/seeds/uris/ipv6 @@ -0,0 +1 @@ +http://[::1]:80/x?y#z \ No newline at end of file diff --git a/fuzz/seeds/uris/ipvfuture b/fuzz/seeds/uris/ipvfuture new file mode 100644 index 0000000..bff6332 --- /dev/null +++ b/fuzz/seeds/uris/ipvfuture @@ -0,0 +1 @@ +http://[v7.x:y]/ \ No newline at end of file diff --git a/fuzz/seeds/uris/pct-encoded b/fuzz/seeds/uris/pct-encoded new file mode 100644 index 0000000..1a24d17 --- /dev/null +++ b/fuzz/seeds/uris/pct-encoded @@ -0,0 +1 @@ +http://example.com/%20 \ No newline at end of file diff --git a/fuzz/seeds/uris/relative b/fuzz/seeds/uris/relative new file mode 100644 index 0000000..71dbab5 --- /dev/null +++ b/fuzz/seeds/uris/relative @@ -0,0 +1 @@ +scheme:foo \ No newline at end of file diff --git a/fuzz/seeds/uris/urn b/fuzz/seeds/uris/urn new file mode 100644 index 0000000..ff4716e --- /dev/null +++ b/fuzz/seeds/uris/urn @@ -0,0 +1 @@ +urn:oasis:names:specification:docbook:dtd:xml:4.1.2 \ No newline at end of file diff --git a/fuzz/seeds/uris/userinfo-port-query-fragment b/fuzz/seeds/uris/userinfo-port-query-fragment new file mode 100644 index 0000000..1e15e0c --- /dev/null +++ b/fuzz/seeds/uris/userinfo-port-query-fragment @@ -0,0 +1 @@ +http://user:pass@example.com:8080/a/b?c=d#frag \ No newline at end of file diff --git a/src/bearer.rs b/src/bearer.rs new file mode 100644 index 0000000..40d099b --- /dev/null +++ b/src/bearer.rs @@ -0,0 +1,209 @@ +// 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). + +use std::convert::TryFrom; + +use crate::{ChallengeRef, ClientError}; + +#[cfg(feature = "server")] +mod server; + +#[cfg(feature = "server")] +#[cfg_attr(docsrs, doc(cfg(feature = "server")))] +pub use self::server::{ChallengeBuilder, Credentials, ErrorCode}; + +use crate::render; +use crate::table::{char_classes, C_SCOPE_TOKEN}; +use crate::uri; + +pub(crate) const REALM: usize = 0; +pub(crate) const SCOPE: usize = 1; +pub(crate) const ERROR: usize = 2; +pub(crate) const ERROR_DESCRIPTION: usize = 3; +pub(crate) const ERROR_URI: usize = 4; +pub(crate) const RESOURCE_METADATA: usize = 5; + +/// Client for a `Bearer` challenge, as in +/// [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). +#[derive(Clone, Debug)] +pub struct Client { + params: Params, +} + +impl Client { + /// Returns the `realm`, if present. + pub fn realm(&self) -> Option<&str> { + self.params.get(REALM) + } + + /// Returns the `scope`, if present. + pub fn scope(&self) -> Option<&str> { + self.params.get(SCOPE) + } + + /// Returns the `error`, if present. + pub fn error(&self) -> Option<&str> { + self.params.get(ERROR) + } + + /// Returns the `error_description`, if present. + pub fn error_description(&self) -> Option<&str> { + self.params.get(ERROR_DESCRIPTION) + } + + /// Returns the `error_uri`, if present. + pub fn error_uri(&self) -> Option<&str> { + self.params.get(ERROR_URI) + } + + /// Returns the `resource_metadata`, if present. + pub fn resource_metadata(&self) -> Option<&str> { + self.params.get(RESOURCE_METADATA) + } + + /// Responds to the challenge with the supplied token. + pub fn respond(&self, token: &str) -> Result { + encode_credentials(token) + } +} + +impl TryFrom<&ChallengeRef<'_>> for Client { + type Error = ClientError; + + fn try_from(value: &ChallengeRef<'_>) -> Result { + if !value.scheme().eq_ignore_ascii_case("Bearer") { + return Err(ClientError::new(format!( + "bearer::Client doesn't support challenge scheme {:?}", + value.scheme() + ))); + } + let raw = match value.body() { + crate::AuthBodyRef::Params(p) => p, + crate::AuthBodyRef::Token68(_) => return Err(ClientError::new("expected auth-params")), + }; + let mut params = Params::new(); + for (name, val) in raw { + let value = val.to_unescaped().into_owned(); + let idx = PARAMS + .iter() + .position(|(n, _, _)| n.eq_ignore_ascii_case(name)); + let Some(idx) = idx else { + continue; + }; + if params.0[idx].is_some() { + return Err(ClientError::new("repeated auth-param")); + } + params.0[idx] = Some(value); + } + params.validate().map_err(ClientError::new)?; + Ok(Client { params }) + } +} + +/// Encodes the given token as a `Bearer` credential. +pub fn encode_credentials(token: &str) -> Result { + if !crate::parser::is_b64token(token) { + return Err(ClientError::new("token must be a b64token")); + } + Ok(format!("Bearer {}", token)) +} + +type Validator = fn(&str) -> bool; +type ParamSpec = (&'static str, Validator, &'static str); + +const PARAMS: [ParamSpec; 6] = [ + ("realm", render::is_quotable, "realm must be quotable"), + ( + "scope", + valid_scope, + "scope must be space-separated scope-tokens", + ), + ("error", valid_nqschar, "error must be printable ASCII"), + ( + "error_description", + valid_nqschar, + "error_description must be printable ASCII", + ), + ( + "error_uri", + valid_error_uri, + "error_uri must be an absolute URI", + ), + ( + "resource_metadata", + valid_resource_metadata, + "resource_metadata must be an https URI with a host and no fragment", + ), +]; + +#[derive(Clone, Debug)] +pub(crate) struct Params([Option; 6]); + +impl Params { + pub(crate) fn new() -> Self { + Self(std::array::from_fn(|_| None)) + } + + pub(crate) fn validate(&self) -> Result<(), &'static str> { + if self.0.iter().all(|v| v.is_none()) { + return Err("at least one auth-param is required"); + } + for (i, value) in self.0.iter().enumerate() { + if let Some(value) = value { + if !(PARAMS[i].1)(value) { + return Err(PARAMS[i].2); + } + } + } + Ok(()) + } + + pub(crate) fn get(&self, i: usize) -> Option<&str> { + self.0[i].as_deref() + } + + #[cfg(feature = "server")] + pub(crate) fn fields(&self) -> [(&'static str, Option<&str>); 6] { + let mut out = [("", None); 6]; + for (i, (name, _, _)) in PARAMS.iter().enumerate() { + out[i] = (name, self.0[i].as_deref()); + } + out + } + + #[cfg(feature = "server")] + pub(crate) fn set(&mut self, i: usize, value: String) { + self.0[i] = Some(value); + } +} + +pub(crate) 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) + }) +} + +pub(crate) fn valid_nqschar(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b == b' ' || (char_classes(b) & C_SCOPE_TOKEN) != 0) +} + +pub(crate) fn valid_error_uri(s: &str) -> bool { + uri::parse(s).is_some() +} + +pub(crate) fn valid_resource_metadata(s: &str) -> bool { + match uri::parse(s) { + Some(u) => { + u.scheme.eq_ignore_ascii_case("https") + && u.host.map(|h| !h.is_empty()).unwrap_or(false) + && u.fragment.is_none() + } + None => false, + } +} diff --git a/src/bearer/server.rs b/src/bearer/server.rs new file mode 100644 index 0000000..3e54f25 --- /dev/null +++ b/src/bearer/server.rs @@ -0,0 +1,167 @@ +// 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::{Challenge, ConfigError, CredentialsSource, ServerError}; +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 { + params: crate::bearer::Params, +} + +impl ChallengeBuilder { + /// Starts building a challenge with no parameters. + pub fn new() -> Self { + Self { + params: crate::bearer::Params::new(), + } + } + + /// Sets the `realm`. + pub fn realm(mut self, realm: impl Into) -> Self { + self.params.set(crate::bearer::REALM, realm.into()); + self + } + + /// Sets the `scope`. + pub fn scope(mut self, scope: impl Into) -> Self { + self.params.set(crate::bearer::SCOPE, scope.into()); + self + } + + /// Sets the `error`. + pub fn error(mut self, error: ErrorCode) -> Self { + self.params + .set(crate::bearer::ERROR, error.as_str().to_owned()); + self + } + + /// Sets the `error_description`. + pub fn error_description(mut self, error_description: impl Into) -> Self { + self.params + .set(crate::bearer::ERROR_DESCRIPTION, error_description.into()); + self + } + + /// Sets the `error_uri`. + pub fn error_uri(mut self, error_uri: impl Into) -> Self { + self.params.set(crate::bearer::ERROR_URI, error_uri.into()); + self + } + + /// Sets the `resource_metadata`. + pub fn resource_metadata(mut self, resource_metadata: impl Into) -> Self { + self.params + .set(crate::bearer::RESOURCE_METADATA, resource_metadata.into()); + self + } + + /// Returns the challenge, or [`ConfigError`] for an invalid value. + pub fn build(self) -> Result { + self.params.validate().map_err(ConfigError)?; + let mut w = HeaderWriter::new("Bearer"); + for (name, value) in self.params.fields() { + if let Some(value) = value { + w.quoted(name, value); + } + } + Ok(Challenge(w.finish())) + } +} + +impl Default for ChallengeBuilder { + fn default() -> Self { + Self::new() + } +} + +/// 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..0c69d3f 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,19 @@ pub mod basic; #[cfg_attr(docsrs, doc(cfg(feature = "digest-scheme")))] pub mod digest; +pub mod bearer; + mod table; +mod uri; -#[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") -))] 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/parser.rs b/src/parser.rs index c276c36..dc44394 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -749,6 +749,11 @@ fn token68_end(input: &str, start: usize) -> Option { (after.is_empty() || after.starts_with(',')).then_some(end) } +/// Returns true if `s` is a complete `b64token` (RFC 6750 section 2.1). +pub(crate) fn is_b64token(s: &str) -> bool { + token68_end(s, 0) == Some(s.len()) +} + /// Returns the value of a hex digit. #[cfg(all(feature = "server", feature = "digest-scheme"))] fn hex_val(b: u8) -> Option { diff --git a/src/render.rs b/src/render.rs index 74b237e..9aedb67 100644 --- a/src/render.rs +++ b/src/render.rs @@ -3,6 +3,7 @@ //! Writes `scheme k=v, k="v", k*=UTF-8''v` header values. +#[cfg(any(feature = "digest-scheme", feature = "server"))] use crate::is_token; #[cfg(feature = "digest-scheme")] use crate::table::C_ATTR; @@ -15,11 +16,13 @@ pub(crate) fn is_quotable(s: &str) -> bool { } /// Writes a challenge or credentials header value. Callers validate values. +#[cfg(any(feature = "digest-scheme", feature = "server"))] pub(crate) struct HeaderWriter { out: String, has_params: bool, } +#[cfg(any(feature = "digest-scheme", feature = "server"))] impl HeaderWriter { pub(crate) fn new(scheme: &str) -> Self { debug_assert!(is_token(scheme)); diff --git a/src/server.rs b/src/server.rs index 75f03b6..343b98d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -256,6 +256,7 @@ impl<'i> sealed::Sealed<'i> for &'i http::HeaderValue { } /// Validates a realm value. +#[cfg(any(feature = "basic-scheme", feature = "digest-scheme"))] pub(crate) fn check_realm(realm: &str) -> Result<(), ConfigError> { if crate::render::is_quotable(realm) { Ok(()) 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/src/uri.rs b/src/uri.rs new file mode 100644 index 0000000..0f22577 --- /dev/null +++ b/src/uri.rs @@ -0,0 +1,351 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +pub(crate) struct Uri<'a> { + pub(crate) scheme: &'a str, + pub(crate) host: Option<&'a str>, + pub(crate) fragment: Option<&'a str>, +} + +pub(crate) fn parse(input: &str) -> Option> { + let bytes = input.as_bytes(); + (!bytes.is_empty() && bytes[0].is_ascii_alphabetic()).then_some(())?; + let mut position = 1 + bytes[1..] + .iter() + .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) + .count(); + let scheme_end = position; + (position < bytes.len() && bytes[position] == b':').then_some(())?; + position += 1; + let mut host_range = None; + if position + 1 < bytes.len() && bytes[position] == b'/' && bytes[position + 1] == b'/' { + position += 2; + let authority_end = memchr::memchr3(b'/', b'?', b'#', &bytes[position..]) + .map(|i| position + i) + .unwrap_or(bytes.len()); + if let Some(at) = bytes[position..authority_end] + .iter() + .position(|&c| c == b'@') + { + scan(&bytes[position..position + at], userinfo_byte).then_some(())?; + position += at + 1; + } + let host_start = position; + if position < authority_end && bytes[position] == b'[' { + let inner_start = position + 1; + let close = inner_start + memchr::memchr(b']', &bytes[inner_start..authority_end])?; + (is_ipv6(&bytes[inner_start..close]) || is_ipvfuture(&bytes[inner_start..close])) + .then_some(())?; + position = close + 1; + } else { + let cursor = ipv4(bytes, position); + let boundary = cursor.is_some_and(|value| { + value == authority_end + || matches!(bytes.get(value), Some(b':' | b'/' | b'?' | b'#')) + }); + if boundary { + position = cursor.unwrap(); + } else { + let mut local = 0; + skip(&bytes[position..authority_end], &mut local, |b| { + pchar_byte(b) && b != b':' && b != b'@' + }); + position += local; + } + } + host_range = Some((host_start, position)); + if position < authority_end && bytes[position] == b':' { + position += 1; + position += bytes[position..authority_end] + .iter() + .take_while(|&&b| b.is_ascii_digit()) + .count(); + } + (position == authority_end).then_some(())?; + path_segments(bytes, &mut position); + } else if position < bytes.len() && bytes[position] == b'/' { + position += 1; + if position < bytes.len() && !matches!(bytes[position], b'?' | b'#' | b'/') { + segment_nz(bytes, &mut position).then_some(())?; + } + path_segments(bytes, &mut position); + } else if position < bytes.len() && !matches!(bytes[position], b'?' | b'#') { + segment_nz(bytes, &mut position).then_some(())?; + path_segments(bytes, &mut position); + } + let mut fragment_range = None; + if position < bytes.len() && bytes[position] == b'?' { + position += 1; + skip(bytes, &mut position, query_byte); + } + if position < bytes.len() && bytes[position] == b'#' { + let fragment_start = position + 1; + position += 1; + skip(bytes, &mut position, query_byte); + fragment_range = Some((fragment_start, position)); + } + (position == bytes.len()).then_some(())?; + Some(Uri { + scheme: &input[..scheme_end], + host: host_range.map(|(a, b)| &input[a..b]), + fragment: fragment_range.map(|(a, b)| &input[a..b]), + }) +} + +fn skip(bytes: &[u8], position: &mut usize, allowed: fn(u8) -> bool) { + while *position < bytes.len() { + if allowed(bytes[*position]) { + *position += 1; + } else if bytes[*position] == b'%' + && *position + 2 < bytes.len() + && bytes[*position + 1].is_ascii_hexdigit() + && bytes[*position + 2].is_ascii_hexdigit() + { + *position += 3; + } else { + break; + } + } +} + +fn scan(bytes: &[u8], allowed: fn(u8) -> bool) -> bool { + let mut position = 0; + skip(bytes, &mut position, allowed); + position == bytes.len() +} + +fn segment_nz(bytes: &[u8], position: &mut usize) -> bool { + let start = *position; + skip(bytes, position, pchar_byte); + *position > start +} + +fn path_segments(bytes: &[u8], position: &mut usize) { + while *position < bytes.len() && bytes[*position] == b'/' { + *position += 1; + skip(bytes, position, pchar_byte); + } +} + +fn ipv4(bytes: &[u8], position: usize) -> Option { + let mut pos = position; + for i in 0..4 { + if i > 0 { + (pos < bytes.len() && bytes[pos] == b'.').then_some(())?; + pos += 1; + } + let start = pos; + pos += bytes[pos..] + .iter() + .take_while(|&&b| b.is_ascii_digit()) + .count(); + (pos > start && valid_dec_octet(&bytes[start..pos])).then_some(())?; + } + Some(pos) +} + +fn is_ipv6(bytes: &[u8]) -> bool { + let double_colon = bytes.windows(2).position(|window| *window == *b"::"); + if let Some(position) = double_colon { + if bytes[position + 2..] + .windows(2) + .any(|window| *window == *b"::") + { + return false; + } + } + let (left, right) = double_colon + .map(|position| (&bytes[..position], &bytes[position + 2..])) + .unwrap_or((bytes, &bytes[0..0])); + let Some(left_count) = validate_groups(left, double_colon.is_none()) else { + return false; + }; + let Some(right_count) = validate_groups(right, true) else { + return false; + }; + if double_colon.is_some() { + left_count + right_count <= 7 + } else { + left_count == 8 + } +} + +fn validate_groups(bytes: &[u8], allow_ipv4_last: bool) -> Option { + let mut position = 0; + let mut count = 0; + while position < bytes.len() { + let start = position; + while position < bytes.len() && bytes[position] != b':' { + position += 1; + } + let group = &bytes[start..position]; + (!group.is_empty()).then_some(())?; + if allow_ipv4_last && position == bytes.len() && group.contains(&b'.') { + (ipv4(group, 0)? == group.len()).then_some(())?; + count += 2; + } else { + (group.len() <= 4 && group.iter().all(|b| b.is_ascii_hexdigit())).then_some(())?; + count += 1; + } + if position < bytes.len() { + (bytes[position] == b':').then_some(())?; + position += 1; + (position < bytes.len() && bytes[position] != b':').then_some(())?; + } + } + Some(count) +} + +fn is_ipvfuture(bytes: &[u8]) -> bool { + if bytes.is_empty() || !(bytes[0] == b'v' || bytes[0] == b'V') { + return false; + } + let hex_len = bytes[1..] + .iter() + .take_while(|&&b| b.is_ascii_hexdigit()) + .count(); + if hex_len == 0 + || hex_len + 1 >= bytes.len() + || bytes[hex_len + 1] != b'.' + || hex_len + 2 >= bytes.len() + { + return false; + } + bytes[hex_len + 2..].iter().all(|&b| userinfo_byte(b)) +} + +fn valid_dec_octet(bytes: &[u8]) -> bool { + !bytes.is_empty() + && bytes.len() <= 3 + && (bytes.len() == 1 || bytes[0] != b'0') + && bytes.iter().all(u8::is_ascii_digit) + && bytes.iter().fold(0u16, |v, &b| v * 10 + (b - b'0') as u16) <= 255 +} + +fn pchar_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() + || b"-._~".contains(&b) + || b"!$&'()*+,;=".contains(&b) + || b == b':' + || b == b'@' +} +fn userinfo_byte(b: u8) -> bool { + pchar_byte(b) && b != b'@' +} +fn query_byte(b: u8) -> bool { + pchar_byte(b) || b == b'/' || b == b'?' +} + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn rfc3986_examples() { + for value in [ + "https://www.example.com/foo/bar?x=1#frag", + "ftp://ftp.is.co.za/rfc/rfc1808.txt", + "http://www.ietf.org/rfc/rfc2396.txt#header1", + "ldap://[2001:db8::7]/c=GB?objectClass?one", + "mailto:John.Doe@example.com", + "news:comp.infosystems.www.servers.unix", + "tel:+1-816-555-1212", + "telnet://192.0.2.16:80/", + "urn:oasis:names:specification:docbook:dtd:xml:4.1.2", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn invalid_ipv6() { + for value in [ + "http://[::1", + "http://[::1/", + "http://[2001:db8::g]/", + "http://[1.2.3.4]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn uri_forms() { + assert!(parse("http://example.com").is_some()); + assert!(parse("http://example.com/").is_some()); + assert!(parse("http://example.com/a/b").is_some()); + assert!(parse("http://example.com/a/b?c=d&e=f").is_some()); + assert!(parse("http://example.com/a/b#c").is_some()); + assert!(parse("scheme:foo").is_some()); + assert!(parse("scheme:").is_some()); + assert!(parse("").is_none()); + assert!(parse("http://").is_some()); + assert!(parse("http:///host").is_some()); + assert!(parse("http://[::1]:8080/x").is_some()); + assert!(parse("http://[v1.fe80::a]/").is_some()); + assert!(parse("http://user:pass@example.com:80/x").is_some()); + assert!(parse("http://example.com/%zz").is_none()); + assert!(parse("http://example.com/%20").is_some()); + } + + #[test] + fn ipv6_alternatives() { + for value in [ + "http://[1:2:3:4:5:6:7:8]/", + "http://[::1:2:3:4:5:6:7]/", + "http://[1::2:3:4:5:6:7]/", + "http://[1:2::3:4:5:6:7]/", + "http://[1::2:3:4:5]/", + "http://[1::2:3:4]/", + "http://[1::2:3]/", + "http://[1::2]/", + "http://[1::]/", + "http://[::1]/", + "http://[::ffff:192.0.2.1]/", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn ipv6_invalid() { + for value in [ + "http://[:::]/", + "http://[1:2:3:4:5:6:7:8:9]/", + "http://[1::2::3]/", + "http://[12345::]/", + "http://[::1.2.3]/", + "http://[1.2.3.4::]/", + "http://[::ffff:1.2.3.256]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn host_fallback() { + for value in [ + "https://1.2.3.4abc/", + "https://1.2.3.4.5/", + "https://999.1.1.1/", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn ipv6_trailing_colon() { + for value in [ + "http://[1:2:3:4:5:6:7:8:]/", + "http://[::1:]:80/", + "http://[1::3:4:5:6:7:]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn ipvfuture_pct_encoded() { + assert!(parse("http://[v7.x%97:y]/").is_none()); + } +} diff --git a/tests/bearer.rs b/tests/bearer.rs new file mode 100644 index 0000000..777dddb --- /dev/null +++ b/tests/bearer.rs @@ -0,0 +1,320 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Bearer` challenges and credentials against RFC 6750 and RFC 9728. + +use std::convert::TryFrom as _; + +use http_auth::bearer::{self, Client}; +#[cfg(feature = "server")] +use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; + +#[cfg(feature = "server")] +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"), + } +} + +#[cfg(feature = "server")] +#[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""# + ); +} + +#[cfg(feature = "server")] +#[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""# + ); +} + +#[cfg(feature = "server")] +#[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())] + ); +} + +#[cfg(feature = "server")] +#[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().build().is_err()); + assert!(ChallengeBuilder::new() + .error_description("") + .build() + .is_err()); + assert!(ChallengeBuilder::new().error_uri("a\"b").build().is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://example.com/a b") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("/relative") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://example.com/%zz") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://[::1/") + .build() + .is_err()); + for ok in [ + "https://example.com/errors#invalid_token", + "urn:example:error", + "http://[2001:db8::1]:8080/e?x=1", + ] { + assert!( + ChallengeBuilder::new().error_uri(ok).build().is_ok(), + "{}", + ok + ); + } + 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()); + for bad in [ + "x\"y", + "http://example.com/.well-known/oauth-protected-resource", + "https://example.com/.well-known/oauth-protected-resource#f", + "https:///no-host", + "/.well-known/oauth-protected-resource", + ] { + assert!( + ChallengeBuilder::new() + .resource_metadata(bad) + .build() + .is_err(), + "{}", + bad + ); + } + for ok in [ + "https://example.com/.well-known/oauth-protected-resource", + "HTTPS://[::1]:8443/.well-known/oauth-protected-resource/api?x=1", + ] { + assert!( + ChallengeBuilder::new() + .resource_metadata(ok) + .build() + .is_ok(), + "{}", + ok + ); + } + assert!(ChallengeBuilder::new().scope("a b").build().is_ok()); +} + +#[cfg(feature = "server")] +#[test] +fn error_status() { + assert_eq!(ErrorCode::InvalidRequest.status(), 400); + assert_eq!(ErrorCode::InvalidToken.status(), 401); + assert_eq!(ErrorCode::InsufficientScope.status(), 403); +} + +#[cfg(feature = "server")] +#[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(_))) + )); +} + +fn client(header: &str) -> Client { + let c = http_auth::parse_challenges(header).unwrap(); + assert_eq!(c.len(), 1); + Client::try_from(&c[0]).unwrap() +} + +#[test] +fn client_rfc6750_example() { + let c = client( + r#"Bearer realm="example", error="invalid_token", error_description="The access token expired""#, + ); + assert_eq!(c.realm(), Some("example")); + assert_eq!(c.error(), Some("invalid_token")); + assert_eq!(c.error_description(), Some("The access token expired")); + assert_eq!(c.scope(), None); + assert_eq!(c.error_uri(), None); + assert_eq!(c.resource_metadata(), None); +} + +#[test] +fn client_rfc9728_example() { + let c = client( + r#"Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource""#, + ); + assert_eq!( + c.resource_metadata(), + Some("https://resource.example.com/.well-known/oauth-protected-resource") + ); + assert_eq!(c.realm(), None); +} + +#[test] +fn client_parsing() { + let c = client(r#"Bearer REALM="a\"b", Scope=x, error=use_dpop_nonce, other="ignored""#); + assert_eq!(c.realm(), Some("a\"b")); + assert_eq!(c.scope(), Some("x")); + assert_eq!(c.error(), Some("use_dpop_nonce")); + + let wrong = http_auth::parse_challenges(r#"Basic realm="x""#).unwrap(); + assert!(Client::try_from(&wrong[0]).is_err()); + for bad in [ + "Bearer", + "Bearer abc=", + r#"Bearer realm="a", realm="b""#, + r#"Bearer scope="a", Scope="b""#, + r#"Bearer error="a", error="b""#, + r#"Bearer error_description="a", error_description="b""#, + r#"Bearer error_uri="https://a/", error_uri="https://b/""#, + r#"Bearer resource_metadata="https://a/", resource_metadata="https://b/""#, + r#"Bearer scope="a b""#, + r#"Bearer error="""#, + r#"Bearer error_description="""#, + r#"Bearer error="\\x""#, + r#"Bearer error_uri="relative""#, + r#"Bearer resource_metadata="http://example.com/m""#, + ] { + let c = http_auth::parse_challenges(bad).unwrap(); + assert!(Client::try_from(&c[0]).is_err(), "{}", bad); + } +} + +#[test] +fn client_respond() { + let c = client(r#"Bearer realm="example""#); + assert_eq!( + c.respond("mF_9.B5f-4.1JqM").unwrap(), + "Bearer mF_9.B5f-4.1JqM" + ); + assert_eq!( + bearer::encode_credentials("abc+/==").unwrap(), + "Bearer abc+/==" + ); + for bad in ["", "a b", "a\r\nX: y", "=abc", "a=b", "caf\u{e9}", "a\"b"] { + assert!(bearer::encode_credentials(bad).is_err(), "{:?}", bad); + assert!(c.respond(bad).is_err(), "{:?}", bad); + } +} + +#[cfg(feature = "server")] +#[test] +fn client_server_round_trip() { + let header = 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(); + let c = client(header.as_str()); + assert_eq!(c.realm(), Some("zen \"ith\" \\")); + assert_eq!(c.scope(), Some("live:read vod:read")); + assert_eq!(c.error(), Some(ErrorCode::InsufficientScope.as_str())); + assert_eq!(c.error_description(), Some("need vod:read")); + assert_eq!(c.error_uri(), Some("https://example.com/e")); + assert_eq!( + c.resource_metadata(), + Some("https://example.com/.well-known/oauth-protected-resource") + ); + + for token in ["mF_9.B5f-4.1JqM", "abc+/==", "x"] { + let header = c.respond(token).unwrap(); + let creds = Credentials::parse(header.as_str()).unwrap().unwrap(); + assert_eq!(creds.token(), token); + } +}