diff --git a/Cargo.toml b/Cargo.toml index b996480..b9ad1a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,3 @@ serde_repr = "0.1" url = "2.5" uuid = { version = "1.11", features = ["serde", "v4"] } reqwest = { version = "0.12", features = ["json", "multipart"] } - -[dev-dependencies] -tokio = { version = "1", features = ["full"] } -mockito = "1.6.1" diff --git a/examples/poem-openapi/Cargo.toml b/examples/poem-openapi/Cargo.toml deleted file mode 100644 index bd7c27f..0000000 --- a/examples/poem-openapi/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "poem-openapi-with-passage-flex" -version = "0.1.0" -edition = "2021" - -[dependencies] -poem = "3" -poem-openapi = { version = "5", features = ["swagger-ui"]} -tokio = { version = "1", features = ["full"] } -serde = "1.0" -uuid = "1.10.0" -thiserror = "1.0.63" -passage_flex = { path = "../../" } -http = "1.1.0" diff --git a/examples/poem-openapi/README.md b/examples/poem-openapi/README.md deleted file mode 100644 index 5ecb656..0000000 --- a/examples/poem-openapi/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# poem-openapi with passage_flex - -This is an example of how to use the `passage_flex` crate with an HTTP server implemented with [poem-openapi](https://crates.io/crates/poem-openapi). - - -## How to run - -```bash -``` - -Start the server, setting `PASSAGE_APP_ID` and `PASSAGE_API_KEY` environment variables with valid values: -```bash -PASSAGE_APP_ID=your-app-id PASSAGE_API_KEY=your-api-key cargo run -``` - -Test the server operations: -```bash -curl -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"email":"demo@passage.id"}' -``` diff --git a/examples/poem-openapi/src/main.rs b/examples/poem-openapi/src/main.rs deleted file mode 100644 index e169489..0000000 --- a/examples/poem-openapi/src/main.rs +++ /dev/null @@ -1,361 +0,0 @@ -use passage_flex::PassageFlex; -use poem::{listener::TcpListener, Route}; -use poem_openapi::{payload::Json, Object, OpenApi, OpenApiService}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::Mutex; -use uuid::Uuid; - -// Represents a user in the system -#[derive(Debug, Serialize, Deserialize, Object)] -struct User { - /// A unique immutable string to identify the user - id: String, - /// The email address of the user - email: String, - /// The authentication token for this user (if authenticated) - auth_token: Option, -} - -// Request payload for user registration -#[derive(Debug, Deserialize, Object)] -struct RegisterRequest { - /// The email address of the user to register - email: String, -} - -// Request payload for user authentication -#[derive(Debug, Deserialize, Object)] -struct AuthenticateRequest { - /// The email address of the user to authenticate - email: String, -} - -// Response payload containing the transaction ID -#[derive(Debug, Serialize, Object)] -struct TransactionResponse { - /// The transaction ID returned by Passage - transaction_id: String, -} - -// Request payload for nonce verification -#[derive(Debug, Deserialize, Object)] -struct VerifyRequest { - /// The nonce to verify - nonce: String, -} - -// Response payload containing the auth token -#[derive(Debug, Serialize, Object)] -struct VerifyResponse { - /// The authentication token for the user - auth_token: String, -} - -// Request query parameters simulating an authenticated user -#[derive(Debug, Deserialize, Object)] -struct AuthenticatedRequest { - /// The authentication token of the user - auth_token: String, -} - -// Response payload containing the list of passkeys for a user -#[derive(Debug, Serialize, Object)] -struct UserPasskeysResponse { - /// The list of passkeys registered for the user - passkeys: Vec, -} - -// Request payload for revoking a passkey -#[derive(Debug, Deserialize, Object)] -struct RevokePasskeyRequest { - /// The ID of the passkey to revoke - passkey_id: String, -} - -// Represents a passkey registered by a user -#[derive(Debug, Serialize, Deserialize, Object)] -struct Passkey { - /// The display name for the passkey - pub friendly_name: String, - /// The ID of the passkey - pub id: String, -} - -// Shared state for managing users (in-memory for simplicity) -struct AppState { - users: Mutex>, - passage_client: PassageFlex, // Passage client instance -} - -// The API struct containing all the endpoints -struct Api { - state: Arc, -} - -#[OpenApi] -impl Api { - /// Register a new user - #[oai(path = "/auth/register", method = "post")] - async fn register_user( - &self, - req: Json, - ) -> poem::Result> { - // Generate a unique identifier for the user - let user_id = Uuid::new_v4().to_string(); - - // Create and store the user - let user = User { - email: req.0.email.clone(), - id: user_id.clone(), - auth_token: None, - }; - - // Store the user in shared state (simulates saving to a database) - let mut users = self.state.users.lock().await; - users.push(user); - - // Create a register transaction using the Passage SDK - let transaction_id = self - .state - .passage_client - .auth - .create_register_transaction(user_id.clone(), req.0.email.clone()) - .await - .map_err(|e| { - poem::Error::from_string( - format!("Passage SDK error: {}", e), - poem::http::StatusCode::INTERNAL_SERVER_ERROR, - ) - })?; // Convert SDK error into poem error - - // Return the transaction ID in the response - Ok(Json(TransactionResponse { transaction_id })) - } - - /// Authenticate an existing user - #[oai(path = "/auth/login", method = "post")] - async fn authenticate_user( - &self, - req: Json, - ) -> poem::Result> { - // Simulate finding the user in the database - let user_email = req.0.email.clone(); - // In a real implementation, you'd query your database here - // For simplicity, we'll just search the in-memory list - let user_id = { - let users = self.state.users.lock().await; - users - .iter() - .find(|user| user.email == user_email) - .map(|user| user.id.clone()) - }; - - if let Some(user_id) = user_id { - // Create an authentication transaction using the Passage SDK - let transaction_id = self - .state - .passage_client - .auth - .create_authenticate_transaction(user_id) - .await - .map_err(|e| match e { - passage_flex::Error::UserHasNoPasskeys => poem::Error::from_string( - "User has no passkeys".to_owned(), - poem::http::StatusCode::BAD_REQUEST, - ), - _ => poem::Error::from_string( - "Internal server error".to_owned(), - poem::http::StatusCode::INTERNAL_SERVER_ERROR, - ), - })?; - Ok(Json(TransactionResponse { transaction_id })) - } else { - Err(poem::Error::from_string( - "User not found".to_owned(), - poem::http::StatusCode::NOT_FOUND, - )) - } - } - - /// Verify the nonce received from Passage and generate an auth token - #[oai(path = "/auth/verify", method = "post")] - async fn verify_nonce(&self, req: Json) -> poem::Result> { - // Verify the nonce using the Passage SDK - match self.state.passage_client.auth.verify_nonce(req.0.nonce).await { - Ok(user_id) => { - // Find the user in the database and set the auth token - let mut users = self.state.users.lock().await; - if let Some(user) = users.iter_mut().find(|user| user.id == user_id) { - // In a real implementation, you'd generate a secure token here - // For simplicity, we'll just use a plaintext UUID - let auth_token = Uuid::new_v4().to_string(); - - // Set the user auth token - user.auth_token = Some(auth_token.clone()); - - // Return the auth token - Ok(Json(VerifyResponse { auth_token })) - } else { - Err(poem::Error::from_string( - "User not found".to_owned(), - poem::http::StatusCode::NOT_FOUND, - )) - } - } - Err(e) => Err(poem::Error::from_string( - format!("Verification error: {}", e), - poem::http::StatusCode::UNAUTHORIZED, - )), - } - } - - /// Add a new passkey for an authenticated user - #[oai(path = "/user/passkeys/add", method = "post")] - async fn add_passkey( - &self, - query: poem::web::Query, - ) -> poem::Result> { - // Verify user identity by finding the user based on auth_token - let users = self.state.users.lock().await; - if let Some(user) = users.iter().find(|user| { - user.auth_token - .as_ref() - .is_some_and(|t| *t == query.auth_token) - }) { - // Create a register transaction using the Passage SDK - let transaction_id = self - .state - .passage_client - .auth - .create_register_transaction(user.id.clone(), user.email.clone()) - .await - .map_err(|e| { - poem::Error::from_string( - format!("Failed to create register transaction: {}", e), - poem::http::StatusCode::INTERNAL_SERVER_ERROR, - ) - })?; - - // Return the transaction ID to the client - Ok(Json(TransactionResponse { transaction_id })) - } else { - Err(poem::Error::from_string( - "Unauthorized".to_owned(), - poem::http::StatusCode::UNAUTHORIZED, - )) - } - } - - /// Get the list of passkeys for an authenticated user - #[oai(path = "/user/passkeys", method = "get")] - async fn get_user_passkeys( - &self, - query: poem::web::Query, - ) -> poem::Result> { - // Verify user identity by finding the user based on auth_token - let users = self.state.users.lock().await; - if let Some(user) = users.iter().find(|user| { - user.auth_token - .as_ref() - .is_some_and(|t| *t == query.auth_token) - }) { - // Retrieve a list of all devices used to register a passkey - let passkeys = self - .state - .passage_client - .user - .list_devices(user.id.clone()) - .await - .map(|devices| { - devices - .into_iter() - .map(|device| Passkey { - friendly_name: device.friendly_name, - id: device.id, - }) - .collect() - }) - .map_err(|e| { - poem::Error::from_string( - format!("Failed to retrieve passkeys: {}", e), - poem::http::StatusCode::INTERNAL_SERVER_ERROR, - ) - })?; - - // Return the list of passkeys to the client - Ok(Json(UserPasskeysResponse { passkeys })) - } else { - Err(poem::Error::from_string( - "Unauthorized".to_owned(), - poem::http::StatusCode::UNAUTHORIZED, - )) - } - } - - /// Revoke a passkey for an authenticated user - #[oai(path = "/user/passkeys/revoke", method = "post")] - async fn revoke_passkey( - &self, - query: poem::web::Query, - req: Json, - ) -> poem::Result> { - // Verify user identity by finding the user based on auth_token - let users = self.state.users.lock().await; - if let Some(user) = users.iter().find(|user| { - user.auth_token - .as_ref() - .is_some_and(|t| *t == query.auth_token) - }) { - // Revoke the passkey device using the Passage SDK - self.state - .passage_client - .user - .revoke_device(user.id.clone(), req.0.passkey_id.clone()) - .await - .map_err(|e| { - poem::Error::from_string( - format!("Failed to revoke passkey: {}", e), - poem::http::StatusCode::INTERNAL_SERVER_ERROR, - ) - })?; - - // Return a success response - Ok(poem_openapi::payload::PlainText("OK".to_string())) - } else { - // If the user is not found or unauthorized, return a 401 error - Err(poem::Error::from_string( - "Unauthorized".to_owned(), - poem::http::StatusCode::UNAUTHORIZED, - )) - } - } -} - -// Main function to start the server -#[tokio::main] -async fn main() -> Result<(), std::io::Error> { - // Initialize the PassageFlex client - let passage_client = PassageFlex::new( - std::env::var("PASSAGE_APP_ID").expect("PASSAGE_APP_ID required"), - std::env::var("PASSAGE_API_KEY").expect("PASSAGE_API_KEY required"), - ); - - // Initialize shared application state - let state = Arc::new(AppState { - users: Mutex::new(Vec::new()), - passage_client, - }); - - // Create API service - let api_service = - OpenApiService::new(Api { state }, "Passkey API", "1.0").server("http://localhost:3000"); - - // Create the app route at the root - let app = Route::new().nest("/", api_service); - - // Run the server - poem::Server::new(TcpListener::bind("0.0.0.0:3000")) - .run(app) - .await -} diff --git a/src/passage_flex.rs b/src/passage_flex.rs index fd79dd6..9f5c76c 100644 --- a/src/passage_flex.rs +++ b/src/passage_flex.rs @@ -68,6 +68,3 @@ impl PassageFlex { self.auth.configuration.base_path = format!("{}/v1/apps/{}", server_url, self.app_id); } } - -#[cfg(test)] -mod test; diff --git a/src/passage_flex/test.rs b/src/passage_flex/test.rs deleted file mode 100644 index 358b7cd..0000000 --- a/src/passage_flex/test.rs +++ /dev/null @@ -1,356 +0,0 @@ -use crate::Error; - -use super::*; -use mockito::{Matcher, Mock, Server, ServerGuard}; - -#[tokio::test] -async fn test_passage_flex_constructor() { - let app_id = "test_app_id".to_string(); - let api_key = "test_api_key".to_string(); - - // Test successful creation - assert!( - std::panic::catch_unwind(|| { - PassageFlex::new(app_id.clone(), api_key.clone()); - }) - .is_ok(), - "Should not panic when app_id and api_key are provided" - ); -} - -async fn setup_passage_flex() -> (String, PassageFlex, ServerGuard) { - // Setup PassageFlex instance - let app_id = "test_app_id".to_string(); - let api_key = "test_api_key".to_string(); - let mut passage = PassageFlex::new(app_id.clone(), api_key.clone()); - // Setup mock server - let server = Server::new_async().await; - // Set server URL to mock server - passage.set_server_url(server.url()); - - (app_id, passage, server) -} - -async fn setup_empty_list_paginated_users_mock(app_id: &str, server: &mut ServerGuard) -> Mock { - server - .mock( - "GET", - format!("/v1/apps/{}/users?page=1&limit=1&identifier=invalid", app_id).as_str(), - ) - .with_status(200) - .with_body( - r#"{ - "users": [], - "_links": { - "first": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - }, - "last": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - }, - "next": { - "href": "" - }, - "previous": { - "href": "" - }, - "self": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - } - }, - "created_before": 1724113247, - "limit": 1, - "page": 1, - "total_users": 0 - }"#, - ) - .create_async() - .await -} - -async fn setup_valid_list_paginated_users_mock(app_id: &str, server: &mut ServerGuard) -> Mock { - server - .mock( - "GET", - format!("/v1/apps/{}/users?page=1&limit=1&identifier=valid", app_id).as_str(), - ) - .with_status(200) - .with_body( - r#"{ - "users": [ - { - "created_at": "2021-01-01T00:00:00Z", - "email": "", - "email_verified": false, - "external_id": "valid", - "id": "test_passage_id", - "last_login_at": "2021-01-02T00:00:00Z", - "login_count": 1, - "phone": "", - "phone_verified": false, - "status": "active", - "updated_at": "2021-01-03T00:00:00Z", - "user_metadata": null - } - ], - "_links": { - "first": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - }, - "last": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - }, - "next": { - "href": "" - }, - "previous": { - "href": "" - }, - "self": { - "href": "api.passage.id/v1/apps/test/users?created_before=1724113247\u0026limit=15\u0026order_by=created_at%3ADESC\u0026page=1" - } - }, - "created_before": 1724113247, - "limit": 1, - "page": 1, - "total_users": 1 - }"#, - ) - .create_async() - .await -} - -async fn setup_valid_get_user_mock(app_id: &str, server: &mut ServerGuard) -> Mock { - server - .mock( - "GET", - format!("/v1/apps/{}/users/test_passage_id", app_id).as_str(), - ) - .with_status(200) - .with_body( - r#"{ - "user": { - "created_at": "2021-01-01T00:00:00Z", - "email": "", - "email_verified": false, - "external_id": "valid", - "id": "test_passage_id", - "last_login_at": "2021-01-02T00:00:00Z", - "login_count": 1, - "phone": "", - "phone_verified": false, - "recent_events": [], - "social_connections": {}, - "status": "active", - "updated_at": "2021-01-03T00:00:00Z", - "user_metadata": null, - "webauthn": false, - "webauthn_devices": [{ - "created_at": "2021-01-01T00:00:00Z", - "cred_id": "test_cred_id", - "friendly_name": "Device 1", - "id": "test_device_id", - "last_login_at": "2021-01-03T00:00:00Z", - "type": "platform", - "updated_at": "2021-01-02T00:00:00Z", - "usage_count": 1, - "icons": {"light": null, "dark": null} - }], - "webauthn_types": ["platform"] - } - }"#, - ) - .create_async() - .await -} - -async fn setup_valid_get_devices_mock(app_id: &str, server: &mut ServerGuard) -> Mock { - server - .mock( - "GET", - format!("/v1/apps/{}/users/test_passage_id/devices", app_id).as_str(), - ) - .with_status(200) - .with_body( - r#"{ - "devices": [ - { - "created_at": "2021-01-01T00:00:00Z", - "cred_id": "test_cred_id", - "friendly_name": "Device 1", - "id": "test_device_id", - "last_login_at": "2021-01-03T00:00:00Z", - "type": "platform", - "updated_at": "2021-01-02T00:00:00Z", - "usage_count": 1, - "icons": {"light": null, "dark": null} - } - ] - }"#, - ) - .create_async() - .await -} - -async fn setup_valid_revoke_device_mock(app_id: &str, server: &mut ServerGuard) -> Mock { - server - .mock( - "DELETE", - format!( - "/v1/apps/{}/users/test_passage_id/devices/test_device_id", - app_id - ) - .as_str(), - ) - .with_status(200) - .create_async() - .await -} - -#[tokio::test] -async fn test_create_register_transaction() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - let m1 = server - .mock( - "POST", - format!("/v1/apps/{}/transactions/register", app_id).as_str(), - ) - .match_body(Matcher::Regex( - r#"\{"external_id":"test","passkey_display_name":"test"\}"#.into(), - )) - .with_status(200) - .with_body(r#"{"transaction_id": "test_transaction_id"}"#) - .create_async() - .await; - - let transaction_id = passage_flex - .auth - .create_register_transaction("test".to_string(), "test".to_string()) - .await - .unwrap(); - m1.assert_async().await; - assert_eq!(transaction_id, "test_transaction_id"); -} - -#[tokio::test] -async fn test_create_authenticate_transaction() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - let m1 = server - .mock( - "POST", - format!("/v1/apps/{}/transactions/authenticate", app_id).as_str(), - ) - .match_body(Matcher::Regex(r#"\{"external_id":"test"\}"#.into())) - .with_status(200) - .with_body(r#"{"transaction_id": "test_transaction_id"}"#) - .create_async() - .await; - - let transaction_id = passage_flex - .auth - .create_authenticate_transaction("test".to_string()) - .await - .unwrap(); - m1.assert_async().await; - assert_eq!(transaction_id, "test_transaction_id"); -} - -#[tokio::test] -async fn test_verify_nonce() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - let m1 = server.mock("POST", format!("/v1/apps/{}/authenticate/verify", app_id).as_str()) - .match_body(Matcher::Regex(r#"\{"nonce":"invalid"\}"#.into())) - .with_status(400) - .with_body(r#"{"error": "Could not verify nonce: nonce is invalid, expired, or cannot be found","code": "invalid_nonce"}"#) - .create_async().await; - - let invalid_result = passage_flex.auth.verify_nonce("invalid".to_string()).await; - assert!(invalid_result.is_err()); - assert!(matches!(invalid_result, Err(Error::InvalidNonce))); - m1.assert_async().await; - - let m2 = server - .mock( - "POST", - format!("/v1/apps/{}/authenticate/verify", app_id).as_str(), - ) - .match_body(Matcher::Regex(r#"\{"nonce":"valid"\}"#.into())) - .with_status(200) - .with_body(r#"{"external_id": "test_external_id"}"#) - .create_async() - .await; - - let external_id = passage_flex - .auth - .verify_nonce("valid".to_string()) - .await - .unwrap(); - m2.assert_async().await; - assert_eq!(external_id, "test_external_id"); -} - -#[tokio::test] -async fn test_get_user() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - - let m1 = setup_empty_list_paginated_users_mock(&app_id, &mut server).await; - let invalid_result = passage_flex.user.get("invalid".to_string()).await; - m1.assert_async().await; - assert!(invalid_result.is_err()); - assert!(matches!(invalid_result, Err(Error::UserNotFound))); - - let m2 = setup_valid_list_paginated_users_mock(&app_id, &mut server).await; - let m3 = setup_valid_get_user_mock(&app_id, &mut server).await; - - let passage_user = passage_flex.user.get("valid".to_string()).await.unwrap(); - m2.assert_async().await; - m3.assert_async().await; - assert_eq!(passage_user.external_id, "valid"); -} - -#[tokio::test] -async fn test_get_devices() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - - let m1 = setup_empty_list_paginated_users_mock(&app_id, &mut server).await; - let invalid_result = passage_flex.user.list_devices("invalid".to_string()).await; - m1.assert_async().await; - assert!(invalid_result.is_err()); - assert!(matches!(invalid_result, Err(Error::UserNotFound))); - - let m2 = setup_valid_list_paginated_users_mock(&app_id, &mut server).await; - let m3 = setup_valid_get_devices_mock(&app_id, &mut server).await; - let devices = passage_flex - .user - .list_devices("valid".to_string()) - .await - .unwrap(); - m2.assert_async().await; - m3.assert_async().await; - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].id, "test_device_id"); -} - -#[tokio::test] -async fn test_revoke_device() { - let (app_id, passage_flex, mut server) = setup_passage_flex().await; - - let m1 = setup_empty_list_paginated_users_mock(&app_id, &mut server).await; - let invalid_result = passage_flex - .user - .revoke_device("invalid".to_string(), "invalid".to_string()) - .await; - m1.assert_async().await; - assert!(invalid_result.is_err()); - assert!(matches!(invalid_result, Err(Error::UserNotFound))); - - let m2 = setup_valid_list_paginated_users_mock(&app_id, &mut server).await; - let m3 = setup_valid_revoke_device_mock(&app_id, &mut server).await; - let result = passage_flex - .user - .revoke_device("valid".to_string(), "test_device_id".to_string()) - .await; - m2.assert_async().await; - m3.assert_async().await; - assert!(result.is_ok()); -}