sofie_identity/oauth/
mod.rs1mod 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 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 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
134pub 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 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}