From 075bc855fdd2789f037517842e38718655283c60 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 5 May 2026 06:08:00 +0000 Subject: [PATCH] Add auth and oauth endpoints --- examples/oauth_login.rs | 52 ++++++++++++++ src/apis/auth.rs | 55 +++++++++++++++ src/apis/mod.rs | 4 ++ src/apis/oauth.rs | 61 +++++++++++++++++ src/client.rs | 12 +++- src/models/mod.rs | 147 ++++++++++++++++++++++++++++++++++------ 6 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 examples/oauth_login.rs create mode 100644 src/apis/auth.rs create mode 100644 src/apis/oauth.rs diff --git a/examples/oauth_login.rs b/examples/oauth_login.rs new file mode 100644 index 0000000..81691a8 --- /dev/null +++ b/examples/oauth_login.rs @@ -0,0 +1,52 @@ +use immich_sdk::{Client, Config}; +use std::time::Duration; +use immich_sdk::models::{OAuthConfigDto, OAuthCallbackDto}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = Config::new("http://localhost:2283") + .with_api_key("your-api-key") + .with_timeout(Duration::from_secs(30)); + let client = Client::new(config)?; + + println!("Starting OAuth authorization process..."); + + // 1. Start OAuth authorization + // In a real scenario, this URL would be opened in a browser. + let auth_config = OAuthConfigDto { + redirect_uri: "http://localhost:8080/callback".to_string(), + code_challenge: None, + state: Some("random_state_string".to_string()), + }; + + let auth_response = client + .oauth() + .authorize(auth_config) + .await?; + + let auth_url = auth_response.url; + println!("Please visit this URL to authorize: {}", auth_url); + + // 2. Simulate the callback from the OAuth provider + // In a real scenario, your web server would receive this POST request. + let callback_data = OAuthCallbackDto { + url: "http://localhost:8080/callback".to_string(), + state: Some("random_state_string".to_string()), + code_verifier: Some("some_verifier".to_string()), + }; + + println!("Simulating OAuth callback with: {:?}", callback_data); + + // 3. Finish OAuth process by exchanging the code for a session token + let login_response = client + .oauth() + .finish_oauth(callback_data) + .await?; + + println!("Successfully logged in!"); + println!("Access Token: {}", login_response.access_token); + println!("User ID: {}", login_response.user_id); + println!("User Email: {}", login_response.user_email); + + Ok(()) +} diff --git a/src/apis/auth.rs b/src/apis/auth.rs new file mode 100644 index 0000000..ca3e7d5 --- /dev/null +++ b/src/apis/auth.rs @@ -0,0 +1,55 @@ +//! Authentication API + +use crate::{ + Client, + error::Result, + models::{ + AuthStatusResponseDto, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, + ValidateAccessTokenResponseDto, + }, +}; + +/// API for authentication +#[derive(Debug, Clone)] +pub struct AuthApi { + client: Client, +} + +impl AuthApi { + /// Create a new auth API instance + pub const fn new(client: Client) -> Self { + Self { client } + } + + /// Login with username and password + pub async fn login(&self, credentials: LoginCredentialDto) -> Result { + let req = self.client.post("/auth/login").json(&credentials); + let response = self.client.execute(req.build()?).await?; + let login_response: LoginResponseDto = response.json().await?; + Ok(login_response) + } + + /// Logout the current user and invalidate the session token + pub async fn logout(&self) -> Result { + let req = self.client.post("/auth/logout"); + let response = self.client.execute(req.build()?).await?; + let logout_response: LogoutResponseDto = response.json().await?; + Ok(logout_response) + } + + /// Get information about the current session + pub async fn get_auth_status(&self) -> Result { + let req = self.client.get("/auth/status"); + let response = self.client.execute(req.build()?).await?; + let auth_status: AuthStatusResponseDto = response.json().await?; + Ok(auth_status) + } + + /// Validate the current authorization method is still valid + pub async fn validate_access_token(&self) -> Result { + let req = self.client.post("/auth/validateToken"); + let response = self.client.execute(req.build()?).await?; + let validate_response: ValidateAccessTokenResponseDto = response.json().await?; + Ok(validate_response) + } +} diff --git a/src/apis/mod.rs b/src/apis/mod.rs index ff5425a..8d2758e 100644 --- a/src/apis/mod.rs +++ b/src/apis/mod.rs @@ -5,6 +5,8 @@ pub mod assets; pub mod search; pub mod server; pub mod timeline; +pub mod auth; +pub mod oauth; // Re-export main API modules pub use albums::AlbumsApi; @@ -12,3 +14,5 @@ pub use assets::AssetsApi; pub use search::SearchApi; pub use server::ServerApi; pub use timeline::TimelineApi; +pub use auth::AuthApi; +pub use oauth::OAuthApi; diff --git a/src/apis/oauth.rs b/src/apis/oauth.rs new file mode 100644 index 0000000..d5ba3de --- /dev/null +++ b/src/apis/oauth.rs @@ -0,0 +1,61 @@ +//! OAuth API + +use crate::{ + Client, + error::Result, + models::{ + OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, UserAdminResponseDto, + LoginResponseDto, + }, +}; + +/// API for OAuth +#[derive(Debug, Clone)] +pub struct OAuthApi { + client: Client, +} + +impl OAuthApi { + /// Create a new oauth API instance + pub const fn new(client: Client) -> Self { + Self { client } + } + + /// Start OAuth authorization + pub async fn authorize(&self, config: OAuthConfigDto) -> Result { + let req = self.client.post("/oauth/authorize").json(&config); + let response = self.client.execute(req.build()?).await?; + let auth_response: OAuthAuthorizeResponseDto = response.json().await?; + Ok(auth_response) + } + + /// Finish OAuth authorization + pub async fn finish_oauth(&self, callback_data: OAuthCallbackDto) -> Result { + let req = self.client.post("/oauth/callback").json(&callback_data); + let response = self.client.execute(req.build()?).await?; + let login_response: LoginResponseDto = response.json().await?; + Ok(login_response) + } + + /// Link an OAuth account + pub async fn link_oauth_account(&self, callback_data: OAuthCallbackDto) -> Result { + let req = self.client.post("/oauth/link").json(&callback_data); + let response = self.client.execute(req.build()?).await?; + let user_admin_response: UserAdminResponseDto = response.json().await?; + Ok(user_admin_response) + } + + /// Redirect OAuth to mobile + pub async fn redirect_oauth_to_mobile(&self) -> Result<()> { + let req = self.client.get("/oauth/mobile-redirect"); + self.client.execute(req.build()?).await?; + Ok(()) + } + + /// Unlink an OAuth account + pub async fn unlink_oauth_account(&self) -> Result<()> { + let req = self.client.post("/oauth/unlink"); + self.client.execute(req.build()?).await?; + Ok(()) + } +} diff --git a/src/client.rs b/src/client.rs index c526931..bd7131b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ //! Client for interacting with the Immich API -use crate::apis::{AlbumsApi, AssetsApi, SearchApi, ServerApi, TimelineApi}; +use crate::apis::{AlbumsApi, AssetsApi, SearchApi, ServerApi, TimelineApi, AuthApi, OAuthApi}; use crate::error::{ImmichError, Result}; use std::sync::Arc; use std::time::Duration; @@ -215,6 +215,16 @@ impl Client { pub fn timeline(&self) -> TimelineApi { TimelineApi::new(self.clone()) } + + /// Access the auth API + pub fn auth(&self) -> AuthApi { + AuthApi::new(self.clone()) + } + + /// Access the oauth API + pub fn oauth(&self) -> OAuthApi { + OAuthApi::new(self.clone()) + } } #[cfg(test)] diff --git a/src/models/mod.rs b/src/models/mod.rs index d9db54e..f4babfd 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -574,24 +574,131 @@ pub struct SearchResponse { pub assets: SearchAssetResult, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_asset_type_serialization() { - let asset_type = AssetType::Image; - let json = serde_json::to_string(&asset_type).unwrap(); - assert_eq!(json, r#""IMAGE""#); - } - - #[test] - fn test_server_version_display() { - let version = ServerVersion { - major: 1, - minor: 137, - patch: 0, - }; - assert_eq!(version.to_string(), "1.137.0"); - } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginCredentialDto { + pub email: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginResponseDto { + pub access_token: String, + pub is_admin: bool, + pub is_onboarded: bool, + pub name: String, + pub profile_image_path: String, + pub should_change_password: bool, + pub user_email: String, + pub user_id: UserId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogoutResponseDto { + pub redirect_uri: String, + pub successful: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthStatusResponseDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + pub is_elevated: bool, + pub password: bool, + pub pin_code: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin_expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateAccessTokenResponseDto { + pub auth_status: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthConfigDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub code_challenge: Option, + pub redirect_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthAuthorizeResponseDto { + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthCallbackDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub code_verifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserAdminResponseDto { + pub avatar_color: UserAvatarColor, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option>, + pub email: String, + pub id: UserId, + pub is_admin: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + pub name: String, + pub oauth_id: String, + pub profile_changed_at: DateTime, + pub profile_image_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_size_in_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_usage_in_bytes: Option, + pub should_change_password: bool, + pub status: UserStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage_label: Option, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UserAvatarColor { + Primary, + Pink, + Red, + Yellow, + Blue, + Green, + Purple, + Orange, + Gray, + Amber, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserLicense { + pub activated_at: DateTime, + pub activation_key: String, + pub license_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UserStatus { + Active, + Removing, + Deleted, }