firebase_rs_sdk/auth/oauth/
credential.rs

1use std::convert::TryFrom;
2
3use serde_json::{json, Value};
4use url::form_urlencoded::Serializer;
5
6use crate::auth::error::{AuthError, AuthResult};
7use crate::auth::model::AuthCredential;
8
9/// Wraps the OAuth credential payload returned by popup/redirect handlers.
10///
11/// The credential format mirrors the JS SDK: the handler supplies a JSON blob
12/// describing the provider response (ID token, access token, pending post body).
13/// `OAuthCredential` exposes helpers to build the `postBody` required by the
14/// `signInWithIdp` REST endpoint.
15#[derive(Debug, Clone)]
16pub struct OAuthCredential {
17    provider_id: String,
18    sign_in_method: String,
19    raw_nonce: Option<String>,
20    token_response: Value,
21}
22
23impl OAuthCredential {
24    pub fn new(
25        provider_id: impl Into<String>,
26        sign_in_method: impl Into<String>,
27        token_response: Value,
28    ) -> Self {
29        Self {
30            provider_id: provider_id.into(),
31            sign_in_method: sign_in_method.into(),
32            raw_nonce: None,
33            token_response,
34        }
35    }
36
37    pub fn with_raw_nonce(mut self, nonce: Option<String>) -> Self {
38        self.raw_nonce = nonce;
39        self
40    }
41
42    pub fn provider_id(&self) -> &str {
43        &self.provider_id
44    }
45
46    pub fn sign_in_method(&self) -> &str {
47        &self.sign_in_method
48    }
49
50    pub fn token_response(&self) -> &Value {
51        &self.token_response
52    }
53
54    pub fn raw_nonce(&self) -> Option<&String> {
55        self.raw_nonce.as_ref()
56    }
57
58    /// Serializes the credential to a JSON value.
59    pub fn to_json(&self) -> Value {
60        let mut value = json!({
61            "providerId": self.provider_id,
62            "signInMethod": self.sign_in_method,
63            "tokenResponse": self.token_response,
64        });
65        if let Some(nonce) = &self.raw_nonce {
66            value["rawNonce"] = json!(nonce);
67        }
68        value
69    }
70
71    /// Serializes the credential to a JSON string.
72    pub fn to_json_string(&self) -> AuthResult<String> {
73        serde_json::to_string(&self.to_json())
74            .map_err(|err| AuthError::InvalidCredential(err.to_string()))
75    }
76
77    /// Reconstructs a credential from a JSON value previously produced via [`to_json`].
78    pub fn from_json(value: Value) -> AuthResult<Self> {
79        let raw_nonce = value
80            .get("rawNonce")
81            .and_then(Value::as_str)
82            .map(|value| value.to_owned());
83        let auth = AuthCredential::from_json(value)?;
84        let oauth = OAuthCredential::try_from(auth)?;
85        Ok(oauth.with_raw_nonce(raw_nonce))
86    }
87
88    /// Reconstructs a credential from a JSON string representation.
89    pub fn from_json_str(data: &str) -> AuthResult<Self> {
90        let value: Value = serde_json::from_str(data)
91            .map_err(|err| AuthError::InvalidCredential(err.to_string()))?;
92        Self::from_json(value)
93    }
94
95    /// Builds the `postBody` query string expected by `signInWithIdp`.
96    pub fn build_post_body(&self) -> AuthResult<String> {
97        let mut serializer = Serializer::new(String::new());
98        let mut has_credential = false;
99
100        if let Some(id_token) = self
101            .token_response
102            .get("idToken")
103            .or_else(|| self.token_response.get("oauthIdToken"))
104            .and_then(Value::as_str)
105        {
106            serializer.append_pair("id_token", id_token);
107            has_credential = true;
108        }
109
110        if let Some(access_token) = self
111            .token_response
112            .get("accessToken")
113            .or_else(|| self.token_response.get("oauthAccessToken"))
114            .and_then(Value::as_str)
115        {
116            serializer.append_pair("access_token", access_token);
117            has_credential = true;
118        }
119
120        if let Some(code) = self.token_response.get("code").and_then(Value::as_str) {
121            serializer.append_pair("code", code);
122            has_credential = true;
123        }
124
125        if let Some(verifier) = self
126            .token_response
127            .get("codeVerifier")
128            .and_then(Value::as_str)
129        {
130            serializer.append_pair("codeVerifier", verifier);
131        }
132
133        if !has_credential {
134            return Err(AuthError::InvalidCredential(
135                "OAuth token response missing id_token/access_token/code".into(),
136            ));
137        }
138
139        if let Some(nonce) = self.raw_nonce() {
140            serializer.append_pair("nonce", nonce);
141        }
142
143        serializer.append_pair("providerId", &self.provider_id);
144
145        Ok(serializer.finish())
146    }
147}
148
149impl From<OAuthCredential> for AuthCredential {
150    fn from(value: OAuthCredential) -> Self {
151        let OAuthCredential {
152            provider_id,
153            sign_in_method,
154            raw_nonce,
155            mut token_response,
156        } = value;
157
158        if let Some(nonce) = raw_nonce {
159            match token_response {
160                Value::Object(ref mut map) => {
161                    map.entry("nonce".to_string())
162                        .or_insert_with(|| json!(nonce.clone()));
163                }
164                _ => {
165                    token_response = json!({
166                        "nonce": nonce,
167                    });
168                }
169            }
170        }
171
172        Self {
173            provider_id,
174            sign_in_method,
175            token_response,
176        }
177    }
178}
179
180impl TryFrom<AuthCredential> for OAuthCredential {
181    type Error = AuthError;
182
183    fn try_from(credential: AuthCredential) -> Result<Self, Self::Error> {
184        let raw_nonce = credential
185            .token_response
186            .get("nonce")
187            .and_then(Value::as_str)
188            .map(|value| value.to_owned());
189
190        Ok(Self {
191            provider_id: credential.provider_id,
192            sign_in_method: credential.sign_in_method,
193            raw_nonce,
194            token_response: credential.token_response,
195        })
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use serde_json::json;
203
204    #[test]
205    fn post_body_includes_id_token() {
206        let credential = AuthCredential {
207            provider_id: "google.com".into(),
208            sign_in_method: "google.com".into(),
209            token_response: json!({ "idToken": "test-id-token" }),
210        };
211
212        let oauth = OAuthCredential::try_from(credential).unwrap();
213        let result = oauth.build_post_body().unwrap();
214        assert!(result.contains("id_token=test-id-token"));
215        assert!(result.contains("providerId=google.com"));
216    }
217
218    #[test]
219    fn post_body_includes_access_token() {
220        let credential = AuthCredential {
221            provider_id: "github.com".into(),
222            sign_in_method: "github.com".into(),
223            token_response: json!({ "accessToken": "gh-token" }),
224        };
225
226        let oauth = OAuthCredential::try_from(credential).unwrap();
227        let result = oauth.build_post_body().unwrap();
228        assert!(result.contains("access_token=gh-token"));
229        assert!(result.contains("providerId=github.com"));
230    }
231
232    #[test]
233    fn post_body_errors_when_missing_tokens() {
234        let credential = AuthCredential {
235            provider_id: "google.com".into(),
236            sign_in_method: "google.com".into(),
237            token_response: json!({}),
238        };
239
240        let oauth = OAuthCredential::try_from(credential).unwrap();
241        let err = oauth.build_post_body().unwrap_err();
242        assert!(matches!(err, AuthError::InvalidCredential(_)));
243    }
244
245    #[test]
246    fn post_body_includes_code_verifier() {
247        let credential = AuthCredential {
248            provider_id: "twitter.com".into(),
249            sign_in_method: "twitter.com".into(),
250            token_response: json!({
251                "code": "auth-code",
252                "codeVerifier": "verifier"
253            }),
254        };
255
256        let oauth = OAuthCredential::try_from(credential).unwrap();
257        let result = oauth.build_post_body().unwrap();
258        assert!(result.contains("code=auth-code"));
259        assert!(result.contains("codeVerifier=verifier"));
260    }
261
262    #[test]
263    fn oauth_credential_json_roundtrip() {
264        let credential =
265            OAuthCredential::new("apple.com", "apple.com", json!({ "idToken": "token" }))
266                .with_raw_nonce(Some("nonce123".to_string()));
267
268        let json_value = credential.to_json();
269        let restored = OAuthCredential::from_json(json_value.clone()).unwrap();
270        assert_eq!(restored.provider_id(), "apple.com");
271        assert_eq!(restored.raw_nonce(), Some(&"nonce123".to_string()));
272
273        let json_string = credential.to_json_string().unwrap();
274        let from_str = OAuthCredential::from_json_str(&json_string).unwrap();
275        assert_eq!(from_str.provider_id(), "apple.com");
276
277        let auth: AuthCredential = credential.into();
278        assert_eq!(
279            auth.token_response.get("nonce").and_then(Value::as_str),
280            Some("nonce123")
281        );
282    }
283}