From c597234cd9a9c49466c7cee4bb09098fc15afb96 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 20:22:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20HydraClient=20=E2=80=94=20OAuth2/OIDC?= =?UTF-8?q?=20admin=20API=20(35=20endpoints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed Hydra admin API client covering OAuth2 clients, login/consent/logout flows, JWK sets, trusted JWT issuers, sessions, and token introspection. Bump: sunbeam-sdk v0.4.0 --- sunbeam-sdk/Cargo.toml | 2 +- sunbeam-sdk/src/auth/hydra/mod.rs | 482 ++++++++++++++++++++++++++++ sunbeam-sdk/src/auth/hydra/types.rs | 317 ++++++++++++++++++ sunbeam-sdk/src/auth/mod.rs | 3 + 4 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 sunbeam-sdk/src/auth/hydra/mod.rs create mode 100644 sunbeam-sdk/src/auth/hydra/types.rs diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index b5e2e9c..1214d27 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sunbeam-sdk" -version = "0.3.0" +version = "0.4.0" edition = "2024" description = "Sunbeam SDK — reusable library for cluster management" repository = "https://src.sunbeam.pt/studio/cli" diff --git a/sunbeam-sdk/src/auth/hydra/mod.rs b/sunbeam-sdk/src/auth/hydra/mod.rs new file mode 100644 index 0000000..42fad64 --- /dev/null +++ b/sunbeam-sdk/src/auth/hydra/mod.rs @@ -0,0 +1,482 @@ +//! Ory Hydra OAuth2/OIDC admin API client. + +pub mod types; + +use crate::client::{AuthMethod, HttpTransport, ServiceClient}; +use crate::error::Result; +use reqwest::Method; +use types::*; + +/// Client for the Ory Hydra Admin API. +pub struct HydraClient { + pub(crate) transport: HttpTransport, +} + +impl ServiceClient for HydraClient { + fn service_name(&self) -> &'static str { + "hydra" + } + + fn base_url(&self) -> &str { + &self.transport.base_url + } + + fn from_parts(base_url: String, auth: AuthMethod) -> Self { + Self { + transport: HttpTransport::new(&base_url, auth), + } + } +} + +impl HydraClient { + /// Build a HydraClient from domain (e.g. `https://auth.{domain}`). + pub fn connect(domain: &str) -> Self { + let base_url = format!("https://auth.{domain}"); + Self::from_parts(base_url, AuthMethod::None) + } + + // -- OAuth2 Clients ----------------------------------------------------- + + pub async fn list_clients( + &self, + limit: Option, + offset: Option, + ) -> Result> { + let limit = limit.unwrap_or(20); + let offset = offset.unwrap_or(0); + self.transport + .json( + Method::GET, + &format!("admin/clients?limit={limit}&offset={offset}"), + Option::<&()>::None, + "hydra list clients", + ) + .await + } + + pub async fn create_client(&self, body: &OAuth2Client) -> Result { + self.transport + .json(Method::POST, "admin/clients", Some(body), "hydra create client") + .await + } + + pub async fn get_client(&self, id: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/clients/{id}"), + Option::<&()>::None, + "hydra get client", + ) + .await + } + + pub async fn update_client(&self, id: &str, body: &OAuth2Client) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/clients/{id}"), + Some(body), + "hydra update client", + ) + .await + } + + pub async fn patch_client( + &self, + id: &str, + patches: &[serde_json::Value], + ) -> Result { + self.transport + .json( + Method::PATCH, + &format!("admin/clients/{id}"), + Some(&patches), + "hydra patch client", + ) + .await + } + + pub async fn delete_client(&self, id: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/clients/{id}"), + Option::<&()>::None, + "hydra delete client", + ) + .await + } + + pub async fn set_client_lifespans( + &self, + id: &str, + body: &TokenLifespans, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/clients/{id}/lifespans"), + Some(body), + "hydra set lifespans", + ) + .await + } + + // -- Login flow --------------------------------------------------------- + + pub async fn get_login_request(&self, challenge: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/oauth2/auth/requests/login?login_challenge={challenge}"), + Option::<&()>::None, + "hydra get login request", + ) + .await + } + + pub async fn accept_login( + &self, + challenge: &str, + body: &AcceptLoginBody, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/login/accept?login_challenge={challenge}"), + Some(body), + "hydra accept login", + ) + .await + } + + pub async fn reject_login( + &self, + challenge: &str, + body: &RejectBody, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/login/reject?login_challenge={challenge}"), + Some(body), + "hydra reject login", + ) + .await + } + + // -- Consent flow ------------------------------------------------------- + + pub async fn get_consent_request(&self, challenge: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/oauth2/auth/requests/consent?consent_challenge={challenge}"), + Option::<&()>::None, + "hydra get consent request", + ) + .await + } + + pub async fn accept_consent( + &self, + challenge: &str, + body: &AcceptConsentBody, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/consent/accept?consent_challenge={challenge}"), + Some(body), + "hydra accept consent", + ) + .await + } + + pub async fn reject_consent( + &self, + challenge: &str, + body: &RejectBody, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/consent/reject?consent_challenge={challenge}"), + Some(body), + "hydra reject consent", + ) + .await + } + + // -- Logout flow -------------------------------------------------------- + + pub async fn get_logout_request(&self, challenge: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/oauth2/auth/requests/logout?logout_challenge={challenge}"), + Option::<&()>::None, + "hydra get logout request", + ) + .await + } + + pub async fn accept_logout(&self, challenge: &str) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/logout/accept?logout_challenge={challenge}"), + Option::<&()>::None, + "hydra accept logout", + ) + .await + } + + pub async fn reject_logout( + &self, + challenge: &str, + body: &RejectBody, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/oauth2/auth/requests/logout/reject?logout_challenge={challenge}"), + Some(body), + "hydra reject logout", + ) + .await + } + + // -- JWK ---------------------------------------------------------------- + + pub async fn get_jwk_set(&self, set_name: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/keys/{set_name}"), + Option::<&()>::None, + "hydra get jwk set", + ) + .await + } + + pub async fn create_jwk_set( + &self, + set_name: &str, + body: &CreateJwkBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("admin/keys/{set_name}"), + Some(body), + "hydra create jwk set", + ) + .await + } + + pub async fn update_jwk_set(&self, set_name: &str, body: &JwkSet) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/keys/{set_name}"), + Some(body), + "hydra update jwk set", + ) + .await + } + + pub async fn delete_jwk_set(&self, set_name: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/keys/{set_name}"), + Option::<&()>::None, + "hydra delete jwk set", + ) + .await + } + + pub async fn get_jwk_key(&self, set_name: &str, kid: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/keys/{set_name}/{kid}"), + Option::<&()>::None, + "hydra get jwk key", + ) + .await + } + + pub async fn update_jwk_key( + &self, + set_name: &str, + kid: &str, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("admin/keys/{set_name}/{kid}"), + Some(body), + "hydra update jwk key", + ) + .await + } + + pub async fn delete_jwk_key(&self, set_name: &str, kid: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/keys/{set_name}/{kid}"), + Option::<&()>::None, + "hydra delete jwk key", + ) + .await + } + + // -- Trusted issuers ---------------------------------------------------- + + pub async fn create_trusted_issuer( + &self, + body: &TrustedJwtIssuer, + ) -> Result { + self.transport + .json( + Method::POST, + "admin/trust/grants/jwt-bearer/issuers", + Some(body), + "hydra create trusted issuer", + ) + .await + } + + pub async fn list_trusted_issuers(&self) -> Result> { + self.transport + .json( + Method::GET, + "admin/trust/grants/jwt-bearer/issuers", + Option::<&()>::None, + "hydra list trusted issuers", + ) + .await + } + + pub async fn get_trusted_issuer(&self, id: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("admin/trust/grants/jwt-bearer/issuers/{id}"), + Option::<&()>::None, + "hydra get trusted issuer", + ) + .await + } + + pub async fn delete_trusted_issuer(&self, id: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/trust/grants/jwt-bearer/issuers/{id}"), + Option::<&()>::None, + "hydra delete trusted issuer", + ) + .await + } + + // -- Sessions ----------------------------------------------------------- + + pub async fn list_consent_sessions(&self, subject: &str) -> Result> { + self.transport + .json( + Method::GET, + &format!("admin/oauth2/auth/sessions/consent?subject={subject}"), + Option::<&()>::None, + "hydra list consent sessions", + ) + .await + } + + pub async fn revoke_consent_sessions( + &self, + subject: &str, + client_id: Option<&str>, + ) -> Result<()> { + let mut path = format!("admin/oauth2/auth/sessions/consent?subject={subject}"); + if let Some(cid) = client_id { + path.push_str(&format!("&client={cid}")); + } + self.transport + .send(Method::DELETE, &path, Option::<&()>::None, "hydra revoke consent") + .await + } + + pub async fn revoke_login_sessions(&self, subject: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/oauth2/auth/sessions/login?subject={subject}"), + Option::<&()>::None, + "hydra revoke login sessions", + ) + .await + } + + // -- Tokens ------------------------------------------------------------- + + pub async fn introspect_token(&self, token: &str) -> Result { + // Hydra requires application/x-www-form-urlencoded for introspect + let resp = self + .transport + .request(Method::POST, "admin/oauth2/introspect") + .form(&[("token", token)]) + .send() + .await + .map_err(|e| crate::error::SunbeamError::network(format!("hydra introspect: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(crate::error::SunbeamError::network(format!( + "hydra introspect: HTTP {status}: {body}" + ))); + } + + resp.json().await.map_err(|e| { + crate::error::SunbeamError::network(format!("hydra introspect parse: {e}")) + }) + } + + pub async fn delete_tokens_for_client(&self, client_id: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("admin/oauth2/tokens?client_id={client_id}"), + Option::<&()>::None, + "hydra delete tokens", + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connect_url() { + let c = HydraClient::connect("sunbeam.pt"); + assert_eq!(c.base_url(), "https://auth.sunbeam.pt"); + assert_eq!(c.service_name(), "hydra"); + } + + #[test] + fn test_from_parts() { + let c = HydraClient::from_parts( + "http://localhost:4445".into(), + AuthMethod::None, + ); + assert_eq!(c.base_url(), "http://localhost:4445"); + } +} diff --git a/sunbeam-sdk/src/auth/hydra/types.rs b/sunbeam-sdk/src/auth/hydra/types.rs new file mode 100644 index 0000000..7aed4ad --- /dev/null +++ b/sunbeam-sdk/src/auth/hydra/types.rs @@ -0,0 +1,317 @@ +//! Hydra admin API types. + +use serde::{Deserialize, Serialize}; + +/// An OAuth2 client registration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OAuth2Client { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub redirect_uris: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_types: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_types: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// Token lifespan configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenLifespans { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_code_grant_access_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_code_grant_id_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_code_grant_refresh_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_credentials_grant_access_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub implicit_grant_access_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub implicit_grant_id_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt_bearer_grant_access_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token_grant_access_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token_grant_id_token_lifespan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token_grant_refresh_token_lifespan: Option, +} + +/// Login request details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginRequest { + #[serde(default)] + pub challenge: String, + #[serde(default)] + pub requested_scope: Vec, + #[serde(default)] + pub requested_access_token_audience: Vec, + #[serde(default)] + pub skip: bool, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub client: Option, + #[serde(default)] + pub request_url: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub oidc_context: Option, +} + +/// Body for accepting a login request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptLoginBody { + pub subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remember: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remember_for: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amr: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_subject_identifier: Option, +} + +/// Consent request details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsentRequest { + #[serde(default)] + pub challenge: String, + #[serde(default)] + pub requested_scope: Vec, + #[serde(default)] + pub requested_access_token_audience: Vec, + #[serde(default)] + pub skip: bool, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub client: Option, + #[serde(default)] + pub request_url: String, + #[serde(default)] + pub login_challenge: Option, + #[serde(default)] + pub login_session_id: Option, + #[serde(default)] + pub acr: Option, + #[serde(default)] + pub amr: Option>, + #[serde(default)] + pub context: Option, + #[serde(default)] + pub oidc_context: Option, +} + +/// Body for accepting a consent request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptConsentBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_scope: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_access_token_audience: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remember: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remember_for: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handled_at: Option, +} + +/// Consent session data. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConsentSession { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consent_request: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_scope: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_access_token_audience: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handled_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option>, +} + +/// Inner session data. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConsentSessionData { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token: Option, +} + +/// Body for rejecting a request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RejectBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_debug: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_code: Option, +} + +/// Redirect response from accept/reject operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectResponse { + pub redirect_to: String, +} + +/// Logout request details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogoutRequest { + #[serde(default)] + pub challenge: String, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub request_url: Option, + #[serde(default)] + pub rp_initiated: bool, + #[serde(default)] + pub client: Option, +} + +/// A JWK set. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct JwkSet { + #[serde(default)] + pub keys: Vec, +} + +/// Body for creating a JWK set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateJwkBody { + pub alg: String, + pub kid: String, + #[serde(rename = "use")] + pub use_: String, +} + +/// A trusted JWT issuer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedJwtIssuer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub issuer: String, + pub subject: String, + #[serde(default)] + pub scope: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +/// Public key info for a trusted issuer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedIssuerKey { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +/// Token introspection result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntrospectResult { + pub active: bool, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub sub: Option, + #[serde(default)] + pub exp: Option, + #[serde(default)] + pub iat: Option, + #[serde(default)] + pub nbf: Option, + #[serde(default)] + pub aud: Option>, + #[serde(default)] + pub iss: Option, + #[serde(default)] + pub token_type: Option, + #[serde(default)] + pub token_use: Option, + #[serde(default, rename = "ext")] + pub extra: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_oauth2_client_roundtrip() { + let client = OAuth2Client { + client_id: Some("sunbeam-cli".into()), + client_name: Some("Sunbeam CLI".into()), + redirect_uris: Some(vec!["http://localhost:9876/callback".into()]), + ..Default::default() + }; + let json = serde_json::to_value(&client).unwrap(); + assert_eq!(json["client_id"], "sunbeam-cli"); + assert!(json.get("client_secret").is_none()); + } + + #[test] + fn test_redirect_response() { + let json = serde_json::json!({"redirect_to": "https://example.com/callback"}); + let r: RedirectResponse = serde_json::from_value(json).unwrap(); + assert_eq!(r.redirect_to, "https://example.com/callback"); + } + + #[test] + fn test_introspect_active() { + let json = serde_json::json!({"active": true, "scope": "openid", "sub": "user-123"}); + let r: IntrospectResult = serde_json::from_value(json).unwrap(); + assert!(r.active); + assert_eq!(r.sub, Some("user-123".into())); + } +} diff --git a/sunbeam-sdk/src/auth/mod.rs b/sunbeam-sdk/src/auth/mod.rs index 3b334e6..d341a12 100644 --- a/sunbeam-sdk/src/auth/mod.rs +++ b/sunbeam-sdk/src/auth/mod.rs @@ -1,5 +1,8 @@ //! OAuth2 Authorization Code flow with PKCE for CLI authentication against Hydra. +#[cfg(feature = "identity")] +pub mod hydra; + use crate::error::{Result, ResultExt, SunbeamError}; use base64::Engine; use chrono::{DateTime, Utc};