Skip to content

Commit 605d923

Browse files
move out of operations module
1 parent a15ce47 commit 605d923

File tree

5 files changed

+245
-243
lines changed

5 files changed

+245
-243
lines changed

crates/icp-cli/src/commands/identity/link/ii.rs

Lines changed: 241 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
1+
use std::net::SocketAddr;
2+
3+
use axum::{
4+
Router,
5+
extract::State,
6+
http::{HeaderMap, HeaderValue, StatusCode, header},
7+
response::IntoResponse,
8+
routing::post,
9+
};
10+
use base64::engine::{Engine as _, general_purpose::URL_SAFE_NO_PAD};
111
use clap::Args;
212
use dialoguer::Password;
313
use elliptic_curve::zeroize::Zeroizing;
414
use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity};
5-
use icp::{context::Context, fs::read_to_string, identity::key, prelude::*};
15+
use icp::{
16+
context::Context,
17+
fs::read_to_string,
18+
identity::{delegation::DelegationChain, key},
19+
prelude::*,
20+
signal,
21+
};
22+
use indicatif::{ProgressBar, ProgressStyle};
623
use snafu::{ResultExt, Snafu};
24+
use tokio::{net::TcpListener, sync::oneshot};
725
use tracing::{info, warn};
826
use url::Url;
927

10-
use crate::{commands::identity::StorageMode, operations::ii_poll};
28+
use crate::commands::identity::StorageMode;
1129

1230
/// Link an Internet Identity to a new identity
1331
#[derive(Debug, Args)]
@@ -16,7 +34,7 @@ pub(crate) struct IiArgs {
1634
name: String,
1735

1836
/// Host of the II login frontend (e.g. https://example.icp0.io)
19-
#[arg(long, default_value = ii_poll::DEFAULT_HOST)]
37+
#[arg(long, default_value = DEFAULT_HOST)]
2038
host: Url,
2139

2240
/// Where to store the session private key
@@ -56,7 +74,7 @@ pub(crate) async fn exec(ctx: &Context, args: &IiArgs) -> Result<(), IiError> {
5674
let basic = BasicIdentity::from_raw_key(&secret_key.serialize_raw());
5775
let der_public_key = basic.public_key().expect("ed25519 always has a public key");
5876

59-
let chain = ii_poll::poll_for_delegation(&args.host, &der_public_key)
77+
let chain = recv_delegation(&args.host, &der_public_key)
6078
.await
6179
.context(PollSnafu)?;
6280

@@ -100,7 +118,7 @@ pub(crate) enum IiError {
100118
StoragePasswordTermRead { source: dialoguer::Error },
101119

102120
#[snafu(display("failed during II authentication"))]
103-
Poll { source: ii_poll::IiPollError },
121+
Poll { source: IiRecvError },
104122

105123
#[snafu(display("invalid public key in delegation chain"))]
106124
DecodeFromKey { source: hex::FromHexError },
@@ -111,3 +129,221 @@ pub(crate) enum IiError {
111129
#[snafu(display("failed to link II identity"))]
112130
Link { source: key::LinkIiIdentityError },
113131
}
132+
133+
/// Fallback host. Dummy value until we get a real domain. A staging instance can be found at ut7yr-7iaaa-aaaag-ak7ca-caia.ic0.app
134+
pub(crate) const DEFAULT_HOST: &str = "https://not.a.domain";
135+
136+
#[derive(Debug, Snafu)]
137+
pub(crate) enum IiRecvError {
138+
#[snafu(display("failed to bind local callback server"))]
139+
BindServer { source: std::io::Error },
140+
141+
#[snafu(display("failed to run local callback server"))]
142+
ServeServer { source: std::io::Error },
143+
144+
#[snafu(display("failed to fetch `{url}`"))]
145+
FetchDiscovery { url: String, source: reqwest::Error },
146+
147+
#[snafu(display("failed to read discovery response from `{url}`"))]
148+
ReadDiscovery { url: String, source: reqwest::Error },
149+
150+
#[snafu(display(
151+
"`{url}` returned an empty login path — the response must be a single non-empty line"
152+
))]
153+
EmptyLoginPath { url: String },
154+
155+
#[snafu(display("interrupted"))]
156+
Interrupted,
157+
}
158+
159+
/// Discovers the login path from `{host}/.well-known/ic-cli-login`, then opens
160+
/// a local HTTP server, builds the login URL, and returns the delegation chain
161+
/// once the frontend POSTs it back.
162+
pub(crate) async fn recv_delegation(
163+
host: &Url,
164+
der_public_key: &[u8],
165+
) -> Result<DelegationChain, IiRecvError> {
166+
let key_b64 = URL_SAFE_NO_PAD.encode(der_public_key);
167+
168+
// Discover the login path.
169+
let discovery_url = host
170+
.join("/.well-known/ic-cli-login")
171+
.expect("joining an absolute path is infallible");
172+
let discovery_url_str = discovery_url.to_string();
173+
let login_path = reqwest::get(discovery_url)
174+
.await
175+
.context(FetchDiscoverySnafu {
176+
url: &discovery_url_str,
177+
})?
178+
.text()
179+
.await
180+
.context(ReadDiscoverySnafu {
181+
url: &discovery_url_str,
182+
})?;
183+
let login_path = login_path.trim();
184+
if login_path.is_empty() {
185+
return EmptyLoginPathSnafu {
186+
url: discovery_url_str,
187+
}
188+
.fail();
189+
}
190+
191+
// Bind on a random port before opening the browser so the callback URL is known.
192+
let listener = TcpListener::bind("127.0.0.1:0")
193+
.await
194+
.context(BindServerSnafu)?;
195+
let addr: SocketAddr = listener.local_addr().context(BindServerSnafu)?;
196+
let callback_url = format!("http://127.0.0.1:{}/", addr.port());
197+
198+
// Build the fragment as a URLSearchParams-compatible string so the frontend
199+
// can parse it with `new URLSearchParams(location.hash.slice(1))`.
200+
let fragment = {
201+
let mut scratch = Url::parse("x:?").expect("infallible");
202+
scratch
203+
.query_pairs_mut()
204+
.append_pair("public_key", &key_b64)
205+
.append_pair("callback", &callback_url);
206+
scratch.query().expect("just set").to_owned()
207+
};
208+
let mut login_url = host.join(login_path).expect("login_path is a valid path");
209+
login_url.set_fragment(Some(&fragment));
210+
211+
eprintln!();
212+
eprintln!(" Press Enter to log in at {}", {
213+
let mut display = login_url.clone();
214+
display.set_fragment(None);
215+
display
216+
});
217+
218+
let (chain_tx, chain_rx) = oneshot::channel::<DelegationChain>();
219+
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
220+
221+
// chain_tx is wrapped in an Option so the handler can take ownership.
222+
let state = CallbackState {
223+
chain_tx: std::sync::Mutex::new(Some(chain_tx)),
224+
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
225+
};
226+
227+
let app = Router::new()
228+
.route("/", post(handle_callback).options(handle_preflight))
229+
.with_state(std::sync::Arc::new(state));
230+
231+
let spinner = ProgressBar::new_spinner();
232+
spinner.set_style(
233+
ProgressStyle::default_spinner()
234+
.template("{spinner:.green} {msg}")
235+
.expect("valid template"),
236+
);
237+
238+
// Detached thread for stdin — tokio's async stdin keeps the runtime alive on drop.
239+
let (enter_tx, mut enter_rx) = tokio::sync::mpsc::channel::<()>(1);
240+
std::thread::spawn(move || {
241+
let mut buf = String::new();
242+
let _ = std::io::stdin().read_line(&mut buf);
243+
let _ = enter_tx.blocking_send(());
244+
});
245+
246+
let serve = axum::serve(listener, app).with_graceful_shutdown(async move {
247+
let _ = shutdown_rx.await;
248+
});
249+
250+
let mut browser_opened = false;
251+
252+
let result = tokio::select! {
253+
_ = signal::stop_signal() => {
254+
spinner.finish_and_clear();
255+
return InterruptedSnafu.fail();
256+
}
257+
res = serve.into_future() => {
258+
res.context(ServeServerSnafu)?;
259+
// Server shut down before we got a chain — shouldn't happen.
260+
return InterruptedSnafu.fail();
261+
}
262+
_ = async {
263+
loop {
264+
tokio::select! {
265+
_ = enter_rx.recv(), if !browser_opened => {
266+
browser_opened = true;
267+
spinner.set_message("Waiting for Internet Identity authentication...");
268+
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
269+
let _ = open::that(login_url.as_str());
270+
}
271+
// Yield so the other branches in the outer select! can fire.
272+
_ = tokio::task::yield_now() => {}
273+
}
274+
}
275+
} => { unreachable!() }
276+
chain = chain_rx => chain,
277+
};
278+
279+
spinner.finish_and_clear();
280+
Ok(result.expect("sender only dropped after sending"))
281+
}
282+
283+
#[derive(Debug)]
284+
struct CallbackState {
285+
chain_tx: std::sync::Mutex<Option<oneshot::Sender<DelegationChain>>>,
286+
shutdown_tx: std::sync::Mutex<Option<oneshot::Sender<()>>>,
287+
}
288+
289+
fn cors_headers() -> HeaderMap {
290+
let mut headers = HeaderMap::new();
291+
headers.insert(
292+
header::ACCESS_CONTROL_ALLOW_ORIGIN,
293+
HeaderValue::from_static("*"),
294+
);
295+
headers.insert(
296+
header::ACCESS_CONTROL_ALLOW_METHODS,
297+
HeaderValue::from_static("POST, OPTIONS"),
298+
);
299+
headers.insert(
300+
header::ACCESS_CONTROL_ALLOW_HEADERS,
301+
HeaderValue::from_static("content-type"),
302+
);
303+
headers
304+
}
305+
306+
async fn handle_preflight() -> impl IntoResponse {
307+
(StatusCode::NO_CONTENT, cors_headers())
308+
}
309+
310+
async fn handle_callback(
311+
State(state): State<std::sync::Arc<CallbackState>>,
312+
headers: HeaderMap,
313+
body: axum::body::Bytes,
314+
) -> impl IntoResponse {
315+
// Only accept POST with JSON content.
316+
let content_type = headers
317+
.get(header::CONTENT_TYPE)
318+
.and_then(|v| v.to_str().ok())
319+
.unwrap_or("");
320+
if !content_type.starts_with("application/json") {
321+
return (
322+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
323+
cors_headers(),
324+
"expected application/json",
325+
)
326+
.into_response();
327+
}
328+
329+
let chain: DelegationChain = match serde_json::from_slice(&body) {
330+
Ok(c) => c,
331+
Err(_) => {
332+
return (
333+
StatusCode::BAD_REQUEST,
334+
cors_headers(),
335+
"invalid delegation chain",
336+
)
337+
.into_response();
338+
}
339+
};
340+
341+
if let Some(tx) = state.chain_tx.lock().unwrap().take() {
342+
let _ = tx.send(chain);
343+
}
344+
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
345+
let _ = tx.send(());
346+
}
347+
348+
(StatusCode::OK, cors_headers(), "").into_response()
349+
}

crates/icp-cli/src/commands/identity/login.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use icp::{
1010
use snafu::{OptionExt, ResultExt, Snafu};
1111
use tracing::info;
1212

13-
use crate::operations::ii_poll;
13+
use crate::commands::identity::link::ii;
1414

1515
/// Re-authenticate an Internet Identity delegation
1616
#[derive(Debug, Args)]
@@ -55,7 +55,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LoginArgs) -> Result<(), LoginErr
5555
.await?
5656
.context(LoadSessionKeySnafu)?;
5757

58-
let chain = ii_poll::poll_for_delegation(&host, &der_public_key)
58+
let chain = ii::recv_delegation(&host, &der_public_key)
5959
.await
6060
.context(PollSnafu)?;
6161

@@ -92,7 +92,7 @@ pub(crate) enum LoginError {
9292
LoadSessionKey { source: key::LoadIdentityError },
9393

9494
#[snafu(display("failed during II authentication"))]
95-
Poll { source: ii_poll::IiPollError },
95+
Poll { source: ii::IiRecvError },
9696

9797
#[snafu(display("failed to update delegation"))]
9898
UpdateDelegation {

0 commit comments

Comments
 (0)