Skip to main content

sofie_identity/oauth/
mod.rs

1mod connections;
2mod flow;
3mod id_token;
4mod keys;
5mod provider;
6mod sign_in;
7
8use std::sync::Arc;
9
10use sofie::db::{Context, Db, Resolve};
11use sofie::prelude::*;
12use sofie::values::Secret;
13use sofie_client::{Outgoing, Vendor};
14
15pub use connections::routes;
16pub use provider::Provider;
17pub(crate) use sign_in::{offered, routes as sign_in_routes};
18
19use crate::AccountId;
20use crate::pages::Choice;
21
22const IDENTITY_INDEX: &str = "account_identity_subject";
23
24pub struct OAuth {
25    base_url: String,
26    http: Outgoing,
27    providers: Vec<Provider>,
28}
29
30impl OAuth {
31    /// `http` must allow every host of the providers' token, user and key endpoints, and of
32    /// each OIDC issuer whose keys are discovered; keys it discovers on another host are
33    /// fetched from a public HTTPS address only.
34    pub fn new(base_url: &str, http: Outgoing, providers: Vec<Provider>) -> Result<Self> {
35        if !(base_url.starts_with("https://") || base_url.starts_with("http://"))
36            || base_url.ends_with('/')
37        {
38            return Err(Error::internal_message(format!(
39                "the OAuth base URL is the app's public URL without a trailing `/`, not `{base_url}`"
40            )));
41        }
42        Ok(Self {
43            base_url: base_url.to_owned(),
44            http,
45            providers,
46        })
47    }
48
49    /// `SOFIE_OAUTH_PROVIDERS` names them (`google`, `microsoft`, `github`, or another
50    /// OIDC provider); none set, none offered. Each reads
51    /// `SOFIE_OAUTH_<NAME>_CLIENT_ID` and `_CLIENT_SECRET`, Microsoft `_TENANT`, and another
52    /// provider `_LABEL`, `_ISSUER`, `_AUTHORIZATION_URL` and `_TOKEN_URL`;
53    /// `SOFIE_OAUTH_BASE_URL`, else `SOFIE_BASE_URL`, is the app's public URL.
54    pub fn from_environment() -> Result<Option<Self>> {
55        let Some(names) = variable("PROVIDERS")? else {
56            return Ok(None);
57        };
58        let providers = names
59            .split(',')
60            .map(str::trim)
61            .filter(|name| !name.is_empty())
62            .map(provider_from_environment)
63            .collect::<Result<Vec<_>>>()?;
64        let hosts = providers
65            .iter()
66            .flat_map(Provider::requested_urls)
67            .filter_map(host_of)
68            .collect::<Vec<_>>()
69            .join(",");
70        let settings = Vendor {
71            hosts: Some(hosts),
72            ..Vendor::default()
73        };
74        let http = sofie_client::for_vendor("oauth", settings)?;
75        let base_url = base_url(sofie::core::environment_variable)?;
76        Self::new(&base_url, http, providers).map(Some)
77    }
78
79    fn provider(&self, name: &str) -> Option<&Provider> {
80        self.providers.iter().find(|provider| provider.name == name)
81    }
82
83    fn choices(&self) -> Vec<Choice> {
84        let choice = |provider: &Provider| Choice {
85            name: provider.name.clone(),
86            label: provider.label.clone(),
87        };
88        self.providers.iter().map(choice).collect()
89    }
90}
91
92fn variable(name: &str) -> Result<Option<String>> {
93    sofie::core::environment_variable(&format!("SOFIE_OAUTH_{name}"))
94}
95
96fn required(name: &str) -> Result<String> {
97    variable(name)?.ok_or_else(|| Error::internal_message(format!("SOFIE_OAUTH_{name} is not set")))
98}
99
100fn base_url(lookup: impl Fn(&str) -> Result<Option<String>>) -> Result<String> {
101    const OWN: &str = "SOFIE_OAUTH_BASE_URL";
102    let variable = sofie::core::base_url(OWN, lookup)?
103        .ok_or_else(|| sofie::core::base_url_missing(OWN, "OAuth sign-in returns to the app"))?;
104    Ok(variable.value)
105}
106
107fn provider_from_environment(name: &str) -> Result<Provider> {
108    let key = |field: &str| format!("{}_{field}", name.to_ascii_uppercase().replace('-', "_"));
109    let client = (
110        required(&key("CLIENT_ID"))?,
111        Secret::new(required(&key("CLIENT_SECRET"))?),
112    );
113    match name {
114        "google" => Provider::google(client),
115        "github" => Provider::github(client),
116        "microsoft" => Provider::microsoft(&required(&key("TENANT"))?, client),
117        other => Provider::openid(
118            other,
119            &required(&key("LABEL"))?,
120            &required(&key("ISSUER"))?,
121            &required(&key("AUTHORIZATION_URL"))?,
122            &required(&key("TOKEN_URL"))?,
123            client,
124        ),
125    }
126}
127
128fn host_of(url: &str) -> Option<String> {
129    let rest = url.split_once("://")?.1;
130    let authority = rest.split(['/', '?']).next()?;
131    Some(authority.split(':').next()?.to_ascii_lowercase())
132}
133
134/// Handlers then sign in with its providers; without it the sign-in page offers none.
135pub fn install(db: &Db, oauth: OAuth) {
136    sofie::db::provide(db, Arc::new(oauth));
137}
138
139pub(crate) struct Installed(pub(crate) Option<Arc<OAuth>>);
140
141impl<C: Context> Resolve<C> for Installed {
142    fn resolve(context: &mut C) -> impl Future<Output = Result<Self>> + Send {
143        std::future::ready(Ok(Self(sofie::db::provided::<OAuth>(context.db()))))
144    }
145}
146
147impl Installed {
148    fn get(&self) -> Result<&OAuth> {
149        self.0.as_deref().ok_or_else(|| {
150            Error::internal_message(
151                "OAuth is not set up: `sofie_identity::oauth::install` it at boot",
152            )
153        })
154    }
155}
156
157#[derive(Aggregate)]
158#[aggregate(behaviors(SoftDelete))]
159pub struct AccountIdentity {
160    meta: Meta,
161    #[owner]
162    account: AccountId,
163    provider: String,
164    /// The provider's own id of the person, which never changes, unlike an address.
165    subject: String,
166    email: Option<Email>,
167}
168
169impl AccountIdentity {
170    #[must_use]
171    pub fn provider(&self) -> &str {
172        &self.provider
173    }
174
175    #[must_use]
176    pub fn email(&self) -> Option<&Email> {
177        self.email.as_ref()
178    }
179}
180
181#[cfg(feature = "account-data")]
182pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
183    let identities = AccountIdentity::query()
184        .where_(AccountIdentity::ACCOUNT.eq(account))
185        .all(read)
186        .await?;
187    Ok(sofie::serde_json::json!(
188        identities
189            .iter()
190            .map(|identity| sofie::serde_json::json!({
191                "provider": identity.provider,
192                "subject": identity.subject,
193                "email": identity.email.as_ref().map(Email::as_str),
194            }))
195            .collect::<Vec<_>>()
196    ))
197}
198
199pub(crate) async fn removing(read: &Read, account: AccountId) -> Result<Vec<AccountIdentity>> {
200    let mut identities = AccountIdentity::query()
201        .where_(AccountIdentity::ACCOUNT.eq(account))
202        .all(read)
203        .await?;
204    for identity in &mut identities {
205        identity.subject = format!("closed-{}", identity.id().uuid());
206        identity.email = None;
207        identity.delete();
208    }
209    Ok(identities)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn read(variables: &[(&str, &str)]) -> Result<String> {
217        base_url(|name| {
218            Ok(variables
219                .iter()
220                .find(|(variable, _)| *variable == name)
221                .map(|(_, value)| (*value).to_owned()))
222        })
223    }
224
225    #[test]
226    fn the_shared_base_url_serves_unless_oauth_has_its_own() {
227        let shared = ("SOFIE_BASE_URL", "https://app.test");
228        assert_eq!(read(&[shared]).unwrap(), "https://app.test");
229        let own = ("SOFIE_OAUTH_BASE_URL", "https://login.app.test");
230        assert_eq!(read(&[shared, own]).unwrap(), "https://login.app.test");
231        let missing = read(&[]).unwrap_err().to_string();
232        assert!(
233            missing.starts_with("neither SOFIE_OAUTH_BASE_URL nor SOFIE_BASE_URL is set"),
234            "{missing}"
235        );
236    }
237}