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}