1use std::path::PathBuf;
14
15use crate::secret::Secret;
16
17pub const API_KEY_ENV: &str = "TAILSCALE_API_KEY";
18pub const OAUTH_CLIENT_ID_ENV: &str = "TAILSCALE_OAUTH_CLIENT_ID";
19pub const OAUTH_CLIENT_SECRET_ENV: &str = "TAILSCALE_OAUTH_CLIENT_SECRET";
20pub const OAUTH_SCOPES_ENV: &str = "TAILSCALE_OAUTH_SCOPES";
21pub const OAUTH_JWT_FILE_ENV: &str = "TAILSCALE_OAUTH_JWT_FILE";
22pub const TAILNET_ENV: &str = "TAILSCALE_TAILNET";
23
24pub const DEFAULT_TAILNET: &str = "-";
26
27pub const ENV_VARS: &[&str] = &[
30 API_KEY_ENV,
31 OAUTH_CLIENT_ID_ENV,
32 OAUTH_CLIENT_SECRET_ENV,
33 OAUTH_SCOPES_ENV,
34 OAUTH_JWT_FILE_ENV,
35 TAILNET_ENV,
36];
37
38#[derive(Debug, Clone)]
40pub enum Credentials {
41 ApiKey(Secret),
46 OauthClient {
48 client_id: String,
49 client_secret: Secret,
50 scopes: Vec<String>,
51 },
52 Federated {
56 client_id: Option<String>,
57 jwt_file: PathBuf,
58 scopes: Vec<String>,
59 },
60}
61
62impl Credentials {
63 pub fn from_env() -> Option<Self> {
65 Self::from_source(|key| std::env::var(key).ok())
66 }
67
68 pub fn from_source(source: impl Fn(&str) -> Option<String>) -> Option<Self> {
73 let get = |key: &str| {
74 source(key)
75 .map(|v| v.trim().to_owned())
76 .filter(|v| !v.is_empty())
77 };
78 let scopes = || {
79 get(OAUTH_SCOPES_ENV).map_or_else(Vec::new, |raw| {
80 raw.split([',', ' '])
81 .map(str::trim)
82 .filter(|s| !s.is_empty())
83 .map(str::to_owned)
84 .collect()
85 })
86 };
87
88 if let Some(key) = get(API_KEY_ENV) {
89 return Some(Self::ApiKey(Secret::new(key)));
90 }
91 if let (Some(client_id), Some(secret)) =
92 (get(OAUTH_CLIENT_ID_ENV), get(OAUTH_CLIENT_SECRET_ENV))
93 {
94 return Some(Self::OauthClient {
95 client_id,
96 client_secret: Secret::new(secret),
97 scopes: scopes(),
98 });
99 }
100 if let Some(jwt_file) = get(OAUTH_JWT_FILE_ENV) {
101 return Some(Self::Federated {
102 client_id: get(OAUTH_CLIENT_ID_ENV),
103 jwt_file: PathBuf::from(jwt_file),
104 scopes: scopes(),
105 });
106 }
107 None
108 }
109
110 pub const fn kind(&self) -> &'static str {
112 match self {
113 Self::ApiKey(_) => "API access token",
114 Self::OauthClient { .. } => "OAuth client",
115 Self::Federated { .. } => "federated identity",
116 }
117 }
118}
119
120pub fn tailnet_from_env() -> String {
122 tailnet_from_source(|key| std::env::var(key).ok())
123}
124
125pub fn tailnet_from_source(source: impl Fn(&str) -> Option<String>) -> String {
127 source(TAILNET_ENV)
128 .map(|v| v.trim().to_owned())
129 .filter(|v| !v.is_empty())
130 .unwrap_or_else(|| DEFAULT_TAILNET.to_owned())
131}
132
133#[cfg(test)]
134mod tests {
135 use std::collections::HashMap;
136
137 use super::*;
138
139 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
140 let map: HashMap<String, String> = pairs
141 .iter()
142 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
143 .collect();
144 move |key| map.get(key).cloned()
145 }
146
147 #[test]
148 fn an_api_key_is_read() {
149 let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-def")]))
150 .expect("a key is a credential");
151 match creds {
152 Credentials::ApiKey(key) => assert_eq!(key.expose(), "tskey-api-example-def"),
153 other => panic!("expected an API access token, got {other:?}"),
154 }
155 }
156
157 #[test]
158 fn an_api_key_wins_over_an_oauth_client() {
159 let creds = Credentials::from_source(env(&[
160 (API_KEY_ENV, "tskey-api-example-def"),
161 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
162 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
163 ]))
164 .expect("a credential");
165 assert_eq!(creds.kind(), "API access token");
166 }
167
168 #[test]
169 fn an_oauth_client_wins_over_a_federated_identity() {
170 let creds = Credentials::from_source(env(&[
171 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
172 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
173 (OAUTH_JWT_FILE_ENV, "/run/secrets/token"),
174 ]))
175 .expect("a credential");
176 assert_eq!(creds.kind(), "OAuth client");
177 }
178
179 #[test]
180 fn a_jwt_file_alone_is_a_federated_identity() {
181 let creds = Credentials::from_source(env(&[(OAUTH_JWT_FILE_ENV, "/run/secrets/token")]))
182 .expect("a credential");
183 match creds {
184 Credentials::Federated {
185 client_id,
186 jwt_file,
187 ..
188 } => {
189 assert_eq!(client_id, None);
190 assert_eq!(jwt_file, PathBuf::from("/run/secrets/token"));
191 }
192 other => panic!("expected a federated identity, got {other:?}"),
193 }
194 }
195
196 #[test]
197 fn half_an_oauth_client_is_not_a_credential() {
198 assert!(Credentials::from_source(env(&[(OAUTH_CLIENT_ID_ENV, "kExAmPlE")])).is_none());
199 assert!(
200 Credentials::from_source(env(&[(OAUTH_CLIENT_SECRET_ENV, "tskey-client-example")]))
201 .is_none()
202 );
203 }
204
205 #[test]
206 fn an_empty_or_blank_value_is_not_a_credential() {
207 assert!(Credentials::from_source(env(&[(API_KEY_ENV, "")])).is_none());
208 assert!(Credentials::from_source(env(&[(API_KEY_ENV, " ")])).is_none());
209 }
210
211 #[test]
212 fn scopes_accept_either_separator() {
213 for raw in [
214 "devices:read,dns:read",
215 "devices:read dns:read",
216 " devices:read , dns:read ",
217 ] {
218 let creds = Credentials::from_source(env(&[
219 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
220 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example"),
221 (OAUTH_SCOPES_ENV, raw),
222 ]))
223 .expect("a credential");
224 match creds {
225 Credentials::OauthClient { scopes, .. } => {
226 assert_eq!(scopes, ["devices:read", "dns:read"], "from {raw:?}");
227 }
228 other => panic!("expected an OAuth client, got {other:?}"),
229 }
230 }
231 }
232
233 #[test]
234 fn the_tailnet_defaults_to_the_one_the_credential_belongs_to() {
235 assert_eq!(tailnet_from_source(env(&[])), DEFAULT_TAILNET);
236 assert_eq!(
237 tailnet_from_source(env(&[(TAILNET_ENV, " ")])),
238 DEFAULT_TAILNET
239 );
240 assert_eq!(
241 tailnet_from_source(env(&[(TAILNET_ENV, "example.com")])),
242 "example.com"
243 );
244 }
245
246 #[test]
247 fn a_credential_never_prints_its_value() {
248 let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-secretpart")]))
249 .expect("a credential");
250 assert!(!format!("{creds:?}").contains("secretpart"), "{creds:?}");
251 }
252}