blob: cd82ee1b2a46463ad84411b1fe8aa61c970011fb [file]
/*
* 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 crate::{connections, contract::*};
use futures_util::future::BoxFuture;
use maka_agent_facts::oauth::Failure;
use maka_agent_facts::oauth::LoginStart;
use maka_agent_facts::oauth::Phase;
use maka_agent_facts::oauth::Target;
use maka_agent_facts::provider::AuthenticationInput;
use maka_agent_ports::model::Connect;
use maka_agent_ports::model::Error;
use maka_agent_ports::model::Socket;
use maka_agent_ports::model::Transport;
use maka_agent_ports::provider;
use maka_agent_ports::provider::authentication::Authenticate;
use maka_config::{
ConfigurationStore,
oauth::enrollment::{LoginCompletion, LoginPreparation, LoginRejection},
};
use maka_host_runtime::application::Context;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
/// API-key authentication only validates and wraps local input. Its Context does
/// not acquire network authority merely to satisfy the provider interface.
struct NoNetwork;
impl Transport for NoNetwork {
fn identity(&self) -> u64 {
0
}
fn request(
&self,
_: maka_capabilities::http::Request,
) -> BoxFuture<'_, Result<maka_capabilities::http::Response, Error>> {
Box::pin(async {
Err(Error::Adapter(
"settings_authentication_network_unavailable".into(),
))
})
}
fn connect(&self, _: Connect) -> BoxFuture<'_, Result<Arc<dyn Socket>, Error>> {
Box::pin(async {
Err(Error::Adapter(
"settings_authentication_network_unavailable".into(),
))
})
}
}
fn rejected(reason: CredentialRejection) -> Result<Value, String> {
connections::encode(CredentialSaveResult::Rejected { reason })
}
pub(crate) async fn save(
store: &Arc<ConfigurationStore>,
snapshot: Arc<maka_providers::catalog::Snapshot>,
input: Value,
context: Context,
) -> Result<Value, String> {
context.operation().ok_or("settings_mutation_required")?;
let Ok(request) = serde_json::from_value::<SaveCredential>(input) else {
return rejected(CredentialRejection::InvalidInput);
};
let Ok(definition) = connections::definition(&request.expected.provider, snapshot) else {
return rejected(CredentialRejection::ProviderUnavailable);
};
if !definition
.descriptor()
.authentication
.iter()
.any(|method| method.id == "api-key" && !method.interactive)
{
return rejected(CredentialRejection::AuthenticationUnavailable);
}
let Some(resource) = context.attachments().get("secret") else {
return rejected(CredentialRejection::InvalidSecret);
};
let bytes = resource.bytes();
if bytes == 0 {
return rejected(CredentialRejection::InvalidSecret);
}
let Ok(secret) = resource.read(0..bytes) else {
return rejected(CredentialRejection::SecretUnavailable);
};
let Ok(secret) = String::from_utf8(secret) else {
return rejected(CredentialRejection::InvalidSecret);
};
if secret.len() != bytes || secret.chars().any(char::is_control) {
return rejected(CredentialRejection::InvalidSecret);
}
enroll(
store,
definition,
request,
AuthenticationInput {
method: "api-key".into(),
input: json!({"apiKey":secret}),
},
context,
false,
)
.await
}
struct LoginInteraction(Context);
impl provider::authentication::Interaction for LoginInteraction {
fn open_external(
&self,
url: String,
user_code: Option<String>,
) -> BoxFuture<'_, Result<(), provider::Error>> {
Box::pin(async move {
let user_code = user_code.unwrap_or_default();
if url != "https://auth.openai.com/codex/device" {
return Err(provider::Error::Invalid("invalid login prompt".into()));
}
self.0
.emit(
serde_json::to_value(LoginPrompt { url, user_code })
.map_err(|_| provider::Error::Unavailable)?,
)
.await
.map_err(|_| provider::Error::Cancelled)
})
}
}
pub(crate) async fn login(
store: &Arc<ConfigurationStore>,
snapshot: Arc<maka_providers::catalog::Snapshot>,
input: Value,
context: Context,
) -> Result<Value, String> {
context.operation().ok_or("settings_mutation_required")?;
let Ok(input) = serde_json::from_value::<LoginCredential>(input) else {
return rejected(CredentialRejection::InvalidInput);
};
let Ok(definition) = connections::definition(&input.expected.provider, snapshot) else {
return rejected(CredentialRejection::ProviderUnavailable);
};
if !definition
.descriptor()
.authentication
.iter()
.any(|method| method.id == input.method && method.interactive)
{
return rejected(CredentialRejection::AuthenticationUnavailable);
}
enroll(
store,
definition,
SaveCredential {
expected: input.expected,
expected_credential: input.expected_credential,
},
AuthenticationInput {
method: input.method,
input: json!({}),
},
context,
true,
)
.await
}
async fn enroll(
store: &Arc<ConfigurationStore>,
definition: provider::Definition,
request: SaveCredential,
authentication: AuthenticationInput,
context: Context,
interactive: bool,
) -> Result<Value, String> {
let operation = context.operation().ok_or("settings_mutation_required")?;
let attempt_id = format!(
"settings-{}",
hex::encode(Sha256::digest(
serde_json::to_vec(operation).map_err(|_| "settings_operation_invalid")?
))
);
let input = LoginStart {
attempt_id,
target: Target::Existing {
expected: request.expected.clone(),
configuration: request.expected.configuration.clone(),
},
expected_credential: request.expected_credential,
authentication,
};
if input.validate().is_err() {
return rejected(CredentialRejection::InvalidInput);
}
let authentication = input.authentication.clone();
let prepared = store
.prepare_oauth_login(input)
.await
.map_err(|_| "settings_effect_unknown")?;
let ticket = match prepared {
LoginPreparation::Ready(ticket) => ticket,
LoginPreparation::Finished(receipt) if receipt.phase == Phase::Authenticated => {
return connections::encode(CredentialSaveResult::Saved {
connection: receipt.connection,
});
}
LoginPreparation::Finished(_) | LoginPreparation::OutcomeUnknown(_) => {
return Err("settings_effect_unknown".into());
}
LoginPreparation::Rejected(reason) => {
return rejected(match reason {
LoginRejection::AttemptConflict => CredentialRejection::AttemptConflict,
LoginRejection::ConnectionNotFound | LoginRejection::ConnectionChanged => {
CredentialRejection::ConnectionChanged
}
LoginRejection::CredentialChanged => CredentialRejection::CredentialChanged,
LoginRejection::SlugTaken => CredentialRejection::SlugTaken,
});
}
};
let transport = if interactive {
let network = ticket.network_configuration();
let policy =
maka_network::Policy::from_host_settings(&network.proxy, network.password.as_deref())
.map_err(|_| "settings_network_unavailable")?;
maka_model::provider_transport(&policy).map_err(|_| "settings_network_unavailable")?
} else {
Arc::new(NoNetwork) as Arc<dyn Transport>
};
let provider_context = provider::Context {
transport,
cancellation: context.scope().cancellation.clone(),
interaction: interactive.then(|| {
Arc::new(LoginInteraction(context)) as Arc<dyn provider::authentication::Interaction>
}),
};
authenticate(
ticket,
definition,
authentication,
provider_context,
interactive,
)
.await
}
async fn authenticate(
ticket: Box<maka_config::oauth::enrollment::PreparedLogin>,
definition: provider::Definition,
authentication: AuthenticationInput,
provider_context: provider::Context,
interactive: bool,
) -> Result<Value, String> {
if !ticket
.claim()
.await
.map_err(|_| "settings_effect_unknown")?
{
return Err("settings_effect_unknown".into());
}
let row = ticket.connection();
let credential = definition
.authenticate(
Authenticate {
connection: provider::Connection {
id: row.connection_id.clone(),
revision: row.revision,
configuration: row.configuration.clone(),
},
method: authentication.method,
input: authentication.input,
},
provider_context,
)
.await;
let credential = match credential {
Ok(credential) => credential,
Err(error) => {
if matches!(error, provider::Error::OutcomeUnknown) {
return Err("settings_effect_unknown".into());
}
ticket
.finish_failure(if matches!(error, provider::Error::Cancelled) {
Phase::Cancelled
} else {
Phase::Failed {
failure: Failure::AuthorizationFailed,
}
})
.await
.map_err(|_| "settings_effect_unknown")?;
if interactive {
let (reason, message) = match error {
provider::Error::Cancelled => (LoginFailure::Cancelled, String::new()),
provider::Error::AuthenticationRequired => {
(LoginFailure::Expired, String::new())
}
provider::Error::Transport(_) | provider::Error::Http(_) => {
(LoginFailure::Transport, String::new())
}
provider::Error::Rejected(message) => (LoginFailure::Authentication, message),
_ => (LoginFailure::Authentication, String::new()),
};
return connections::encode(CredentialLoginResult::Failed { reason, message });
}
return rejected(CredentialRejection::Authentication);
}
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| "settings_clock_unavailable")?
.as_secs();
match ticket
.complete(credential, now)
.await
.map_err(|_| "settings_effect_unknown")?
{
LoginCompletion::Committed(receipt) => connections::encode(CredentialSaveResult::Saved {
connection: receipt.connection,
}),
LoginCompletion::ConnectionChanged => rejected(CredentialRejection::ConnectionChanged),
LoginCompletion::CredentialChanged => rejected(CredentialRejection::CredentialChanged),
LoginCompletion::AttemptConflict => rejected(CredentialRejection::AttemptConflict),
LoginCompletion::SlugTaken => rejected(CredentialRejection::SlugTaken),
}
}
#[cfg(test)]
mod tests;