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 no_credential_is_a_valid_answer() {
149 assert!(Credentials::from_source(env(&[])).is_none());
150 }
151
152 #[test]
153 fn an_api_key_is_read() {
154 let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-def")]))
155 .expect("a key is a credential");
156 match creds {
157 Credentials::ApiKey(key) => assert_eq!(key.expose(), "tskey-api-example-def"),
158 other => panic!("expected an API access token, got {other:?}"),
159 }
160 }
161
162 #[test]
163 fn an_api_key_wins_over_an_oauth_client() {
164 let creds = Credentials::from_source(env(&[
165 (API_KEY_ENV, "tskey-api-example-def"),
166 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
167 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
168 ]))
169 .expect("a credential");
170 assert_eq!(creds.kind(), "API access token");
171 }
172
173 #[test]
174 fn an_oauth_client_wins_over_a_federated_identity() {
175 let creds = Credentials::from_source(env(&[
176 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
177 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
178 (OAUTH_JWT_FILE_ENV, "/run/secrets/token"),
179 ]))
180 .expect("a credential");
181 assert_eq!(creds.kind(), "OAuth client");
182 }
183
184 #[test]
185 fn a_jwt_file_alone_is_a_federated_identity() {
186 let creds = Credentials::from_source(env(&[(OAUTH_JWT_FILE_ENV, "/run/secrets/token")]))
187 .expect("a credential");
188 match creds {
189 Credentials::Federated {
190 client_id,
191 jwt_file,
192 ..
193 } => {
194 assert_eq!(client_id, None);
195 assert_eq!(jwt_file, PathBuf::from("/run/secrets/token"));
196 }
197 other => panic!("expected a federated identity, got {other:?}"),
198 }
199 }
200
201 #[test]
202 fn half_an_oauth_client_is_not_a_credential() {
203 assert!(Credentials::from_source(env(&[(OAUTH_CLIENT_ID_ENV, "kExAmPlE")])).is_none());
204 assert!(
205 Credentials::from_source(env(&[(OAUTH_CLIENT_SECRET_ENV, "tskey-client-example")]))
206 .is_none()
207 );
208 }
209
210 #[test]
211 fn an_empty_or_blank_value_is_not_a_credential() {
212 assert!(Credentials::from_source(env(&[(API_KEY_ENV, "")])).is_none());
213 assert!(Credentials::from_source(env(&[(API_KEY_ENV, " ")])).is_none());
214 }
215
216 #[test]
217 fn scopes_accept_either_separator() {
218 for raw in [
219 "devices:read,dns:read",
220 "devices:read dns:read",
221 " devices:read , dns:read ",
222 ] {
223 let creds = Credentials::from_source(env(&[
224 (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
225 (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example"),
226 (OAUTH_SCOPES_ENV, raw),
227 ]))
228 .expect("a credential");
229 match creds {
230 Credentials::OauthClient { scopes, .. } => {
231 assert_eq!(scopes, ["devices:read", "dns:read"], "from {raw:?}");
232 }
233 other => panic!("expected an OAuth client, got {other:?}"),
234 }
235 }
236 }
237
238 #[test]
239 fn the_tailnet_defaults_to_the_one_the_credential_belongs_to() {
240 assert_eq!(tailnet_from_source(env(&[])), DEFAULT_TAILNET);
241 assert_eq!(
242 tailnet_from_source(env(&[(TAILNET_ENV, " ")])),
243 DEFAULT_TAILNET
244 );
245 assert_eq!(
246 tailnet_from_source(env(&[(TAILNET_ENV, "example.com")])),
247 "example.com"
248 );
249 }
250
251 #[test]
252 fn a_credential_never_prints_its_value() {
253 let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-secretpart")]))
254 .expect("a credential");
255 assert!(!format!("{creds:?}").contains("secretpart"), "{creds:?}");
256 }
257}