Skip to main content

isb_server/auth/
edge.rs

1//! Edge identities: the person a front door already verified. On a tailnet
2//! listener that is tailscaled's whois of the real socket peer; behind
3//! Cloudflare Access it is a verified `Cf-Access-Jwt-Assertion`. Who
4//! resolves a request to one is the daemon's gate; this module is what the
5//! identity endpoints make of it.
6//!
7//! - **First-run setup**: a claimable edge identity creates the first admin
8//!   with no setup token. Reaching the port already took getting past the
9//!   edge, so the race the setup token exists to stop is run only among the
10//!   people the tailnet or the Access policy let in.
11//! - **Signing in**: an edge identity is an external identity (provider
12//!   `tailnet` or `access`) with the usual rules
13//!   ([`super::AuthStore::external_sign_in`]): a linked identity signs its user in,
14//!   a verified email links to the user who has it, and a new account needs
15//!   an invitation or open sign-up.
16//!
17//! Only people count: a tagged tailnet node and an Access service token are
18//! never edge identities (they are what orgs' agent identities are for).
19
20use std::sync::Arc;
21
22use serde::Serialize;
23
24use super::agent_identities::AgentKind;
25use super::external::ExternalIdentity;
26use crate::server::http::Request;
27
28/// A person a front door verified.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct EdgeIdentity {
31    pub kind: AgentKind,
32    /// The tailnet login, or the Access subject (a stable per-user id).
33    pub subject: String,
34    /// What to call them: the tailnet login, or the Access email.
35    pub name: String,
36    /// An email the front door vouches for, when there is one: the Access
37    /// email, or a tailnet login shaped like an address (`someone@github`
38    /// is not one).
39    pub email: Option<String>,
40    /// The tailnet node they came from.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub node: Option<String>,
43    /// May claim first-run setup: off when a superadmin allow list for this
44    /// front door exists and does not name them.
45    pub can_claim: bool,
46}
47
48/// The edge identity behind a request, if any (the daemon's gate).
49pub type EdgeFn = Arc<dyn Fn(&Request) -> Option<EdgeIdentity> + Send + Sync>;
50
51impl EdgeIdentity {
52    /// The provider name its `user_identities` rows carry.
53    pub fn provider(&self) -> &'static str {
54        self.kind.as_str()
55    }
56
57    /// How a person would name the front door.
58    pub fn label(&self) -> &'static str {
59        match self.kind {
60            AgentKind::Tailnet => "Tailscale",
61            AgentKind::Access => "Cloudflare Access",
62        }
63    }
64
65    /// As an external identity, for linking and signing in.
66    pub fn external(&self) -> ExternalIdentity {
67        ExternalIdentity {
68            provider: self.provider().into(),
69            subject: self.subject.clone(),
70            email: self.email.clone(),
71            email_verified: self.email.is_some(),
72            name: None,
73        }
74    }
75}
76
77/// A tailnet login that is a deliverable address: `local@domain.tld`.
78/// Tailscale's GitHub logins (`someone@github`) are not.
79pub fn login_email(login: &str) -> Option<String> {
80    let (local, domain) = login.split_once('@')?;
81    let ok = !local.is_empty()
82        && domain.contains('.')
83        && !domain.starts_with('.')
84        && !domain.ends_with('.')
85        && !login.contains(char::is_whitespace);
86    ok.then(|| login.to_ascii_lowercase())
87}
88
89/// The label of an identity row whose provider is an edge.
90pub fn provider_label(provider: &str) -> Option<&'static str> {
91    match provider {
92        "tailnet" => Some("Tailscale"),
93        "access" => Some("Cloudflare Access"),
94        _ => None,
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn only_addresses_are_emails() {
104        assert_eq!(
105            login_email("Ada@Example.com").as_deref(),
106            Some("ada@example.com")
107        );
108        assert_eq!(login_email("ada@github"), None);
109        assert_eq!(login_email("tagged-devices"), None);
110        assert_eq!(login_email("@example.com"), None);
111        assert_eq!(login_email("a@example."), None);
112    }
113}