Skip to main content

aperion_shield/identity/
config.rs

1//! `identity.yaml` -- the configuration file that lists the identity
2//! providers Shield can talk to (ID.me, mock, future Okta/Auth0/...).
3//!
4//! Loaded from one of:
5//!   1. The `--identity-config <PATH>` CLI flag.
6//!   2. `$APERION_SHIELD_IDENTITY_CONFIG` if set.
7//!   3. `~/.aperion-shield/identity.yaml`.
8//!   4. Built-in defaults (mock-only) -- so `aperion-shield` always has
9//!      *something* to fall back to, and tests don't need a real file
10//!      on disk.
11//!
12//! Schema (all fields optional unless marked required):
13//!
14//! ```yaml
15//! identity:
16//!   enabled: true
17//!   callback_host: 127.0.0.1     # NEVER 0.0.0.0 -- local only
18//!   callback_port: 0             # 0 = OS-assigned random port
19//!   hold_seconds: 120            # how long Shield blocks before
20//!                                #   returning "verify out-of-band"
21//!   providers:
22//!     - id: id_me                # required: matches rules[*].identity.provider
23//!       kind: id_me              # required: "id_me" | "mock"
24//!       sandbox: true            # talk to the ID.me sandbox host
25//!       client_id_env: IDME_CLIENT_ID
26//!       client_secret_env: IDME_CLIENT_SECRET
27//!       scopes: ["openid", "ial2"]
28//!     - id: mock
29//!       kind: mock
30//!       subject: "[email protected]"
31//!       email: "[email protected]"
32//!       loa: 2
33//! ```
34
35use serde::{Deserialize, Serialize};
36use std::path::{Path, PathBuf};
37
38/// Top-level YAML wrapper -- matches the existing `shieldset:` style
39/// (one named root key) so a single file *could* hold both rules and
40/// identity config in the future.
41#[derive(Debug, Deserialize)]
42struct Root {
43    identity: IdentityConfig,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct IdentityConfig {
48    #[serde(default = "default_true")]
49    pub enabled: bool,
50
51    #[serde(default = "default_callback_host")]
52    pub callback_host: String,
53
54    #[serde(default)]
55    pub callback_port: u16,
56
57    #[serde(default = "default_hold")]
58    pub hold_seconds: u64,
59
60    #[serde(default)]
61    pub providers: Vec<ProviderConfig>,
62}
63
64impl Default for IdentityConfig {
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            callback_host: default_callback_host(),
69            callback_port: 0,
70            hold_seconds: default_hold(),
71            providers: vec![ProviderConfig::default_mock()],
72        }
73    }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProviderConfig {
78    /// Stable id referenced by `rules[*].identity.provider` in shieldset.yaml.
79    pub id: String,
80    pub kind: ProviderKind,
81
82    // ID.me fields ---------------------------------------------------
83    #[serde(default)]
84    pub sandbox: bool,
85    /// Name of the env var holding the client_id.
86    #[serde(default)]
87    pub client_id_env: Option<String>,
88    /// Name of the env var holding the client_secret.
89    #[serde(default)]
90    pub client_secret_env: Option<String>,
91    /// OAuth scopes to request. Default is `["openid"]`.
92    #[serde(default = "default_scopes")]
93    pub scopes: Vec<String>,
94    /// Override the authorize endpoint (advanced; default depends on
95    /// `sandbox`).
96    #[serde(default)]
97    pub authorize_url: Option<String>,
98    #[serde(default)]
99    pub token_url: Option<String>,
100    #[serde(default)]
101    pub userinfo_url: Option<String>,
102
103    // Mock fields ----------------------------------------------------
104    /// Mock-only: synthetic subject id the provider returns on every
105    /// verification.
106    #[serde(default)]
107    pub subject: Option<String>,
108    /// Mock-only: synthetic email returned on verification.
109    #[serde(default)]
110    pub email: Option<String>,
111    /// Mock-only: LOA claimed by the synthetic verification.
112    #[serde(default)]
113    pub loa: u8,
114}
115
116impl ProviderConfig {
117    pub fn default_mock() -> Self {
118        Self {
119            id: "mock".to_string(),
120            kind: ProviderKind::Mock,
121            sandbox: false,
122            client_id_env: None,
123            client_secret_env: None,
124            scopes: default_scopes(),
125            authorize_url: None,
126            token_url: None,
127            userinfo_url: None,
128            subject: Some("mock-subject-0001".to_string()),
129            email: Some("[email protected]".to_string()),
130            loa: 2,
131        }
132    }
133}
134
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "snake_case")]
137pub enum ProviderKind {
138    /// Build the URL, exchange the code, fetch userinfo from ID.me.
139    IdMe,
140    /// Local "always verifies" provider used for tests and demos.
141    Mock,
142}
143
144fn default_true() -> bool { true }
145fn default_callback_host() -> String { "127.0.0.1".to_string() }
146fn default_hold() -> u64 { 120 }
147fn default_scopes() -> Vec<String> { vec!["openid".to_string()] }
148
149impl IdentityConfig {
150    /// Parse YAML text.
151    pub fn from_yaml(raw: &str) -> anyhow::Result<Self> {
152        let root: Root = serde_yaml::from_str(raw)?;
153        Ok(root.identity)
154    }
155
156    /// Load using the documented precedence: explicit path > env var >
157    /// `~/.aperion-shield/identity.yaml` > built-in defaults.
158    pub fn load(explicit: Option<&Path>) -> anyhow::Result<Self> {
159        if let Some(p) = explicit {
160            let raw = std::fs::read_to_string(p)?;
161            return Self::from_yaml(&raw);
162        }
163        if let Ok(p) = std::env::var("APERION_SHIELD_IDENTITY_CONFIG") {
164            if !p.is_empty() {
165                let raw = std::fs::read_to_string(&p)?;
166                return Self::from_yaml(&raw);
167            }
168        }
169        if let Some(home) = dirs::home_dir() {
170            let p = home.join(".aperion-shield").join("identity.yaml");
171            if p.exists() {
172                let raw = std::fs::read_to_string(&p)?;
173                return Self::from_yaml(&raw);
174            }
175        }
176        Ok(Self::default())
177    }
178
179    /// Best-effort resolution of the state directory: respects
180    /// `$APERION_SHIELD_STATE_DIR`, then falls back to
181    /// `~/.aperion-shield`.
182    pub fn state_dir() -> PathBuf {
183        if let Ok(d) = std::env::var("APERION_SHIELD_STATE_DIR") {
184            if !d.is_empty() {
185                return PathBuf::from(d);
186            }
187        }
188        dirs::home_dir()
189            .map(|h| h.join(".aperion-shield"))
190            .unwrap_or_else(|| PathBuf::from(".aperion-shield"))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn defaults_have_mock_provider() {
200        let c = IdentityConfig::default();
201        assert!(c.enabled);
202        assert_eq!(c.callback_host, "127.0.0.1");
203        assert_eq!(c.hold_seconds, 120);
204        assert_eq!(c.providers.len(), 1);
205        assert_eq!(c.providers[0].id, "mock");
206        assert_eq!(c.providers[0].kind, ProviderKind::Mock);
207    }
208
209    #[test]
210    fn parses_full_yaml() {
211        let yaml = r#"
212identity:
213  enabled: true
214  callback_host: 127.0.0.1
215  callback_port: 0
216  hold_seconds: 90
217  providers:
218    - id: id_me
219      kind: id_me
220      sandbox: true
221      client_id_env: IDME_CLIENT_ID
222      client_secret_env: IDME_CLIENT_SECRET
223      scopes: ["openid", "ial2"]
224    - id: mock
225      kind: mock
226      subject: "[email protected]"
227      loa: 2
228"#;
229        let c = IdentityConfig::from_yaml(yaml).unwrap();
230        assert_eq!(c.hold_seconds, 90);
231        assert_eq!(c.providers.len(), 2);
232        assert_eq!(c.providers[0].kind, ProviderKind::IdMe);
233        assert!(c.providers[0].sandbox);
234        assert_eq!(c.providers[0].scopes, vec!["openid".to_string(), "ial2".into()]);
235        assert_eq!(c.providers[1].kind, ProviderKind::Mock);
236    }
237}