Skip to main content

sofie_identity/oauth/
provider.rs

1use sofie::core::{Error, Result};
2use sofie::values::Secret;
3
4use super::keys::{Keys, KeysAt};
5
6/// Microsoft's `iss` names the account's tenant, filled from `tid`, never `common`
7/// (<https://learn.microsoft.com/entra/identity-platform/access-tokens#validate-the-issuer>).
8pub(crate) const TENANT_ID: &str = "{tenantid}";
9
10#[derive(Debug)]
11pub(crate) enum Claims {
12    /// OIDC: the ID token the token endpoint answers, from `issuer`, signed by `keys`.
13    IdToken { issuer: String, keys: Keys },
14    /// Plain OAuth 2: a JSON user endpoint, and one listing verified addresses.
15    UserEndpoints { user: String, emails: String },
16}
17
18/// One provider an account signs in with; its name is the stored identity's and the form's.
19#[derive(Debug)]
20pub struct Provider {
21    pub(crate) name: String,
22    pub(crate) label: String,
23    pub(crate) authorization_url: String,
24    pub(crate) token_url: String,
25    pub(crate) claims: Claims,
26    pub(crate) client_id: String,
27    pub(crate) client_secret: Secret<String>,
28    pub(crate) scopes: String,
29}
30
31impl Provider {
32    pub fn openid(
33        name: &str,
34        label: &str,
35        issuer: &str,
36        authorization_url: &str,
37        token_url: &str,
38        client: (String, Secret<String>),
39    ) -> Result<Self> {
40        if name.is_empty()
41            || !name
42                .bytes()
43                .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
44        {
45            return Err(Error::internal_message(format!(
46                "an OAuth provider's name is lowercase letters and `-`, not `{name}`"
47            )));
48        }
49        let (client_id, client_secret) = client;
50        Ok(Self {
51            name: name.to_owned(),
52            label: label.to_owned(),
53            authorization_url: authorization_url.to_owned(),
54            token_url: token_url.to_owned(),
55            claims: Claims::IdToken {
56                issuer: issuer.to_owned(),
57                keys: Keys::new(KeysAt::Discovery),
58            },
59            client_id,
60            client_secret,
61            scopes: "openid email".to_owned(),
62        })
63    }
64
65    pub fn google(client: (String, Secret<String>)) -> Result<Self> {
66        Self::openid(
67            "google",
68            "Google",
69            "https://accounts.google.com",
70            "https://accounts.google.com/o/oauth2/v2/auth",
71            "https://oauth2.googleapis.com/token",
72            client,
73        )
74        .map(|google| google.keys_at("https://www.googleapis.com/oauth2/v3/certs"))
75    }
76
77    /// Work, school and personal Microsoft accounts of one tenant (`common` for any).
78    pub fn microsoft(tenant: &str, client: (String, Secret<String>)) -> Result<Self> {
79        Self::openid(
80            "microsoft",
81            "Microsoft",
82            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
83            &format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"),
84            &format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"),
85            client,
86        )
87        .map(|microsoft| {
88            microsoft.keys_at(&format!(
89                "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
90            ))
91        })
92    }
93
94    /// A provider whose key set is known needs no discovery document.
95    fn keys_at(mut self, url: &str) -> Self {
96        if let Claims::IdToken { keys, .. } = &mut self.claims {
97            *keys = Keys::new(KeysAt::Url(url.to_owned()));
98        }
99        self
100    }
101
102    /// GitHub speaks plain OAuth 2, so the account and its verified addresses are asked of its API.
103    pub fn github(client: (String, Secret<String>)) -> Result<Self> {
104        let (client_id, client_secret) = client;
105        Ok(Self {
106            name: "github".to_owned(),
107            label: "GitHub".to_owned(),
108            authorization_url: "https://github.com/login/oauth/authorize".to_owned(),
109            token_url: "https://github.com/login/oauth/access_token".to_owned(),
110            claims: Claims::UserEndpoints {
111                user: "https://api.github.com/user".to_owned(),
112                emails: "https://api.github.com/user/emails".to_owned(),
113            },
114            client_id,
115            client_secret,
116            scopes: "read:user user:email".to_owned(),
117        })
118    }
119
120    #[must_use]
121    pub fn name(&self) -> &str {
122        &self.name
123    }
124
125    /// Every URL this side requests, so the outgoing client allows their hosts only.
126    pub(crate) fn requested_urls(&self) -> Vec<&str> {
127        let mut urls = vec![self.token_url.as_str()];
128        match &self.claims {
129            Claims::IdToken { issuer, keys } => urls.push(keys.url().unwrap_or(issuer)),
130            Claims::UserEndpoints { user, emails } => urls.extend([user.as_str(), emails.as_str()]),
131        }
132        urls
133    }
134}