| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| use maka_agent_facts::configuration::*; | |
| use maka_agent_facts::oauth::LoginStart; | |
| use maka_agent_facts::oauth::Target; | |
| use maka_agent_facts::provider::AuthenticationInput; | |
| use maka_agent_facts::provider::Credential; | |
| use maka_agent_facts::provider::Identity; | |
| use maka_config::{ConfigError, ConfigurationStore, oauth::enrollment::*}; | |
| use maka_event_log::root::{RootNamespaces, RootOwner}; | |
| use maka_plugin_core::scope::Scope; | |
| use serde_json::json; | |
| use sqlx::Connection; | |
| use std::{path::Path, sync::Arc}; | |
| fn namespaces(path: &Path) -> RootNamespaces { | |
| RootNamespaces { | |
| ownership: path.join("owners"), | |
| control: path.join("control"), | |
| } | |
| } | |
| async fn open(path: &Path, create: bool) -> Arc<ConfigurationStore> { | |
| let root = path.join("root"); | |
| let owner = if create { | |
| RootOwner::create(&root, &namespaces(path)) | |
| } else { | |
| RootOwner::open(&root, &namespaces(path)) | |
| } | |
| .unwrap(); | |
| Arc::new(ConfigurationStore::for_root(Arc::new(owner)).await.unwrap()) | |
| } | |
| async fn prepare(store: &Arc<ConfigurationStore>, input: LoginStart) -> PreparedLogin { | |
| let LoginPreparation::Ready(ticket) = store.prepare_oauth_login(input).await.unwrap() else { | |
| panic!("expected unpublished login ticket") | |
| }; | |
| *ticket | |
| } | |
| fn credential(secret: &str) -> Credential { | |
| Credential { | |
| secret: secret.into(), | |
| refresh_at: Some(50_000), | |
| } | |
| } | |
| fn create(attempt: &str) -> LoginStart { | |
| LoginStart { | |
| expected_credential: None, | |
| attempt_id: attempt.into(), | |
| target: Target::Create { | |
| provider: Identity { | |
| package_id: "external.account".into(), | |
| entry_id: "account-entry".into(), | |
| scope: Scope::Profile, | |
| name: "account".into(), | |
| }, | |
| configuration: json!({"endpoint":"https://account.test/v1"}), | |
| slug: "chosen-account".into(), | |
| name: "My account".into(), | |
| }, | |
| authentication: AuthenticationInput { | |
| method: "account".into(), | |
| input: json!({"key":"private-input-a"}), | |
| }, | |
| } | |
| } | |
| async fn existing(store: &ConfigurationStore, attempt: &str, id: &str) -> LoginStart { | |
| let row = store | |
| .catalog() | |
| .await | |
| .unwrap() | |
| .connections | |
| .into_iter() | |
| .find(|r| r.connection_id == id) | |
| .unwrap(); | |
| LoginStart { | |
| expected_credential: match store | |
| .credential_status(CredentialLocator::Connection { | |
| connection_id: row.connection_id.clone(), | |
| kind: ConnectionCredentialKind::Provider, | |
| }) | |
| .await | |
| .unwrap() | |
| { | |
| maka_agent_facts::configuration::CredentialVaultQueryResult::Status { status } => { | |
| status.basis() | |
| } | |
| _ => panic!("connection status"), | |
| }, | |
| attempt_id: attempt.into(), | |
| target: Target::Existing { | |
| expected: ConnectionCredentialTarget { | |
| connection_id: row.connection_id, | |
| revision: row.revision, | |
| slug: row.slug, | |
| provider: row.provider, | |
| configuration: row.configuration.clone(), | |
| }, | |
| configuration: row.configuration, | |
| }, | |
| authentication: create(attempt).authentication, | |
| } | |
| } | |
| async fn sql(path: &Path) -> sqlx::SqliteConnection { | |
| sqlx::SqliteConnection::connect_with( | |
| &sqlx::sqlite::SqliteConnectOptions::new() | |
| .filename(path.join("root/configuration-rust.sqlite")), | |
| ) | |
| .await | |
| .unwrap() | |
| } | |
| #[tokio::test] | |
| async fn provider_login_explanation_survives_restart_without_creating_a_connection() { | |
| use maka_agent_facts::oauth::Failure; | |
| use maka_agent_facts::oauth::Phase; | |
| let temp = tempfile::tempdir().unwrap(); | |
| let store = open(temp.path(), true).await; | |
| let input = create("rejected-login"); | |
| let ticket = prepare(&store, input.clone()).await; | |
| assert!(ticket.claim().await.unwrap()); | |
| let phase = Phase::Failed { | |
| failure: Failure::Provider { | |
| message: "Example provider: account approval required (HTTP 403).".into(), | |
| }, | |
| }; | |
| assert_eq!(ticket.finish_failure(phase.clone()).await.unwrap(), phase); | |
| drop(ticket); | |
| Arc::try_unwrap(store).ok().unwrap().close().await.unwrap(); | |
| let store = open(temp.path(), false).await; | |
| let LoginPreparation::Finished(receipt) = store.prepare_oauth_login(input).await.unwrap() | |
| else { | |
| panic!("a completed rejection must not start authorization again"); | |
| }; | |
| assert_eq!(receipt.phase, phase); | |
| assert!(store.catalog().await.unwrap().connections.is_empty()); | |
| Arc::try_unwrap(store).ok().unwrap().close().await.unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn authentication_receipts_bind_inputs_and_survive_reopen_without_publishing_drafts() { | |
| let temp = tempfile::tempdir().unwrap(); | |
| let store = open(temp.path(), true).await; | |
| let input = create("account-login"); | |
| let mut ticket = prepare(&store, input.clone()).await; | |
| ticket | |
| .configure_creation(json!({"endpoint":"https://account.test/v1", "oneTimeDefault":true})) | |
| .unwrap(); | |
| let duplicate = prepare(&store, input.clone()).await; | |
| let collision = prepare(&store, create("slug-collision")).await; | |
| assert!(store.catalog().await.unwrap().connections.is_empty()); | |
| assert!( | |
| store | |
| .oauth_login_receipt(input.attempt_id.clone()) | |
| .await | |
| .unwrap() | |
| .is_none() | |
| ); | |
| assert!(ticket.claim().await.unwrap()); | |
| assert!( | |
| !duplicate.claim().await.unwrap(), | |
| "a claim never permits a second exchange" | |
| ); | |
| assert!(matches!( | |
| store.prepare_oauth_login(input.clone()).await.unwrap(), | |
| LoginPreparation::OutcomeUnknown(_) | |
| )); | |
| let LoginCompletion::Committed(receipt) = | |
| ticket.complete(credential("grant-a"), 1).await.unwrap() | |
| else { | |
| panic!("atomic enrollment"); | |
| }; | |
| // A lost commit reply retries persistence, never the grant exchange. | |
| assert_eq!( | |
| ticket.complete(credential("grant-a"), 1).await.unwrap(), | |
| LoginCompletion::Committed(receipt.clone()) | |
| ); | |
| assert_eq!( | |
| duplicate | |
| .complete(credential("duplicate"), 1) | |
| .await | |
| .unwrap(), | |
| LoginCompletion::AttemptConflict | |
| ); | |
| assert_eq!( | |
| collision | |
| .complete(credential("collision"), 1) | |
| .await | |
| .unwrap(), | |
| LoginCompletion::SlugTaken | |
| ); | |
| let snapshot = store.catalog().await.unwrap(); | |
| assert_eq!(snapshot.connections[0], *ticket.connection()); | |
| assert_eq!( | |
| snapshot.connections[0].provider, | |
| receipt.connection.provider | |
| ); | |
| assert_eq!(snapshot.connections[0].slug, "chosen-account"); | |
| assert_eq!(snapshot.connections[0].name, "My account"); | |
| assert_eq!( | |
| snapshot.connections[0].configuration["oneTimeDefault"], | |
| true | |
| ); | |
| drop((ticket, duplicate, collision)); | |
| store.shutdown().await.unwrap(); | |
| drop(store); | |
| let store = open(temp.path(), false).await; | |
| assert!( | |
| matches!(store.prepare_oauth_login(input.clone()).await.unwrap(), | |
| LoginPreparation::Finished(saved) if saved == receipt) | |
| ); | |
| let mut different = input.clone(); | |
| different.authentication.input = json!({"key":"private-input-b"}); | |
| assert!(matches!( | |
| store.prepare_oauth_login(different).await.unwrap(), | |
| LoginPreparation::Rejected(LoginRejection::AttemptConflict) | |
| )); | |
| let mut different = input; | |
| different.authentication.method = "another-account".into(); | |
| assert!(matches!( | |
| store.prepare_oauth_login(different).await.unwrap(), | |
| LoginPreparation::Rejected(LoginRejection::AttemptConflict) | |
| )); | |
| assert_eq!(store.catalog().await.unwrap(), snapshot); | |
| let mut db = sql(temp.path()).await; | |
| let (target, method, digest): (String, String, String) = | |
| sqlx::query_as("SELECT target, method, request_fingerprint FROM oauth_login_receipts WHERE attempt_id = 'account-login'") | |
| .fetch_one(&mut db) | |
| .await | |
| .unwrap(); | |
| assert!(!format!("{target}{method}{digest}").contains("private-input")); | |
| db.close().await.unwrap(); | |
| store.shutdown().await.unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn repeated_authentication_retains_original_receipts_across_restart() { | |
| let temp = tempfile::tempdir().unwrap(); | |
| let store = open(temp.path(), true).await; | |
| let original = create("initial"); | |
| let ticket = prepare(&store, original.clone()).await; | |
| ticket | |
| .complete(credential("initial-grant"), 1) | |
| .await | |
| .unwrap(); | |
| let id = ticket.identity().connection_id.clone(); | |
| let uncertain = existing(&store, "uncertain-exchange", &id).await; | |
| let pending = prepare(&store, uncertain.clone()).await; | |
| assert!(pending.claim().await.unwrap()); | |
| drop(pending); | |
| let cancelled = existing(&store, "cancelled-login", &id).await; | |
| let pending = prepare(&store, cancelled.clone()).await; | |
| assert!(pending.claim().await.unwrap()); | |
| assert_eq!( | |
| pending | |
| .finish_failure(maka_agent_facts::oauth::Phase::Cancelled) | |
| .await | |
| .unwrap(), | |
| maka_agent_facts::oauth::Phase::Cancelled | |
| ); | |
| assert!( | |
| matches!(store.prepare_oauth_login(cancelled).await.unwrap(), | |
| LoginPreparation::Finished(saved) if saved.phase == maka_agent_facts::oauth::Phase::Cancelled) | |
| ); | |
| drop(pending); | |
| let mut last = None; | |
| for n in 0..257 { | |
| let input = existing(&store, &format!("retained-{n}"), &id).await; | |
| let ticket = prepare(&store, input.clone()).await; | |
| assert!(matches!( | |
| ticket | |
| .complete(credential("replacement-grant"), n) | |
| .await | |
| .unwrap(), | |
| LoginCompletion::Committed(_) | |
| )); | |
| last = Some(input); | |
| } | |
| let first = store | |
| .oauth_login_receipt("retained-0".into()) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(first.connection.connection_id, id); | |
| assert_eq!(first.phase, maka_agent_facts::oauth::Phase::Authenticated); | |
| assert!( | |
| store | |
| .oauth_login_receipt("retained-1".into()) | |
| .await | |
| .unwrap() | |
| .is_some() | |
| ); | |
| let mut db = sql(temp.path()).await; | |
| let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_login_receipts") | |
| .fetch_one(&mut db) | |
| .await | |
| .unwrap(); | |
| assert_eq!( | |
| count, 260, | |
| "completed receipts and unresolved grant identities remain durable" | |
| ); | |
| db.close().await.unwrap(); | |
| drop(ticket); | |
| store.shutdown().await.unwrap(); | |
| drop(store); | |
| let store = open(temp.path(), false).await; | |
| let LoginPreparation::Finished(saved) = store.prepare_oauth_login(original).await.unwrap() | |
| else { | |
| panic!("original completed login receipt was lost"); | |
| }; | |
| assert_eq!(saved.connection.connection_id, id); | |
| assert_eq!(saved.phase, maka_agent_facts::oauth::Phase::Authenticated); | |
| assert!( | |
| matches!( | |
| store.prepare_oauth_login(uncertain).await.unwrap(), | |
| LoginPreparation::OutcomeUnknown(_) | |
| ), | |
| "restart must not retry an uncertain authentication callback" | |
| ); | |
| assert!(matches!( | |
| store.prepare_oauth_login(last.unwrap()).await.unwrap(), | |
| LoginPreparation::Finished(_) | |
| )); | |
| assert_eq!(store.catalog().await.unwrap().connections.len(), 1); | |
| store.shutdown().await.unwrap(); | |
| } | |
| #[path = "oauth_login/discovery.rs"] | |
| mod discovery; | |
| #[path = "oauth_login/recovery.rs"] | |
| mod recovery; | |
| #[path = "oauth_login/inventory.rs"] | |
| mod inventory; | |
| #[tokio::test] | |
| async fn enrollment_cas_rejects_refresh_and_logout_that_leave_the_connection_unchanged() { | |
| let temp = tempfile::tempdir().unwrap(); | |
| let store = open(temp.path(), true).await; | |
| let first = prepare(&store, create("original")).await; | |
| assert!(first.claim().await.unwrap()); | |
| assert!(matches!( | |
| first.complete(credential("original-key"), 1).await.unwrap(), | |
| LoginCompletion::Committed(_) | |
| )); | |
| let id = first.identity().connection_id.clone(); | |
| let observed = existing(&store, "stale-observation", &id).await; | |
| let Target::Existing { | |
| expected: connection, | |
| .. | |
| } = &observed.target | |
| else { | |
| unreachable!() | |
| }; | |
| let connection = connection.clone(); | |
| let before = store.catalog().await.unwrap(); | |
| let current = store | |
| .provider_credential(connection.clone()) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| let rotated = current | |
| .commit_refresh(credential("rotated-key"), 2) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(store.catalog().await.unwrap(), before); | |
| assert!(matches!( | |
| store.prepare_oauth_login(observed.clone()).await.unwrap(), | |
| LoginPreparation::Rejected(LoginRejection::CredentialChanged) | |
| )); | |
| assert!( | |
| store | |
| .oauth_login_receipt(observed.attempt_id.clone()) | |
| .await | |
| .unwrap() | |
| .is_none() | |
| ); | |
| let after_refresh = existing(&store, "stale-after-logout", &id).await; | |
| assert_eq!(after_refresh.expected_credential.as_ref(), Some(&rotated)); | |
| assert!(matches!( | |
| store | |
| .delete_credential(DeleteCredentialInput { | |
| expected: rotated, | |
| expected_connection: Some(connection.clone()) | |
| }) | |
| .await | |
| .unwrap(), | |
| CredentialMutationResult::Committed { .. } | |
| )); | |
| assert_eq!( | |
| store.catalog().await.unwrap(), | |
| before, | |
| "logout without a saved test does not change the connection" | |
| ); | |
| assert!(matches!( | |
| store.prepare_oauth_login(after_refresh).await.unwrap(), | |
| LoginPreparation::Rejected(LoginRejection::CredentialChanged) | |
| )); | |
| let mut fresh = observed; | |
| fresh.attempt_id = "fresh-after-logout".into(); | |
| fresh.expected_credential = None; | |
| let ready = prepare(&store, fresh).await; | |
| assert!(ready.claim().await.unwrap()); | |
| assert!(matches!( | |
| ready.complete(credential("fresh-key"), 3).await.unwrap(), | |
| LoginCompletion::Committed(_) | |
| )); | |
| let row = ready.connection(); | |
| let value = store | |
| .provider_credential(ConnectionCredentialTarget { | |
| connection_id: row.connection_id.clone(), | |
| revision: row.revision, | |
| slug: row.slug.clone(), | |
| provider: row.provider.clone(), | |
| configuration: row.configuration.clone(), | |
| }) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(value.credential().secret, "fresh-key"); | |
| assert_ne!(value.basis().credential_id, current.basis().credential_id); | |
| store.shutdown().await.unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn credential_delete_cas_rejects_an_edited_connection_with_the_same_vault_generation() { | |
| let temp = tempfile::tempdir().unwrap(); | |
| let store = open(temp.path(), true).await; | |
| let first = prepare(&store, create("delete-original")).await; | |
| assert!(first.claim().await.unwrap()); | |
| assert!(matches!( | |
| first | |
| .complete(credential("keep-this-key"), 1) | |
| .await | |
| .unwrap(), | |
| LoginCompletion::Committed(_) | |
| )); | |
| let observed = existing( | |
| &store, | |
| "delete-observation", | |
| &first.identity().connection_id, | |
| ) | |
| .await; | |
| let Target::Existing { | |
| expected: connection, | |
| .. | |
| } = observed.target | |
| else { | |
| unreachable!() | |
| }; | |
| let old = observed.expected_credential.unwrap(); | |
| let row = store.catalog().await.unwrap().connections.remove(0); | |
| let result = store | |
| .update_connection(UpdateCatalogConnectionInput { | |
| expected: ConnectionVersionBasis { | |
| connection_id: row.connection_id.clone(), | |
| revision: row.revision, | |
| }, | |
| changes: ConnectionCatalogEntryUpdate { | |
| name: Some(row.name), | |
| configuration: Some(json!({"endpoint":"https://changed.test"})), | |
| enabled: Some(row.enabled), | |
| enabled_model_ids: Some(row.enabled_model_ids), | |
| model_overrides: Patch::Keep, | |
| request_body_overlay: Patch::Keep, | |
| }, | |
| }) | |
| .await | |
| .unwrap(); | |
| assert!(matches!(result, CatalogMutationResult::Committed { .. })); | |
| assert!(matches!( | |
| store | |
| .delete_credential(DeleteCredentialInput { | |
| expected: old.clone(), | |
| expected_connection: Some(connection) | |
| }) | |
| .await | |
| .unwrap(), | |
| CredentialMutationResult::ConnectionStale { .. } | |
| )); | |
| let CredentialVaultQueryResult::Status { status } = | |
| store.credential_status(old.locator.clone()).await.unwrap() | |
| else { | |
| unreachable!() | |
| }; | |
| assert_eq!( | |
| status.basis(), | |
| Some(old.clone()), | |
| "stale delete must leave the actual generation untouched" | |
| ); | |
| let row = store.catalog().await.unwrap().connections.remove(0); | |
| let current = ConnectionCredentialTarget { | |
| connection_id: row.connection_id, | |
| revision: row.revision, | |
| slug: row.slug, | |
| provider: row.provider, | |
| configuration: row.configuration, | |
| }; | |
| assert!(matches!( | |
| store | |
| .delete_credential(DeleteCredentialInput { | |
| expected: old, | |
| expected_connection: Some(current) | |
| }) | |
| .await | |
| .unwrap(), | |
| CredentialMutationResult::Committed { .. } | |
| )); | |
| store.shutdown().await.unwrap(); | |
| } | |
| #[test] | |
| fn enrollment_wire_requires_an_explicit_generation_and_rejects_cross_connection_basis() { | |
| let original = create("wire-input"); | |
| let mut value = serde_json::to_value(&original).unwrap(); | |
| assert!(serde_json::from_value::<LoginStart>(value.clone()).is_ok()); | |
| value.as_object_mut().unwrap().remove("expectedCredential"); | |
| assert!(serde_json::from_value::<LoginStart>(value).is_err()); | |
| let mut invalid = original.clone(); | |
| invalid.expected_credential = Some(CredentialVersionBasis { | |
| locator: CredentialLocator::Connection { | |
| connection_id: "00000000-0000-4000-8000-000000000001".into(), | |
| kind: ConnectionCredentialKind::Provider, | |
| }, | |
| credential_id: "00000000-0000-4000-8000-000000000002".into(), | |
| revision: 1, | |
| }); | |
| assert!(invalid.validate().is_err(), "creation must expect absence"); | |
| let Target::Create { | |
| provider, | |
| configuration, | |
| slug, | |
| .. | |
| } = original.target | |
| else { | |
| unreachable!() | |
| }; | |
| invalid.target = Target::Existing { | |
| expected: ConnectionCredentialTarget { | |
| connection_id: "00000000-0000-4000-8000-000000000003".into(), | |
| revision: 1, | |
| slug, | |
| provider, | |
| configuration: configuration.clone(), | |
| }, | |
| configuration, | |
| }; | |
| assert!( | |
| invalid.validate().is_err(), | |
| "observed generation cannot belong to another recipient" | |
| ); | |
| } | |
| #[test] | |
| fn credential_delete_wire_requires_connection_basis_and_distinguishes_network_credentials() { | |
| let network = DeleteCredentialInput { | |
| expected: CredentialVersionBasis { | |
| locator: CredentialLocator::NetworkProxy { | |
| kind: PasswordKind::Password, | |
| }, | |
| credential_id: "00000000-0000-4000-8000-000000000001".into(), | |
| revision: 1, | |
| }, | |
| expected_connection: None, | |
| }; | |
| assert!(validation::delete_credential(&network).is_ok()); | |
| let mut value = serde_json::to_value(&network).unwrap(); | |
| assert!(serde_json::from_value::<DeleteCredentialInput>(value.clone()).is_ok()); | |
| value.as_object_mut().unwrap().remove("expectedConnection"); | |
| assert!(serde_json::from_value::<DeleteCredentialInput>(value).is_err()); | |
| let mut invalid = network; | |
| invalid.expected.locator = CredentialLocator::Connection { | |
| connection_id: "00000000-0000-4000-8000-000000000002".into(), | |
| kind: ConnectionCredentialKind::Provider, | |
| }; | |
| assert!(validation::delete_credential(&invalid).is_err()); | |
| } |