Skip to content

Commit 5f140e1

Browse files
committed
first pass for login with social option using auth portal
1 parent ac1d692 commit 5f140e1

7 files changed

Lines changed: 400 additions & 298 deletions

File tree

‎crates/chat-cli/src/auth/builder_id.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ impl DeviceRegistration {
210210
secret_store
211211
.set_secret(Self::SECRET_KEY, &serde_json::to_string(&self)?)
212212
.await?;
213+
info!("save builderid token");
213214
Ok(())
214215
}
215216
}

‎crates/chat-cli/src/auth/mod.rs‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod consts;
33
pub mod pkce;
44
mod scope;
55

6+
pub mod portal;
67
pub mod social;
78
use aws_sdk_ssooidc::config::{
89
ConfigBag,
@@ -68,11 +69,7 @@ pub enum AuthError {
6869
#[error(
6970
"Authentication failed: The identity provider denied access. Please ensure you grant all required permissions."
7071
)]
71-
SocialAuthProviderDeniedAccess,
72-
#[error("Authentication failed: The identity provider reported an error: {0}")]
7372
SocialAuthProviderFailure(String),
74-
#[error("Invalid access code. Please check your invitation code and try again.")]
75-
SocialInvalidInvitationCode,
7673
}
7774

7875
impl From<aws_sdk_ssooidc::Error> for AuthError {

‎crates/chat-cli/src/auth/pkce.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ impl PkceRegistration {
287287
Self::recv_code_with_extra_accepts(listener, expected_state, 0).await
288288
}
289289

290-
// NEW: extended version that accepts N extra connections to serve /index.html again
290+
// extended version that accepts N extra connections to serve /index.html again
291291
pub async fn recv_code_with_extra_accepts(
292292
listener: tokio::net::TcpListener,
293293
expected_state: String,
@@ -426,7 +426,7 @@ impl PkceHttpService {
426426
}
427427
}
428428

429-
Self::redirect_to_index(&host, "")
429+
Self::redirect_to_index(&host, "?success=true")
430430
}
431431

432432
fn redirect_to_index(host: &str, query_params: &str) -> Result<ServiceResponse, AuthError> {

‎crates/chat-cli/src/auth/portal.rs‎

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
//! Unified auth portal integration for streamlined authentication
2+
//! Handles callbacks from https://app.kiro.aws.dev/signin
3+
4+
use std::collections::HashMap;
5+
use std::time::Duration;
6+
7+
use bytes::Bytes;
8+
use http_body_util::Full;
9+
use hyper::body::Incoming;
10+
use hyper::server::conn::http1;
11+
use hyper::service::Service;
12+
use hyper::{
13+
Request,
14+
Response,
15+
};
16+
use hyper_util::rt::TokioIo;
17+
use serde::Deserialize;
18+
use tokio::net::TcpListener;
19+
use tracing::{
20+
debug,
21+
error,
22+
info,
23+
};
24+
25+
use crate::auth::AuthError;
26+
use crate::auth::pkce::{
27+
generate_code_challenge,
28+
generate_code_verifier,
29+
};
30+
use crate::auth::social::{
31+
CALLBACK_PORTS,
32+
SocialProvider,
33+
};
34+
use crate::database::{
35+
Database,
36+
Secret,
37+
};
38+
39+
const AUTH_PORTAL_URL: &str = "https://gamma.app.kiro.aws.dev/signin";
40+
const DEFAULT_AUTHORIZATION_TIMEOUT: Duration = Duration::from_secs(600);
41+
const USER_AGENT: &str = "Kiro-CLI";
42+
43+
#[derive(Debug, Clone)]
44+
struct AuthPortalCallback {
45+
login_option: String,
46+
code: Option<String>,
47+
issuer_uri: Option<String>,
48+
state: String,
49+
path: String,
50+
}
51+
52+
pub enum PortalResult {
53+
Social(SocialProvider),
54+
Internal { issuer_uri: String },
55+
}
56+
57+
/// Local-only: open unified portal and handle single callback
58+
pub async fn start_unified_auth(db: &mut Database) -> Result<PortalResult, AuthError> {
59+
info!("Starting unified auth portal flow");
60+
61+
// PKCE params for portal + social token exchange
62+
let verifier = generate_code_verifier();
63+
let challenge = generate_code_challenge(&verifier);
64+
let state = rand::random::<[u8; 16]>()
65+
.iter()
66+
.map(|b| format!("{:02x}", b))
67+
.collect::<String>();
68+
69+
let listener = bind_allowed_port(CALLBACK_PORTS)
70+
.await
71+
.map_err(|e| AuthError::OAuthCustomError(format!("Failed to bind listener: {}", e)))?;
72+
let port = listener
73+
.local_addr()
74+
.map_err(|e| AuthError::OAuthCustomError(format!("Failed to get local addr: {}", e)))?
75+
.port();
76+
77+
// We pass the base (no path). Portal will redirect to /oauth/callback or /signin/callback under
78+
// this base.
79+
let redirect_base = format!("http://localhost:{}", port);
80+
info!(%port, %redirect_base, "Unified auth portal listening (base) for callback");
81+
82+
let auth_url = format!(
83+
"{}?state={}&code_challenge={}&code_challenge_method=S256&redirect_uri={}&redirect_from=kirocli",
84+
AUTH_PORTAL_URL,
85+
state,
86+
challenge,
87+
urlencoding::encode(&redirect_base)
88+
);
89+
90+
crate::util::open::open_url_async(&auth_url)
91+
.await
92+
.map_err(|e| AuthError::OAuthCustomError(format!("Failed to open browser: {}", e)))?;
93+
94+
let callback = wait_for_auth_callback(listener, state.clone()).await?;
95+
96+
match callback.login_option.as_str() {
97+
"google" | "github" => {
98+
let provider = if callback.login_option == "google" {
99+
SocialProvider::Google
100+
} else {
101+
SocialProvider::Github
102+
};
103+
104+
let code = callback.code.ok_or(AuthError::OAuthMissingCode)?;
105+
let redirect_uri = format!(
106+
"http://localhost:{}{}?login_option={}",
107+
port,
108+
callback.path,
109+
urlencoding::encode(&callback.login_option)
110+
);
111+
112+
exchange_social_token(db, provider, &verifier, &code, &redirect_uri).await?;
113+
Ok(PortalResult::Social(provider))
114+
},
115+
"internal" => {
116+
let issuer_uri = callback
117+
.issuer_uri
118+
.ok_or_else(|| AuthError::OAuthCustomError("Missing issuer_uri for internal auth".into()))?;
119+
// DO NOT register here. Let caller run start_pkce_authorization(issuer_uri).
120+
Ok(PortalResult::Internal { issuer_uri })
121+
},
122+
other => Err(AuthError::OAuthCustomError(format!("Unknown login_option: {}", other))),
123+
}
124+
}
125+
126+
async fn wait_for_auth_callback(
127+
listener: TcpListener,
128+
expected_state: String,
129+
) -> Result<AuthPortalCallback, AuthError> {
130+
let (tx, mut rx) = tokio::sync::mpsc::channel::<AuthPortalCallback>(1);
131+
// Accept a single connection
132+
let server_handle = tokio::spawn(async move {
133+
if let Ok((stream, _)) = listener.accept().await {
134+
let io = TokioIo::new(stream);
135+
let service = AuthCallbackService { tx: tx.clone() };
136+
let _ = http1::Builder::new().serve_connection(io, service).await;
137+
}
138+
});
139+
140+
let callback = tokio::select! {
141+
result = rx.recv() => {
142+
result.ok_or(AuthError::OAuthCustomError("Failed to receive callback".into()))?
143+
},
144+
_ = tokio::time::sleep(DEFAULT_AUTHORIZATION_TIMEOUT) => {
145+
return Err(AuthError::OAuthTimeout);
146+
}
147+
};
148+
149+
server_handle.abort();
150+
151+
if callback.state != expected_state {
152+
return Err(AuthError::OAuthStateMismatch {
153+
actual: callback.state,
154+
expected: expected_state,
155+
});
156+
}
157+
158+
Ok(callback)
159+
}
160+
161+
#[derive(Clone)]
162+
struct AuthCallbackService {
163+
tx: tokio::sync::mpsc::Sender<AuthPortalCallback>,
164+
}
165+
166+
impl Service<Request<Incoming>> for AuthCallbackService {
167+
type Error = AuthError;
168+
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
169+
type Response = Response<Full<Bytes>>;
170+
171+
fn call(&self, req: Request<Incoming>) -> Self::Future {
172+
let tx = self.tx.clone();
173+
174+
Box::pin(async move {
175+
let uri = req.uri();
176+
let path = uri.path();
177+
178+
if path == "/oauth/callback" || path == "/signin/callback" {
179+
let query_params = uri
180+
.query()
181+
.map(|q| {
182+
q.split('&')
183+
.filter_map(|kv| kv.split_once('='))
184+
.map(|(k, v)| (k.to_string(), urlencoding::decode(v).unwrap_or_default().to_string()))
185+
.collect::<HashMap<_, _>>()
186+
})
187+
.unwrap_or_default();
188+
189+
let callback = AuthPortalCallback {
190+
login_option: query_params.get("login_option").cloned().unwrap_or_default(),
191+
code: query_params.get("code").cloned(),
192+
issuer_uri: query_params.get("issuer_uri").cloned(),
193+
state: query_params.get("state").cloned().unwrap_or_default(),
194+
path: path.to_string(),
195+
};
196+
197+
debug!(
198+
login_option=%callback.login_option,
199+
code_present=%callback.code.is_some(),
200+
issuer_uri=?callback.issuer_uri,
201+
state=%callback.state,
202+
"Parsed portal callback query"
203+
);
204+
205+
let _ = tx.send(callback).await;
206+
207+
Ok(Response::builder()
208+
.status(302)
209+
.header("Location", AUTH_PORTAL_URL)
210+
.header("Cache-Control", "no-store")
211+
.body("".into())
212+
.expect("valid builder"))
213+
} else {
214+
info!(%path, "Ignoring non-callback path");
215+
Ok(Response::builder()
216+
.status(404)
217+
.body(Full::new(Bytes::from("")))
218+
.expect("valid response"))
219+
}
220+
})
221+
}
222+
}
223+
224+
async fn exchange_social_token(
225+
database: &mut Database,
226+
provider: SocialProvider,
227+
code_verifier: &str,
228+
code: &str,
229+
redirect_uri: &str,
230+
) -> Result<(), AuthError> {
231+
use reqwest::Client;
232+
use time::OffsetDateTime;
233+
234+
use crate::auth::consts::SOCIAL_AUTH_SERVICE_ENDPOINT;
235+
use crate::auth::social::SocialToken;
236+
237+
#[derive(Deserialize)]
238+
struct TokenResp {
239+
#[serde(rename = "accessToken")]
240+
access_token: String,
241+
#[serde(rename = "refreshToken")]
242+
refresh_token: String,
243+
#[serde(rename = "expiresIn")]
244+
expires_in: u64,
245+
#[serde(rename = "profileArn")]
246+
profile_arn: Option<String>,
247+
}
248+
249+
let client = Client::new();
250+
let token_request = serde_json::json!({
251+
"code": code,
252+
"code_verifier": code_verifier,
253+
"redirect_uri": redirect_uri,
254+
});
255+
256+
let response = client
257+
.post(format!("{}/oauth/token", SOCIAL_AUTH_SERVICE_ENDPOINT))
258+
.header("Content-Type", "application/json")
259+
.header("User-Agent", USER_AGENT)
260+
.json(&token_request)
261+
.send()
262+
.await?;
263+
264+
if response.status().is_success() {
265+
let tr: TokenResp = response.json().await?;
266+
let token = SocialToken {
267+
access_token: Secret(tr.access_token),
268+
expires_at: OffsetDateTime::now_utc() + time::Duration::seconds(tr.expires_in as i64),
269+
refresh_token: Some(Secret(tr.refresh_token)),
270+
provider,
271+
profile_arn: tr.profile_arn,
272+
};
273+
token.save(database).await?;
274+
token.save_profile_if_any(database).await?;
275+
Ok(())
276+
} else {
277+
let status = response.status();
278+
let body = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
279+
error!("Token exchange failed: {} - {}", status, body);
280+
Err(AuthError::SocialAuthProviderFailure(format!(
281+
"Token exchange failed: {}",
282+
body
283+
)))
284+
}
285+
}
286+
287+
async fn bind_allowed_port(ports: &[u16]) -> Result<TcpListener, AuthError> {
288+
for port in ports {
289+
match TcpListener::bind(("127.0.0.1", *port)).await {
290+
Ok(listener) => return Ok(listener),
291+
Err(e) => {
292+
debug!("Failed to bind to port {}: {}", port, e);
293+
},
294+
}
295+
}
296+
Err(std::io::Error::new(std::io::ErrorKind::AddrInUse, "Failed to bind to any port").into())
297+
}

0 commit comments

Comments
 (0)