Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions crates/ironclaw_reborn_composition/src/auth_prompt.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
use async_trait::async_trait;
use ironclaw_auth::{
AuthProductError, AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl,
AuthProductError, AuthProviderId, CredentialAccountLabel, GOOGLE_PROVIDER_ID,
OAuthAuthorizationUrl,
};
use ironclaw_host_api::{RuntimeCredentialAccountSetup, UserId};
use ironclaw_product_adapters::{
AuthPromptChallengeKind, AuthPromptView, ProductAdapterError, RedactedString,
};
use ironclaw_turns::{TurnRunId, TurnScope};

const GOOGLE_OAUTH_REFRESH_GUIDANCE: &str = concat!(
"Sign in with Google using the authorization link. ",
"The link requests offline access and fresh consent so Ironclaw can renew the credential ",
"automatically. If the account still expires after about an hour, reconnect it through this ",
"link and approve the Google consent screen again."
);

/// Redacted view of a pending auth challenge used for product auth prompt
/// enrichment. Contains only data safe to surface over product adapters.
/// No raw secrets, PKCE verifiers, state hashes, or tokens.
Expand Down Expand Up @@ -135,10 +143,14 @@ pub(crate) async fn auth_prompt_view_for_blocked_auth(
authorization_url: None,
expires_at: None,
};
Ok(match challenge {
let view = match challenge {
Some(c) => c.enrich(base_view),
None => auth_prompt_from_credential_requirement(base_view, credential_requirements),
})
};
Ok(with_google_oauth_refresh_guidance(
view,
credential_requirements,
))
}

fn auth_prompt_from_credential_requirement(
Expand All @@ -161,3 +173,30 @@ fn auth_prompt_from_credential_requirement(
view.provider = Some(provider);
view
}

fn with_google_oauth_refresh_guidance(
mut view: AuthPromptView,
credential_requirements: &[ironclaw_host_api::RuntimeCredentialAuthRequirement],
) -> AuthPromptView {
let should_append = matches!(
credential_requirements,
[requirement] if is_google_oauth_requirement(requirement)
);
if should_append && !view.body.contains(GOOGLE_OAUTH_REFRESH_GUIDANCE) {
if !view.body.trim().is_empty() {
view.body.push_str("\n\n");
}
view.body.push_str(GOOGLE_OAUTH_REFRESH_GUIDANCE);
}
view
}

fn is_google_oauth_requirement(
requirement: &ironclaw_host_api::RuntimeCredentialAuthRequirement,
) -> bool {
requirement.provider.as_str() == GOOGLE_PROVIDER_ID
&& matches!(
requirement.setup,
RuntimeCredentialAccountSetup::OAuth { .. }
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,41 @@ async fn google_exchange_fails_closed_when_response_omits_scope() {
assert!(store.put_handles().is_empty());
}

#[tokio::test]
async fn google_exchange_allows_access_only_response_without_blocking_callback() {
let egress = Arc::new(RecordingEgress::ok(
br#"{"access_token":"access-token","scope":"gmail.readonly","expires_in":3600}"#.to_vec(),
));
let store = Arc::new(RecordingSecretStore::recording());
let client = HostOAuthProviderClient::new(
google_spec(),
egress,
store.clone(),
Arc::new(NoopObligationHandler),
OAuthClientId::new("google-client").unwrap(),
OAuthRedirectUri::new("https://app.example/callback").unwrap(),
)
.unwrap();

let exchange = client
.exchange_callback(
exchange_context(),
callback_request("google", "work google", &["gmail.readonly"]),
)
.await
.expect("missing refresh token should not block production callbacks");

assert!(exchange.refresh_secret.is_none());
assert_eq!(store.put_handles().len(), 1);
assert!(
store
.put_handles()
.iter()
.all(|handle| !handle.contains("refresh")),
"access-only exchanges should not fabricate a refresh secret"
);
}

#[tokio::test]
async fn exchange_maps_provider_5xx_to_retryable_backend_unavailable() {
let egress = Arc::new(RecordingEgress::with_status(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,92 @@ async fn webui_event_stream_does_not_downgrade_oauth_requirement_to_manual_token
);
}

#[tokio::test]
async fn webui_event_stream_omits_google_refresh_guidance_for_mixed_auth_prompt() {
let tenant_id = TenantId::new("webui-events-tenant").unwrap();
let user_id = UserId::new("webui-events-user").unwrap();
let agent_id = AgentId::new("webui-events-agent").unwrap();
let thread_id = ThreadId::new("webui-events-mixed-auth-fallback-thread").unwrap();
let turn_run = TurnRunId::new();
let gate_ref = "gate:auth-required";
let scope = TurnScope::new(
tenant_id.clone(),
Some(agent_id.clone()),
None,
thread_id.clone(),
);
let credential_requirements = vec![
RuntimeCredentialAuthRequirement {
provider: RuntimeCredentialAccountProviderId::new("google").unwrap(),
setup: RuntimeCredentialAccountSetup::OAuth {
scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()],
},
requester_extension: ExtensionId::new("google-calendar").unwrap(),
provider_scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()],
},
RuntimeCredentialAuthRequirement {
provider: RuntimeCredentialAccountProviderId::new("github").unwrap(),
setup: RuntimeCredentialAccountSetup::ManualToken,
requester_extension: ExtensionId::new("github").unwrap(),
provider_scopes: Vec::new(),
},
];
let event_log_dyn: Arc<dyn DurableEventLog> = Arc::new(InMemoryDurableEventLog::new());
let services = build_reborn_projection_services(
event_log_dyn,
ReplyTargetBindingRef::new("webui-events-reply").unwrap(),
)
.with_turn_events(
Arc::new(FakeTurnEventSource {
events: vec![TurnLifecycleEvent {
cursor: TurnEventCursor(1),
scope: scope.clone(),
occurred_at: Some(chrono::Utc::now()),
owner_user_id: Some(user_id.clone()),
run_id: turn_run,
status: TurnStatus::BlockedAuth,
kind: TurnEventKind::Blocked,
blocked_gate: Some(TurnBlockedGateMetadata {
gate_ref: GateRef::new(gate_ref).unwrap(),
gate_kind: TurnBlockedGateKind::Auth,
credential_requirements: credential_requirements.clone(),
}),
sanitized_reason: Some("Multiple credentials required".to_string()),
}],
}),
Arc::new(FakeTurnCoordinator {
state: TurnRunState {
credential_requirements,
..turn_run_state(&scope, &user_id, turn_run, TurnEventCursor(1))
},
}),
);

let events = services
.webui_event_stream()
.drain(ProjectionSubscriptionRequest {
actor: TurnActor::new(user_id),
scope,
after_cursor: None,
})
.await
.unwrap();

assert!(
events.iter().any(|event| matches!(
event.payload(),
ProductOutboundPayload::AuthPrompt(prompt)
if prompt.turn_run_id == turn_run
&& prompt.auth_request_ref == gate_ref
&& prompt.challenge_kind.is_none()
&& prompt.provider.is_none()
&& !prompt.body.contains("offline access")
&& !prompt.body.contains("reconnect it through this link")
)),
"events: {events:#?}"
);
}

#[tokio::test]
async fn webui_event_stream_surfaces_auth_challenge_lookup_failure() {
let tenant_id = TenantId::new("webui-events-tenant").unwrap();
Expand Down Expand Up @@ -391,6 +477,8 @@ async fn webui_event_stream_creates_google_oauth_prompt_for_runtime_credential_g
url.starts_with("https://accounts.google.com/")
&& url.contains("https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly")
)
&& prompt.body.contains("offline access")
&& prompt.body.contains("reconnect it through this link")
&& prompt.account_label.is_none()
)), "events: {events:#?}");
}
Expand Down
Loading