2026-03-04 14:02:15 +01:00
|
|
|
use axum::{
|
|
|
|
|
extract::{Request, State},
|
2026-07-19 10:22:12 +00:00
|
|
|
http::{StatusCode, header},
|
2026-03-04 14:02:15 +01:00
|
|
|
middleware::Next,
|
|
|
|
|
response::{IntoResponse, Response},
|
|
|
|
|
};
|
|
|
|
|
use base64::Engine;
|
2026-07-16 14:20:20 +00:00
|
|
|
use std::sync::{Arc, LazyLock};
|
|
|
|
|
use std::time::Duration;
|
2026-03-04 14:02:15 +01:00
|
|
|
|
2026-07-16 14:20:20 +00:00
|
|
|
use crate::application::dtos::folder_dto::FolderDto;
|
2026-03-04 14:02:15 +01:00
|
|
|
use crate::common::di::AppState;
|
|
|
|
|
use crate::interfaces::middleware::auth::CurrentUser;
|
|
|
|
|
|
2026-07-16 14:20:20 +00:00
|
|
|
/// Markerless-chroot cache: default-drive root folder id → `FolderDto`.
|
|
|
|
|
///
|
|
|
|
|
/// This middleware wraps EVERY protected NextCloud route (DAV files,
|
|
|
|
|
/// per-chunk uploads, trashbin, previews, avatars, OCS polls). With the
|
|
|
|
|
/// app-password verification already cached, the chroot resolution was the
|
|
|
|
|
/// last per-request DB work: `find_default_for_user` (now cached in
|
|
|
|
|
/// `DrivePgRepository`) plus this folder-by-PK fetch. A desktop sync run
|
|
|
|
|
/// issues hundreds of these per minute for a value that changes only on a
|
|
|
|
|
/// root-folder rename — the 30 s TTL bounds that staleness (mirrors
|
|
|
|
|
/// `drive_role_cache` / the default-drive cache; measured in
|
|
|
|
|
/// `benches/CHROOT-CACHE.md`).
|
|
|
|
|
///
|
|
|
|
|
/// Only the MARKERLESS branch is cached: it targets the caller's own
|
|
|
|
|
/// default drive root, so no per-request authorization decision is being
|
|
|
|
|
/// skipped. The drive-marker branch keeps its `get_folder_with_perms`
|
|
|
|
|
/// check on every request.
|
2026-07-18 16:12:04 +00:00
|
|
|
// `Arc<FolderDto>` values: a hit hands back a refcount bump instead of a
|
|
|
|
|
// deep clone of the DTO's ~5 owned Strings (moka's `get` clones `V`), and
|
|
|
|
|
// the same `Arc` then rides inside `NcSession` for the whole request.
|
|
|
|
|
static NC_CHROOT_CACHE: LazyLock<moka::sync::Cache<uuid::Uuid, Arc<FolderDto>>> =
|
|
|
|
|
LazyLock::new(|| {
|
|
|
|
|
moka::sync::Cache::builder()
|
|
|
|
|
.max_capacity(100_000)
|
|
|
|
|
.time_to_live(Duration::from_secs(30))
|
|
|
|
|
.build()
|
|
|
|
|
});
|
2026-07-16 14:20:20 +00:00
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum NextcloudAuthError {
|
|
|
|
|
#[error("Unauthorized")]
|
|
|
|
|
Unauthorized,
|
|
|
|
|
#[error("Nextcloud services unavailable")]
|
|
|
|
|
ServiceUnavailable,
|
|
|
|
|
#[error("Internal error: {0}")]
|
|
|
|
|
Internal(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IntoResponse for NextcloudAuthError {
|
|
|
|
|
fn into_response(self) -> Response {
|
|
|
|
|
match self {
|
|
|
|
|
NextcloudAuthError::Unauthorized => (
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
[(header::WWW_AUTHENTICATE, "Basic realm=\"OxiCloud\"")],
|
|
|
|
|
"Unauthorized",
|
|
|
|
|
)
|
|
|
|
|
.into_response(),
|
|
|
|
|
NextcloudAuthError::ServiceUnavailable => {
|
|
|
|
|
(StatusCode::SERVICE_UNAVAILABLE, "Nextcloud unavailable").into_response()
|
|
|
|
|
}
|
|
|
|
|
NextcloudAuthError::Internal(_) => {
|
|
|
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn basic_auth_middleware(
|
|
|
|
|
State(state): State<Arc<AppState>>,
|
|
|
|
|
mut request: Request,
|
|
|
|
|
next: Next,
|
|
|
|
|
) -> Result<Response, NextcloudAuthError> {
|
|
|
|
|
tracing::debug!("[NC] {} {}", request.method(), request.uri());
|
|
|
|
|
|
2026-07-19 10:22:12 +00:00
|
|
|
// Borrow the Authorization header directly rather than cloning the whole
|
|
|
|
|
// HeaderMap per NC sync request; the borrow ends at `parse_basic_auth`
|
|
|
|
|
// below, before any request mutation (benches/ROUND14.md §A4).
|
|
|
|
|
let auth_header = request
|
|
|
|
|
.headers()
|
2026-03-04 14:02:15 +01:00
|
|
|
.get(header::AUTHORIZATION)
|
|
|
|
|
.and_then(|value| value.to_str().ok())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"[NC] 401 no auth header: {} {}",
|
|
|
|
|
request.method(),
|
|
|
|
|
request.uri()
|
|
|
|
|
);
|
|
|
|
|
NextcloudAuthError::Unauthorized
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-06-15 22:59:34 +02:00
|
|
|
let (raw_username, password) =
|
2026-03-04 14:02:15 +01:00
|
|
|
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
|
|
|
|
|
|
2026-09-13 18:46:42 +02:00
|
|
|
// Canonicalise the whole Basic-Auth username to lowercase.
|
|
|
|
|
//
|
|
|
|
|
// Usernames are canonical (lowercase) in the DB post-migration
|
|
|
|
|
// (`docs/plan/username-lowercase.md`), and NC / DAVX5 clients that
|
|
|
|
|
// cached URLs from before the migration keep sending `Alice:pass`
|
|
|
|
|
// — the server continues to accept that indefinitely by
|
|
|
|
|
// lowercasing here. Safe for the multi-drive `user~drive_uuid`
|
|
|
|
|
// composite because UUID hex `[0-9a-f-]` lowercases to itself.
|
|
|
|
|
//
|
|
|
|
|
// ASCII-only by `validate_username`'s charset check, so
|
|
|
|
|
// `to_ascii_lowercase` is deterministic and locale-safe.
|
|
|
|
|
let raw_username = raw_username.to_ascii_lowercase();
|
|
|
|
|
|
2026-06-15 22:59:34 +02:00
|
|
|
// ── Multi-drive composite-username parse ────────────────────────
|
|
|
|
|
// POC wire shape: `{username}~{drive_marker}` may appear in the
|
|
|
|
|
// Basic Auth header. `~` was chosen because it needs no URL
|
|
|
|
|
// encoding and doesn't collide with UUID hyphens. The marker
|
|
|
|
|
// after `~` is a chroot SELECTOR (handled by `NcSession` via the
|
|
|
|
|
// URL `{user}` segment), NOT an auth credential — the password
|
|
|
|
|
// is verified against the username PREFIX. The middleware just
|
|
|
|
|
// peels the prefix off so the app-password lookup uses the
|
|
|
|
|
// canonical name. When no `~` is present, the request is a
|
|
|
|
|
// plain single-drive ("home") NC sync.
|
|
|
|
|
//
|
|
|
|
|
// Reject `name~` (empty marker) and `~marker` (empty username)
|
|
|
|
|
// at the auth boundary rather than treating them as "missing
|
|
|
|
|
// marker" — they are unambiguous typos that would otherwise
|
|
|
|
|
// silently fall into a different code path.
|
2026-07-21 10:25:36 +00:00
|
|
|
// Borrow the prefix / marker out of the already-owned `raw_username`
|
|
|
|
|
// (`split_once` yields `&str` slices) instead of allocating a duplicate
|
|
|
|
|
// `String` per request — `username` is only ever passed by reference, and
|
|
|
|
|
// `raw_username` outlives every use before it moves into `NcSession`
|
|
|
|
|
// (benches/ROUND29.md §E).
|
|
|
|
|
let (username, drive_marker): (&str, Option<&str>) = match raw_username.split_once('~') {
|
2026-06-15 22:59:34 +02:00
|
|
|
Some(("", _)) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"[NC] 401 malformed composite username (empty prefix): {}",
|
|
|
|
|
raw_username
|
|
|
|
|
);
|
|
|
|
|
return Err(NextcloudAuthError::Unauthorized);
|
|
|
|
|
}
|
|
|
|
|
Some((_, "")) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"[NC] 401 malformed composite username (empty marker): {}",
|
|
|
|
|
raw_username
|
|
|
|
|
);
|
|
|
|
|
return Err(NextcloudAuthError::Unauthorized);
|
|
|
|
|
}
|
2026-07-21 10:25:36 +00:00
|
|
|
Some((u, m)) => (u, Some(m)),
|
|
|
|
|
None => (raw_username.as_str(), None),
|
2026-06-15 22:59:34 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-27 12:22:59 -07:00
|
|
|
// Check account lockout before attempting password verification (saves CPU).
|
2026-05-11 13:05:03 -07:00
|
|
|
// The lockout is per (account, IP), see #323 for rationale.
|
2026-06-10 09:27:32 +00:00
|
|
|
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
|
2026-03-04 21:40:38 +01:00
|
|
|
if let Some(auth_svc) = state.auth_service.as_ref()
|
2026-07-21 10:25:36 +00:00
|
|
|
&& let Err(secs) = auth_svc.login_lockout.check(username, &client_ip)
|
2026-03-04 21:40:38 +01:00
|
|
|
{
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
username = %username,
|
2026-04-27 12:22:59 -07:00
|
|
|
client_ip = %client_ip,
|
2026-03-04 21:40:38 +01:00
|
|
|
lockout_remaining_secs = secs,
|
2026-05-11 13:05:03 -07:00
|
|
|
"[NC] Account locked, too many failed attempts from this IP"
|
2026-03-04 21:40:38 +01:00
|
|
|
);
|
|
|
|
|
return Err(NextcloudAuthError::Unauthorized);
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let nextcloud = state
|
|
|
|
|
.nextcloud
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or(NextcloudAuthError::ServiceUnavailable)?;
|
|
|
|
|
|
|
|
|
|
match nextcloud
|
|
|
|
|
.app_passwords
|
2026-07-21 10:25:36 +00:00
|
|
|
.verify_basic_auth(username, &password)
|
2026-03-04 14:02:15 +01:00
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok((user_id, uname, email, role)) => {
|
|
|
|
|
// Reset lockout counter on success
|
|
|
|
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
2026-07-21 10:25:36 +00:00
|
|
|
auth_svc.login_lockout.record_success(username, &client_ip);
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
2026-06-02 14:46:27 +02:00
|
|
|
// External users must never authenticate against the NC
|
|
|
|
|
// surface — that whole subtree (WebDAV files, uploads,
|
|
|
|
|
// trashbin, OCS user info, sharees autocomplete, etc.) has
|
|
|
|
|
// no semantic meaning for a magic-link-only principal, and
|
|
|
|
|
// an app password would be a persistent credential
|
|
|
|
|
// bypassing the magic-link-eligibility rule. POST
|
|
|
|
|
// /api/auth/app-passwords also gates externals upfront;
|
|
|
|
|
// this is the belt-and-braces check in case one slipped
|
|
|
|
|
// through (e.g. user later flipped to is_external).
|
|
|
|
|
if let Some(auth_svc) = state.auth_service.as_ref()
|
2026-06-10 09:27:32 +00:00
|
|
|
&& let Ok(flags) = auth_svc
|
2026-06-02 14:46:27 +02:00
|
|
|
.auth_application_service
|
2026-06-10 09:27:32 +00:00
|
|
|
.get_user_flags(user_id)
|
2026-06-02 14:46:27 +02:00
|
|
|
.await
|
2026-06-10 09:27:32 +00:00
|
|
|
&& flags.is_external
|
2026-06-02 14:46:27 +02:00
|
|
|
{
|
|
|
|
|
tracing::info!(
|
|
|
|
|
target: "audit",
|
|
|
|
|
event = "auth.nc_basic_rejected",
|
|
|
|
|
reason = "external_user",
|
|
|
|
|
user_id = %user_id,
|
|
|
|
|
"👮🏻♂️ External user attempted NC Basic auth — rejected"
|
|
|
|
|
);
|
|
|
|
|
return Err(NextcloudAuthError::Unauthorized);
|
|
|
|
|
}
|
2026-06-06 11:54:23 +02:00
|
|
|
// Populate the deferred `user_id` field on the request
|
|
|
|
|
// tracing span (declared in `middleware/trace_span.rs::ClientIpMakeSpan`).
|
|
|
|
|
// Mirrors what `interfaces/middleware/auth.rs` does for the
|
|
|
|
|
// JWT path so the two auth surfaces produce log lines with
|
|
|
|
|
// the same structured shape — without this, every NC
|
|
|
|
|
// request would appear in the logs with `user_id=-`,
|
|
|
|
|
// making it harder to correlate WebDAV / OCS activity to
|
|
|
|
|
// a specific principal.
|
2026-07-18 16:12:04 +00:00
|
|
|
// `field::display` renders lazily into the subscriber's buffer —
|
|
|
|
|
// no per-request `to_string` (mirrors the JWT path since ROUND5).
|
|
|
|
|
tracing::Span::current().record("user_id", tracing::field::display(user_id));
|
|
|
|
|
// One shared identity: the same `Arc` serves the
|
|
|
|
|
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
|
|
|
|
|
// code built the struct, cloned it for the extension, then
|
|
|
|
|
// moved the original — 2-3 String allocs per request).
|
2026-08-08 19:55:18 +02:00
|
|
|
// Nextcloud clients are always unbound — they authenticate
|
|
|
|
|
// with app passwords via Basic Auth, no WebCrypto, no DPoP.
|
|
|
|
|
// Middleware exempts unbound sessions per Gate 9 design.
|
2026-07-18 16:12:04 +00:00
|
|
|
let current_user = Arc::new(CurrentUser {
|
2026-03-04 14:02:15 +01:00
|
|
|
id: user_id,
|
|
|
|
|
username: uname,
|
|
|
|
|
email,
|
|
|
|
|
role,
|
2026-08-08 19:55:18 +02:00
|
|
|
dpop_jkt: None,
|
2026-07-18 16:12:04 +00:00
|
|
|
});
|
2026-06-15 22:59:34 +02:00
|
|
|
|
|
|
|
|
// ── Resolve chroot from the Basic Auth drive marker ─────
|
2026-06-19 01:08:07 +02:00
|
|
|
// No marker → caller's default personal drive's root folder
|
|
|
|
|
// (post-D0 every internal user has one — provisioned by the
|
|
|
|
|
// lifecycle hook via the atomic four-write transaction in
|
|
|
|
|
// §3 of docs/plan/drive.md). With a marker →
|
2026-06-15 22:59:34 +02:00
|
|
|
// `get_folder_with_perms` enforces per-folder access (404
|
|
|
|
|
// anti-enumeration on miss / no-read). Today this is the
|
|
|
|
|
// sole chroot source; tomorrow it'll come from the
|
|
|
|
|
// app-password row instead.
|
2026-06-19 01:08:07 +02:00
|
|
|
//
|
|
|
|
|
// Pre-D0 this lookup name-matched `"My Folder - <username>"`
|
|
|
|
|
// against the user's root folders; that broke after the
|
|
|
|
|
// wrapper was renamed to `"Personal"` and shared across all
|
|
|
|
|
// users — name-matching was the wrong axis. The drive lookup
|
|
|
|
|
// is the right one: name-independent, secondary-drive-safe.
|
2026-06-15 22:59:34 +02:00
|
|
|
use crate::application::ports::folder_ports::FolderUseCase;
|
2026-06-19 01:08:07 +02:00
|
|
|
use crate::domain::repositories::drive_repository::DriveRepository;
|
2026-07-21 10:25:36 +00:00
|
|
|
let chroot = match drive_marker {
|
2026-06-15 22:59:34 +02:00
|
|
|
None => {
|
2026-06-19 07:49:33 +02:00
|
|
|
match state
|
|
|
|
|
.drive_repo
|
|
|
|
|
.find_default_for_user(current_user.id)
|
|
|
|
|
.await
|
|
|
|
|
{
|
2026-07-16 14:20:20 +00:00
|
|
|
Ok(drive_with_name) => {
|
|
|
|
|
let root_id = drive_with_name.drive.root_folder_id;
|
|
|
|
|
match NC_CHROOT_CACHE.get(&root_id) {
|
|
|
|
|
Some(cached) => Some(cached),
|
|
|
|
|
None => {
|
|
|
|
|
let fetched = state
|
|
|
|
|
.applications
|
|
|
|
|
.folder_service
|
|
|
|
|
.get_folder(&root_id.to_string())
|
|
|
|
|
.await
|
2026-07-18 16:12:04 +00:00
|
|
|
.ok()
|
|
|
|
|
.map(Arc::new);
|
2026-07-16 14:20:20 +00:00
|
|
|
if let Some(f) = &fetched {
|
2026-07-18 16:12:04 +00:00
|
|
|
NC_CHROOT_CACHE.insert(root_id, Arc::clone(f));
|
2026-07-16 14:20:20 +00:00
|
|
|
}
|
|
|
|
|
fetched
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 22:59:34 +02:00
|
|
|
Err(_) => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(folder_id) => state
|
|
|
|
|
.applications
|
|
|
|
|
.folder_service
|
|
|
|
|
.get_folder_with_perms(folder_id, current_user.id)
|
|
|
|
|
.await
|
2026-07-18 16:12:04 +00:00
|
|
|
.ok()
|
|
|
|
|
.map(Arc::new),
|
2026-06-15 22:59:34 +02:00
|
|
|
};
|
|
|
|
|
if chroot.is_none() {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"[NC] 404 chroot not resolvable: user={} marker={:?}",
|
|
|
|
|
current_user.username,
|
|
|
|
|
drive_marker
|
|
|
|
|
);
|
|
|
|
|
return Err(NextcloudAuthError::Unauthorized);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 16:12:04 +00:00
|
|
|
// Record from the local before it moves into the session —
|
|
|
|
|
// the old code re-read the just-inserted extension and paid a
|
|
|
|
|
// `to_string` for the span value.
|
|
|
|
|
if let Some(c) = &chroot {
|
|
|
|
|
tracing::Span::current().record("chroot_id", tracing::field::display(&c.id));
|
|
|
|
|
}
|
|
|
|
|
request.extensions_mut().insert(Arc::clone(¤t_user));
|
2026-06-15 22:59:34 +02:00
|
|
|
request.extensions_mut().insert(Arc::new(
|
|
|
|
|
crate::interfaces::nextcloud::session::NcSession {
|
|
|
|
|
user: current_user,
|
2026-07-18 16:12:04 +00:00
|
|
|
raw_username,
|
2026-06-15 22:59:34 +02:00
|
|
|
chroot,
|
|
|
|
|
},
|
|
|
|
|
));
|
2026-03-04 14:02:15 +01:00
|
|
|
Ok(next.run(request).await)
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// Record failed attempt for lockout tracking
|
|
|
|
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
2026-07-21 10:25:36 +00:00
|
|
|
auth_svc.login_lockout.record_failure(username, &client_ip);
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
Err(NextcloudAuthError::Unauthorized)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a `Basic` Authorization header into `(username, password)`.
|
|
|
|
|
pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> {
|
|
|
|
|
let mut parts = header_value.splitn(2, ' ');
|
|
|
|
|
let scheme = parts.next()?.trim();
|
|
|
|
|
let encoded = parts.next()?.trim();
|
|
|
|
|
|
|
|
|
|
if !scheme.eq_ignore_ascii_case("Basic") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
|
|
|
.decode(encoded)
|
|
|
|
|
.ok()?;
|
|
|
|
|
let decoded = String::from_utf8(decoded).ok()?;
|
|
|
|
|
let (user, pass) = decoded.split_once(':')?;
|
|
|
|
|
|
2026-09-13 18:46:42 +02:00
|
|
|
// Canonicalise the username to lowercase here too, so any caller
|
|
|
|
|
// that reaches for `parse_basic_auth` directly (bypassing the
|
|
|
|
|
// middleware wrapper) also sees the canonical form. Redundant with
|
|
|
|
|
// the middleware's explicit `to_ascii_lowercase` on `raw_username`
|
|
|
|
|
// — belt-and-braces to keep the invariant local to the parser too.
|
|
|
|
|
// See `docs/plan/username-lowercase.md § 4. NextCloud DAV surface`.
|
|
|
|
|
Some((user.to_ascii_lowercase(), pass.to_string()))
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_valid_basic_auth() {
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode("alice:secret123");
|
|
|
|
|
let header = format!("Basic {}", encoded);
|
|
|
|
|
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
|
|
|
|
assert_eq!(user, "alice");
|
|
|
|
|
assert_eq!(pass, "secret123");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_basic_auth_with_colon_in_password() {
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass:with:colons");
|
|
|
|
|
let header = format!("Basic {}", encoded);
|
|
|
|
|
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
|
|
|
|
assert_eq!(user, "user");
|
|
|
|
|
assert_eq!(pass, "pass:with:colons");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_basic_auth_bearer_scheme_rejected() {
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
|
|
|
|
let header = format!("Bearer {}", encoded);
|
|
|
|
|
assert!(parse_basic_auth(&header).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_basic_auth_missing_colon() {
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode("nocolon");
|
|
|
|
|
let header = format!("Basic {}", encoded);
|
|
|
|
|
assert!(parse_basic_auth(&header).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_basic_auth_invalid_base64() {
|
|
|
|
|
assert!(parse_basic_auth("Basic not-valid-base64!!!").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_basic_auth_case_insensitive_scheme() {
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
|
|
|
|
let header = format!("BASIC {}", encoded);
|
|
|
|
|
let result = parse_basic_auth(&header);
|
|
|
|
|
assert!(result.is_some());
|
|
|
|
|
}
|
|
|
|
|
}
|