1
- use anyhow:: Result ;
1
+ use std:: time:: { Duration , Instant } ;
2
+
3
+ use anyhow:: { Context , Result } ;
2
4
use dialoguer:: Input ;
5
+ use reqwest:: Client ;
6
+ use serde:: Deserialize ;
3
7
4
8
use crate :: { config, config:: Config } ;
5
9
6
- pub fn prompt ( ) -> Result < ( ) > {
10
+ pub async fn prompt ( _cfg : & Config ) -> Result < ( ) > {
7
11
print ! ( "Welcome to the Svix CLI!\n \n " ) ;
8
12
9
- let auth_token = Input :: new ( )
10
- . with_prompt ( "Auth Token" )
11
- . validate_with ( {
12
- move |input : & String | -> Result < ( ) > {
13
- if !input. trim ( ) . is_empty ( ) {
14
- Ok ( ( ) )
15
- } else {
16
- Err ( anyhow:: anyhow!( "auth token cannot be empty" ) )
13
+ let selections = & [ "Login in dashboard.svix.com" , "Input token manually" ] ;
14
+ let selection = dialoguer:: Select :: new ( )
15
+ . with_prompt ( "How would you like to authenticate?" )
16
+ . items ( selections)
17
+ . default ( 0 )
18
+ . interact ( ) ?;
19
+
20
+ let auth_token = if selection == 0 {
21
+ dashboard_login ( ) . await ?
22
+ } else {
23
+ Input :: new ( )
24
+ . with_prompt ( "Auth Token" )
25
+ . validate_with ( {
26
+ move |input : & String | -> Result < ( ) > {
27
+ if !input. trim ( ) . is_empty ( ) {
28
+ Ok ( ( ) )
29
+ } else {
30
+ Err ( anyhow:: anyhow!( "auth token cannot be empty" ) )
31
+ }
17
32
}
18
- }
19
- } )
20
- . interact_text ( ) ?
21
- . trim ( )
22
- . to_string ( ) ;
33
+ } )
34
+ . interact_text ( ) ?
35
+ . trim ( )
36
+ . to_string ( )
37
+ } ;
23
38
24
39
// Load from disk and update the prompted fields.
25
40
// There are other fields (not prompted for) related to "relay" for the `listen` command
@@ -45,3 +60,108 @@ pub fn prompt() -> Result<()> {
45
60
) ;
46
61
Ok ( ( ) )
47
62
}
63
+
64
+ #[ derive( Debug , Deserialize ) ]
65
+ #[ serde( rename_all = "camelCase" ) ]
66
+ struct CliStartLoginSessionOut {
67
+ session_id : String ,
68
+ }
69
+
70
+ #[ derive( Debug , Deserialize ) ]
71
+ #[ serde( rename_all = "camelCase" ) ]
72
+ struct AuthTokenOut {
73
+ token : String ,
74
+ }
75
+
76
+ #[ derive( Debug , Deserialize ) ]
77
+ #[ serde( rename_all = "camelCase" ) ]
78
+ struct DiscoverySessionOut {
79
+ pub region : String ,
80
+ }
81
+
82
+ const DASHBOARD_URL : & str = "https://dashboard.svix.com" ;
83
+ const LOGIN_SERVER_URL : & str = "https://api.svix.com" ;
84
+
85
+ pub async fn dashboard_login ( ) -> Result < String > {
86
+ let client = reqwest:: Client :: new ( ) ;
87
+
88
+ let start_session = client
89
+ . post ( format ! ( "{LOGIN_SERVER_URL}/dashboard/cli/login/start" ) )
90
+ . send ( )
91
+ . await
92
+ . context ( "Failed to get session ID. Could not connect to server." ) ?
93
+ . json :: < CliStartLoginSessionOut > ( )
94
+ . await
95
+ . context ( "Failed to get session ID. Invalid response." ) ?;
96
+
97
+ let session_id = start_session. session_id ;
98
+ let code = & session_id[ 0 ..4 ] . to_uppercase ( ) ;
99
+
100
+ let url = format ! ( "{DASHBOARD_URL}/cli/login?sessionId={session_id}&code={code}" ) ;
101
+
102
+ println ! ( "\n Please approve the login in your browser, then return here." ) ;
103
+ println ! ( "Verification code: \x1b [32m{}\x1b [0m\n " , code) ;
104
+
105
+ if let Err ( e) = open:: that ( & url) {
106
+ eprintln ! ( "Failed to open browser: {}" , e) ;
107
+ println ! ( "Please manually open this URL in your browser: {}" , url) ;
108
+ }
109
+
110
+ println ! ( "Waiting for approval..." ) ;
111
+
112
+ // First, poll the discovery endpoint to get the region
113
+ let discovery_poll_url = format ! ( "{LOGIN_SERVER_URL}/dashboard/cli/login/discovery/complete" ) ;
114
+ let discovery_data: DiscoverySessionOut =
115
+ poll_session ( & client, & discovery_poll_url, & session_id) . await ?;
116
+
117
+ let region = discovery_data. region ;
118
+ let region_server_url = format ! ( "https://api.{region}.svix.com" ) ;
119
+ let token_poll_url = format ! ( "{region_server_url}/dashboard/cli/login/token/complete" ) ;
120
+
121
+ // Then, poll the token endpoint to get the auth token
122
+ let token_data: AuthTokenOut = poll_session ( & client, & token_poll_url, & session_id) . await ?;
123
+
124
+ println ! ( "Authentication successful!\n " ) ;
125
+ Ok ( token_data. token )
126
+ }
127
+
128
+ const MAX_POLL_TIME : Duration = Duration :: from_secs ( 5 * 60 ) ;
129
+
130
+ async fn poll_session < T > ( client : & Client , poll_url : & str , session_id : & str ) -> Result < T >
131
+ where
132
+ T : for < ' de > serde:: Deserialize < ' de > ,
133
+ {
134
+ let start_time: Instant = Instant :: now ( ) ;
135
+
136
+ while start_time. elapsed ( ) < MAX_POLL_TIME {
137
+ let response = client
138
+ . post ( poll_url)
139
+ . json ( & serde_json:: json!( { "sessionId" : session_id } ) )
140
+ . send ( )
141
+ . await
142
+ . context ( "Failed to connect to authentication server" ) ?;
143
+
144
+ if response. status ( ) . is_success ( ) {
145
+ return response
146
+ . json :: < T > ( )
147
+ . await
148
+ . context ( "Failed to parse authentication data" ) ;
149
+ } else if response. status ( ) != reqwest:: StatusCode :: NOT_FOUND {
150
+ // Bail if session exists but has an error (is expired or something else)
151
+ let error_message = match response. json :: < serde_json:: Value > ( ) . await {
152
+ Ok ( json) => json
153
+ . get ( "detail" )
154
+ . and_then ( |d| d. as_str ( ) )
155
+ . unwrap_or ( "Unknown error" )
156
+ . to_string ( ) ,
157
+ Err ( _) => "Unknown error" . to_string ( ) ,
158
+ } ;
159
+
160
+ anyhow:: bail!( "Authentication failed: {error_message}" ) ;
161
+ }
162
+
163
+ std:: thread:: sleep ( std:: time:: Duration :: from_secs ( 1 ) ) ;
164
+ }
165
+
166
+ anyhow:: bail!( "Authentication failed." ) ;
167
+ }
0 commit comments