diff --git a/examples/auth_slack.rs b/examples/auth_slack.rs new file mode 100644 index 0000000..45ccfd7 --- /dev/null +++ b/examples/auth_slack.rs @@ -0,0 +1,189 @@ +#![expect( + clippy::print_stdout, + clippy::exit, + clippy::expect_used, + reason = "CLI examples can be more lax" +)] +//! End-to-end Slack OAuth v2 token acquisition example. +//! +//! Slack's OAuth v2 deviates from RFC 6749 in several ways: +//! +//! - **Nested token response**: The access token lives inside an `authed_user` +//! sub-object, not at the top level. This example uses +//! [`CliTokenClientBuilder::token_response_type`] with a custom +//! [`From for TokenResponseFields`] to extract the +//! standard fields from the nested structure. +//! +//! - **Comma-delimited scopes**: Slack returns granted scopes separated by +//! commas instead of the RFC 6749 §3.3 space delimiter. The `From` impl +//! normalizes commas to spaces so loopauth's scope resolution works +//! correctly. +//! +//! - **`user_scope` parameter**: Slack uses a non-standard `user_scope` query +//! parameter on the authorization URL (rather than the standard `scope` +//! parameter). [`ExtraAuthParams`] handles this via `on_auth_url`. +//! +//! - **HTTPS redirect required**: Slack requires `https://` redirect URIs, +//! even on localhost. This example uses [`TlsCertificate::ensure_localhost`] +//! with a fixed port. +//! +//! # Required environment variables +//! +//! | Variable | Description | +//! |--------------------------|---------------------------------------------| +//! | `LOOPAUTH_CLIENT_ID` | OAuth 2.0 client ID from api.slack.com | +//! | `LOOPAUTH_CLIENT_SECRET` | OAuth 2.0 client secret | +//! | `LOOPAUTH_TLS_DIR` | Directory for managed TLS certs (via mkcert)| +//! +//! # Optional environment variables +//! +//! | Variable | Description | Default | +//! |-------------------|--------------------------------|-----------------------------------------------------------| +//! | `LOOPAUTH_SCOPES` | Comma-separated user scopes | `channels:history,channels:read,groups:history,groups:read`| +//! | `LOOPAUTH_PORT` | Port for the HTTPS loopback | `8443` | +//! +//! # Setup +//! +//! 1. Go to and create a new app. +//! 2. Under **OAuth & Permissions**, add `https://127.0.0.1:8443/callback` +//! as a redirect URL. +//! 3. Under **User Token Scopes**, add the scopes you need. +//! 4. Copy the **Client ID** and **Client Secret** from **Basic Information**. +//! 5. Install [`mkcert`](https://github.com/FiloSottile/mkcert) and run +//! `mkcert -install` once to trust the local CA. +//! +//! ```sh +//! LOOPAUTH_CLIENT_ID=... \ +//! LOOPAUTH_CLIENT_SECRET=... \ +//! LOOPAUTH_TLS_DIR=~/.config/loopauth-slack/tls \ +//! cargo run --example auth_slack +//! ``` + +use loopauth::{CliTokenClient, TlsCertificate, TokenResponseFields}; + +const SLACK_AUTH_URL: &str = "https://slack.com/oauth/v2/authorize"; +const SLACK_TOKEN_URL: &str = "https://slack.com/api/oauth.v2.access"; +const DEFAULT_SCOPES: &str = "channels:history,channels:read,groups:history,groups:read"; +const DEFAULT_PORT: u16 = 8443; +const FAILURE_EXIT_CODE: i32 = 1; +const SIGINT_EXIT_CODE: i32 = 130; + +// Slack nests user tokens inside `authed_user` rather than at the top level. +// We deserialize into this shape and then convert to `TokenResponseFields`. + +#[derive(serde::Deserialize)] +struct SlackV2TokenResponse { + authed_user: SlackAuthedUser, +} + +#[derive(serde::Deserialize)] +struct SlackAuthedUser { + access_token: String, + refresh_token: Option, + expires_in: Option, + scope: Option, +} + +impl From for TokenResponseFields { + fn from(resp: SlackV2TokenResponse) -> Self { + // Slack returns scopes comma-separated; RFC 6749 §3.3 uses spaces. + let scope = resp.authed_user.scope.map(|s| s.replace(',', " ")); + + Self::new(resp.authed_user.access_token) + .with_refresh_token(resp.authed_user.refresh_token) + .with_expires_in(resp.authed_user.expires_in) + .with_token_type(Some("Bearer".to_string())) + .with_scope(scope) + } +} + +#[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 client_secret = require_env("LOOPAUTH_CLIENT_SECRET"); + let user_scopes = + std::env::var("LOOPAUTH_SCOPES").unwrap_or_else(|_| DEFAULT_SCOPES.to_string()); + let port: u16 = std::env::var("LOOPAUTH_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_PORT); + + let auth_url = url::Url::parse(SLACK_AUTH_URL).expect("Slack auth URL is valid"); + let token_url = url::Url::parse(SLACK_TOKEN_URL).expect("Slack token URL is valid"); + + // Slack requires HTTPS redirect URIs, even on localhost. + let tls_dir = require_env("LOOPAUTH_TLS_DIR"); + tracing::info!("using managed TLS certificates in {tls_dir}"); + let cert = TlsCertificate::ensure_localhost(&tls_dir).unwrap_or_else(|e| { + tracing::error!("TLS certificate setup failed: {e}"); + if matches!(e, loopauth::TlsCertificateError::MkcertNotFound) { + println!("\n{}", TlsCertificate::SETUP_GUIDE_MANAGED); + } + std::process::exit(FAILURE_EXIT_CODE); + }); + + let client = CliTokenClient::builder() + .client_id(client_id) + .client_secret(client_secret) + .auth_url(auth_url) + .token_url(token_url) + // Parse Slack's nested `authed_user` response into standard fields. + .token_response_type::() + .use_https_with(cert) + .require_port(port) + // Slack uses `user_scope` instead of the standard `scope` parameter. + .on_auth_url(move |params| { + params.append("user_scope", &user_scopes); + }) + .on_url(|url| { + tracing::info!("opening: {url}"); + tracing::info!("waiting for browser callback... (Ctrl+C to cancel)"); + }) + .build(); + + tracing::info!("starting Slack OAuth v2 authorization flow"); + + match client.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}"); + } + + let scopes: Vec = tokens.scopes().iter().map(ToString::to_string).collect(); + if !scopes.is_empty() { + println!("scopes : {}", scopes.join(", ")); + } + + if let Some(expires) = tokens.expires_at() + && let Ok(remaining) = expires.duration_since(std::time::SystemTime::now()) + { + println!("expires in : {}s", remaining.as_secs()); + } + } + 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); + } + } +} + +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); + }) +} diff --git a/justfile b/justfile index 6f7cc79..c8fabf2 100644 --- a/justfile +++ b/justfile @@ -117,6 +117,11 @@ run-auth-discovery-example: run-auth-jira-example: cargo run --example auth_jira +# Run auth example for Slack OAuth v2 +[group('integration-test')] +run-auth-slack-example: + cargo run --example auth_slack + [group('cargo')] verify-publish: cargo publish --dry-run --allow-dirty diff --git a/src/builder.rs b/src/builder.rs index bf45d00..e294881 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,6 +1,7 @@ use crate::error::{AuthError, CallbackError, RefreshError}; use crate::jwks::{JwksValidator, JwksValidatorStorage, RemoteJwksValidator}; use crate::oidc::OpenIdConfiguration; +use crate::token_response::{TokenParser, default_token_parser}; /// Whether JWKS signature verification is performed on received ID tokens. /// @@ -178,6 +179,7 @@ pub struct CliTokenClient { oidc_jwks: Option, http_client: reqwest::Client, transport: Arc, + token_parser: TokenParser, } impl CliTokenClient { @@ -371,6 +373,7 @@ impl CliTokenClient { &self.token_url, self.client_id.as_str(), self.client_secret.as_deref(), + &self.token_parser, refresh_token, &self.scopes, ) @@ -443,16 +446,6 @@ impl CliTokenClient { } } -#[derive(serde::Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, - token_type: Option, - id_token: Option, - scope: Option, -} - /// Parse an `id_token` JWT from a token response, if `openid` was in the requested scopes. /// /// Returns `Ok(None)` when `openid` was not requested or when the provider omitted `id_token`. @@ -632,6 +625,7 @@ async fn handle_callback( &auth.token_url, auth.client_id.as_str(), auth.client_secret.as_deref(), + &auth.token_parser, &code, redirect_uri_url.as_str(), code_verifier, @@ -676,7 +670,7 @@ async fn handle_callback( // Send success HTML to callback handler (renderer > html string > default) let html = render_success_html( &token_set, - &auth.scopes, + token_set.scopes(), redirect_uri_url, auth.client_id.as_str(), auth.success_renderer.as_deref(), @@ -746,6 +740,7 @@ async fn exchange_code( token_url: &url::Url, client_id: &str, client_secret: Option<&str>, + token_parser: &TokenParser, code: &str, redirect_uri: &str, code_verifier: &str, @@ -778,29 +773,26 @@ async fn exchange_code( } let body = response.text().await?; - let token_response: TokenResponse = - serde_json::from_str(&body).map_err(|e| AuthError::Server(format!("{e}: {body}")))?; + let fields = token_parser(&body).map_err(|e| AuthError::TokenParse(format!("{e}: {body}")))?; - let expires_at = token_response + let expires_at = fields .expires_in - .map(|secs| t0 + std::time::Duration::from_secs(secs)); + .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs))); - let oidc = parse_oidc_if_requested(token_response.id_token.as_deref(), scopes) - .map_err(AuthError::IdToken)?; + let oidc = + parse_oidc_if_requested(fields.id_token.as_deref(), scopes).map_err(AuthError::IdToken)?; // RFC 6749 §5.1: if scope omitted, use requested scopes - let resolved_scopes = token_response + let resolved_scopes = fields .scope .as_deref() .map_or_else(|| scopes.to_vec(), parse_scopes); Ok(crate::token::TokenSet::new( - token_response.access_token, - token_response.refresh_token, + fields.access_token, + fields.refresh_token, expires_at, - token_response - .token_type - .unwrap_or_else(|| "Bearer".to_string()), + fields.token_type.unwrap_or_else(|| "Bearer".to_string()), oidc, resolved_scopes, )) @@ -811,6 +803,7 @@ async fn exchange_refresh_token( token_url: &url::Url, client_id: &str, client_secret: Option<&str>, + token_parser: &TokenParser, refresh_token: &str, scopes: &[crate::scope::OAuth2Scope], ) -> Result, RefreshError> { @@ -850,17 +843,19 @@ async fn exchange_refresh_token( return Err(RefreshError::TokenExchange { status, body }); } - let token_response: TokenResponse = response.json().await?; // RefreshError::Request via #[from] reqwest::Error + let body = response.text().await?; + let fields = + token_parser(&body).map_err(|e| RefreshError::TokenParse(format!("{e}: {body}")))?; - let expires_at = token_response + let expires_at = fields .expires_in - .map(|secs| t0 + std::time::Duration::from_secs(secs)); + .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs))); - let oidc = parse_oidc_if_requested(token_response.id_token.as_deref(), scopes) + let oidc = parse_oidc_if_requested(fields.id_token.as_deref(), scopes) .map_err(RefreshError::IdToken)?; // RFC 6749 §5.1: if scope omitted, use requested scopes - let resolved_scopes = token_response + let resolved_scopes = fields .scope .as_deref() .map_or_else(|| scopes.to_vec(), parse_scopes); @@ -869,17 +864,15 @@ async fn exchange_refresh_token( // the client MUST discard the old one and replace it with the new one. // When the server omits refresh_token from the response, the original // refresh token remains valid and must be preserved. - let resolved_refresh_token = token_response + let resolved_refresh_token = fields .refresh_token .or_else(|| Some(refresh_token.to_string())); Ok(crate::token::TokenSet::new( - token_response.access_token, + fields.access_token, resolved_refresh_token, expires_at, - token_response - .token_type - .unwrap_or_else(|| "Bearer".to_string()), + fields.token_type.unwrap_or_else(|| "Bearer".to_string()), oidc, resolved_scopes, )) @@ -1006,6 +999,7 @@ struct BuilderConfig { on_auth_url: Option, on_url: Option, on_server_ready: Option, + token_parser: Option, } impl Default for BuilderConfig { @@ -1024,6 +1018,7 @@ impl Default for BuilderConfig { on_url: None, on_server_ready: None, issuer: None, + token_parser: None, } } } @@ -1299,6 +1294,29 @@ impl CliTokenClientBuilder { self } + /// Use a custom token response type for non-standard providers. + /// + /// The type `R` must implement [`serde::Deserialize`] and + /// Into<[TokenResponseFields](crate::TokenResponseFields)>. It will be deserialized from the + /// token endpoint's JSON response and converted into the standard fields. + /// This is useful for providers like Slack that nest tokens inside a + /// sub-object rather than placing them at the top level. + /// + /// When not called, the standard OAuth 2.0 flat response format is used. + /// + /// [`TokenResponseFields`]: crate::TokenResponseFields + #[must_use] + pub fn token_response_type(mut self) -> Self + where + R: serde::de::DeserializeOwned + + Into + + Send + + 'static, + { + self.config.token_parser = Some(crate::token_response::custom_token_parser::()); + self + } + /// Register a callback that appends extra query parameters to the authorization URL. /// /// The callback receives a `&mut` [`ExtraAuthParams`] and may call @@ -1665,6 +1683,7 @@ fn build_client( .build() .unwrap_or_default(), transport, + token_parser: config.token_parser.unwrap_or_else(default_token_parser), } } diff --git a/src/error.rs b/src/error.rs index 887df28..2b87b6f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,10 @@ pub enum AuthError { /// A network-level request error occurred. #[error("request failed: {0}")] Request(#[from] reqwest::Error), + /// The token endpoint returned a 2xx response whose body could not be + /// parsed into the expected token response structure. + #[error("failed to parse token response: {0}")] + TokenParse(String), /// An internal server or channel error occurred. #[error("server error: {0}")] Server(String), @@ -80,6 +84,10 @@ pub enum RefreshError { /// A network-level request error occurred. #[error("request failed: {0}")] Request(#[from] reqwest::Error), + /// The token endpoint returned a 2xx response whose body could not be + /// parsed into the expected token response structure. + #[error("failed to parse token response: {0}")] + TokenParse(String), /// An error occurred while validating the `id_token`. #[error(transparent)] IdToken(#[from] IdTokenError), @@ -171,6 +179,18 @@ mod tests { ); } + #[test] + fn auth_error_token_parse_contains_message() { + let err = AuthError::TokenParse("bad json".to_string()); + assert!(err.to_string().contains("bad json")); + } + + #[test] + fn refresh_error_token_parse_contains_message() { + let err = RefreshError::TokenParse("bad json".to_string()); + assert!(err.to_string().contains("bad json")); + } + #[test] fn token_store_error_serialization_contains_message() { let err = TokenStoreError::Serialization("bad json".to_string()); diff --git a/src/lib.rs b/src/lib.rs index 1666c45..5a36d48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,6 +115,7 @@ mod server; mod store; mod tls; mod token; +mod token_response; #[cfg(any(test, doctest, feature = "testing"))] #[doc(hidden)] @@ -133,3 +134,4 @@ pub use tls::{TlsCertificate, TlsCertificateError}; pub use token::{ AccessToken, RefreshOutcome, RefreshToken, TokenSet, Unvalidated, Validated, ValidationState, }; +pub use token_response::TokenResponseFields; diff --git a/src/test_support.rs b/src/test_support.rs index 4b6ff77..7a47121 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -39,6 +39,8 @@ struct FakeTokenResponse { refresh_token: Option, #[serde(skip_serializing_if = "Option::is_none")] id_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, } /// Builder for [`FakeOAuthServer`] with opt-in discovery and JWKS capabilities. @@ -448,6 +450,7 @@ async fn token_handler( expires_in: DEFAULT_TOKEN_EXPIRY_SECS, refresh_token: None, id_token: None, + scope: None, })) } @@ -475,6 +478,7 @@ async fn refresh_token_handler( expires_in: state.2, refresh_token: Some(state.1.clone()), id_token: None, + scope: None, })) } @@ -500,6 +504,7 @@ async fn refresh_no_rt_handler( expires_in: state.1, refresh_token: None, id_token: None, + scope: None, })) } @@ -558,5 +563,121 @@ async fn oidc_token_handler( expires_in: DEFAULT_TOKEN_EXPIRY_SECS, refresh_token: None, id_token: Some(id_token), + scope: None, + })) +} + +/// A token response that nests the access token inside a sub-object, +/// matching the pattern used by Slack OAuth v2 and similar providers. +#[derive(Debug, Serialize)] +struct NestedFakeTokenResponse { + ok: bool, + authed_user: NestedAuthedUser, +} + +#[derive(Debug, Serialize)] +struct NestedAuthedUser { + access_token: String, + token_type: String, + expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, +} + +impl FakeOAuthServer { + /// Like `start`, but the /token endpoint returns a nested response where + /// `access_token` lives inside an `authed_user` sub-object (Slack v2 style). + pub async fn start_with_nested_response(token_value: impl Into) -> Self { + let token = Arc::new(token_value.into()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let token_clone = Arc::clone(&token); + let app = Router::new() + .route("/authorize", get(authorize_handler)) + .route("/token", post(nested_token_handler)) + .with_state(token_clone); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + Self { + port, + access_token: token.as_ref().clone(), + refresh_token: String::new(), + rsa_private_key: None, + } + } +} + +impl FakeOAuthServer { + /// Like `start`, but the /token endpoint includes a `scope` field in the response. + /// + /// Useful for testing that loopauth uses the provider-granted scopes (from the + /// response) rather than the builder-configured scopes. + pub async fn start_with_scope( + token_value: impl Into, + response_scope: impl Into, + ) -> Self { + let state = Arc::new((token_value.into(), response_scope.into())); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let state_clone = Arc::clone(&state); + let app = Router::new() + .route("/authorize", get(authorize_handler)) + .route("/token", post(scoped_token_handler)) + .with_state(state_clone); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + Self { + port, + access_token: state.0.clone(), + refresh_token: String::new(), + rsa_private_key: None, + } + } +} + +async fn scoped_token_handler( + State(state): State>, + Form(body): Form>, +) -> Result, StatusCode> { + match body.get("code_verifier") { + Some(cv) if !cv.is_empty() => {} + _ => return Err(StatusCode::BAD_REQUEST), + } + + Ok(Json(FakeTokenResponse { + access_token: state.0.clone(), + token_type: "Bearer".to_string(), + expires_in: DEFAULT_TOKEN_EXPIRY_SECS, + refresh_token: None, + id_token: None, + scope: Some(state.1.clone()), + })) +} + +async fn nested_token_handler( + State(token): State>, + Form(body): Form>, +) -> Result, StatusCode> { + match body.get("code_verifier") { + Some(cv) if !cv.is_empty() => {} + _ => return Err(StatusCode::BAD_REQUEST), + } + + Ok(Json(NestedFakeTokenResponse { + ok: true, + authed_user: NestedAuthedUser { + access_token: token.as_ref().clone(), + token_type: "Bearer".to_string(), + expires_in: DEFAULT_TOKEN_EXPIRY_SECS, + refresh_token: None, + }, })) } diff --git a/src/token_response.rs b/src/token_response.rs new file mode 100644 index 0000000..d6bc508 --- /dev/null +++ b/src/token_response.rs @@ -0,0 +1,261 @@ +//! Custom token response parsing for non-standard OAuth providers. +//! +//! Standard OAuth 2.0 token responses place `access_token` at the top level. +//! Some providers (e.g., Slack v2) nest tokens inside a sub-object. To support +//! these, define a custom [`serde::Deserialize`] type and implement +//! `Into`, then pass it to +//! [`CliTokenClientBuilder::token_response_type`](crate::CliTokenClientBuilder::token_response_type). +//! +//! By default, [`TokenResponseFields`] is deserialized directly from the +//! response body, which handles the standard flat OAuth 2.0 format. + +/// The standard fields extracted from a token endpoint response. +/// +/// Consumed internally by loopauth to build a [`crate::TokenSet`]. +/// +/// For the standard flat OAuth 2.0 response format, this struct is +/// deserialized directly. For non-standard providers, define a custom type +/// that implements `Into` and pass it to +/// [`CliTokenClientBuilder::token_response_type`](crate::CliTokenClientBuilder::token_response_type). +/// +/// # Example: Custom provider response +/// +/// ``` +/// use loopauth::TokenResponseFields; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct SlackV2TokenResponse { +/// authed_user: SlackAuthedUser, +/// } +/// +/// #[derive(Deserialize)] +/// struct SlackAuthedUser { +/// access_token: String, +/// refresh_token: Option, +/// expires_in: Option, +/// } +/// +/// impl From for TokenResponseFields { +/// fn from(resp: SlackV2TokenResponse) -> Self { +/// TokenResponseFields::new(resp.authed_user.access_token) +/// .with_refresh_token(resp.authed_user.refresh_token) +/// .with_expires_in(resp.authed_user.expires_in) +/// .with_token_type(Some("Bearer".to_string())) +/// } +/// } +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TokenResponseFields { + /// The access token issued by the authorization server. + pub access_token: String, + /// The refresh token, if the server issued one. + #[serde(default)] + pub refresh_token: Option, + /// The lifetime in seconds of the access token. + #[serde(default)] + pub expires_in: Option, + /// The token type (e.g., `"Bearer"`). + #[serde(default)] + pub token_type: Option, + /// The ID token JWT, if `OpenID Connect` was requested. + #[serde(default)] + pub id_token: Option, + /// The scope granted by the server (space-separated). + #[serde(default)] + pub scope: Option, +} + +impl TokenResponseFields { + /// Create a new `TokenResponseFields` with the required access token. + /// + /// All optional fields default to `None`. Use the `with_*` methods to set them. + #[must_use] + pub const fn new(access_token: String) -> Self { + Self { + access_token, + refresh_token: None, + expires_in: None, + token_type: None, + id_token: None, + scope: None, + } + } + + /// Set the refresh token. + #[must_use] + pub fn with_refresh_token(mut self, refresh_token: Option) -> Self { + self.refresh_token = refresh_token; + self + } + + /// Set the token lifetime in seconds. + #[must_use] + pub const fn with_expires_in(mut self, expires_in: Option) -> Self { + self.expires_in = expires_in; + self + } + + /// Set the token type. + #[must_use] + pub fn with_token_type(mut self, token_type: Option) -> Self { + self.token_type = token_type; + self + } + + /// Set the ID token JWT. + #[must_use] + pub fn with_id_token(mut self, id_token: Option) -> Self { + self.id_token = id_token; + self + } + + /// Set the granted scope. + #[must_use] + pub fn with_scope(mut self, scope: Option) -> Self { + self.scope = scope; + self + } +} + +// ── Parser closure type ───────────────────────────────────────────────────── + +pub type TokenParser = Box Result + Send + Sync>; + +pub fn default_token_parser() -> TokenParser { + Box::new(|body: &str| serde_json::from_str(body).map_err(|e| e.to_string())) +} + +pub fn custom_token_parser() -> TokenParser +where + R: serde::de::DeserializeOwned + Into + Send + 'static, +{ + Box::new(|body: &str| { + let response: R = serde_json::from_str(body).map_err(|e| e.to_string())?; + Ok(response.into()) + }) +} + +#[cfg(test)] +mod tests { + #![expect(clippy::unwrap_used, reason = "tests use unwrap for brevity")] + use super::*; + + #[test] + fn default_parser_parses_standard_flat_response() { + let json = r#"{ + "access_token": "tok_abc", + "refresh_token": "ref_xyz", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "read write" + }"#; + let parser = default_token_parser(); + let fields = parser(json).unwrap(); + assert_eq!(fields.access_token, "tok_abc", "access_token should match"); + assert_eq!( + fields.refresh_token.as_deref(), + Some("ref_xyz"), + "refresh_token should match" + ); + assert_eq!(fields.expires_in, Some(3600), "expires_in should match"); + assert_eq!( + fields.token_type.as_deref(), + Some("Bearer"), + "token_type should match" + ); + assert_eq!( + fields.scope.as_deref(), + Some("read write"), + "scope should match" + ); + assert!(fields.id_token.is_none(), "id_token should be None"); + } + + #[test] + fn default_parser_handles_minimal_response() { + let json = r#"{"access_token": "tok"}"#; + let parser = default_token_parser(); + let fields = parser(json).unwrap(); + assert_eq!(fields.access_token, "tok", "access_token should match"); + assert!( + fields.refresh_token.is_none(), + "refresh_token should be None" + ); + assert!(fields.expires_in.is_none(), "expires_in should be None"); + } + + #[test] + fn default_parser_rejects_missing_access_token() { + let json = r#"{"refresh_token": "ref"}"#; + let parser = default_token_parser(); + assert!(parser(json).is_err(), "should fail without access_token"); + } + + // ── Custom nested response (Slack-style) ──────────────────────────── + + #[derive(serde::Deserialize)] + struct NestedTokenResponse { + authed_user: NestedUser, + } + + #[derive(serde::Deserialize)] + struct NestedUser { + access_token: String, + refresh_token: Option, + expires_in: Option, + } + + impl From for TokenResponseFields { + fn from(resp: NestedTokenResponse) -> Self { + Self::new(resp.authed_user.access_token) + .with_refresh_token(resp.authed_user.refresh_token) + .with_expires_in(resp.authed_user.expires_in) + .with_token_type(Some("Bearer".to_string())) + } + } + + #[test] + fn custom_parser_parses_nested_response() { + let json = r#"{ + "ok": true, + "authed_user": { + "access_token": "xoxp-slack-token", + "refresh_token": "xoxe-refresh", + "expires_in": 43200 + } + }"#; + let parser = custom_token_parser::(); + let fields = parser(json).unwrap(); + assert_eq!( + fields.access_token, "xoxp-slack-token", + "should extract nested access_token" + ); + assert_eq!( + fields.refresh_token.as_deref(), + Some("xoxe-refresh"), + "should extract nested refresh_token" + ); + assert_eq!( + fields.expires_in, + Some(43200), + "should extract nested expires_in" + ); + assert_eq!( + fields.token_type.as_deref(), + Some("Bearer"), + "should use impl-provided token_type" + ); + } + + #[test] + fn custom_parser_rejects_flat_response() { + let json = r#"{"access_token": "tok"}"#; + let parser = custom_token_parser::(); + assert!( + parser(json).is_err(), + "nested parser should reject flat response" + ); + } +} diff --git a/tests/custom_token_response.rs b/tests/custom_token_response.rs new file mode 100644 index 0000000..a6546c9 --- /dev/null +++ b/tests/custom_token_response.rs @@ -0,0 +1,117 @@ +#![expect( + clippy::indexing_slicing, + clippy::expect_used, + clippy::unwrap_used, + reason = "tests do not need to meet production lint standards" +)] +use loopauth::{CliTokenClient, TokenResponseFields, test_support::FakeOAuthServer}; + +#[derive(serde::Deserialize)] +struct NestedTokenResponse { + authed_user: NestedAuthedUser, +} + +#[derive(serde::Deserialize)] +struct NestedAuthedUser { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +impl From for TokenResponseFields { + fn from(resp: NestedTokenResponse) -> Self { + Self::new(resp.authed_user.access_token) + .with_refresh_token(resp.authed_user.refresh_token) + .with_expires_in(resp.authed_user.expires_in) + .with_token_type(Some("Bearer".to_string())) + } +} + +#[tokio::test] +async fn nested_response_smoke_test() { + let fake = FakeOAuthServer::start_with_nested_response("nested_token").await; + tokio::task::yield_now().await; + + let client = reqwest::Client::new(); + let response = client + .post(fake.token_url()) + .form(&[("code_verifier", "test_verifier"), ("code", "fake_code")]) + .send() + .await + .expect("token request should succeed"); + assert_eq!(response.status(), 200, "should return 200"); + let body: serde_json::Value = response.json().await.expect("body should be JSON"); + assert_eq!( + body["authed_user"]["access_token"], "nested_token", + "access_token should be nested" + ); + assert!( + body.get("access_token").is_none(), + "no top-level access_token" + ); +} + +#[tokio::test] +async fn full_round_trip_with_custom_token_response_type() { + let fake = FakeOAuthServer::start_with_nested_response("xoxp-nested-token").await; + tokio::task::yield_now().await; + + let (url_tx, url_rx) = std::sync::mpsc::channel::(); + + tokio::spawn(async move { + if let Ok(url) = url_rx.recv() { + let _ = reqwest::get(url).await; + } + }); + + let client = CliTokenClient::builder() + .client_id("test-client") + .auth_url(fake.auth_url()) + .token_url(fake.token_url()) + .open_browser(false) + .token_response_type::() + .on_url(move |url| { + let _ = url_tx.send(url.to_string()); + }) + .build(); + + let tokens = client.run_authorization_flow().await.unwrap(); + assert_eq!( + tokens.access_token().as_str(), + "xoxp-nested-token", + "should extract token from nested response" + ); +} + +#[tokio::test] +async fn default_parser_rejects_nested_response() { + let fake = FakeOAuthServer::start_with_nested_response("xoxp-nested-token").await; + tokio::task::yield_now().await; + + let (url_tx, url_rx) = std::sync::mpsc::channel::(); + + tokio::spawn(async move { + if let Ok(url) = url_rx.recv() { + let _ = reqwest::get(url).await; + } + }); + + // Build WITHOUT .token_response_type — should fail to parse nested response + let client = CliTokenClient::builder() + .client_id("test-client") + .auth_url(fake.auth_url()) + .token_url(fake.token_url()) + .open_browser(false) + .on_url(move |url| { + let _ = url_tx.send(url.to_string()); + }) + .build(); + + let result = client.run_authorization_flow().await; + assert!( + result.is_err(), + "default parser should fail on nested response" + ); +} diff --git a/tests/page_context_scopes.rs b/tests/page_context_scopes.rs new file mode 100644 index 0000000..02cb8ae --- /dev/null +++ b/tests/page_context_scopes.rs @@ -0,0 +1,142 @@ +#![expect( + clippy::unwrap_used, + reason = "tests do not need to meet production lint standards" +)] + +use async_trait::async_trait; +use loopauth::{ + CliTokenClient, PageContext, RequestScope, SuccessPageRenderer, test_support::FakeOAuthServer, +}; +use std::sync::{Arc, Mutex}; + +/// Captures the scopes from `PageContext` during rendering. +struct ScopeCapturingRenderer { + captured_scopes: Arc>>, +} + +#[async_trait] +impl SuccessPageRenderer for ScopeCapturingRenderer { + async fn render_success(&self, ctx: &PageContext<'_>) -> String { + let scopes: Vec = ctx.scopes().iter().map(ToString::to_string).collect(); + *self.captured_scopes.lock().unwrap() = scopes; + "ok".to_string() + } +} + +/// When the provider returns a narrower scope than requested, `PageContext.scopes()` +/// should reflect the provider-granted scopes, not the builder-configured scopes. +/// +/// RFC 6749 §5.1: "If the scope of the access token is identical to the scope +/// requested by the client, the authorization server MAY omit the scope +/// response parameter. If the issued scope differs, the authorization server +/// MUST include the scope response parameter." +#[tokio::test] +async fn page_context_shows_response_granted_scopes_not_builder_scopes() { + // Server grants only "read", even though client requests "read" and "write" + let fake = FakeOAuthServer::start_with_scope("tok", "read").await; + tokio::task::yield_now().await; + + let captured_scopes: Arc>> = Arc::new(Mutex::new(vec![])); + let renderer_scopes = Arc::clone(&captured_scopes); + + let (url_tx, url_rx) = std::sync::mpsc::channel::(); + + tokio::spawn(async move { + if let Ok(url) = url_rx.recv() { + let _ = reqwest::get(url).await; + } + }); + + let client = CliTokenClient::builder() + .client_id("test-client") + .auth_url(fake.auth_url()) + .token_url(fake.token_url()) + .add_scopes([ + RequestScope::Custom("read".into()), + RequestScope::Custom("write".into()), + ]) + .open_browser(false) + .success_renderer(ScopeCapturingRenderer { + captured_scopes: renderer_scopes, + }) + .on_url(move |url| { + let _ = url_tx.send(url.to_string()); + }) + .build(); + + let tokens = client.run_authorization_flow().await.unwrap(); + + // TokenSet.scopes() should reflect the response-granted scope + let token_scopes: Vec = tokens.scopes().iter().map(ToString::to_string).collect(); + assert_eq!( + token_scopes, + vec!["read"], + "TokenSet should have response-granted scopes" + ); + + // PageContext.scopes() should ALSO reflect the response-granted scope, + // not the builder-configured ["read", "write"] + let page_scopes = captured_scopes.lock().unwrap().clone(); + assert_eq!( + page_scopes, + vec!["read"], + "PageContext should show response-granted scopes, not builder-configured scopes" + ); +} + +/// When the provider omits scope from the response, `PageContext.scopes()` should +/// fall back to the requested scopes per RFC 6749 §5.1. +#[tokio::test] +async fn page_context_falls_back_to_requested_scopes_when_response_omits_scope() { + // Server returns no scope field — standard FakeOAuthServer behavior + let fake = FakeOAuthServer::start("tok").await; + tokio::task::yield_now().await; + + let captured_scopes: Arc>> = Arc::new(Mutex::new(vec![])); + let renderer_scopes = Arc::clone(&captured_scopes); + + let (url_tx, url_rx) = std::sync::mpsc::channel::(); + + tokio::spawn(async move { + if let Ok(url) = url_rx.recv() { + let _ = reqwest::get(url).await; + } + }); + + let client = CliTokenClient::builder() + .client_id("test-client") + .auth_url(fake.auth_url()) + .token_url(fake.token_url()) + .add_scopes([ + RequestScope::Custom("read".into()), + RequestScope::Custom("write".into()), + ]) + .open_browser(false) + .success_renderer(ScopeCapturingRenderer { + captured_scopes: renderer_scopes, + }) + .on_url(move |url| { + let _ = url_tx.send(url.to_string()); + }) + .build(); + + let tokens = client.run_authorization_flow().await.unwrap(); + + // When provider omits scope, TokenSet uses the requested scopes + let token_scopes: Vec = tokens.scopes().iter().map(ToString::to_string).collect(); + assert!( + token_scopes.contains(&"read".to_string()), + "TokenSet should fall back to requested scopes" + ); + assert!( + token_scopes.contains(&"write".to_string()), + "TokenSet should fall back to requested scopes" + ); + + // PageContext should match TokenSet — both use response-granted (or fallback) scopes + let page_scopes = captured_scopes.lock().unwrap().clone(); + assert_eq!( + page_scopes, token_scopes, + "PageContext scopes should match TokenSet scopes" + ); +}