| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- //! DCR tests (todo 30) against a `wiremock` fake AS — never a live server.
- //!
- //! All tokens and secrets below are synthetic test values.
- use std::{fs, path::PathBuf};
- use provision::cli_driver::DEMO_POST_LOGOUT_URI;
- use provision::dcr::{
- ClientRecord, POST_LOGOUT_NOT_ATTACHED_NOTE, PostLogoutAttach, PostLogoutOutcome,
- ReconcileOutcome, RegisterInputs, Registration, load, persist, reconcile, register,
- };
- use wiremock::{
- Mock, MockServer, ResponseTemplate,
- matchers::{body_json, header, method, path},
- };
- const FIRST_IAT: &str = "iat-first-use-only";
- const FRESH_IAT: &str = "iat-fresh-second-use-only";
- fn inputs(issuer: &str) -> RegisterInputs {
- RegisterInputs {
- issuer: issuer.to_owned(),
- ..RegisterInputs::default()
- }
- }
- fn expected_body() -> serde_json::Value {
- serde_json::json!({
- "client_name": "d-OAuth-Client",
- "token_endpoint_auth_method": "client_secret_basic",
- "grant_types": ["authorization_code", "refresh_token"],
- "response_types": ["code"],
- "redirect_uris": ["http://127.0.0.1:3000/callback"],
- "scope": "demo.read",
- "dpop_bound_access_tokens": true,
- })
- }
- fn registration_response(server: &MockServer) -> serde_json::Value {
- serde_json::json!({
- "client_id": "client-1",
- "client_secret": "secret-1",
- "registration_access_token": "rat-1",
- "registration_client_uri": format!("{}/register/client-1", server.uri()),
- "redirect_uris": ["http://127.0.0.1:3000/callback"],
- "client_name": "d-OAuth-Client",
- "token_endpoint_auth_method": "client_secret_basic",
- "grant_types": ["authorization_code", "refresh_token"],
- "response_types": ["code"],
- "scope": "demo.read",
- "dpop_bound_access_tokens": true,
- "client_id_issued_at": 1_700_000_000,
- })
- }
- fn test_client() -> reqwest::Client {
- oauth_core::discovery::build_client().expect("test http client")
- }
- /// Install `fake-d-oauth.sh` executable in a fresh tempdir; return the tempdir,
- /// CLI path, and state dir.
- fn install_fake_cli() -> (tempfile::TempDir, PathBuf, PathBuf) {
- let dir = tempfile::tempdir().expect("tempdir");
- let cli = dir.path().join("fake-d-oauth.sh");
- let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fake-d-oauth.sh");
- let script = fs::read_to_string(&source).expect("read fixture");
- fs::write(&cli, script).expect("write fake cli");
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt as _;
- fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod fake cli");
- }
- let state = dir.path().join("state");
- fs::create_dir(&state).expect("state dir");
- (dir, cli, state)
- }
- /// Write a `.env` file pointing the fake CLI at `state`.
- fn write_attach_env(dir: &tempfile::TempDir, state: &std::path::Path) -> PathBuf {
- let path = dir.path().join("attach.env");
- fs::write(&path, format!("FAKE_STATE_DIR={}\n", state.display())).expect("write env file");
- path
- }
- /// Attach config wired to the fake CLI.
- fn attach_config(cli: PathBuf, env_file: PathBuf) -> PostLogoutAttach {
- PostLogoutAttach {
- cli: Some(cli),
- env_file: Some(env_file),
- uri: DEMO_POST_LOGOUT_URI.to_owned(),
- }
- }
- /// The argv line the fake CLI records for the attach step.
- fn attach_line(client_id: &str) -> String {
- format!(
- "client attach-redirect-uri --client-id {client_id} --uri {DEMO_POST_LOGOUT_URI} --kind post_logout"
- )
- }
- #[cfg(unix)]
- fn assert_0600(path: &std::path::Path) {
- use std::os::unix::fs::PermissionsExt as _;
- let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
- assert_eq!(mode, 0o600, "client record must be owner-only");
- }
- #[tokio::test]
- async fn dcr_registers_and_persists_0600() {
- let server = MockServer::start().await;
- Mock::given(method("POST"))
- .and(path("/register"))
- .and(header(
- "authorization",
- format!("Bearer {FIRST_IAT}").as_str(),
- ))
- .and(body_json(expected_body()))
- .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
- .mount(&server)
- .await;
- let dir = tempfile::tempdir().expect("tempdir");
- let out = dir.path().join("client.json");
- let record = register(&test_client(), &inputs(&server.uri()), FIRST_IAT)
- .await
- .expect("register");
- assert_eq!(record.client_id, "client-1");
- assert_eq!(
- record.registration_client_uri,
- format!("{}/register/client-1", server.uri())
- );
- persist(&record, &out).expect("persist");
- #[cfg(unix)]
- assert_0600(&out);
- let stored = load(&out).expect("reload");
- assert_eq!(stored, record);
- // The persisted file carries the minted credentials only — never the IAT.
- let raw = fs::read_to_string(&out).expect("read record");
- assert!(!raw.contains(FIRST_IAT));
- // The redacted Debug never leaks the secret or the management token.
- let debug = format!("{record:?}");
- assert!(!debug.contains("secret-1"));
- assert!(!debug.contains("rat-1"));
- }
- #[tokio::test]
- async fn dcr_rerun_with_stale_record_reregisters_with_fresh_iat() {
- let server = MockServer::start().await;
- let stale_uri = format!("{}/register/stale-client", server.uri());
- Mock::given(method("GET"))
- .and(path("/register/stale-client"))
- .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
- "error": "invalid_client",
- })))
- .mount(&server)
- .await;
- Mock::given(method("POST"))
- .and(path("/register"))
- .and(header(
- "authorization",
- format!("Bearer {FRESH_IAT}").as_str(),
- ))
- .and(body_json(expected_body()))
- .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
- .mount(&server)
- .await;
- let dir = tempfile::tempdir().expect("tempdir");
- let out = dir.path().join("client.json");
- persist(
- &ClientRecord {
- client_id: "stale-client".to_owned(),
- client_secret: "stale-secret".to_owned(),
- registration_access_token: "stale-rat".to_owned(),
- registration_client_uri: stale_uri,
- },
- &out,
- )
- .expect("persist stale record");
- let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
- assert_eq!(status, 401);
- Ok(FRESH_IAT.to_owned())
- })
- .await
- .expect("reconcile");
- assert_eq!(outcome, ReconcileOutcome::ReRegistered);
- let stored = load(&out).expect("reload");
- assert_eq!(stored.client_id, "client-1");
- assert!(
- !fs::read_to_string(&out)
- .expect("read")
- .contains("stale-secret")
- );
- #[cfg(unix)]
- assert_0600(&out);
- }
- #[tokio::test]
- async fn dcr_live_record_is_kept_without_reregistering() {
- let server = MockServer::start().await;
- Mock::given(method("GET"))
- .and(path("/register/client-1"))
- .and(header("authorization", "Bearer rat-1"))
- .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
- "client_id": "client-1",
- })))
- .mount(&server)
- .await;
- let dir = tempfile::tempdir().expect("tempdir");
- let out = dir.path().join("client.json");
- let before = ClientRecord {
- client_id: "client-1".to_owned(),
- client_secret: "secret-1".to_owned(),
- registration_access_token: "rat-1".to_owned(),
- registration_client_uri: format!("{}/register/client-1", server.uri()),
- };
- persist(&before, &out).expect("persist");
- let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |_| {
- panic!("a live record must not mint a fresh IAT");
- })
- .await
- .expect("reconcile");
- assert_eq!(outcome, ReconcileOutcome::Kept);
- assert_eq!(load(&out).expect("reload"), before);
- }
- #[tokio::test]
- async fn dcr_invalid_redirect_uri_prints_remediation() {
- let server = MockServer::start().await;
- Mock::given(method("POST"))
- .and(path("/register"))
- .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
- "error": "invalid_redirect_uri",
- "error_description": "redirect URI is not in the catalog",
- })))
- .mount(&server)
- .await;
- match register(&test_client(), &inputs(&server.uri()), FIRST_IAT).await {
- Err(provision::dcr::DcrError::Rejected {
- message,
- code,
- status,
- }) => {
- assert_eq!(status, 400);
- assert_eq!(code.as_deref(), Some("invalid_redirect_uri"));
- assert!(
- message.contains("dcr_redirect_policy=catalog"),
- "remediation: {message}"
- );
- assert!(
- message.contains("catalog_loopback"),
- "remediation: {message}"
- );
- }
- other => panic!("expected an invalid_redirect_uri rejection, got {other:?}"),
- }
- }
- #[tokio::test]
- async fn dcr_stale_record_without_iat_source_fails_typed() {
- let server = MockServer::start().await;
- Mock::given(method("GET"))
- .and(path("/register/stale-client"))
- .respond_with(ResponseTemplate::new(404))
- .mount(&server)
- .await;
- let dir = tempfile::tempdir().expect("tempdir");
- let out = dir.path().join("client.json");
- persist(
- &ClientRecord {
- client_id: "stale-client".to_owned(),
- client_secret: "stale-secret".to_owned(),
- registration_access_token: "stale-rat".to_owned(),
- registration_client_uri: format!("{}/register/stale-client", server.uri()),
- },
- &out,
- )
- .expect("persist stale record");
- match reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
- Err(provision::dcr::DcrError::FreshIatRequired { status })
- })
- .await
- {
- Err(provision::dcr::DcrError::FreshIatRequired { status }) => assert_eq!(status, 404),
- other => panic!("expected FreshIatRequired, got {other:?}"),
- }
- }
- #[tokio::test]
- async fn dcr_register_then_attaches_post_logout() {
- let server = MockServer::start().await;
- Mock::given(method("POST"))
- .and(path("/register"))
- .and(header(
- "authorization",
- format!("Bearer {FIRST_IAT}").as_str(),
- ))
- .and(body_json(expected_body()))
- .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
- .mount(&server)
- .await;
- let (dir, cli, state) = install_fake_cli();
- let env_file = write_attach_env(&dir, &state);
- let out = dir.path().join("client.json");
- let reg_inputs = inputs(&server.uri());
- let attach = attach_config(cli, env_file);
- let registration = Registration {
- inputs: ®_inputs,
- out: &out,
- attach: &attach,
- };
- let (record, outcome) = registration
- .register_and_attach(&test_client(), FIRST_IAT)
- .await
- .expect("register then attach");
- assert_eq!(record.client_id, "client-1");
- assert_eq!(outcome, PostLogoutOutcome::Attached);
- assert!(out.exists(), "the record must be persisted before attach");
- let log = fs::read_to_string(state.join("call.log")).expect("call log");
- assert_eq!(log.lines().last(), Some(attach_line("client-1").as_str()));
- let _ = dir;
- }
- #[tokio::test]
- async fn dcr_reconcile_reregister_then_attaches_post_logout() {
- let server = MockServer::start().await;
- let stale_uri = format!("{}/register/stale-client", server.uri());
- Mock::given(method("GET"))
- .and(path("/register/stale-client"))
- .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
- "error": "invalid_client",
- })))
- .mount(&server)
- .await;
- Mock::given(method("POST"))
- .and(path("/register"))
- .and(header(
- "authorization",
- format!("Bearer {FRESH_IAT}").as_str(),
- ))
- .and(body_json(expected_body()))
- .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
- .mount(&server)
- .await;
- let (dir, cli, state) = install_fake_cli();
- let env_file = write_attach_env(&dir, &state);
- let out = dir.path().join("client.json");
- persist(
- &ClientRecord {
- client_id: "stale-client".to_owned(),
- client_secret: "stale-secret".to_owned(),
- registration_access_token: "stale-rat".to_owned(),
- registration_client_uri: stale_uri,
- },
- &out,
- )
- .expect("persist stale record");
- let reg_inputs = inputs(&server.uri());
- let attach = attach_config(cli, env_file);
- let registration = Registration {
- inputs: ®_inputs,
- out: &out,
- attach: &attach,
- };
- let (outcome, attached) = registration
- .reconcile_and_attach(&test_client(), |status| {
- assert_eq!(status, 401);
- Ok(FRESH_IAT.to_owned())
- })
- .await
- .expect("reconcile then attach");
- assert_eq!(outcome, ReconcileOutcome::ReRegistered);
- assert_eq!(attached, Some(PostLogoutOutcome::Attached));
- assert_eq!(load(&out).expect("reload").client_id, "client-1");
- let log = fs::read_to_string(state.join("call.log")).expect("call log");
- assert!(
- log.lines().any(|line| line == attach_line("client-1")),
- "attach call missing from log: {log}"
- );
- let _ = dir;
- }
- #[tokio::test]
- async fn dcr_live_record_is_kept_without_attaching() {
- let server = MockServer::start().await;
- Mock::given(method("GET"))
- .and(path("/register/client-1"))
- .and(header("authorization", "Bearer rat-1"))
- .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
- "client_id": "client-1",
- })))
- .mount(&server)
- .await;
- let (dir, cli, state) = install_fake_cli();
- let env_file = write_attach_env(&dir, &state);
- let out = dir.path().join("client.json");
- persist(
- &ClientRecord {
- client_id: "client-1".to_owned(),
- client_secret: "secret-1".to_owned(),
- registration_access_token: "rat-1".to_owned(),
- registration_client_uri: format!("{}/register/client-1", server.uri()),
- },
- &out,
- )
- .expect("persist live record");
- let reg_inputs = inputs(&server.uri());
- let attach = attach_config(cli, env_file);
- let registration = Registration {
- inputs: ®_inputs,
- out: &out,
- attach: &attach,
- };
- let (outcome, attached) = registration
- .reconcile_and_attach(&test_client(), |_| {
- panic!("a live record must not mint a fresh IAT")
- })
- .await
- .expect("reconcile");
- assert_eq!(outcome, ReconcileOutcome::Kept);
- assert_eq!(attached, None);
- assert!(
- !state.join("call.log").exists(),
- "a live record must not trigger the attach step"
- );
- let _ = dir;
- }
- #[test]
- fn dcr_attach_skipped_without_cli_is_typed() {
- let skip = PostLogoutAttach {
- cli: None,
- env_file: None,
- uri: DEMO_POST_LOGOUT_URI.to_owned(),
- };
- assert_eq!(
- skip.apply("client-1").expect("skip is not an error"),
- PostLogoutOutcome::SkippedNoCli
- );
- // Only one of the two CLI paths is not enough to attach.
- let partial = PostLogoutAttach {
- cli: Some(PathBuf::from("/bin/false")),
- env_file: None,
- uri: DEMO_POST_LOGOUT_URI.to_owned(),
- };
- assert_eq!(
- partial.apply("client-1").expect("skip is not an error"),
- PostLogoutOutcome::SkippedNoCli
- );
- assert!(!POST_LOGOUT_NOT_ATTACHED_NOTE.is_empty());
- }
|