-
Notifications
You must be signed in to change notification settings - Fork 290
Add Interactive Browser Authentication Support #2418
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: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
use azure_identity::interactive_credential::interactive_browser_credential::InteractiveBrowserCredential; | ||
use oauth2::TokenResponse; | ||
use reqwest::Client; | ||
use std::error::Error; | ||
use url::Url; | ||
|
||
#[tokio::main] | ||
async fn main() -> Result<(), Box<dyn Error>> { | ||
let test_subscription_id = | ||
std::env::var("AZURE_SUBSCRIPTION_ID").expect("AZURE_SUBSCRIPTION_ID required"); | ||
let test_tenant_id = std::env::var("AZURE_TENANT_ID").expect("AZURE_TENANT_ID required"); | ||
|
||
let _ = run_app_inter(test_subscription_id, test_tenant_id).await?; | ||
Ok(()) | ||
} | ||
|
||
async fn run_app_inter(subscription_id: String, tenant_id: String) -> Result<(), Box<dyn Error>> { | ||
let interactive_credentials = InteractiveBrowserCredential::new(None, Some(tenant_id), None)?; | ||
|
||
let token_response = interactive_credentials | ||
.get_token(Some(&["https://management.azure.com/.default"])) | ||
.await?; | ||
|
||
let access_token_secret = token_response.access_token().secret(); | ||
|
||
let url = Url::parse(&format!( | ||
"https://management.azure.com/subscriptions/{subscription_id}/providers/Microsoft.Storage/storageAccounts?api-version=2019-06-01" | ||
))?; | ||
|
||
let resp = Client::new() | ||
.get(url) | ||
.header("Authorization", format!("Bearer {}", access_token_secret)) | ||
.send() | ||
.await? | ||
.text() | ||
.await?; | ||
|
||
println!("Res interactive: {resp}"); | ||
Ok(()) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
use super::internal_server::*; | ||
use crate::authorization_code_flow; | ||
use azure_core::{error::ErrorKind, http::new_http_client, http::Url, Error}; | ||
use oauth2::{ | ||
basic::BasicTokenType, AuthorizationCode, ClientId, EmptyExtraTokenFields, | ||
StandardTokenResponse, | ||
}; | ||
use std::{str::FromStr, sync::Arc}; | ||
|
||
/// Default OAuth scopes used when none are provided. | ||
#[allow(dead_code)] | ||
const DEFAULT_SCOPE_ARR: [&str; 3] = ["openid", "offline_access", "profile"]; | ||
/// Default client ID for interactive browser authentication. | ||
#[allow(dead_code)] | ||
const DEFAULT_DEVELOPER_SIGNON_CLIENT_ID: &str = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"; | ||
/// Default tenant ID used when none is specified. | ||
#[allow(dead_code)] | ||
const DEFAULT_ORGANIZATIONS_TENANT_ID: &str = "organizations"; | ||
|
||
/// Provides interactive browser-based authentication. | ||
#[derive(Clone)] | ||
pub struct InteractiveBrowserCredential { | ||
/// Client ID of the application. | ||
pub(crate) client_id: ClientId, | ||
/// Tenant ID for the authentication request. | ||
pub(crate) tenant_id: String, | ||
/// Redirect URI where the authentication response is sent. | ||
pub(crate) redirect_url: Url, | ||
} | ||
|
||
impl InteractiveBrowserCredential { | ||
/// Creates a new `InteractiveBrowserCredential` instance with optional parameters. | ||
pub fn new( | ||
client_id: Option<ClientId>, | ||
M-Patrone marked this conversation as resolved.
Show resolved
Hide resolved
|
||
tenant_id: Option<String>, | ||
redirect_url: Option<Url>, | ||
Comment on lines
+34
to
+36
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. These should be passed into an |
||
) -> azure_core::Result<Arc<Self>> { | ||
let client_id = client_id | ||
.unwrap_or_else(|| ClientId::new(DEFAULT_DEVELOPER_SIGNON_CLIENT_ID.to_owned())); | ||
|
||
let tenant_id = tenant_id.unwrap_or_else(|| DEFAULT_ORGANIZATIONS_TENANT_ID.to_owned()); | ||
|
||
let redirect_url = redirect_url.unwrap_or_else(|| { | ||
Url::from_str(&format!("http://localhost:{}", LOCAL_SERVER_PORT)) | ||
.expect("Failed to parse redirect URL") | ||
}); | ||
|
||
Ok(Arc::new(Self { | ||
client_id, | ||
tenant_id, | ||
redirect_url, | ||
})) | ||
} | ||
|
||
/// Starts the interactive browser authentication flow and returns an access token. | ||
/// | ||
/// If no scopes are provided, default scopes will be used. | ||
#[allow(dead_code)] | ||
pub async fn get_token( | ||
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. This should be wrapped in the internal cache to prevent every call redundantly authenticating. See for example ClientAssertionCredential. Here's a gotcha though: the cache implicitly assumes it holds tokens for only one identity. That won't work for this particular credential because users may choose a different account every time they authenticate. We have to prevent this credential getting into a state where, say, it returns Cosmos tokens for user A and Key Vault tokens for user B. I imagine the simplest solution would be having the credential remember the last authenticated user (this would require parsing id tokens) and clear the cache when the application authenticates a new user. That may be complex to implement 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. Hi @chlowell , I've now implemented the cache similar to how it's done in ` ClientAssertionCredential. However, I'm still trying to fully understand the concern regarding caching tokens from potentially different identities. From my current perspective, it seems that when someone wants to authenticate with a different account (e.g., switching from user A to user B), they would typically create a new instance of fn foo(){
// User A
let options_a = InteractiveBrowserCredentialOptions {
client_id: None,
tenant_id: Some(tenant_id_a),
redirect_url: None,
};
let credential_a = InteractiveBrowserCredential::new(options_a)?;
let token_a = credential_a.get_token(&["https://management.azure.com/.default"]).await?;
// Do something with token_a...
// User B
let options_b = InteractiveBrowserCredentialOptions {
client_id: None,
tenant_id: Some(tenant_id_b),
redirect_url: None,
};
let credential_b = InteractiveBrowserCredential::new(options_b)?;
let token_b = credential_b.get_token(&["https://management.azure.com/.default"]).await?;
// Do something with token_b...
} So my (possibly naive) assumption was: if the developer needs to authenticate a different user, they would instantiate a separate credential, similar to how other credentials behave. That said, I might very well be missing an important part of the picture. I'm still relatively new to this, and I want to make sure I understand the full context. If you have time, could you perhaps elaborate a bit more on the potential issue – maybe even with some pseudocode or a concrete example where this problem could arise? Thanks again for your guidance and patience. I really appreciate it and I’m learning a lot through this process. 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.
That assumption doesn't hold for an interactive credential like this because the developer can't control the authenticated account or know it before You're correct that a developer who wanted to get tokens for different users simultaneously would need multiple instances of this credential, however that isn't a common scenario and would be difficult to accomplish because again, the user (of the program) completes the interactive login and therefore decides which account to authenticate. The common case is a single-user program like the Azure CLI, a tool users run locally to do something with Azure resources. I should reiterate that implementing this credential is particularly difficult because it has more moving parts than other types and entails additional work for caching and handling user accounts, parts of which may not be documented. Honestly, it's a bad first issue. 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. Hi @chlowell, thank you so much for the detailed explanation. I really appreciate it! I realize now that I had a bit of a misunderstanding around the caching behavior. I assumed that caching would inherently avoid the situation where different users get mixed up, because once a token is cached, no new browser flow would be triggered – so no opportunity for a different user to log in. So the correct behavior would be: the credential needs to detect when a different user logs in (e.g., by parsing the ID token) and clear the cache when that happens, so tokens from different identities don’t get mixed up. Got it! Even though this might not be the ideal "first issue" 😅, I’d still really like to give it a proper shot and learn from it. If you agree, I’ll try to model the solution after the existing caching mechanisms like in Thanks again for your help and patience! 🙌 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.
I believe that's the simplest way to get the correct behavior. An ID token won't always provide the data necessary to identify a user account uniquely and consistently, so you'd instead use the "home account ID", which so far as I know isn't documented. To get one, add I don't want to discourage you working on this PR--your contribution's welcome--only to make sure you're aware that this feature is particularly complex and getting this PR to the finish line will probably require significant effort all around. Continuing to work on it and abandoning it are both reasonable choices 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. @chlowell Thanks a lot for the helpful reply! That’s really good to know about the ID token. I had just implemented the Hybrid Flow and was planning to cache the Whether or not this PR ends up being merged is totally fine with me. I’ve already learned so much and that’s a huge win on its own for me 😄. If at some point I start to clutter your inbox too much, feel free to wave the white flag, no hard feelings 😄. For now, I’d still love to keep exploring and pushing it a bit further if that’s okay! 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. @chlowell, apologies for the delay. I'm just finalizing the changes. 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. No, that's an implementation detail. The 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. @chlowell I tested the behavior, and it seems that when both 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. Thanks for doing the science. We need |
||
&self, | ||
scopes: Option<&[&str]>, | ||
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. This function should require at least one scope and add
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. @chlowell Thank you very much for your feedback, and apologies for the delayed response. I'm currently working on implementing
I haven’t had the chance to fully dive into this yet, but maybe someone has encountered a similar issue before. The error seems to be triggered by this part of the code:
This eventually calls into the function here: exchange 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. I ran into the same problem last week. It appears related to rust-lang/rust#64552. I gave up on finding a workaround because replacing oauth2 with my own code was much faster. That's what I would do in this case as well. The protocol is documented here, though that doc doesn't cover the public client case we're interested in--in the exchange request, a public client would omit 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. @chlowell Thanks a lot for your helpful response! I really appreciate you taking the time. Over the last couple of days, I dug into the issue to better understand the root cause—mainly because the original error message was so vague that it didn’t point me in any useful direction. That was the most frustrating part. So I started isolating the problem step by step. It became clear that the error happens during the exchange call in the authorization code flow. To narrow it down further, I stripped my code down to this minimal example: let b = AuthorizationCode::new("test_auth_code".to_string()).clone();
let c = self.options.http_client.clone();
let a = authorization_code_flow.exchange(c, b).await; That’s where the error triggered. I then created a dedicated test function to extract and observe the behavior more closely: fn get_access_token_test(
&self,
scopes: &[&str],
) -> impl Send + Future<Output = azure_core::Result<AccessToken>> {
assert_send(async move {
let authorization_code_flow = authorization_code_flow::authorize(
ClientId::new("jkadjfa".to_string()),
None,
&"jkadjfa".to_string(),
Url::from_str("str").unwrap(),
&scopes,
);
let b = AuthorizationCode::new("djfak".to_string()).clone();
let c = new_http_client();
let a = authorization_code_flow.exchange(c, b).await?.clone();
Ok(AccessToken::new("test", OffsetDateTime::now_utc()))
})
}
} And here's the helper function for asserting Send: fn assert_send<T>(fut: impl Send + Future<Output = T>) -> impl Send + Future<Output = T> {
fut
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl TokenCredential for InteractiveBrowserCredential {
async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
self.get_access_token_test(scopes).await
}
} I used this in a custom TokenCredential implementation so I could observe the issue directly when calling get_token. This gave me a much more helpful error message:
So the issue was clearly a Send problem related to the lifetime of the closure passed to request_async. To fix it, I restructured my exchange method to use Arc and clone it inside the closure, like this: pub async fn exchange(
self,
http_client: Arc<dyn HttpClient>,
code: oauth2::AuthorizationCode,
) -> azure_core::Result<
oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>,
> {
// let oauth_http_client = Oauth2HttpClient::new(http_client.clone());
// let client = |request: HttpRequest| oauth_http_client.request(request);
//improve problem with implementing the `send`
let oauth_http_client = Arc::new(Oauth2HttpClient::new(http_client.clone()));
let client = {
let oauth_http_client = oauth_http_client.clone();
move |request: HttpRequest| {
let oauth_http_client = oauth_http_client.clone();
async move { oauth_http_client.request(request).await }
}
};
self.client
.exchange_code(code)
// Send the PKCE code verifier in the token request
.set_pkce_verifier(self.pkce_code_verifier)
.request_async(&client)
.await
.context(
ErrorKind::Credential,
"exchanging an authorization code for a token failed",
)
} This resolved the Send issue for now—though I still want to run a few more tests to be completely sure. Thanks again for your time and suggestion to replace the oauth2 crate. That’s still on the table, but I wanted to understand the root problem first—and I think I’ve got a clearer picture now. 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. I'm sorry to make this feature bigger, however it will also need an auth code flow implementation because we want to remove the 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. @chlowell, no worries at all. I really appreciate your guidance throughout this process. Digging into this implementation has been quite insightful for me. It also gave me the opportunity to learn how to handle custom OAuth2 properties returned in the token response body, such as Based on your comment, I understand that you want to remove the That brings me to a quick question: Would you prefer that I
I'm totally flexible and happy to go with whichever option fits the project's needs and your preferences best — just let me know how you'd like to proceed. 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. I would add it in this PR. In my imagination it isn't a lot of code because you can assume the server is Entra ID (e.g. simply request 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. @chlowell just a quick question: since we should remove the 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.
|
||
) -> azure_core::Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>> { | ||
let scopes = scopes.unwrap_or(&DEFAULT_SCOPE_ARR); | ||
|
||
let authorization_code_flow = authorization_code_flow::authorize( | ||
M-Patrone marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.client_id.clone(), | ||
None, | ||
&self.tenant_id, | ||
self.redirect_url.clone(), | ||
scopes, | ||
); | ||
|
||
let auth_code = open_url(authorization_code_flow.authorize_url.as_ref()).await; | ||
|
||
match auth_code { | ||
Some(code) => { | ||
authorization_code_flow | ||
.exchange(new_http_client(), AuthorizationCode::new(code)) | ||
.await | ||
} | ||
None => Err(Error::message( | ||
ErrorKind::Other, | ||
"Failed to retrieve authorization code.", | ||
)), | ||
} | ||
} | ||
} | ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use tracing::debug; | ||
use tracing::Level; | ||
use tracing_subscriber; | ||
static INIT: std::sync::Once = std::sync::Once::new(); | ||
|
||
fn init_tracing() { | ||
INIT.call_once(|| { | ||
tracing_subscriber::fmt() | ||
.with_max_level(Level::DEBUG) | ||
.init(); | ||
}); | ||
} | ||
|
||
#[tokio::test] | ||
async fn interactive_auth_flow_should_return_token() { | ||
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. This mustn't run by default. CI runs can't complete the interactive login and we don't want to open a browser every time someone working on the crate runs 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. This would be a good place for something under |
||
init_tracing(); | ||
debug!("Starting interactive authentication test"); | ||
|
||
let credential = InteractiveBrowserCredential::new(None, None, None) | ||
.expect("Failed to create credential"); | ||
|
||
let token_response = credential.get_token(None).await; | ||
debug!("Authentication result: {:#?}", token_response); | ||
assert!(token_response.is_ok()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
use std::io::{self, BufRead, BufReader, Write}; | ||
use std::net::{Shutdown, TcpListener, TcpStream}; | ||
use std::time::Duration; | ||
use tracing::{error, info}; | ||
|
||
///The port where the local server is listening on the auth_code | ||
#[allow(dead_code)] | ||
pub const LOCAL_SERVER_PORT: u16 = 47828; | ||
|
||
/// Opens the given URL in the default system browser and starts a local web server | ||
/// to receive the authorization code. | ||
#[allow(dead_code)] | ||
#[cfg(target_os = "windows")] | ||
pub async fn open_url(url: &str) -> Option<String> { | ||
use async_process::Command; | ||
let spawned = Command::new("cmd").args(["/C", "explorer", url]).spawn(); | ||
handle_browser_command(spawned) | ||
} | ||
|
||
/// Opens the given URL in the default system browser and starts a local web server | ||
/// to receive the authorization code. | ||
#[allow(dead_code)] | ||
#[cfg(target_os = "macos")] | ||
pub async fn open_url(url: &str) -> Option<String> { | ||
use async_process::Command; | ||
let spawned = Command::new("open").arg(url).spawn(); | ||
handle_browser_command(spawned) | ||
} | ||
|
||
/// Opens the given URL in the default system browser and starts a local web server | ||
/// to receive the authorization code. | ||
#[allow(dead_code)] | ||
#[cfg(target_os = "linux")] | ||
pub async fn open_url(url: &str) -> Option<String> { | ||
use async_process::Command; | ||
|
||
if let Some(command) = find_linux_browser_command().await { | ||
let spawned = Command::new(command).arg(url).spawn(); | ||
return handle_browser_command(spawned); | ||
} | ||
|
||
info!("Open the following link manually in your browser: {url}"); | ||
None | ||
} | ||
|
||
/// Method to check if the command to open the link in a browser is available on the computer | ||
/// exists. | ||
#[allow(dead_code)] | ||
#[cfg(target_os = "linux")] | ||
async fn is_command_available(cmd: &str) -> bool { | ||
use async_process::Command; | ||
Command::new("which") | ||
.arg(cmd) | ||
.output() | ||
.await | ||
.map(|o| !o.stdout.is_empty()) | ||
.unwrap_or(false) | ||
} | ||
|
||
/// Method with all the commands which could open the browser to call the authorization url | ||
/// If there is no command installed or available on the system, it returns a 'None' and the link | ||
/// will be logged | ||
#[allow(dead_code)] | ||
#[cfg(target_os = "linux")] | ||
async fn find_linux_browser_command() -> Option<String> { | ||
let candidates = [ | ||
"xdg-open", | ||
"gnome-open", | ||
"kfmclient", | ||
"microsoft-edge", | ||
"wslview", | ||
]; | ||
for cmd in candidates.iter() { | ||
if is_command_available(cmd).await { | ||
return Some(cmd.to_string()); | ||
} | ||
} | ||
None | ||
} | ||
|
||
/// starting the browser if the browser could be started, then the webserver should be started to | ||
/// get the auth code | ||
#[allow(dead_code)] | ||
fn handle_browser_command(result: Result<async_process::Child, io::Error>) -> Option<String> { | ||
match result { | ||
Ok(_) => start_webserver(), | ||
Err(e) => { | ||
error!("Failed to start browser command: {e}"); | ||
None | ||
} | ||
} | ||
} | ||
|
||
/// Starts the webserver on the `http://localhost`. Returns None, if the server could not have | ||
/// started | ||
#[allow(dead_code)] | ||
/// Starts a simple HTTP server on localhost to receive the auth code. | ||
fn start_webserver() -> Option<String> { | ||
TcpListener::bind(("127.0.0.1", LOCAL_SERVER_PORT)) | ||
.ok() | ||
.and_then(handle_tcp_connection) | ||
} | ||
|
||
fn handle_tcp_connection(listener: TcpListener) -> Option<String> { | ||
listener | ||
.incoming() | ||
.take(1) | ||
.next()? | ||
.ok() | ||
.and_then(handle_client) | ||
} | ||
/// Main method to handle the incomming traffic. | ||
M-Patrone marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// After a 10s timeout the stream will be closed | ||
/// if the stream could be opened, we read the whole request and try to extract the auth_code | ||
/// Returns also the html code to show if it worked | ||
#[allow(dead_code)] | ||
fn handle_client(mut stream: TcpStream) -> Option<String> { | ||
stream | ||
.set_read_timeout(Some(Duration::from_secs(10))) | ||
.ok()?; | ||
|
||
let buf_reader = BufReader::new(&stream); | ||
let mut request_lines = vec![]; | ||
for line in buf_reader.lines().map_while(Result::ok) { | ||
if line.is_empty() { | ||
break; | ||
} | ||
request_lines.push(line); | ||
} | ||
|
||
let request = request_lines.join("\n"); | ||
|
||
let auth_code = extract_auth_code(&request); | ||
let response_body = r#"<!DOCTYPE html> | ||
<html><head><title>Auth Complete</title></head> | ||
<body><p>Authentication complete. You may close this tab.</p></body> | ||
</html>"#; | ||
|
||
let response = format!( | ||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}", | ||
response_body.len(), | ||
response_body | ||
); | ||
|
||
stream.write_all(response.as_bytes()).ok()?; | ||
stream.flush().ok()?; | ||
stream.shutdown(Shutdown::Both).ok()?; | ||
|
||
auth_code | ||
} | ||
|
||
/// Extracts the `code` query parameter from the request. | ||
#[allow(dead_code)] | ||
fn extract_auth_code(request: &str) -> Option<String> { | ||
let code_start = request.rfind("code=")? + 5; | ||
let rest = &request[code_start..]; | ||
let end = rest.find('&').unwrap_or(rest.len()); | ||
Some(rest[..end].to_string()) | ||
} | ||
|
||
#[cfg(test)] | ||
mod test_internal_server { | ||
use super::*; | ||
use tracing::debug; | ||
use tracing::Level; | ||
use tracing_subscriber::FmtSubscriber; | ||
fn init_logger() { | ||
let subscriber = FmtSubscriber::builder() | ||
.with_max_level(Level::DEBUG) | ||
.finish(); | ||
let _ = tracing::subscriber::set_global_default(subscriber); | ||
} | ||
|
||
#[tokio::test] | ||
async fn test_valid_command() { | ||
init_logger(); | ||
assert!(is_command_available("ls").await); | ||
} | ||
|
||
#[tokio::test] | ||
async fn test_invalid_command() { | ||
init_logger(); | ||
assert!(!is_command_available("non_existing_command_foo").await); | ||
} | ||
|
||
#[test] | ||
fn test_extract_code_param() { | ||
let url = "GET /?code=abc123&state=xyz"; | ||
assert_eq!(extract_auth_code(url).unwrap(), "abc123"); | ||
} | ||
|
||
#[test] | ||
fn test_extract_code_at_end() { | ||
let url = "GET /?state=xyz&code=abc123"; | ||
assert_eq!(extract_auth_code(url).unwrap(), "abc123"); | ||
} | ||
|
||
#[test] | ||
fn test_extract_code_missing() { | ||
let url = "GET /?state=only"; | ||
assert!(extract_auth_code(url).is_none()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
mod internal_server; | ||
|
||
pub mod interactive_browser_credential; |
Uh oh!
There was an error while loading. Please reload this page.