|
| 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