use crate::auth::{AuthProvider, Credential};
use crate::error::Result;
use crate::resource::Resource;
use compact_str::CompactString;
use secrecy::{ExposeSecret, SecretString};
use std::borrow::Cow;
use std::future::Ready;
#[derive(Clone, Copy, Debug)]
enum TokenKind {
Bearer,
Header(&'static str),
}
#[derive(Debug)]
pub struct StaticTokenAuth {
kind: TokenKind,
token: SecretString,
scope_host: Option<CompactString>,
}
impl StaticTokenAuth {
#[must_use]
pub fn bearer(token: impl Into<String>) -> Self {
Self {
kind: TokenKind::Bearer,
token: secret(token),
scope_host: None,
}
}
#[must_use]
pub fn bearer_scoped(token: impl Into<String>, host: impl Into<CompactString>) -> Self {
Self {
kind: TokenKind::Bearer,
token: secret(token),
scope_host: Some(host.into()),
}
}
#[must_use]
pub fn github_pat(token: impl Into<String>) -> Self {
Self::bearer(token)
}
#[must_use]
pub fn header(name: &'static str, token: impl Into<String>) -> Self {
Self {
kind: TokenKind::Header(name),
token: secret(token),
scope_host: None,
}
}
}
fn secret(s: impl Into<String>) -> SecretString {
SecretString::new(s.into().into_boxed_str())
}
fn host_in_scope(host: &str, base: &str) -> bool {
host == base
|| (host.len() > base.len()
&& host.ends_with(base)
&& host.as_bytes()[host.len() - base.len() - 1] == b'.')
}
impl AuthProvider for StaticTokenAuth {
type RefreshFuture<'a> = Ready<Result<()>>;
fn credential(&self, resource: &Resource) -> Credential<'_> {
if let Some(scope) = &self.scope_host {
let in_scope = resource
.url
.host_str()
.is_some_and(|h| host_in_scope(h, scope));
if !in_scope {
return Credential::Anonymous;
}
}
let value = Cow::Borrowed(self.token.expose_secret());
match self.kind {
TokenKind::Bearer => Credential::Bearer(value),
TokenKind::Header(name) => Credential::Header { name, value },
}
}
fn refresh(&self) -> Self::RefreshFuture<'_> {
std::future::ready(Ok(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn resource() -> Resource {
Resource::new(Url::parse("https://x.dev/a").unwrap())
}
#[test]
fn bearer_exposes_token_only_in_credential() {
let auth = StaticTokenAuth::bearer("s3cr3t");
match auth.credential(&resource()) {
Credential::Bearer(v) => assert_eq!(v, "s3cr3t"),
other => panic!("expected bearer, got {other:?}"),
}
assert!(!format!("{auth:?}").contains("s3cr3t"));
}
#[test]
fn custom_header_credential() {
let auth = StaticTokenAuth::header("x-api-key", "abc");
match auth.credential(&resource()) {
Credential::Header { name, value } => {
assert_eq!(name, "x-api-key");
assert_eq!(value, "abc");
}
other => panic!("expected header, got {other:?}"),
}
}
#[test]
fn scoped_token_confined_to_host_and_subdomains() {
let auth = StaticTokenAuth::bearer_scoped("s3cr3t", "github.com");
let on = Resource::new(Url::parse("https://github.com/o/r").unwrap());
let sub = Resource::new(Url::parse("https://api.github.com/x").unwrap());
let off = Resource::new(Url::parse("https://evil.dev/x").unwrap());
let lookalike = Resource::new(Url::parse("https://notgithub.com/x").unwrap());
assert!(matches!(auth.credential(&on), Credential::Bearer(_)));
assert!(matches!(auth.credential(&sub), Credential::Bearer(_)));
assert!(matches!(auth.credential(&off), Credential::Anonymous));
assert!(matches!(auth.credential(&lookalike), Credential::Anonymous));
}
}