Skip to main content

bitwarden_importers/importers/onepassword/access/sso/
mod.rs

1//! Login with single sign-on: enroll this device with the approval of an enrolled one, then sign in
2//! with the credentials it hands over.
3
4use std::time::Duration;
5
6use serde_json::json;
7use url::{Url, form_urlencoded};
8use zeroize::Zeroizing;
9
10use self::{
11    enrollment::enroll_device,
12    ui::{SsoLoginResult, SsoUi},
13};
14use super::{
15    credentials::SsoCredentials,
16    device::ClientInfo,
17    error::OnePasswordError,
18    login::{AUTH_COMPLETE_ENDPOINT, AUTH_START_ENDPOINT, SignInMethods, fetch_auth_methods},
19    mac::MacSigner,
20    opdata::{AesKey, decode64_loose},
21    rest::RestClient,
22    session::Session,
23    srp::perform_and_verify_with_x,
24    wire::{AuthComplete, LoginInfo, NewSession, SsoLoginUrl, SsoSession},
25};
26
27mod cpace;
28mod enrollment;
29#[cfg(test)]
30mod test_support;
31pub mod ui;
32
33const SSO_START_ENDPOINT: &str = "v3/auth/sso/oidc/start";
34const SSO_VERIFY_ENDPOINT: &str = "v3/auth/sso/oidc/verify";
35const DEVICE_NOT_ENROLLED: &str = "device_not_enrolled";
36
37/// The timing knobs of the SSO network flows, grouped so the tests can shrink them.
38pub(super) struct Timing {
39    /// Delay before retrying a request of `post_json_until_result`, which the server answered
40    /// with 200 and an empty body.
41    until_result_delay: Duration,
42    /// Maximum number of attempts of such a request before it fails.
43    until_result_attempts: u32,
44    /// Delay between two polls of the enrollment status. The 1Password web client polls every
45    /// 2 seconds.
46    enrollment_poll_interval: Duration,
47    /// How long the enrollment status is polled before the enrollment times out.
48    enrollment_poll_timeout: Duration,
49}
50
51impl Default for Timing {
52    fn default() -> Timing {
53        Timing {
54            until_result_delay: Duration::from_millis(500),
55            until_result_attempts: 20,
56            enrollment_poll_interval: Duration::from_secs(2),
57            enrollment_poll_timeout: Duration::from_secs(5 * 60),
58        }
59    }
60}
61
62impl Timing {
63    /// The number of enrollment polls that fit the timeout. Zero for an interval of zero.
64    fn enrollment_poll_attempts(&self) -> u128 {
65        self.enrollment_poll_timeout
66            .as_nanos()
67            .checked_div(self.enrollment_poll_interval.as_nanos())
68            .unwrap_or(0)
69    }
70}
71
72/// Signs in with single sign-on. Returns the signed session and the account unlock key that opens
73/// the account's keysets.
74///
75/// The device is always new to the account, so the user approves it on an enrolled device through
76/// `ui`.
77pub(super) async fn sso_login(
78    credentials: &SsoCredentials,
79    client_info: &ClientInfo,
80    ui: &dyn SsoUi,
81    rest: &RestClient,
82    timing: &Timing,
83) -> Result<(Session, Zeroizing<Vec<u8>>), OnePasswordError> {
84    // 1. Get the login info to find out if SSO is available and where its API is.
85    let login_info = fetch_auth_methods(&credentials.username, rest).await?;
86
87    // 2. Refuse an account that does not sign in with SSO.
88    let account = require_sso(&login_info, &credentials.username)?;
89
90    // 3. From here on the requests go to the SSO API of the account.
91    let sso_rest = rest.with_base_url(account.api_url());
92
93    // 4. The server names the identity provider page to send the user to.
94    let sso_login_url = start_sso_login(&account, &sso_rest).await?;
95
96    // 5. The user signs in at the identity provider, which redirects back with a code.
97    let (code, state) = perform_sso_login(&sso_login_url, ui).await?;
98
99    // 6. The server trades the code for an SSO session and a sign-in token.
100    let sso_session = verify_sso_login(&code, &state, client_info, &sso_rest).await?;
101
102    // 7. The requests that follow belong to this SSO session.
103    let sso_rest = sso_rest.with_session_id(&sso_session.user.session_uuid)?;
104
105    // 8. The server has not seen this device.
106    sso_session.require_new_device()?;
107
108    // 9. The user approves the device on an enrolled one, which hands over the credential bundle.
109    let sign_in_token = sso_session.sso_auth.sign_in_token_details.token.as_str();
110    let bundle = enroll_device(
111        &credentials.username,
112        &account.sign_in_address,
113        sign_in_token,
114        ui,
115        &sso_rest,
116        timing,
117    )
118    .await?;
119
120    // 10. The bundle holds the SRP x that signs in without a password.
121    let srp_x = Zeroizing::new(decode64_loose(&bundle.srpx)?);
122    let master_key = Zeroizing::new(decode64_loose(&bundle.auk.k)?);
123    let (session_key, session_rest) = login(
124        &credentials.username,
125        &account,
126        client_info,
127        &sso_session,
128        &srp_x,
129        &sso_rest,
130    )
131    .await?;
132
133    // 11. The account unlock key plays the role of the master key.
134    Ok((Session::new(session_key, session_rest), master_key))
135}
136
137/// What an SSO login needs from the login info of the account.
138struct SsoAccount {
139    user_uuid: String,
140    /// The address the SSO endpoints are at, as the server sent it. The enrollment key exchange
141    /// hashes it verbatim.
142    sign_in_address: String,
143}
144
145impl SsoAccount {
146    /// The root of the SSO API.
147    fn api_url(&self) -> String {
148        format!("{}/api", self.sign_in_address.trim_end_matches('/'))
149    }
150}
151
152/// Checks that the account signs in with SSO and that its login info says who the user is and
153/// where the SSO endpoints are.
154fn require_sso(login_info: &LoginInfo, username: &str) -> Result<SsoAccount, OnePasswordError> {
155    if !SignInMethods::of(login_info).sso {
156        return Err(OnePasswordError::Unsupported(format!(
157            "no SSO login method found for account {username}"
158        )));
159    }
160
161    let (Some(user_uuid), Some(sign_in_address)) = (
162        non_empty(&login_info.user_uuid),
163        non_empty(&login_info.sign_in_address),
164    ) else {
165        return Err(OnePasswordError::Internal(
166            "SSO login info is missing userUuid or signInAddress".into(),
167        ));
168    };
169    validate_sign_in_address(sign_in_address)?;
170
171    Ok(SsoAccount {
172        user_uuid: user_uuid.to_string(),
173        sign_in_address: sign_in_address.to_string(),
174    })
175}
176
177fn non_empty(value: &Option<String>) -> Option<&str> {
178    value.as_deref().filter(|value| !value.is_empty())
179}
180
181/// The server picks this address and every SSO request goes to it, so it has to be a plain https
182/// address the API path can be appended to. The address is not echoed in the error: it can carry
183/// credentials.
184fn validate_sign_in_address(address: &str) -> Result<(), OnePasswordError> {
185    let valid = Url::parse(address).is_ok_and(|url| {
186        has_allowed_scheme(&url)
187            && url.has_host()
188            && url.query().is_none()
189            && url.fragment().is_none()
190    });
191    if !valid {
192        return Err(OnePasswordError::Internal(
193            "the SSO sign-in address is not an https URL".into(),
194        ));
195    }
196    Ok(())
197}
198
199/// Requests to a server supplied URL carry credentials and the user opens one in a browser, so
200/// only https will do. The fake servers of the tests have no TLS, hence http under test.
201fn has_allowed_scheme(url: &Url) -> bool {
202    url.scheme() == "https" || (cfg!(test) && url.scheme() == "http")
203}
204
205fn is_web_url(url: &str) -> bool {
206    Url::parse(url).is_ok_and(|url| has_allowed_scheme(&url) && url.has_host())
207}
208
209/// The identity provider sends the user back here. For the desktop client 1Password picks its own
210/// app scheme, `onepassword://`, which the UI catches before anything opens it.
211fn is_redirect_uri(url: &str) -> bool {
212    is_web_url(url)
213        || Url::parse(url).is_ok_and(|url| url.scheme() == "onepassword" && url.has_host())
214}
215
216/// Asks for the URL of the identity provider login.
217async fn start_sso_login(
218    account: &SsoAccount,
219    rest: &RestClient,
220) -> Result<String, OnePasswordError> {
221    let response: SsoLoginUrl = rest
222        .post_json(SSO_START_ENDPOINT, json!({ "userUuid": account.user_uuid }))
223        .await?;
224    Ok(response.auth_redirect)
225}
226
227/// Has the user log in with the identity provider, and returns the authorization code and the
228/// state it redirects back with.
229async fn perform_sso_login(
230    sso_login_url: &str,
231    ui: &dyn SsoUi,
232) -> Result<(String, String), OnePasswordError> {
233    // Neither URL is echoed in the errors: they carry the state.
234    if !is_web_url(sso_login_url) {
235        return Err(OnePasswordError::Internal(
236            "the SSO login URL is not an https URL".into(),
237        ));
238    }
239
240    let redirect_to = url_parameter(sso_login_url, "redirect_uri").ok_or_else(|| {
241        OnePasswordError::Internal("no redirect_uri found in the SSO login URL".into())
242    })?;
243    if !is_redirect_uri(&redirect_to) {
244        return Err(OnePasswordError::Internal(
245            "the SSO redirect URI is neither an https nor a onepassword URL".into(),
246        ));
247    }
248
249    let redirected_to = match ui.perform_sso_login(sso_login_url, &redirect_to).await {
250        SsoLoginResult::RedirectedTo(url) => url,
251        SsoLoginResult::Cancel => return Err(OnePasswordError::Canceled),
252    };
253
254    // The URL is not echoed in the errors: it carries the authorization code.
255    let code = url_parameter(&redirected_to, "code").ok_or_else(|| {
256        OnePasswordError::Internal("no code returned from the SSO provider".into())
257    })?;
258    let state = url_parameter(&redirected_to, "state").ok_or_else(|| {
259        OnePasswordError::Internal("no state returned from the SSO provider".into())
260    })?;
261    Ok((code, state))
262}
263
264/// Exchanges the authorization code for an SSO session.
265async fn verify_sso_login(
266    code: &str,
267    state: &str,
268    client_info: &ClientInfo,
269    rest: &RestClient,
270) -> Result<SsoSession, OnePasswordError> {
271    rest.post_json(
272        SSO_VERIFY_ENDPOINT,
273        json!({
274            "code": code,
275            "state": state,
276            "device": client_info.sso_verify_device_body(),
277        }),
278    )
279    .await
280}
281
282impl SsoSession {
283    /// A device with a fresh id cannot be known to the server, so anything but
284    /// `device_not_enrolled` is unexpected.
285    fn require_new_device(&self) -> Result<(), OnePasswordError> {
286        if self.state != DEVICE_NOT_ENROLLED {
287            return Err(OnePasswordError::Internal(format!(
288                "unexpected SSO session state: {}",
289                self.state
290            )));
291        }
292        Ok(())
293    }
294}
295
296/// Signs in with the sign-in token the SSO login earned, proving knowledge of the SRP x from the
297/// credential bundle. Returns the session key and a client that signs its requests with it.
298///
299/// Fails with `Unsupported` when the server asks for a second factor.
300async fn login(
301    username: &str,
302    account: &SsoAccount,
303    client_info: &ClientInfo,
304    sso_session: &SsoSession,
305    srp_x: &[u8],
306    rest: &RestClient,
307) -> Result<(AesKey, RestClient), OnePasswordError> {
308    let sign_in_token = sso_session.sso_auth.sign_in_token_details.token.as_str();
309
310    // 1. Start a new session. The session id of the SSO login stays in use.
311    let started = start_auth(
312        sign_in_token,
313        &client_info.device_uuid,
314        &account.user_uuid,
315        rest,
316    )
317    .await?;
318    let (Some(key_uuid), Some(auth)) = (started.key_uuid, started.auth) else {
319        return Err(OnePasswordError::Internal(format!(
320            "missing SRP parameters in the start response, its status is '{}'",
321            started.status
322        )));
323    };
324
325    // 2. Do the regular SRP exchange with the x from the bundle. Only the salt of the parameters is
326    //    needed, as nothing is derived from a password.
327    let session_key = perform_and_verify_with_x(
328        srp_x,
329        username,
330        &key_uuid,
331        &decode64_loose(&auth.salt)?,
332        &sso_session.user.session_uuid,
333        rest,
334    )
335    .await?;
336
337    // 3. Sign the following requests with the session key, counting them from 1.
338    let signed_rest = rest.with_signer(MacSigner::with_request_id(&session_key, 1));
339
340    // 4. Tell the server which device this is. It may answer by asking for a second factor.
341    complete_auth(client_info, sign_in_token, &session_key, &signed_rest).await?;
342
343    Ok((session_key, signed_rest))
344}
345
346async fn start_auth(
347    sign_in_token: &str,
348    device_uuid: &str,
349    user_uuid: &str,
350    rest: &RestClient,
351) -> Result<NewSession, OnePasswordError> {
352    rest.post_json(
353        AUTH_START_ENDPOINT,
354        json!({
355            "signInToken": sign_in_token,
356            "deviceUuid": device_uuid,
357            "userUuid": user_uuid,
358        }),
359    )
360    .await
361}
362
363async fn complete_auth(
364    client_info: &ClientInfo,
365    sign_in_token: &str,
366    session_key: &AesKey,
367    rest: &RestClient,
368) -> Result<(), OnePasswordError> {
369    let response: AuthComplete = rest
370        .post_encrypted_json(
371            AUTH_COMPLETE_ENDPOINT,
372            json!({
373                "client": client_info.client_id(),
374                "signInToken": sign_in_token,
375                "device": client_info.sso_complete_device_body(),
376            }),
377            session_key,
378        )
379        .await?;
380
381    if response.mfa.is_some() {
382        return Err(OnePasswordError::Unsupported(
383            "two-factor authentication after SSO is not supported".into(),
384        ));
385    }
386    Ok(())
387}
388
389/// The form-decoded value of the first parameter called `name` in the query or the fragment of
390/// `url`, as OAuth encodes the redirect. A missing or empty value is `None`.
391fn url_parameter(url: &str, name: &str) -> Option<String> {
392    let (url, fragment) = url.split_once('#').unwrap_or((url, ""));
393    let query = url.split_once('?').map_or("", |(_, query)| query);
394
395    [query, fragment]
396        .into_iter()
397        .flat_map(|part| form_urlencoded::parse(part.as_bytes()))
398        .find(|(key, _)| key == name)
399        .map(|(_, value)| value.into_owned())
400        .filter(|value| !value.is_empty())
401}
402
403#[cfg(test)]
404mod tests {
405    use std::sync::Mutex;
406
407    use async_trait::async_trait;
408    use serde_json::{Value, json};
409    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
410
411    use super::{
412        test_support::{SIGN_IN_TOKEN, rest_client, vectors},
413        ui::SsoEnrollmentContext,
414        *,
415    };
416
417    const LOGIN_URL: &str = "https://idp.example.com/authorize?client_id=abc&response_type=code\
418        &redirect_uri=https%3A%2F%2Facme.1password.com%2Fsso%2Foidc%2Fredirect%2F&scope=openid";
419    const REDIRECT_TO: &str = "https://acme.1password.com/sso/oidc/redirect/";
420
421    /// Plays the identity provider step, and records what it was asked to open.
422    struct ScriptedUi {
423        result: Mutex<Option<SsoLoginResult>>,
424        calls: Mutex<Vec<(String, String)>>,
425    }
426
427    impl ScriptedUi {
428        fn new(result: SsoLoginResult) -> ScriptedUi {
429            ScriptedUi {
430                result: Mutex::new(Some(result)),
431                calls: Mutex::new(Vec::new()),
432            }
433        }
434
435        fn redirected_to(url: &str) -> ScriptedUi {
436            ScriptedUi::new(SsoLoginResult::RedirectedTo(url.into()))
437        }
438
439        fn calls(&self) -> Vec<(String, String)> {
440            self.calls.lock().expect("not poisoned").clone()
441        }
442    }
443
444    #[async_trait]
445    impl SsoUi for ScriptedUi {
446        async fn perform_sso_login(
447            &self,
448            sso_login_url: &str,
449            redirect_to: &str,
450        ) -> SsoLoginResult {
451            self.calls
452                .lock()
453                .expect("not poisoned")
454                .push((sso_login_url.into(), redirect_to.into()));
455            self.result
456                .lock()
457                .expect("not poisoned")
458                .take()
459                .expect("the login runs once")
460        }
461
462        async fn begin_sso_enrollment(&self) -> Box<dyn SsoEnrollmentContext> {
463            unreachable!("the login steps never enroll")
464        }
465    }
466
467    fn login_info(user_uuid: Option<&str>, sign_in_address: Option<&str>) -> LoginInfo {
468        serde_json::from_value(json!({
469            "userUuid": user_uuid,
470            "signInAddress": sign_in_address,
471            "authMethods": [
472                {"type": "PASSWORD+SK"},
473                {"type": "OIDC", "provider": "Acme", "iconUrl": null},
474            ],
475        }))
476        .expect("valid login info")
477    }
478
479    fn sso_account() -> SsoAccount {
480        require_sso(
481            &login_info(Some("USERUUID"), Some("https://acme.1password.com")),
482            "[email protected]",
483        )
484        .expect("an SSO account")
485    }
486
487    fn client_info() -> ClientInfo {
488        ClientInfo::for_desktop("device-uuid")
489    }
490
491    /// What `v3/auth/sso/oidc/verify` answers, `state` being what the server says about the device.
492    fn verify_response(state: &str) -> Value {
493        json!({
494            "type": state,
495            "user": {
496                "sessionUuid": "SESSIONUUID",
497                "accountUuid": "ACCOUNTUUID",
498                "userUuid": "USERUUID",
499                "email": "[email protected]",
500            },
501            "ssoAuth": {
502                "signInTokenDetails": {
503                    "token": SIGN_IN_TOKEN,
504                    "publicKey": "PUBLICKEY",
505                    "exp": "2030-01-01T00:00:00Z",
506                },
507            },
508        })
509    }
510
511    fn session(state: &str) -> SsoSession {
512        serde_json::from_value(verify_response(state)).expect("a valid session")
513    }
514
515    #[test]
516    fn requires_the_oidc_method() {
517        let mut info = login_info(Some("USERUUID"), Some("https://acme.1password.com"));
518        info.auth_methods.retain(|method| method.kind != "OIDC");
519
520        let error = require_sso(&info, "[email protected]").err();
521
522        assert!(
523            matches!(&error, Some(OnePasswordError::Unsupported(what)) if what.contains("[email protected]")),
524            "unexpected result: {error:?}"
525        );
526    }
527
528    #[test]
529    fn requires_the_user_uuid_and_the_sign_in_address() {
530        for (user_uuid, sign_in_address) in [
531            (None, Some("https://acme.1password.com")),
532            (Some(""), Some("https://acme.1password.com")),
533            (Some("USERUUID"), None),
534            (Some("USERUUID"), Some("")),
535            (None, None),
536        ] {
537            let error =
538                require_sso(&login_info(user_uuid, sign_in_address), "[email protected]").err();
539
540            assert!(
541                matches!(error, Some(OnePasswordError::Internal(_))),
542                "unexpected result for {user_uuid:?} and {sign_in_address:?}: {error:?}"
543            );
544        }
545    }
546
547    #[test]
548    fn accepts_the_login_info_of_an_sso_account() {
549        let account = sso_account();
550
551        assert_eq!(account.user_uuid, "USERUUID");
552        assert_eq!(account.sign_in_address, "https://acme.1password.com");
553        assert_eq!(account.api_url(), "https://acme.1password.com/api");
554    }
555
556    #[test]
557    fn a_trailing_slash_is_not_doubled_in_the_api_url() {
558        let account = require_sso(
559            &login_info(Some("USERUUID"), Some("https://acme.1password.com/")),
560            "[email protected]",
561        )
562        .expect("an SSO account");
563
564        assert_eq!(account.sign_in_address, "https://acme.1password.com/");
565        assert_eq!(account.api_url(), "https://acme.1password.com/api");
566    }
567
568    #[test]
569    fn accepts_the_address_forms_of_a_web_server() {
570        for address in [
571            "https://acme.1password.com",
572            "https://acme.1password.com/",
573            "http://127.0.0.1:8080",
574            "https://sso.example.com:8443/base",
575        ] {
576            require_sso(
577                &login_info(Some("USERUUID"), Some(address)),
578                "[email protected]",
579            )
580            .unwrap_or_else(|error| panic!("'{address}' is rejected: {error}"));
581        }
582    }
583
584    #[test]
585    fn rejects_a_sign_in_address_that_is_not_a_web_address() {
586        for address in [
587            "acme.1password.com",
588            "//acme.1password.com",
589            "/api",
590            "ftp://acme.1password.com",
591            "file://acme.1password.com/api",
592            "javascript:alert(1)",
593            "mailto:[email protected]",
594            "https://",
595            "https://acme.1password.com?next=/",
596            "https://acme.1password.com/?",
597            "https://acme.1password.com#top",
598            "https://acme.1password.com/#",
599            "not a url",
600            "http://user:[email protected]?next=/",
601            "ftp://user:[email protected]",
602        ] {
603            let error = require_sso(
604                &login_info(Some("USERUUID"), Some(address)),
605                "[email protected]",
606            )
607            .err();
608
609            assert!(
610                matches!(&error, Some(OnePasswordError::Internal(what)) if !what.contains(address) && !what.contains("SECRET")),
611                "unexpected result for '{address}': {error:?}"
612            );
613        }
614    }
615
616    #[tokio::test]
617    async fn start_sends_only_the_user_uuid() {
618        let server = MockServer::start().await;
619        server
620            .register(
621                Mock::given(matchers::method("POST"))
622                    .and(matchers::path("/api/v3/auth/sso/oidc/start"))
623                    .and(matchers::body_json(json!({"userUuid": "USERUUID"})))
624                    .respond_with(
625                        ResponseTemplate::new(200)
626                            .set_body_json(json!({"authRedirect": LOGIN_URL})),
627                    )
628                    .expect(1),
629            )
630            .await;
631
632        let url = start_sso_login(&sso_account(), &rest_client(&server))
633            .await
634            .expect("starts");
635
636        assert_eq!(url, LOGIN_URL);
637        server.verify().await;
638    }
639
640    #[tokio::test]
641    async fn start_returns_a_server_error() {
642        let server = MockServer::start().await;
643        server
644            .register(
645                Mock::given(matchers::path("/api/v3/auth/sso/oidc/start")).respond_with(
646                    ResponseTemplate::new(400)
647                        .set_body_json(json!({"errorCode": 117, "errorMessage": "gone"})),
648                ),
649            )
650            .await;
651
652        let error = start_sso_login(&sso_account(), &rest_client(&server))
653            .await
654            .err();
655
656        assert!(matches!(error, Some(OnePasswordError::NotFound)));
657    }
658
659    #[tokio::test]
660    async fn perform_returns_the_code_and_state_of_the_c_example() {
661        let redirect = vectors().redirect;
662        let ui = ScriptedUi::redirected_to(&redirect.url);
663
664        let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
665
666        assert_eq!(code, redirect.code);
667        assert_eq!(state, redirect.state);
668        assert_eq!(
669            ui.calls(),
670            [(LOGIN_URL.to_string(), REDIRECT_TO.to_string())]
671        );
672    }
673
674    #[tokio::test]
675    async fn perform_accepts_the_desktop_app_redirect_uri() {
676        let login_url = "https://idp.example.com/authorize?client_id=abc\
677            &redirect_uri=onepassword%3A%2F%2Fsso%2Foidc%2Fredirect&response_mode=query";
678        let ui = ScriptedUi::redirected_to("onepassword://sso/oidc/redirect?code=c0de&state=st4te");
679
680        let (code, state) = perform_sso_login(login_url, &ui).await.expect("performs");
681
682        assert_eq!((code.as_str(), state.as_str()), ("c0de", "st4te"));
683        assert_eq!(
684            ui.calls(),
685            [(
686                login_url.to_string(),
687                "onepassword://sso/oidc/redirect".to_string()
688            )]
689        );
690    }
691
692    #[tokio::test]
693    async fn perform_reads_the_parameters_from_the_query_too() {
694        let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb?code=c0de&state=st4te");
695
696        let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
697
698        assert_eq!((code.as_str(), state.as_str()), ("c0de", "st4te"));
699    }
700
701    #[tokio::test]
702    async fn perform_does_not_mistake_session_state_for_state() {
703        let ui = ScriptedUi::redirected_to(
704            "https://acme.1password.com/cb#session_state=WRONG&code=c0de&state=st4te",
705        );
706
707        let (_, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
708
709        assert_eq!(state, "st4te");
710    }
711
712    #[tokio::test]
713    async fn perform_requires_a_state_of_its_own() {
714        let ui = ScriptedUi::redirected_to(
715            "https://acme.1password.com/cb#code=c0de&session_state=WRONG",
716        );
717
718        let error = perform_sso_login(LOGIN_URL, &ui).await.err();
719
720        assert!(
721            matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("no state")),
722            "unexpected result: {error:?}"
723        );
724    }
725
726    #[tokio::test]
727    async fn perform_form_decodes_the_redirect() {
728        let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=a%2Bb&state=c+d");
729
730        let (code, state) = perform_sso_login(LOGIN_URL, &ui).await.expect("performs");
731
732        assert_eq!(code, "a+b");
733        assert_eq!(state, "c d");
734    }
735
736    #[tokio::test]
737    async fn perform_requires_a_code_and_a_state() {
738        for (url, missing) in [
739            ("https://acme.1password.com/cb#state=st4te", "code"),
740            ("https://acme.1password.com/cb#code=&state=st4te", "code"),
741            ("https://acme.1password.com/cb#code=c0de", "state"),
742            ("https://acme.1password.com/cb#code=c0de&state=", "state"),
743            ("https://acme.1password.com/cb", "code"),
744        ] {
745            let error = perform_sso_login(LOGIN_URL, &ScriptedUi::redirected_to(url))
746                .await
747                .err();
748
749            assert!(
750                matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains(&format!("no {missing}"))),
751                "unexpected result for '{url}': {error:?}"
752            );
753        }
754    }
755
756    #[tokio::test]
757    async fn perform_requires_a_redirect_uri_before_asking_the_user() {
758        for login_url in [
759            "https://idp.example.com/authorize?client_id=abc",
760            "https://idp.example.com/authorize?post_redirect_uri=https%3A%2F%2Fexample.com",
761            "https://idp.example.com/authorize?redirect_uri=",
762        ] {
763            let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
764
765            let error = perform_sso_login(login_url, &ui).await.err();
766
767            assert!(
768                matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("redirect_uri")),
769                "unexpected result for '{login_url}': {error:?}"
770            );
771            assert!(ui.calls().is_empty());
772        }
773    }
774
775    #[tokio::test]
776    async fn perform_rejects_a_redirect_uri_that_is_not_a_web_address() {
777        for redirect_uri in [
778            "javascript%3Aalert(1)",
779            "file%3A%2F%2F%2Fcb",
780            "ftp%3A%2F%2Facme.1password.com%2Fcb",
781            "%2F%2Facme.1password.com%2Fcb",
782            "acme.1password.com%2Fcb",
783            "onepassword%3A%2Fsso",
784        ] {
785            let login_url = format!(
786                "https://idp.example.com/authorize?redirect_uri={redirect_uri}&state=SECRET"
787            );
788            let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
789
790            let error = perform_sso_login(&login_url, &ui).await.err();
791
792            assert!(
793                matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains("redirect URI") && !what.contains("SECRET")),
794                "unexpected result for '{login_url}': {error:?}"
795            );
796            assert!(ui.calls().is_empty(), "'{login_url}' reached the user");
797        }
798    }
799
800    #[tokio::test]
801    async fn perform_rejects_a_login_url_that_is_not_a_web_address() {
802        for login_url in [
803            "javascript:alert(1)//?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
804            "file://idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
805            "ftp://idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
806            "file:///authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
807            "https://?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
808            "//idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
809            "idp.example.com/authorize?redirect_uri=https%3A%2F%2Facme.1password.com&state=SECRET",
810        ] {
811            let ui = ScriptedUi::redirected_to("https://acme.1password.com/cb#code=c&state=s");
812
813            let error = perform_sso_login(login_url, &ui).await.err();
814
815            assert!(
816                matches!(&error, Some(OnePasswordError::Internal(what)) if !what.contains("SECRET")),
817                "unexpected result for '{login_url}': {error:?}"
818            );
819            assert!(ui.calls().is_empty(), "'{login_url}' reached the user");
820        }
821    }
822
823    #[tokio::test]
824    async fn perform_reports_a_cancel_as_canceled() {
825        let ui = ScriptedUi::new(SsoLoginResult::Cancel);
826
827        let error = perform_sso_login(LOGIN_URL, &ui).await.err();
828
829        assert!(
830            matches!(error, Some(OnePasswordError::Canceled)),
831            "unexpected result: {error:?}"
832        );
833        assert_eq!(ui.calls().len(), 1);
834    }
835
836    #[test]
837    fn url_parameters_match_whole_keys_in_the_query_and_the_fragment() {
838        let url = "https://h/p?x_code=1&code=2&y=code=3#z=4&code=5";
839
840        assert_eq!(url_parameter(url, "code").as_deref(), Some("2"));
841        assert_eq!(url_parameter(url, "x_code").as_deref(), Some("1"));
842        assert_eq!(url_parameter(url, "z").as_deref(), Some("4"));
843        assert_eq!(url_parameter(url, "y").as_deref(), Some("code=3"));
844        assert_eq!(url_parameter(url, "cod"), None);
845        assert_eq!(
846            url_parameter("https://h/p#code=5", "code").as_deref(),
847            Some("5")
848        );
849        assert_eq!(
850            url_parameter("https://h/p?a&code=1", "code").as_deref(),
851            Some("1")
852        );
853    }
854
855    #[test]
856    fn url_parameters_are_form_decoded() {
857        assert_eq!(
858            url_parameter("u?a=%E2%9C%93+%2B%3D%26", "a").as_deref(),
859            Some("\u{2713} +=&")
860        );
861    }
862
863    #[tokio::test]
864    async fn verify_sends_the_code_state_and_device_descriptor() {
865        let server = MockServer::start().await;
866        server
867            .register(
868                Mock::given(matchers::method("POST"))
869                    .and(matchers::path("/api/v3/auth/sso/oidc/verify"))
870                    .and(matchers::body_json(json!({
871                        "code": "c0de",
872                        "state": "st4te",
873                        "device": client_info().sso_verify_device_body(),
874                    })))
875                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
876                        "type": "device_not_enrolled",
877                        "user": {
878                            "sessionUuid": "SESSIONUUID",
879                            "accountUuid": "ACCOUNTUUID",
880                            "userUuid": "USERUUID",
881                            "email": "[email protected]",
882                        },
883                        "ssoAuth": {
884                            "signInTokenDetails": {
885                                "token": "SIGN_IN_TOKEN",
886                                "publicKey": "PUBLICKEY",
887                                "exp": "2030-01-01T00:00:00Z",
888                            },
889                        },
890                    })))
891                    .expect(1),
892            )
893            .await;
894
895        let session = verify_sso_login("c0de", "st4te", &client_info(), &rest_client(&server))
896            .await
897            .expect("verifies");
898
899        assert_eq!(session.state, "device_not_enrolled");
900        assert_eq!(session.user.session_uuid, "SESSIONUUID");
901        assert_eq!(
902            session.sso_auth.sign_in_token_details.token.as_str(),
903            "SIGN_IN_TOKEN"
904        );
905        server.verify().await;
906    }
907
908    #[tokio::test]
909    async fn verify_returns_a_server_error() {
910        let server = MockServer::start().await;
911        server
912            .register(
913                Mock::given(matchers::path("/api/v3/auth/sso/oidc/verify")).respond_with(
914                    ResponseTemplate::new(400)
915                        .set_body_json(json!({"errorCode": 500, "errorMessage": "bad code"})),
916                ),
917            )
918            .await;
919
920        let error = verify_sso_login("c0de", "st4te", &client_info(), &rest_client(&server))
921            .await
922            .err();
923
924        assert!(
925            matches!(error, Some(OnePasswordError::Internal(what)) if what.contains("bad code"))
926        );
927    }
928
929    #[test]
930    fn the_sign_in_token_stays_out_of_debug_output() {
931        let output = format!("{:?}", session("device_not_enrolled"));
932
933        assert!(!output.contains("SIGN_IN_TOKEN"), "leaked: {output}");
934    }
935
936    #[test]
937    fn a_new_device_is_not_enrolled() {
938        assert!(session("device_not_enrolled").require_new_device().is_ok());
939    }
940
941    #[test]
942    fn any_other_session_state_is_an_error() {
943        for state in ["", "found", "Found", "device_deleted", "unknown"] {
944            let error = session(state).require_new_device().err();
945
946            assert!(
947                matches!(&error, Some(OnePasswordError::Internal(what)) if what.contains(state)),
948                "unexpected result for '{state}': {error:?}"
949            );
950        }
951    }
952}