From 14d1f3361163691f3bf9e9f61246fed9f691a83c Mon Sep 17 00:00:00 2001 From: Josh Clayton Date: Tue, 31 Mar 2026 17:52:28 -0400 Subject: [PATCH] feat: Add Jira OAuth 2.0 example Demonstrates the Authorization Code + PKCE flow against Atlassian's OAuth 2.0 (3LO) endpoints, including the audience and prompt=consent parameters Atlassian requires, and a post-auth call to the accessible-resources endpoint to surface the cloud ID needed for Jira API calls. Co-Authored-By: Claude Sonnet 4.6 --- examples/auth_jira.rs | 175 ++++++++++++++++++++++++++++++++++++++++++ justfile | 5 ++ 2 files changed, 180 insertions(+) create mode 100644 examples/auth_jira.rs diff --git a/examples/auth_jira.rs b/examples/auth_jira.rs new file mode 100644 index 0000000..f7f489b --- /dev/null +++ b/examples/auth_jira.rs @@ -0,0 +1,175 @@ +#![expect( + clippy::print_stdout, + clippy::exit, + clippy::expect_used, + reason = "CLI examples can be more lax" +)] +//! End-to-end Jira token acquisition example. +//! +//! Atlassian's OAuth 2.0 (3LO) flow differs from generic providers in two ways: +//! `audience` and `prompt=consent` are required on the authorization request, +//! and redirect URIs must include an exact port number. +//! +//! # Required environment variables +//! +//! | Variable | Description | +//! |----------------------|----------------------------------------------------| +//! | `LOOPAUTH_CLIENT_ID` | OAuth 2.0 client ID from developer.atlassian.com | +//! +//! # Optional environment variables +//! +//! | Variable | Description | Default | +//! |--------------------------|------------------------------------|-----------------------------------------------------| +//! | `LOOPAUTH_CLIENT_SECRET` | Client secret | - | +//! | `LOOPAUTH_SCOPES` | Comma-separated scopes | `read:issue:jira,read:issue:jira-software,...` | +//! | `LOOPAUTH_PORT` | Port for the loopback server | OS-assigned — **must** match your registered callback | +//! +//! # Setup +//! +//! 1. Go to and create an +//! OAuth 2.0 (3LO) app. +//! 2. Under **Permissions**, add the Jira API and enable the relevant +//! granular scopes you need. See `DEFAULT_SCOPES` below for a +//! minimal set of Jira read scopes. +//! 3. Under **Authorization**, add `http://127.0.0.1:/callback` as a +//! callback URL. Atlassian requires an exact URI match including port; +//! pick a fixed port (e.g. `8080`) and use it consistently. +//! 4. Under **Settings**, copy the **Client ID** and **Secret**. +//! +//! ```sh +//! LOOPAUTH_CLIENT_ID=... \ +//! LOOPAUTH_CLIENT_SECRET=... \ +//! LOOPAUTH_PORT=8080 \ +//! cargo run --example auth_jira +//! ``` +//! +//! # Security note +//! +//! The `client_secret` cannot be kept confidential once distributed (it is trivially +//! extractable from the executable). This is an accepted limitation of desktop OAuth clients. +//! PKCE mitigates authorization code interception but does not protect the secret. + +use loopauth::{CliTokenClient, RequestScope}; + +const ATLASSIAN_AUTH_URL: &str = "https://auth.atlassian.com/authorize"; +const ATLASSIAN_TOKEN_URL: &str = "https://auth.atlassian.com/oauth/token"; +const ATLASSIAN_RESOURCES_URL: &str = "https://api.atlassian.com/oauth/token/accessible-resources"; +const DEFAULT_SCOPES: &str = "read:issue:jira,read:issue:jira-software,read:comment:jira,read:project:jira,read:user:jira,read:issue.changelog:jira,offline_access"; +const FAILURE_EXIT_CODE: i32 = 1; +const SIGINT_EXIT_CODE: i32 = 130; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let client_id = require_env("LOOPAUTH_CLIENT_ID"); + let auth_url = url::Url::parse(ATLASSIAN_AUTH_URL).expect("Atlassian auth URL is valid"); + let token_url = url::Url::parse(ATLASSIAN_TOKEN_URL).expect("Atlassian token URL is valid"); + + let client_secret = std::env::var("LOOPAUTH_CLIENT_SECRET").ok(); + let scopes = parse_scopes( + &std::env::var("LOOPAUTH_SCOPES").unwrap_or_else(|_| DEFAULT_SCOPES.to_string()), + ); + let port_hint = std::env::var("LOOPAUTH_PORT") + .ok() + .and_then(|p| p.parse::().ok()); + + let mut builder = CliTokenClient::builder() + .client_id(client_id) + .auth_url(auth_url) + .token_url(token_url) + .add_scopes(scopes) + .on_auth_url(|params| { + params.append("audience", "api.atlassian.com"); + params.append("prompt", "consent"); + }) + .on_url(|url| { + tracing::info!("opening: {url}"); + tracing::info!("waiting for browser callback... (Ctrl+C to cancel)"); + }); + + if let Some(secret) = client_secret { + builder = builder.client_secret(secret); + } + if let Some(port) = port_hint { + builder = builder.port_hint(port); + } + + let auth = builder.build(); + + tracing::info!("starting authorization flow"); + + match auth.run_authorization_flow().await { + Ok(tokens) => { + println!("\n=== Authentication successful ==="); + println!("access_token : {}", tokens.access_token()); + + if let Some(rt) = tokens.refresh_token() { + println!("refresh_token: {rt}"); + } + + print_accessible_resources(tokens.access_token().as_str()).await; + } + Err(loopauth::AuthError::Cancelled) => { + tracing::info!("cancelled"); + std::process::exit(SIGINT_EXIT_CODE); + } + Err(e) => { + tracing::error!("authentication failed: {e}"); + std::process::exit(FAILURE_EXIT_CODE); + } + } +} + +async fn print_accessible_resources(access_token: &str) { + let client = reqwest::Client::new(); + let response = client + .get(ATLASSIAN_RESOURCES_URL) + .bearer_auth(access_token) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + match resp.json::>().await { + Ok(sites) => { + println!("\n=== Accessible Jira sites ==="); + for site in &sites { + let id = site.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let name = site.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let url = site.get("url").and_then(|v| v.as_str()).unwrap_or("?"); + println!("name : {name}"); + println!("url : {url}"); + println!("api : https://api.atlassian.com/ex/jira/{id}/rest/api/3"); + } + } + Err(e) => tracing::warn!("could not parse accessible-resources response: {e}"), + } + } + Ok(resp) => tracing::warn!( + status = resp.status().as_u16(), + "accessible-resources request failed" + ), + Err(e) => tracing::warn!("accessible-resources request error: {e}"), + } +} + +fn require_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| { + tracing::error!("{name} ENV var not set"); + std::process::exit(FAILURE_EXIT_CODE); + }) +} + +fn parse_scopes(s: &str) -> Vec { + s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(RequestScope::from) + .collect() +} diff --git a/justfile b/justfile index f24da73..6f7cc79 100644 --- a/justfile +++ b/justfile @@ -112,6 +112,11 @@ run-auth-example: run-auth-discovery-example: cargo run --example auth_discovery +# Run auth example for Jira +[group('integration-test')] +run-auth-jira-example: + cargo run --example auth_jira + [group('cargo')] verify-publish: cargo publish --dry-run --allow-dirty