-
Notifications
You must be signed in to change notification settings - Fork 322
feat: Support Qv2 CLI social login #3060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: qv2
Are you sure you want to change the base?
Changes from all commits
949aff1
3104e90
f0c0bbe
bbef143
14bed1a
8ce3a8f
af9e55a
8a6caf3
3b506ca
2f98df4
51c24c1
85f3be5
e6645f6
ec75414
3458ea4
39c4f9a
ac1d692
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,10 +3,21 @@ mod consts; | |
pub mod pkce; | ||
mod scope; | ||
|
||
pub mod social; | ||
use aws_sdk_ssooidc::config::{ | ||
ConfigBag, | ||
RuntimeComponents, | ||
}; | ||
use aws_sdk_ssooidc::error::SdkError; | ||
use aws_sdk_ssooidc::operation::create_token::CreateTokenError; | ||
use aws_sdk_ssooidc::operation::register_client::RegisterClientError; | ||
use aws_sdk_ssooidc::operation::start_device_authorization::StartDeviceAuthorizationError; | ||
use aws_smithy_runtime_api::client::identity::http::Token; | ||
use aws_smithy_runtime_api::client::identity::{ | ||
Identity, | ||
IdentityFuture, | ||
ResolveIdentity, | ||
}; | ||
pub use builder_id::{ | ||
is_logged_in, | ||
logout, | ||
|
@@ -15,6 +26,7 @@ pub use consts::START_URL; | |
use thiserror::Error; | ||
|
||
use crate::aws_common::SdkErrorDisplay; | ||
use crate::database::Database; | ||
|
||
#[derive(Debug, Error)] | ||
pub enum AuthError { | ||
|
@@ -48,6 +60,19 @@ pub enum AuthError { | |
OAuthCustomError(String), | ||
#[error(transparent)] | ||
DatabaseError(#[from] crate::database::DatabaseError), | ||
#[error(transparent)] | ||
Reqwest(#[from] reqwest::Error), | ||
#[error("HTTP error: {0}")] | ||
HttpStatus(reqwest::StatusCode), | ||
// Social auth specific errors | ||
#[error( | ||
"Authentication failed: The identity provider denied access. Please ensure you grant all required permissions." | ||
)] | ||
SocialAuthProviderDeniedAccess, | ||
#[error("Authentication failed: The identity provider reported an error: {0}")] | ||
SocialAuthProviderFailure(String), | ||
#[error("Invalid access code. Please check your invitation code and try again.")] | ||
SocialInvalidInvitationCode, | ||
} | ||
|
||
impl From<aws_sdk_ssooidc::Error> for AuthError { | ||
|
@@ -73,3 +98,33 @@ impl From<SdkError<StartDeviceAuthorizationError>> for AuthError { | |
Self::SdkStartDeviceAuthorization(Box::new(value)) | ||
} | ||
} | ||
/// Unified bearer token resolver that tries both social and builder ID tokens | ||
#[derive(Debug, Clone)] | ||
pub struct UnifiedBearerResolver; | ||
|
||
impl ResolveIdentity for UnifiedBearerResolver { | ||
fn resolve_identity<'a>( | ||
&'a self, | ||
_runtime_components: &'a RuntimeComponents, | ||
_config_bag: &'a ConfigBag, | ||
) -> IdentityFuture<'a> { | ||
IdentityFuture::new_boxed(Box::pin(async { | ||
let database = Database::new().await?; | ||
|
||
if let Ok(Some(token)) = builder_id::BuilderIdToken::load(&database).await { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to check both all times? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
return Ok(Identity::new( | ||
Token::new(token.access_token.0.clone(), Some(token.expires_at.into())), | ||
Some(token.expires_at.into()), | ||
)); | ||
} | ||
|
||
if let Ok(Some(token)) = social::SocialToken::load(&database).await { | ||
return Ok(Identity::new( | ||
Token::new(token.access_token.0.clone(), Some(token.expires_at.into())), | ||
Some(token.expires_at.into()), | ||
)); | ||
} | ||
Err(AuthError::NoToken.into()) | ||
})) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ | |
|
||
use std::future::Future; | ||
use std::pin::Pin; | ||
use std::sync::Arc; | ||
use std::time::Duration; | ||
|
||
pub use aws_sdk_ssooidc::client::Client; | ||
|
@@ -282,42 +283,83 @@ impl PkceRegistration { | |
Ok(()) | ||
} | ||
|
||
async fn recv_code(listener: TcpListener, expected_state: String) -> Result<String, AuthError> { | ||
async fn recv_code(listener: tokio::net::TcpListener, expected_state: String) -> Result<String, AuthError> { | ||
Self::recv_code_with_extra_accepts(listener, expected_state, 0).await | ||
} | ||
|
||
// NEW: extended version that accepts N extra connections to serve /index.html again | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
pub async fn recv_code_with_extra_accepts( | ||
listener: tokio::net::TcpListener, | ||
expected_state: String, | ||
extra_accepts: usize, | ||
) -> Result<String, AuthError> { | ||
// Channel for delivering (code, state) from any served connection | ||
let (code_tx, mut code_rx) = tokio::sync::mpsc::channel::<Result<(String, String), AuthError>>(1); | ||
let (stream, _) = listener.accept().await?; | ||
let stream = TokioIo::new(stream); // Wrapper to implement Hyper IO traits for Tokio types. | ||
let code_tx_arc = Arc::new(code_tx); | ||
|
||
// Host used by the service to 302 -> /index.html | ||
let host = listener.local_addr()?.to_string(); | ||
tokio::spawn(async move { | ||
if let Err(err) = http1::Builder::new() | ||
.serve_connection(stream, PkceHttpService { | ||
code_tx: std::sync::Arc::new(code_tx), | ||
host, | ||
}) | ||
.await | ||
{ | ||
error!(?err, "Error occurred serving the connection"); | ||
|
||
// Serve multiple connections to handle both /oauth/callback and /index.html | ||
// Keep the behavior as close as possible to the previous version: | ||
// first connection is typically the callback, but we allow extra accepts | ||
// (callback + potential error redirect + index.html). | ||
let max_accepts = 1 + extra_accepts; | ||
let server_handle = tokio::spawn({ | ||
let code_tx_arc = code_tx_arc.clone(); | ||
let host = host.clone(); | ||
async move { | ||
for _ in 0..max_accepts { | ||
if let Ok((stream, _)) = listener.accept().await { | ||
let stream = TokioIo::new(stream); | ||
let service = PkceHttpService { | ||
code_tx: code_tx_arc.clone(), | ||
host: host.clone(), | ||
}; | ||
tokio::spawn(async move { | ||
if let Err(err) = http1::Builder::new().serve_connection(stream, service).await { | ||
debug!(?err, "Error serving connection"); | ||
} | ||
}); | ||
} else { | ||
// Accept error; break to avoid a tight loop | ||
break; | ||
} | ||
} | ||
} | ||
}); | ||
match code_rx.recv().await { | ||
Some(Ok((code, state))) => { | ||
debug!(code = "<redacted>", state, "Received code and state"); | ||
if state != expected_state { | ||
return Err(AuthError::OAuthStateMismatch { | ||
actual: state, | ||
expected: expected_state, | ||
}); | ||
|
||
// Wait for authorization code (or an error) with the same timeout semantics | ||
let result = tokio::select! { | ||
msg = code_rx.recv() => { | ||
match msg { | ||
Some(Ok((code, state))) => { | ||
debug!(code = "<redacted>", state, "Received code and state"); | ||
if state != expected_state { | ||
Err(AuthError::OAuthStateMismatch { | ||
actual: state, | ||
expected: expected_state, | ||
}) | ||
} else { | ||
Ok(code) | ||
} | ||
} | ||
Some(Err(e)) => Err(e), | ||
None => Err(AuthError::OAuthMissingCode), | ||
} | ||
// Give time for the user to be redirected to index.html. | ||
tokio::time::sleep(Duration::from_millis(200)).await; | ||
Ok(code) | ||
}, | ||
Some(Err(err)) => { | ||
// Give time for the user to be redirected to index.html. | ||
tokio::time::sleep(Duration::from_millis(200)).await; | ||
Err(err) | ||
}, | ||
None => Err(AuthError::OAuthMissingCode), | ||
} | ||
} | ||
_ = tokio::time::sleep(DEFAULT_AUTHORIZATION_TIMEOUT) => { | ||
Err(AuthError::OAuthTimeout) | ||
} | ||
}; | ||
|
||
// Small grace period so the browser can load /index.html after the 302 | ||
tokio::time::sleep(Duration::from_millis(200)).await; | ||
|
||
// Let the server task finish (best-effort) | ||
let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whats the reasoning behind the 2 seconds? why is this best effort? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 2-second window is a small buffer for the browser to finish loading /index.html after the 302 redirect. |
||
|
||
result | ||
} | ||
} | ||
|
||
|
@@ -459,14 +501,14 @@ impl PkceQueryParams { | |
/// Generates a random 43-octet URL safe string according to the RFC recommendation. | ||
/// | ||
/// Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 | ||
fn generate_code_verifier() -> String { | ||
pub fn generate_code_verifier() -> String { | ||
URL_SAFE.encode(rand::random::<[u8; 32]>()).replace('=', "") | ||
} | ||
|
||
/// Base64 URL encoded sha256 hash of the code verifier. | ||
/// | ||
/// Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 | ||
fn generate_code_challenge(code_verifier: &str) -> String { | ||
pub fn generate_code_challenge(code_verifier: &str) -> String { | ||
use sha2::{ | ||
Digest, | ||
Sha256, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit
: This logic could be extracted to their own method.