Skip to main content

link_r/auth/
static_token.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! A fixed-credential provider (PAT / bearer / custom header).
5//!
6//! The token is held in a [`SecretString`] so it is redacted in `Debug` and
7//! zeroized on drop; it is exposed only when building a request header.
8
9use crate::auth::{AuthProvider, Credential};
10use crate::error::Result;
11use crate::resource::Resource;
12use compact_str::CompactString;
13use secrecy::{ExposeSecret, SecretString};
14use std::borrow::Cow;
15use std::future::Ready;
16
17#[derive(Clone, Copy, Debug)]
18enum TokenKind {
19    Bearer,
20    Header(&'static str),
21}
22
23/// An [`AuthProvider`] holding one fixed secret credential.
24///
25/// A credential may be *host-scoped* ([`StaticTokenAuth::bearer_scoped`]): it is
26/// then sent only to that host (or its subdomains), so a page that links to a
27/// foreign host reached via an in-scope crawl can never exfiltrate the token.
28#[derive(Debug)]
29pub struct StaticTokenAuth {
30    kind: TokenKind,
31    token: SecretString,
32    /// When set, the credential is only sent to this host or its subdomains.
33    scope_host: Option<CompactString>,
34}
35
36impl StaticTokenAuth {
37    /// `Authorization: Bearer <token>`, sent to any host.
38    ///
39    /// Prefer [`StaticTokenAuth::bearer_scoped`] for private crawls so the token is
40    /// confined to the intended host.
41    #[must_use]
42    pub fn bearer(token: impl Into<String>) -> Self {
43        Self {
44            kind: TokenKind::Bearer,
45            token: secret(token),
46            scope_host: None,
47        }
48    }
49
50    /// `Authorization: Bearer <token>`, sent only to `host` and its subdomains.
51    #[must_use]
52    pub fn bearer_scoped(token: impl Into<String>, host: impl Into<CompactString>) -> Self {
53        Self {
54            kind: TokenKind::Bearer,
55            token: secret(token),
56            scope_host: Some(host.into()),
57        }
58    }
59
60    /// A GitHub personal access token (sent as a bearer token, which the GitHub
61    /// REST API accepts).
62    #[must_use]
63    pub fn github_pat(token: impl Into<String>) -> Self {
64        Self::bearer(token)
65    }
66
67    /// A custom header credential, e.g. `x-api-key: <token>`.
68    #[must_use]
69    pub fn header(name: &'static str, token: impl Into<String>) -> Self {
70        Self {
71            kind: TokenKind::Header(name),
72            token: secret(token),
73            scope_host: None,
74        }
75    }
76}
77
78fn secret(s: impl Into<String>) -> SecretString {
79    SecretString::new(s.into().into_boxed_str())
80}
81
82/// Whether `host` equals `base` or is a subdomain of it (`a.b.dev` under `b.dev`).
83fn host_in_scope(host: &str, base: &str) -> bool {
84    host == base
85        || (host.len() > base.len()
86            && host.ends_with(base)
87            && host.as_bytes()[host.len() - base.len() - 1] == b'.')
88}
89
90impl AuthProvider for StaticTokenAuth {
91    type RefreshFuture<'a> = Ready<Result<()>>;
92
93    fn credential(&self, resource: &Resource) -> Credential<'_> {
94        // A scoped token is withheld from any host outside its scope.
95        if let Some(scope) = &self.scope_host {
96            let in_scope = resource
97                .url
98                .host_str()
99                .is_some_and(|h| host_in_scope(h, scope));
100            if !in_scope {
101                return Credential::Anonymous;
102            }
103        }
104        let value = Cow::Borrowed(self.token.expose_secret());
105        match self.kind {
106            TokenKind::Bearer => Credential::Bearer(value),
107            TokenKind::Header(name) => Credential::Header { name, value },
108        }
109    }
110
111    fn refresh(&self) -> Self::RefreshFuture<'_> {
112        std::future::ready(Ok(()))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use url::Url;
120
121    fn resource() -> Resource {
122        Resource::new(Url::parse("https://x.dev/a").unwrap())
123    }
124
125    #[test]
126    fn bearer_exposes_token_only_in_credential() {
127        let auth = StaticTokenAuth::bearer("s3cr3t");
128        match auth.credential(&resource()) {
129            Credential::Bearer(v) => assert_eq!(v, "s3cr3t"),
130            other => panic!("expected bearer, got {other:?}"),
131        }
132        // Debug must not leak the secret.
133        assert!(!format!("{auth:?}").contains("s3cr3t"));
134    }
135
136    #[test]
137    fn custom_header_credential() {
138        let auth = StaticTokenAuth::header("x-api-key", "abc");
139        match auth.credential(&resource()) {
140            Credential::Header { name, value } => {
141                assert_eq!(name, "x-api-key");
142                assert_eq!(value, "abc");
143            }
144            other => panic!("expected header, got {other:?}"),
145        }
146    }
147
148    #[test]
149    fn scoped_token_confined_to_host_and_subdomains() {
150        let auth = StaticTokenAuth::bearer_scoped("s3cr3t", "github.com");
151        let on = Resource::new(Url::parse("https://github.com/o/r").unwrap());
152        let sub = Resource::new(Url::parse("https://api.github.com/x").unwrap());
153        let off = Resource::new(Url::parse("https://evil.dev/x").unwrap());
154        let lookalike = Resource::new(Url::parse("https://notgithub.com/x").unwrap());
155        assert!(matches!(auth.credential(&on), Credential::Bearer(_)));
156        assert!(matches!(auth.credential(&sub), Credential::Bearer(_)));
157        assert!(matches!(auth.credential(&off), Credential::Anonymous));
158        assert!(matches!(auth.credential(&lookalike), Credential::Anonymous));
159    }
160}