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 } ;
111use clap:: Args ;
212use dialoguer:: Password ;
313use elliptic_curve:: zeroize:: Zeroizing ;
414use 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 } ;
623use snafu:: { ResultExt , Snafu } ;
24+ use tokio:: { net:: TcpListener , sync:: oneshot} ;
725use tracing:: { info, warn} ;
826use 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+ }
0 commit comments