├── .config └── nextest.toml ├── .cursor └── rules │ ├── rfcs.mdc │ ├── sqlx.mdc │ └── torii.mdc ├── .github ├── dependabot.yaml └── workflows │ ├── ci.yaml │ └── docs.yaml ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── RELEASING.md ├── assets └── splash.jpeg ├── docker-compose.yaml ├── docs ├── .gitignore ├── book.toml ├── package-lock.json ├── package.json ├── src │ ├── SUMMARY.md │ ├── core-concepts │ │ └── index.md │ ├── getting-started.md │ ├── index.ts │ └── introduction.md └── wrangler.jsonc ├── examples └── todos │ ├── Cargo.toml │ ├── README.md │ ├── src │ ├── main.rs │ ├── routes.rs │ └── templates.rs │ └── templates │ ├── base.html │ ├── index.html │ ├── sign_in.html │ ├── sign_up.html │ └── todo.partial.html ├── rfcs ├── 001-plugin-events.md ├── 002-plugin-interfaces.md ├── 003-mongodb-storage.md ├── 004-postgresql-storage.md ├── 005-mysql-storage.md ├── 006-redis-storage.md ├── 007-memcached-storage.md └── 008-dynamodb-storage.md ├── torii-auth-magic-link ├── Cargo.toml ├── examples │ ├── README.md │ └── magic-link.rs └── src │ └── lib.rs ├── torii-auth-oauth ├── Cargo.toml ├── examples │ ├── github │ │ ├── README.md │ │ └── github.rs │ └── google │ │ ├── README.md │ │ └── google.rs └── src │ ├── lib.rs │ └── providers │ ├── github.rs │ ├── google.rs │ └── mod.rs ├── torii-auth-passkey ├── Cargo.toml ├── examples │ └── passkey.rs └── src │ └── lib.rs ├── torii-auth-password ├── Cargo.toml ├── examples │ ├── README.md │ └── password.rs └── src │ └── lib.rs ├── torii-core ├── Cargo.toml ├── README.md └── src │ ├── error.rs │ ├── events.rs │ ├── lib.rs │ ├── plugin.rs │ ├── session.rs │ ├── storage.rs │ └── user.rs ├── torii-migration ├── Cargo.toml └── src │ └── lib.rs ├── torii-storage-postgres ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ ├── magic_link.rs │ ├── migrations │ └── mod.rs │ ├── oauth.rs │ ├── passkey.rs │ ├── password.rs │ └── session.rs ├── torii-storage-seaorm ├── Cargo.toml └── src │ ├── entities │ ├── magic_link.rs │ ├── mod.rs │ ├── oauth.rs │ ├── passkey.rs │ ├── passkey_challenge.rs │ ├── pkce_verifier.rs │ ├── session.rs │ └── user.rs │ ├── lib.rs │ ├── magic_link.rs │ ├── migrations │ ├── m20250304_000001_create_user_table.rs │ ├── m20250304_000002_create_session_table.rs │ ├── m20250304_000003_create_oauth_table.rs │ ├── m20250304_000004_create_passkeys_table.rs │ ├── m20250304_000005_create_magic_links.rs │ └── mod.rs │ ├── oauth.rs │ ├── passkey.rs │ ├── password.rs │ ├── session.rs │ └── user.rs ├── torii-storage-sqlite ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ ├── magic_link.rs │ ├── migrations │ └── mod.rs │ ├── oauth.rs │ ├── passkey.rs │ ├── password.rs │ └── session.rs └── torii ├── Cargo.toml ├── README.md ├── src └── lib.rs └── tests ├── jwt_sessions.rs ├── magic_link.rs ├── password.rs ├── postgres.rs ├── seaorm.rs └── sqlite.rs /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.ci] 2 | # Do not cancel the test run on the first failure. 3 | fail-fast = false 4 | 5 | [profile.ci.junit] 6 | path = "junit.xml" 7 | -------------------------------------------------------------------------------- /.cursor/rules/rfcs.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: When writing RFC (Request for comments) 3 | globs: rfcs/*.md 4 | --- 5 | 6 | Every RFC must begin with the following: 7 | 8 | 1. A number, padded to 3 digits, and a title. 9 | 2. A table immediately after the title of the following structure. The statuses are implemented, draft, and in progress. 10 | 11 | | Date | Author | Status | 12 | | ---------- | ------------ | -------------- | 13 | | 2025-02-19 | @cmackenzie1 | ✅ Implemented | 14 | 15 | 3. A motivation section describing WHY this proposed RFC is needed. 16 | 4. An design section detailing what is being added 17 | 5. An implementation section going deeper into how it will interact with the existing components (if any). 18 | 6. A section outlining possible issues that could arise from the RFC being accepted or changes to support it. -------------------------------------------------------------------------------- /.cursor/rules/sqlx.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: When writing rust sqlx queries 3 | globs: *.rs 4 | --- 5 | - Never use the sqlx query macros `query!` or `query_as!` when writing sqlx queries 6 | - Always use the non-macro version when writing sqlx queries 7 | - Prefer passing borrowed values when using bind -------------------------------------------------------------------------------- /.cursor/rules/torii.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: When writing Rust code for Torii 3 | globs: *.rs 4 | --- 5 | 6 | # Your rule content 7 | 8 | - Adhere to existing style 9 | - Prefer writing out builders instead of deriving them 10 | - Keep in mind there can be many auth plugins registered 11 | - Focus on writing secure code 12 | - Use Tokio as the async runtime 13 | - Ensure .await works correctly by ensuring Send + Sync on types that cross the await boundary -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 1 8 | groups: 9 | all-dependencies: 10 | patterns: 11 | - "*" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | services: 15 | postgres: 16 | image: postgres:17 17 | env: 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: postgres 20 | POSTGRES_DB: postgres 21 | ports: 22 | - 5432:5432 23 | options: >- 24 | --health-cmd pg_isready 25 | --health-interval 10s 26 | --health-timeout 5s 27 | --health-retries 5 28 | 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - uses: dtolnay/rust-toolchain@stable 35 | with: 36 | components: llvm-tools-preview, rustfmt, clippy 37 | 38 | - uses: taiki-e/install-action@cargo-llvm-cov 39 | - uses: taiki-e/install-action@nextest 40 | 41 | - name: Lint (clippy) 42 | run: cargo clippy --all-features --all-targets 43 | 44 | - name: Run tests (with coverage) 45 | run: cargo llvm-cov nextest --profile ci --all-features 46 | 47 | - name: Generate coverage report 48 | run: cargo llvm-cov report --lcov --output-path lcov.info 49 | 50 | - name: Upload coverage to Codecov 51 | uses: codecov/codecov-action@v5 52 | with: 53 | files: lcov.info 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | 56 | - name: Upload test results to Codecov 57 | if: ${{ !cancelled() }} 58 | uses: codecov/test-results-action@v1 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | # TODO: Provide preview of docs on PRs 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | docs: 11 | name: Build documentation 12 | runs-on: ubuntu-latest 13 | env: 14 | MDBOOK_VERSION: v0.4.45 # https://github.com/rust-lang/mdBook/releases 15 | WRANGLER_VERSION: 4.4.0 # https://www.npmjs.com/package/wrangler 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Install mdbook 23 | run: | 24 | curl -L https://github.com/rust-lang/mdBook/releases/download/${{ env.MDBOOK_VERSION }}/mdbook-${{ env.MDBOOK_VERSION }}-x86_64-unknown-linux-gnu.tar.gz | tar xz 25 | chmod +x mdbook 26 | mv mdbook /usr/local/bin/ 27 | 28 | - name: Build documentation 29 | run: cd docs && mdbook build 30 | 31 | - name: Build & Deploy Worker 32 | uses: cloudflare/wrangler-action@v3 33 | with: 34 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 35 | wranglerVersion: ${{ env.WRANGLER_VERSION }} 36 | workingDirectory: "docs" 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode,intellij+all,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=rust,visualstudiocode,intellij+all,macos 3 | 4 | ### Intellij+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # AWS User-specific 16 | .idea/**/aws.xml 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/jarRepositories.xml 41 | # .idea/modules.xml 42 | # .idea/*.iml 43 | # .idea/modules 44 | # *.iml 45 | # *.ipr 46 | 47 | # CMake 48 | cmake-build-*/ 49 | 50 | # Mongo Explorer plugin 51 | .idea/**/mongoSettings.xml 52 | 53 | # File-based project format 54 | *.iws 55 | 56 | # IntelliJ 57 | out/ 58 | 59 | # mpeltonen/sbt-idea plugin 60 | .idea_modules/ 61 | 62 | # JIRA plugin 63 | atlassian-ide-plugin.xml 64 | 65 | # Cursive Clojure plugin 66 | .idea/replstate.xml 67 | 68 | # SonarLint plugin 69 | .idea/sonarlint/ 70 | 71 | # Crashlytics plugin (for Android Studio and IntelliJ) 72 | com_crashlytics_export_strings.xml 73 | crashlytics.properties 74 | crashlytics-build.properties 75 | fabric.properties 76 | 77 | # Editor-based Rest Client 78 | .idea/httpRequests 79 | 80 | # Android studio 3.1+ serialized cache file 81 | .idea/caches/build_file_checksums.ser 82 | 83 | ### Intellij+all Patch ### 84 | # Ignore everything but code style settings and run configurations 85 | # that are supposed to be shared within teams. 86 | 87 | .idea/* 88 | 89 | !.idea/codeStyles 90 | !.idea/runConfigurations 91 | 92 | ### macOS ### 93 | # General 94 | .DS_Store 95 | .AppleDouble 96 | .LSOverride 97 | 98 | # Icon must end with two \r 99 | Icon 100 | 101 | # Thumbnails 102 | ._* 103 | 104 | # Files that might appear in the root of a volume 105 | .DocumentRevisions-V100 106 | .fseventsd 107 | .Spotlight-V100 108 | .TemporaryItems 109 | .Trashes 110 | .VolumeIcon.icns 111 | .com.apple.timemachine.donotpresent 112 | 113 | # Directories potentially created on remote AFP share 114 | .AppleDB 115 | .AppleDesktop 116 | Network Trash Folder 117 | Temporary Items 118 | .apdisk 119 | 120 | ### macOS Patch ### 121 | # iCloud generated files 122 | *.icloud 123 | 124 | ### Rust ### 125 | # Generated by Cargo 126 | # will have compiled files and executables 127 | debug/ 128 | target/ 129 | 130 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 131 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 132 | Cargo.lock 133 | 134 | # These are backup files generated by rustfmt 135 | **/*.rs.bk 136 | 137 | # MSVC Windows builds of rustc generate these, which store debugging information 138 | *.pdb 139 | 140 | ### VisualStudioCode ### 141 | .vscode/* 142 | !.vscode/settings.json 143 | !.vscode/tasks.json 144 | !.vscode/launch.json 145 | !.vscode/extensions.json 146 | !.vscode/*.code-snippets 147 | 148 | # Local History for Visual Studio Code 149 | .history/ 150 | 151 | # Built Visual Studio Code Extensions 152 | *.vsix 153 | 154 | ### VisualStudioCode Patch ### 155 | # Ignore all local history of files 156 | .history 157 | .ionide 158 | 159 | # End of https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode,intellij+all,macos 160 | 161 | .env 162 | .env.* 163 | *.db 164 | *.db-shm 165 | *.db-wal 166 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - Added a new crate, `torii-storage-seaorm`, which is a storage backend for the torii authentication ecosystem that uses SeaORM to target SQLite, Postgres, and MySQL. 13 | - Added JWT-based session support with configurable expiry time. 14 | - `JwtSessionManager`: A session manager that uses JWTs to store session data without requiring database lookup. 15 | - JWT sessions can store user metadata (IP, user agent) directly in the token. 16 | - JWT sessions can be configured with a custom issuer and expiration time. 17 | - Support for both RS256 (RSA+SHA256) and HS256 (HMAC+SHA256) algorithms: 18 | - RS256: Uses asymmetric cryptography with separate signing and verification keys 19 | - HS256: Uses symmetric cryptography with a single secret key 20 | - Updated passkey example to use SimpleWebAuthn browser library from the CDN for improved WebAuthn support 21 | 22 | ### Changed 23 | 24 | #### `torii-core` 25 | 26 | - `SessionStorage::get_session` now returns a `Result, Error>` instead of `Result`. This reverts the change from `0.2.3`. 27 | - `SessionToken` type now supports both opaque tokens and JWT tokens. 28 | - Added `JwtConfig` for configuring JWT session parameters. 29 | - Added `UserManager` trait to standardize user management operations. 30 | - Added `DefaultUserManager` implementation that wraps a `UserStorage`. 31 | 32 | #### Authentication Plugins 33 | 34 | - **BREAKING CHANGE**: Refactored all auth plugins to support the new UserManager architecture: 35 | - `PasswordPlugin` now accepts a UserManager and PasswordStorage 36 | - `OAuthPlugin` now accepts a UserManager and OAuthStorage 37 | - `PasskeyPlugin` now accepts a UserManager and PasskeyStorage 38 | - `MagicLinkPlugin` now accepts a UserManager and MagicLinkStorage 39 | - All plugins now delegate user operations to the UserManager and use storage for auth-specific operations 40 | - Updated examples to demonstrate proper usage with the new architecture 41 | 42 | #### `torii-auth-passkey` 43 | 44 | - **BREAKING CHANGE**: Completely redesigned the passkey authentication API for improved type safety and usability: 45 | - Added a `PasskeyAuthPlugin` trait to define standardized authentication methods 46 | - Replaced string and JSON value parameters with proper strongly-typed structures: 47 | - `PasskeyRegistrationRequest` and `PasskeyRegistrationCompletion` 48 | - `PasskeyLoginRequest` and `PasskeyLoginCompletion` 49 | - `ChallengeId` type for better type safety 50 | - Created separate public-facing credential types: 51 | - `PasskeyCredentialCreationOptions` 52 | - `PasskeyCredentialRequestOptions` 53 | - Enhanced error handling with detailed context information using `PasskeyErrorContext` 54 | - Updated the `torii` integration to use the new API with both structured types and convenient alternatives 55 | 56 | #### `torii` 57 | 58 | - **BREAKING CHANGE**: Redesigned Torii struct to simplify type parameters and support separate storage backends: 59 | - Reduced generic parameters from 3 to 2, keeping only storage types and using trait objects for managers 60 | - Added more flexible constructors: 61 | - `new(storage)`: Simplest case with single storage for both users and sessions 62 | - `with_storages(user_storage, session_storage)`: For separate storage backends 63 | - `with_managers(user_storage, session_storage, user_manager, session_manager)`: For custom managers with plugin support 64 | - `with_custom_managers(user_manager, session_manager)`: For standalone managers without plugin support 65 | - Added explanatory documentation for each approach 66 | - Updated example application to demonstrate the different usage patterns 67 | 68 | - Login methods now accept an optional user agent and ip address parameter which will be stored with the session in the database. 69 | - Added new methods to configure session type: 70 | - `with_jwt_sessions()`: Configure Torii to use JWT sessions exclusively 71 | - Session configuration now supports JWT settings through `SessionConfig`. 72 | - **Magic Link API**: Fixed `generate_magic_token()` to return the generated token rather than discarding it 73 | - The method now returns `Result` instead of `Result<(), ToriiError>` 74 | - Consistently use `"magic_link"` for plugin name (was inconsistently using `"magic-link"`) 75 | - Added re-export of the `MagicToken` type from `torii_core` 76 | - Improved documentation for both token generation and verification 77 | 78 | ## [0.2.3] - 2025-03-05 79 | 80 | ### Added 81 | 82 | #### `torii-auth-magic-link` 83 | 84 | A new plugin for generating and verifying magic links has been added. 85 | 86 | ### Changed 87 | 88 | #### `torii-core` 89 | 90 | - `SessionStorage::get_session` now returns a `Result` instead of `Result, Error>`. Users should check the error for details on if the session was found, or expired. 91 | - Session creation, deletion, and cleanup are now handled by the `SessionManager` trait and the `DefaultSessionManager` implementation. 92 | - Plugins no longer require a `SessionStorage` parameter, the top level `Torii` struct now holds a `SessionManager` instance and login methods continue to return a `Session` instance. 93 | 94 | ### Removed 95 | 96 | #### `torii-core` 97 | 98 | - `Storage` struct has been removed. Use `Arc` and `Arc` directly instead. 99 | - `AsRef and AsRef` have been removed. Use `as_str()` instead when needing a database serializable string. 100 | 101 | --- 102 | 103 | ## [0.2.0] - 2025-02-27 104 | 105 | ### Added 106 | 107 | This is the first release of the torii authentication ecosystem, it includes the following crates: 108 | 109 | - `torii-core`: Core functionality for the torii authentication ecosystem. 110 | - `torii-auth-password`: Password authentication plugin for the torii authentication ecosystem. 111 | - `torii-auth-oauth`: OAuth authentication plugin for the torii authentication ecosystem. 112 | - `torii-auth-passkey`: Passkey authentication plugin for the torii authentication ecosystem. 113 | - `torii-storage-sqlite`: SQLite storage backend for the torii authentication ecosystem. 114 | - `torii-storage-postgres`: Postgres storage backend for the torii authentication ecosystem. 115 | - `torii`: Main crate for the torii authentication ecosystem. 116 | 117 | Users should use the `torii` crate with feature flags to enable the authentication plugins and storage backends they need. 118 | 119 | ```toml 120 | [dependencies] 121 | torii = { version = "0.2.0", features = ["password", "sqlite"] } 122 | ``` 123 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Torii-rs Project Guidelines 2 | 3 | ## Build and Test Commands 4 | - **Build:** `make build` or `cargo build --all-features` 5 | - **Format:** `make fmt` or `cargo fmt --all` 6 | - **Lint:** `make lint` or `cargo clippy --all-features -- -D warnings` 7 | - **Run all tests:** `make test` or `cargo nextest run --no-fail-fast --all-features` 8 | - **Run single test:** `cargo nextest run test_name` or `cargo test -- test_name` 9 | - **Run with coverage:** `make coverage` or `cargo llvm-cov nextest --all-features` 10 | - **Full check:** `make check` (runs fmt, lint, test) 11 | - **Documentation:** `make docs` or `cargo doc --all-features --no-deps --open` 12 | 13 | ## Code Style Guidelines 14 | - **Error Handling:** Use `thiserror` with structured error types and `#[from]` for conversions 15 | - **Naming:** PascalCase for types, snake_case for functions/variables, SCREAMING_SNAKE_CASE for constants 16 | - **Types:** Use newtype pattern for type safety (e.g., `UserId`, `SessionToken`) 17 | - **Imports:** Group by category (std lib first, external crates, then internal modules) 18 | - **Traits:** Use `async_trait` for async interfaces; design with composition in mind 19 | - **Documentation:** Add doc comments to public interfaces and modules 20 | - **Testing:** Write unit tests in modules with `#[cfg(test)]`; use `#[tokio::test]` for async tests 21 | - **Builder Pattern:** Use for complex struct creation with validation at build time 22 | 23 | ## Project Structure 24 | - Core functionality in `torii-core` crate 25 | - Storage backends in separate crates (`torii-storage-*`) 26 | - Authentication methods in separate crates (`torii-auth-*`) -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Torii 2 | 3 | Thank you for considering contributing to Torii! This document outlines the process for contributing to the project and our expectations for contributions. 4 | 5 | ## Code of Conduct 6 | 7 | Contributors are expected to maintain a respectful and inclusive environment. Be considerate of differing viewpoints and experiences, and focus on constructive feedback and collaboration. 8 | 9 | ## Getting Started 10 | 11 | 1. Fork the repository 12 | 2. Clone your fork: `git clone https://github.com/your-username/torii-rs.git` 13 | 3. Create a branch for your feature or fix: `git checkout -b your-feature-name` 14 | 4. Make your changes following our code style. 15 | 5. Run tests and linting: `make check` and `make fmt` 16 | 6. Submit a pull request 17 | 18 | ## AI/LLM Usage Policy 19 | 20 | We allow and encourage AI-assisted contributions, but we maintain high standards for code quality: 21 | 22 | - **Allowed AI/LLM usage**: 23 | - Code generation assistance 24 | - Documentation writing 25 | - Test case generation 26 | - Refactoring suggestions 27 | - Debugging help 28 | 29 | - **Guidelines for AI-assisted contributions**: 30 | - Review and understand all AI-generated code before submitting 31 | - Test all AI-generated code thoroughly 32 | - Ensure AI-generated code follows our code style and architecture 33 | - Properly attribute AI assistance in commit messages 34 | - Be prepared to explain and defend any AI-assisted code 35 | 36 | - **What we consider "AI slop" (not acceptable)**: 37 | - Blindly pasting AI-generated code without review 38 | - Code that doesn't follow project conventions 39 | - Poorly tested or buggy AI-generated solutions 40 | - Unnecessary or overly complex implementations 41 | - Boilerplate comments or unhelpful documentation 42 | - Solutions that don't actually solve the problem 43 | 44 | All contributions, whether AI-assisted or not, are subject to the same quality standards and review process. 45 | 46 | ## Torii Architecture 47 | 48 | ```mermaid 49 | graph TD 50 | torii[torii] 51 | torii_core[torii-core] 52 | torii_auth_password[torii-auth-password] 53 | torii_auth_oauth[torii-auth-oauth] 54 | torii_storage_sqlite[torii-storage-sqlite] 55 | 56 | %% Core dependencies 57 | torii_auth_password --> torii_core 58 | torii_auth_oauth --> torii_core 59 | torii_storage_sqlite --> torii_core 60 | 61 | %% Main crate dependencies 62 | torii --> torii_core 63 | torii --> torii_auth_password 64 | torii --> torii_auth_oauth 65 | torii --> torii_storage_sqlite 66 | 67 | %% Storage dependencies 68 | torii_auth_password --> torii_storage_sqlite 69 | torii_auth_oauth --> torii_storage_sqlite 70 | 71 | %% Style nodes 72 | classDef default fill:#f9f,stroke:#333,stroke-width:2px; 73 | classDef core fill:#bbf,stroke:#333,stroke-width:2px; 74 | classDef storage fill:#bfb,stroke:#333,stroke-width:2px; 75 | 76 | class torii_core core; 77 | class torii_storage_sqlite storage; 78 | ``` 79 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | # This is the list of crates in the workspace, keep in order of leaf to root. 3 | members = [ 4 | "torii-core", 5 | "torii-migration", 6 | "torii-storage-sqlite", 7 | "torii-storage-postgres", 8 | "torii-storage-seaorm", 9 | "torii-auth-password", 10 | "torii-auth-oauth", 11 | "torii-auth-passkey", 12 | "torii-auth-magic-link", 13 | "torii", 14 | "examples/todos", 15 | ] 16 | resolver = "3" 17 | 18 | [workspace.package] 19 | edition = "2024" 20 | repository = "https://github.com/cmackenzie1/torii-rs" 21 | license = "MIT" 22 | 23 | [workspace.dependencies] 24 | async-trait = "0.1" 25 | base64 = "0.22" 26 | chrono = { version = "0.4", features = ["serde"] } 27 | dashmap = "6.1" 28 | jsonwebtoken = "9.3" 29 | rand = "0.9" 30 | regex = "1" 31 | serde = { version = "1", features = ["derive"] } 32 | serde_json = "1" 33 | thiserror = "2" 34 | tokio = { version = "1.0", features = ["full"] } 35 | tracing = "0.1" 36 | tracing-subscriber = "0.3" 37 | uuid = { version = "1", features = ["v4"] } 38 | sqlx = { version = "0.8", features = ["runtime-tokio", "chrono", "uuid"] } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2025] [Cole Mackenzie] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all setup fmt lint test coverage watch-test watch-lint audit clean build release check update help 2 | 3 | # Default target when running just 'make' 4 | all: help 5 | 6 | # Colors for help output 7 | BLUE := \033[36m 8 | RESET := \033[0m 9 | 10 | # Install all dependencies and tools 11 | setup: 12 | @cargo install cargo-nextest 13 | @rustup component add clippy 14 | @rustup component add rustfmt 15 | 16 | # Format code 17 | fmt: 18 | @cargo fmt --all 19 | 20 | # Run clippy with all features 21 | lint: 22 | @cargo clippy --all-features -- -D warnings 23 | 24 | # Run tests using nextest 25 | test: 26 | @cargo nextest run --no-fail-fast --all-features 27 | 28 | # Run tests with coverage report 29 | coverage: 30 | @cargo llvm-cov nextest --all-features 31 | 32 | # Clean build artifacts 33 | clean: 34 | @cargo clean 35 | 36 | # Build with all features 37 | build: 38 | @cargo build --all-features 39 | 40 | # Build for release 41 | release: 42 | @cargo build --release 43 | 44 | # Run all checks (format, lint, test) 45 | check: fmt lint test 46 | 47 | # Update dependencies 48 | update: 49 | @cargo update 50 | 51 | # Generate documentation 52 | docs: 53 | @cargo doc --all-features --no-deps --open 54 | 55 | # Help command to list all available commands 56 | help: 57 | @echo "Available commands:" 58 | @echo "${BLUE}make setup${RESET} - Install all dependencies and tools" 59 | @echo "${BLUE}make fmt${RESET} - Format code" 60 | @echo "${BLUE}make lint${RESET} - Run clippy with all features" 61 | @echo "${BLUE}make test${RESET} - Run tests using nextest" 62 | @echo "${BLUE}make coverage${RESET} - Run tests with coverage report" 63 | @echo "${BLUE}make clean${RESET} - Clean build artifacts" 64 | @echo "${BLUE}make build${RESET} - Build with all features" 65 | @echo "${BLUE}make release${RESET} - Build for release" 66 | @echo "${BLUE}make check${RESET} - Run all checks (format, lint, test)" 67 | @echo "${BLUE}make update${RESET} - Update dependencies" 68 | @echo "${BLUE}make docs${RESET} - Generate documentation" 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Torii 2 | 3 | [![CI](https://github.com/cmackenzie1/torii-rs/actions/workflows/ci.yaml/badge.svg)](https://github.com/cmackenzie1/torii-rs/actions/workflows/ci.yaml) 4 | [![codecov](https://codecov.io/gh/cmackenzie1/torii-rs/branch/main/graph/badge.svg?token=MHF0G453L0)](https://codecov.io/gh/cmackenzie1/torii-rs) 5 | [![docs.rs](https://img.shields.io/docsrs/torii)](https://docs.rs/torii/latest/torii/) 6 | [![Crates.io Version](https://img.shields.io/crates/v/torii)](https://crates.io/crates/torii) 7 | 8 | > [!WARNING] 9 | > This project is in early development and is not production-ready. The API is subject to change without notice. 10 | 11 | ## Overview 12 | 13 | Torii is a powerful authentication framework for Rust applications that gives you complete control over your users' data. Unlike hosted solutions like Auth0, Clerk, or WorkOS that store user information in their cloud, Torii lets you own and manage your authentication stack while providing modern auth features through a flexible plugin system. 14 | 15 | With Torii, you get the best of both worlds - powerful authentication capabilities like passwordless login, social OAuth, and passkeys, combined with full data sovereignty and the ability to store user data wherever you choose. 16 | 17 | Checkout the example [todos](./examples/todos/README.md) to see Torii in action. 18 | 19 | ## Features 20 | 21 | | Plugin | SQLite | PostgreSQL | MySQL (using [SeaORM](https://github.com/SeaQL/sea-orm)) | 22 | |-------------------------------------------------|--------|------------|----------------------------------------------------------| 23 | | [Password](./torii-auth-password/README.md) | ✅ | ✅ | ✅ | 24 | | [OAuth2/OIDC](./torii-auth-oauth/README.md) | ✅ | ✅ | ✅ | 25 | | [Passkey](./torii-auth-passkey/README.md) | ✅ | ✅ | ✅ | 26 | | [Magic Link](./torii-auth-magic-link/README.md) | ✅ | ✅ | ✅ | 27 | 28 | ✅ = Supported 29 | 🚧 = Planned/In Development 30 | ❌ = Not Supported 31 | 32 | ## Security 33 | 34 | > [!IMPORTANT] 35 | > As this project is in early development, it has not undergone security audits and should not be used in production environments. The maintainers are not responsible for any security issues that may arise from using this software. 36 | 37 | ## Contributing 38 | 39 | As this project is in its early stages, we welcome discussions and feedback, but please note that major changes may occur. 40 | 41 | ## License 42 | 43 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. 44 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing Torii 2 | 3 | This is manual process that must be done from a clean git working directory with no uncommitted changes. The order of operations is roughly as follows: 4 | 5 | 1. Ensure CHANGELOG.md is updated with the new changes. 6 | 2. Build the project to ensure everything is working. 7 | 3. Release the new version. 8 | 9 | ## Prerequisites 10 | 11 | - A clean git working directory with no uncommitted changes. 12 | - The `cargo-release` tool must be installed using `cargo install cargo-release`. 13 | - CHANGELOG.md must be updated with the new changes. 14 | 15 | ## Releasing 16 | 17 | Before releasing, ensure the CHANGELOG.md is updated with the new changes and that the following command builds successfully: 18 | 19 | ```bash 20 | cargo release 21 | ``` 22 | 23 | If the build succeeds, rerun the command with the `--execute` flag to publish the release: 24 | 25 | ```bash 26 | cargo release --execute 27 | ``` 28 | 29 | This will: 30 | 31 | - Bump the version in the related `Cargo.toml` files. 32 | - Create a new git tag. 33 | - Push the new tag to the remote repository. 34 | - Publish the release to crates.io. 35 | -------------------------------------------------------------------------------- /assets/splash.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmackenzie1/torii-rs/d866ee618ae675d696cf7b414b711d022b233507/assets/splash.jpeg -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17-alpine 4 | environment: 5 | POSTGRES_PASSWORD: postgres 6 | ports: 7 | - 5432:5432 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book/ 2 | node_modules/ 3 | .wrangler/ 4 | .DS_Store 5 | .dist/ 6 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Cole MacKenzie"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Torii Documentation" 7 | 8 | [output.html] 9 | git-repository-url = "https://github.com/cmackenzie1/torii-rs" 10 | edit-url-template = "https://github.com/cmackenzie1/torii-rs/edit/main/docs/{path}" 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "torii-docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Torii Documentation", 6 | "scripts": { 7 | "dev": "wrangler dev", 8 | "build": "mdbook build", 9 | "deploy": "mdbook build && wrangler deploy", 10 | "mdbook:install": "cargo install mdbook" 11 | }, 12 | "devDependencies": { 13 | "wrangler": "^4.4.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./introduction.md) 4 | - [Getting Started](./getting-started.md) 5 | - [Core Concepts](./core-concepts/index.md) 6 | -------------------------------------------------------------------------------- /docs/src/core-concepts/index.md: -------------------------------------------------------------------------------- 1 | # Core Concepts 2 | 3 | Torii is built around several core concepts that form the foundation of the authentication system. Understanding these concepts is essential for effectively implementing and extending Torii in your applications. 4 | 5 | ## Main Components 6 | 7 | The Torii framework consists of several key components: 8 | 9 | 1. **The Torii Coordinator**: The main `Torii` struct that coordinates all authentication activities 10 | 2. **Storage Backends**: Implementations for persisting user and session data 11 | 3. **Authentication Plugins**: Modules for different authentication methods 12 | 4. **User and Session Management**: APIs for creating and verifying sessions 13 | 14 | ## Users 15 | 16 | Users are the central entity in the Torii authentication system. Each user represents an individual who can authenticate with your application. 17 | 18 | ### User Structure 19 | 20 | The core `User` struct contains the following fields: 21 | 22 | | Field | Type | Description | 23 | | ----------------- | ----------------------- | ---------------------------------------------- | 24 | | id | `UserId` | The unique identifier for the user | 25 | | name | `Option` | The user's name (optional) | 26 | | email | `String` | The user's email address | 27 | | email_verified_at | `Option>` | Timestamp when the email was verified (if any) | 28 | | created_at | `DateTime` | Timestamp when the user was created | 29 | | updated_at | `DateTime` | Timestamp when the user was last updated | 30 | 31 | ### User IDs 32 | 33 | Each user has a unique `UserId` that identifies them in the system. This ID is: 34 | 35 | - Stable and will not change during the user's lifetime 36 | - Treated as an opaque identifier rather than a specific format (though it uses UUIDs internally by default) 37 | - Used to link user accounts to authentication methods, sessions, and application data 38 | 39 | ## Sessions 40 | 41 | Sessions represent authenticated user sessions and are created when a user successfully logs in. 42 | 43 | ### Session Structure 44 | 45 | The `Session` struct contains the following fields: 46 | 47 | | Field | Type | Description | 48 | | ---------- | ---------------- | ----------------------------------------------------- | 49 | | token | `SessionToken` | The unique token identifying the session | 50 | | user_id | `UserId` | The ID of the authenticated user | 51 | | user_agent | `Option` | The user agent of the client that created the session | 52 | | ip_address | `Option` | The IP address of the client that created the session | 53 | | created_at | `DateTime` | Timestamp when the session was created | 54 | | expires_at | `DateTime` | Timestamp when the session will expire | 55 | 56 | ### Session Tokens 57 | 58 | Each session is identified by a unique `SessionToken` that: 59 | 60 | - Functions as a bearer token or cookie for authentication 61 | - Should be kept secret and transmitted securely (e.g., via HTTPS) 62 | - Has an expiration time after which it will no longer be valid 63 | - Can be revoked to force a user to log out 64 | 65 | ### Session Types 66 | 67 | Torii supports two types of sessions: 68 | 69 | 1. **Database Sessions** (default): Sessions are stored in your database and can be individually revoked 70 | 2. **JWT Sessions** (optional): Stateless sessions using JWT tokens that don't require database lookups but cannot be individually revoked 71 | 72 | ## Authentication Methods 73 | 74 | Torii provides several authentication methods through its plugin system: 75 | 76 | ### Password Authentication 77 | 78 | Traditional email/password authentication with secure password hashing. 79 | 80 | Key features: 81 | - Argon2id password hashing 82 | - Email verification capabilities 83 | - Password reset functionality 84 | 85 | ### OAuth Authentication 86 | 87 | Social login and OpenID Connect support for external identity providers. 88 | 89 | Supported providers: 90 | - Google 91 | - GitHub 92 | - More providers can be added 93 | 94 | ### Passkey Authentication (WebAuthn) 95 | 96 | Passwordless authentication using the Web Authentication API (WebAuthn). 97 | 98 | Key features: 99 | - FIDO2-compliant 100 | - Supports hardware security keys, platform authenticators (Windows Hello, Touch ID, etc.) 101 | - Challenge-response authentication flow 102 | 103 | ### Magic Link Authentication 104 | 105 | Email-based passwordless authentication using one-time tokens. 106 | 107 | Key features: 108 | - Generates secure tokens 109 | - Time-limited validation 110 | - Simple user experience 111 | 112 | ## Storage System 113 | 114 | Torii abstracts the storage layer through traits, allowing different storage backend implementations: 115 | 116 | ### Available Storage Backends 117 | 118 | 1. **SQLite**: For development, testing, or small applications 119 | 2. **PostgreSQL**: For production-ready applications requiring a robust database 120 | 3. **SeaORM**: Supporting SQLite, PostgreSQL, and MySQL through the SeaORM ORM 121 | 122 | Each storage backend implements the following core storage traits: 123 | - `UserStorage`: For user management 124 | - `SessionStorage`: For session management 125 | - `PasswordStorage`: For password authentication 126 | - `OAuthStorage`: For OAuth accounts 127 | - `PasskeyStorage`: For WebAuthn credentials 128 | - `MagicLinkStorage`: For magic link tokens 129 | 130 | ## Initialization Patterns 131 | 132 | Torii provides several ways to initialize the system based on your application's needs: 133 | 134 | 1. **Single Storage**: Use the same storage for users and sessions 135 | ```rust 136 | Torii::new(storage) 137 | ``` 138 | 139 | 2. **Split Storage**: Use different storage backends for users and sessions 140 | ```rust 141 | Torii::with_storages(user_storage, session_storage) 142 | ``` 143 | 144 | 3. **Custom Managers**: Provide custom user and session managers 145 | ```rust 146 | Torii::with_managers(user_storage, session_storage, user_manager, session_manager) 147 | ``` 148 | 149 | 4. **Stateless Managers**: Use custom managers without storage 150 | ```rust 151 | Torii::with_custom_managers(user_manager, session_manager) 152 | ``` 153 | 154 | ## Error Handling 155 | 156 | Torii uses a structured error system with the `ToriiError` enum that includes: 157 | 158 | - `PluginNotFound`: When an authentication plugin is not available 159 | - `AuthError`: When authentication fails 160 | - `StorageError`: When there's an issue with the storage backend 161 | 162 | Understanding these core concepts provides the foundation for working with Torii's authentication flows in your applications. 163 | -------------------------------------------------------------------------------- /docs/src/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | async fetch(request, env) { 3 | return env.ASSETS.fetch(request); 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /docs/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction to Torii 2 | 3 | Torii is a powerful authentication framework for Rust applications that gives you complete control over your users' data. Unlike hosted solutions like Auth0, Clerk, or WorkOS that store user information in their cloud, Torii lets you own and manage your authentication stack while providing modern auth features through a flexible plugin system. 4 | 5 | With Torii, you get the best of both worlds - powerful authentication capabilities combined with full data sovereignty and the ability to store user data wherever you choose. 6 | 7 | > **Warning:** This project is in early development and is not production-ready. The API is subject to change without notice. As this project has not undergone security audits, it should not be used in production environments. 8 | 9 | ## Key Features 10 | 11 | - **Data Sovereignty**: Your user data stays in your own database 12 | - **Multiple Authentication Methods**: 13 | - Password-based authentication 14 | - Social OAuth/OpenID Connect 15 | - Passkey/WebAuthn support 16 | - Magic Link authentication 17 | - **Flexible Storage**: Store user data in SQLite, PostgreSQL, or MySQL (using SeaORM) 18 | - **JWT Support**: Optional stateless JWT sessions 19 | - **Extensible Plugin System**: Add custom authentication methods or storage backends 20 | 21 | ## Storage Support 22 | 23 | | Plugin | SQLite | PostgreSQL | MySQL (using SeaORM) | 24 | |--------------------|--------|------------|----------------------| 25 | | Password | ✅ | ✅ | ✅ | 26 | | OAuth2/OIDC | ✅ | ✅ | ✅ | 27 | | Passkey | ✅ | ✅ | ✅ | 28 | | Magic Link | ✅ | ✅ | ✅ | 29 | -------------------------------------------------------------------------------- /docs/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "torii-docs", 3 | "main": "src/index.ts", 4 | "compatibility_date": "2025-01-01", 5 | "account_id": "9167117c70d2a9b285caf25f61414eaa", 6 | "workers_dev": false, 7 | "routes": [ 8 | { 9 | "pattern": "torii.rs", 10 | "custom_domain": true 11 | } 12 | ], 13 | "assets": { 14 | "directory": "./book", 15 | "binding": "ASSETS" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/todos/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-example-todos" 3 | version = "0.1.2" 4 | edition = "2021" 5 | publish = false 6 | 7 | [dependencies] 8 | # torii 9 | torii = { path = "../../torii", version = "0.2.3", features = [ 10 | "password", 11 | "seaorm-sqlite", 12 | ] } 13 | 14 | # web server 15 | askama = "0.14" 16 | axum = { version = "0.8", features = ["macros"] } 17 | axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } 18 | 19 | # "stdlib" 20 | chrono = { version = "0.4.39", features = ["serde"] } 21 | serde = { version = "1.0", features = ["derive"] } 22 | serde_json = "1.0" 23 | uuid = { version = "1.13.1", features = ["v4", "v7"] } 24 | 25 | # async/runtime 26 | dashmap = "6.0" 27 | tokio = { version = "1.0", features = ["full"] } 28 | tracing-subscriber = "0.3" 29 | tracing.workspace = true 30 | -------------------------------------------------------------------------------- /examples/todos/README.md: -------------------------------------------------------------------------------- 1 | # torii-example-todos 2 | 3 | This is a simple example of how to use torii to build a todo list application using: 4 | 5 | - SQLite for storage 6 | - Axum for the web server with HTMX and Askama for templating 7 | - Email/Password authentication 8 | 9 | ## Running the example 10 | 11 | ```bash 12 | cargo run 13 | ``` 14 | 15 | ## Running the migrations 16 | 17 | ```bash 18 | cargo run -- --db-url sqlite://todos.db 19 | ``` 20 | -------------------------------------------------------------------------------- /examples/todos/src/main.rs: -------------------------------------------------------------------------------- 1 | use dashmap::DashMap; 2 | use std::{net::SocketAddr, sync::Arc}; 3 | use torii::{SeaORMStorage, Torii}; 4 | 5 | mod routes; 6 | mod templates; 7 | 8 | /// Application state shared between route handlers 9 | /// Contains references to: 10 | /// - torii: Coordinates authentication plugins 11 | #[derive(Clone)] 12 | pub(crate) struct AppState { 13 | torii: Arc>, 14 | todos: Arc>, 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct Todo { 19 | pub id: String, 20 | pub title: String, 21 | pub completed_at: Option, 22 | pub user_id: String, 23 | } 24 | 25 | #[tokio::main] 26 | async fn main() { 27 | tracing_subscriber::fmt::init(); 28 | 29 | // Create a new storage instance for our application 30 | let storage = Arc::new( 31 | SeaORMStorage::connect("sqlite://todos.db?mode=rwc") 32 | .await 33 | .expect("Failed to connect to database"), 34 | ); 35 | 36 | // Migrate the storage schema 37 | storage.migrate().await.expect("Failed to migrate storage"); 38 | 39 | // For demonstration, we can use different approaches: 40 | 41 | // 1. Simplest approach with a single storage backend 42 | let torii = Torii::new(storage).with_password_plugin(); 43 | 44 | // Alternative Options: 45 | // 46 | // 2. If you want separate storage for sessions (e.g., Redis): 47 | // let user_storage = storage.clone(); 48 | // let session_storage = Arc::new(RedisStorage::connect("redis://localhost").await.unwrap()); 49 | // let torii = Torii::with_storages(user_storage, session_storage).with_password_plugin(); 50 | // 51 | // 3. If you need custom managers for additional behavior: 52 | // let user_manager: Arc = Arc::new(CustomUserManager::new(storage.clone())); 53 | // let session_manager: Arc = Arc::new(DefaultSessionManager::new(storage.clone())); 54 | // let torii = Torii::with_managers(storage.clone(), storage.clone(), user_manager, session_manager); 55 | // 56 | // 4. If your managers fully encapsulate their storage and you don't need plugins: 57 | // let user_manager: Arc = Arc::new(MyUserManager::new(my_db_conn.clone())); 58 | // let session_manager: Arc = Arc::new(RedisSessionManager::new("redis://localhost")); 59 | // let torii = Torii::<()>::with_custom_managers(user_manager, session_manager); 60 | 61 | let app_state = AppState { 62 | torii: Arc::new(torii), 63 | todos: Arc::new(DashMap::new()), 64 | }; 65 | 66 | let app = routes::create_router(app_state); 67 | 68 | tokio::spawn(async move { 69 | let listener = tokio::net::TcpListener::bind("0.0.0.0:4000") 70 | .await 71 | .expect("Failed to bind to port"); 72 | println!( 73 | "Listening on {}", 74 | listener.local_addr().expect("Failed to get local address") 75 | ); 76 | axum::serve( 77 | listener, 78 | app.into_make_service_with_connect_info::(), 79 | ) 80 | .await 81 | .expect("Server error"); 82 | }); 83 | 84 | println!("Please open the following URL in your browser: http://localhost:4000/"); 85 | println!("Press Enter or Ctrl+C to exit..."); 86 | let _ = std::io::stdin().read_line(&mut String::new()); 87 | } 88 | -------------------------------------------------------------------------------- /examples/todos/src/templates.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use torii::User; 3 | 4 | use crate::Todo; 5 | 6 | #[derive(Default)] 7 | pub struct Context { 8 | pub user: Option, 9 | } 10 | 11 | #[derive(Template)] 12 | #[template(path = "todo.partial.html")] 13 | pub struct TodoPartial { 14 | pub todo: Todo, 15 | } 16 | 17 | #[derive(Template)] 18 | #[template(path = "index.html")] 19 | pub struct IndexTemplate { 20 | pub context: Context, 21 | pub todos: Vec, 22 | } 23 | 24 | #[derive(Template)] 25 | #[template(path = "sign_up.html")] 26 | pub struct SignUpTemplate { 27 | pub context: Context, 28 | } 29 | 30 | #[derive(Template)] 31 | #[template(path = "sign_in.html")] 32 | pub struct SignInTemplate { 33 | pub context: Context, 34 | } 35 | -------------------------------------------------------------------------------- /examples/todos/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{{ title }}{% endblock %} 6 | {% block head %}{% endblock %} 7 | 8 | 11 | 12 | 13 | 14 |
15 | 24 |
25 |
26 | {% block content %}

Placeholder content

{% endblock %} 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/todos/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Todos{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Todos

8 |
9 |
10 |

My Tasks

11 |
12 |
13 |
14 | 15 | 16 |
17 |
18 |
    19 | {% for todo in todos %} 20 | {% include "todo.partial.html" %} 21 | {% endfor %} 22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /examples/todos/templates/sign_in.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Sign In{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Todos

8 |
9 |
10 |

Sign In

11 |
12 |
13 | 17 | 21 | 22 |
23 |
24 |

Don't have an account? Sign up

25 |
26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /examples/todos/templates/sign_up.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Sign Up{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Todos

8 |
9 |
10 |

Sign Up

11 |
12 |
13 | 17 | 21 | 22 |
23 |
24 |

Already have an account? Sign in

25 |
26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /examples/todos/templates/todo.partial.html: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | 10 | 14 |
    15 |
  • 16 | -------------------------------------------------------------------------------- /rfcs/001-plugin-events.md: -------------------------------------------------------------------------------- 1 | # RFC 001: Plugin Event System 2 | 3 | | Date | Author | Status | 4 | | ---------- | ------------ | -------------- | 5 | | 2025-02-19 | @cmackenzie1 | ✅ Implemented | 6 | 7 | ## Summary 8 | 9 | Add an event system to enable loose coupling between plugins while allowing them to react to actions performed by other plugins. 10 | 11 | ## Motivation 12 | 13 | Currently, plugins operate in isolation and have no way to coordinate or react to actions performed by other plugins. An event system would allow plugins to: 14 | 15 | - Respond to actions from other plugins (e.g, index users in a search index) 16 | - Maintain plugin-specific data consistency 17 | - Enable cross-plugin features like account linking 18 | - Facilitate audit logging and monitoring 19 | 20 | ## Design 21 | 22 | ### Event Types 23 | 24 | The following events are currently implemented: 25 | 26 | ```rust 27 | pub enum Event { 28 | UserCreated(User), 29 | UserUpdated(User), 30 | UserDeleted(UserId), 31 | SessionCreated(UserId, Session), 32 | SessionDeleted(UserId, SessionId), 33 | } 34 | ``` 35 | 36 | ### Event Handler Trait 37 | 38 | ```rust 39 | #[async_trait] 40 | pub trait EventHandler: Send + Sync + 'static { 41 | async fn handle_event(&self, event: &Event) -> Result<(), Error>; 42 | } 43 | ``` 44 | 45 | ### Event Bus 46 | 47 | The event bus manages event handlers and event distribution: 48 | 49 | ```rust 50 | pub struct EventBus { 51 | handlers: Arc>>>, 52 | } 53 | 54 | impl EventBus { 55 | pub fn new() -> Self; 56 | pub async fn register(&self, handler: Arc); 57 | pub async fn emit(&self, event: &Event) -> Result<(), Error>; 58 | } 59 | ``` 60 | 61 | ## Examples 62 | 63 | ### Implementing an Event Handler 64 | 65 | ```rust 66 | struct MyHandler; 67 | 68 | #[async_trait] 69 | impl EventHandler for MyHandler { 70 | async fn handle_event(&self, event: &Event) -> Result<(), Error> { 71 | match event { 72 | Event::UserCreated(user) => { 73 | // Handle user creation 74 | Ok(()) 75 | } 76 | // Handle other events... 77 | _ => Ok(()), 78 | } 79 | } 80 | } 81 | 82 | // Register the handler 83 | let event_bus = EventBus::new(); 84 | event_bus.register(Arc::new(MyHandler)).await; 85 | ``` 86 | 87 | ### Emitting Events 88 | 89 | ```rust 90 | let event_bus = EventBus::new(); 91 | let user = User::builder() 92 | .id(UserId::new("test")) 93 | .email("test@example.com") 94 | .build()?; 95 | 96 | event_bus.emit(&Event::UserCreated(user)).await?; 97 | ``` 98 | 99 | ## Benefits 100 | 101 | 1. **Loose Coupling**: Plugins can interact without direct dependencies 102 | 2. **Extensibility**: New events can be added without breaking existing plugins 103 | 3. **Observability**: Easy to monitor and log inter-plugin interactions 104 | 4. **Flexibility**: Plugins can choose which events to handle 105 | 5. **Async Support**: All event handling is async-compatible 106 | 6. **Thread Safety**: Event bus is thread-safe using `Arc` and `RwLock` 107 | 108 | ### Current Features 109 | 110 | - User lifecycle events (created, updated, deleted) 111 | - Session lifecycle events (created, deleted) 112 | - Async event handling 113 | - Error propagation 114 | - Thread-safe handler management 115 | 116 | ### Known Limitations 117 | 118 | 1. Events are not currently persisted 119 | 2. No event filtering mechanism 120 | 3. Events are processed sequentially 121 | 4. No replay capability 122 | 5. No way to unregister handlers 123 | 124 | ## Future Work 125 | 126 | 1. Add event persistence layer 127 | 2. Implement event filtering 128 | 3. Add parallel event processing 129 | 4. Support event replay for recovery 130 | 5. Add handler unregistration 131 | 6. Consider adding more granular events 132 | 7. Add event metadata (timestamp, correlation ID) 133 | 134 | ## Questions 135 | 136 | 1. Should events be persisted? 137 | 2. How to handle event versioning? 138 | 3. Should we add handler priorities? 139 | 4. How to handle event ordering? 140 | 5. Should we add handler unregistration? 141 | 142 | ## References 143 | 144 | - [Event Sourcing Pattern](https://martinfowler.com/eaaDev/EventSourcing.html) 145 | - [Observer Pattern](https://en.wikipedia.org/wiki/Observer_pattern) 146 | - [Pub/Sub Pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) 147 | -------------------------------------------------------------------------------- /rfcs/002-plugin-interfaces.md: -------------------------------------------------------------------------------- 1 | # RFC 002: Separate Core Plugin Interfaces 2 | 3 | | Date | Author | Status | 4 | | ---------- | ------------ | -------------- | 5 | | 2025-02-19 | @cmackenzie1 | ✅ Implemented | 6 | 7 | ## Summary 8 | 9 | Split the plugin system into specialized interfaces to improve modularity, type safety, and maintainability. Each interface has a focused responsibility: 10 | 11 | 1. `AuthPlugin` - Core authentication functionality 12 | 2. `StoragePlugin` - Data persistence operations 13 | 3. `EventHandler` - Event processing capabilities 14 | 4. `EmailPasswordStorage` - Email/password specific storage operations 15 | 5. `OAuthStorage` - OAuth specific storage operations 16 | 17 | ## Motivation 18 | 19 | A single monolithic plugin interface forces plugins to implement functionality they don't need, leading to: 20 | 21 | 1. **Unnecessary Implementation Burden**: Plugins must implement unused methods 22 | 2. **Poor Type Safety**: No compile-time guarantees about plugin capabilities 23 | 3. **Testing Complexity**: Mocking requires implementing unused methods 24 | 4. **Limited Flexibility**: Difficult to compose plugin functionality 25 | 26 | ## Design 27 | 28 | ### Core Interfaces 29 | 30 | ```rust 31 | #[async_trait] 32 | pub trait AuthPlugin: Plugin + Send + Sync + 'static + DowncastSync { 33 | /// Unique identifier for this auth method 34 | fn auth_method(&self) -> &str; 35 | 36 | /// Authenticate a user and create a session 37 | async fn authenticate(&self, credentials: &Credentials) -> Result; 38 | 39 | /// Validate an existing session 40 | async fn validate_session(&self, session: &Session) -> Result; 41 | 42 | /// Handle logout/session termination 43 | async fn logout(&self, session: &Session) -> Result<(), Error>; 44 | } 45 | 46 | #[async_trait] 47 | pub trait StoragePlugin: Send + Sync + 'static { 48 | type Config; 49 | 50 | /// Initialize storage with config 51 | async fn initialize(&self, config: Self::Config) -> Result<(), Error>; 52 | 53 | /// Storage health check 54 | async fn health_check(&self) -> Result<(), Error>; 55 | 56 | /// Clean up expired data 57 | async fn cleanup(&self) -> Result<(), Error>; 58 | } 59 | 60 | #[async_trait] 61 | pub trait EventHandler: Send + Sync + 'static { 62 | /// Handle plugin events 63 | async fn handle_event(&self, event: &Event) -> Result<(), Error>; 64 | } 65 | 66 | #[async_trait] 67 | pub trait EmailPasswordStorage: UserStorage { 68 | /// Store a password hash for a user 69 | async fn set_password_hash(&self, user_id: &UserId, hash: &str) -> Result<(), Self::Error>; 70 | 71 | /// Retrieve a user's password hash 72 | async fn get_password_hash(&self, user_id: &UserId) -> Result, Self::Error>; 73 | } 74 | 75 | #[async_trait] 76 | pub trait OAuthStorage: UserStorage { 77 | /// Create a new OAuth account linked to a user 78 | async fn create_oauth_account( 79 | &self, 80 | provider: &str, 81 | subject: &str, 82 | user_id: &UserId, 83 | ) -> Result; 84 | 85 | /// Find a user by their OAuth provider and subject 86 | async fn get_user_by_provider_and_subject( 87 | &self, 88 | provider: &str, 89 | subject: &str, 90 | ) -> Result, Self::Error>; 91 | } 92 | ``` 93 | 94 | ### Plugin Registration 95 | 96 | ```rust 97 | pub struct PluginManager { 98 | auth_plugins: DashMap>, 99 | storage: Storage, 100 | } 101 | 102 | impl PluginManager { 103 | pub fn register_auth_plugin(&mut self, plugin: T) { 104 | self.auth_plugins.insert(plugin.name().to_string(), Arc::new(plugin)); 105 | } 106 | 107 | pub fn storage(&self) -> &Storage { 108 | &self.storage 109 | } 110 | } 111 | ``` 112 | 113 | ## Benefits 114 | 115 | 1. **Clear Responsibilities**: Each interface has a focused set of related functionality 116 | 2. **Type Safety**: Compile-time verification of plugin capabilities 117 | 3. **Easier Testing**: Mock only the interfaces you need 118 | 4. **Flexible Composition**: Plugins can implement multiple interfaces as needed 119 | 5. **Future Extensibility**: New plugin types can be added without affecting existing ones 120 | 121 | ## References 122 | 123 | - [RFC 0001: Plugin Event System](./001-plugin-events.md) 124 | -------------------------------------------------------------------------------- /rfcs/003-mongodb-storage.md: -------------------------------------------------------------------------------- 1 | # RFC 003: MongoDB Storage Provider 2 | 3 | | Date | Author | Status | 4 | | ---------- | ------------ | -------- | 5 | | 2025-02-19 | @cmackenzie1 | 📝 Draft | 6 | 7 | ## Summary 8 | 9 | Add MongoDB as a storage provider for Torii, enabling users to store authentication data in MongoDB while maintaining the same storage interface defined in [RFC 0002](./002-plugin-interfaces.md). 10 | 11 | ## Motivation 12 | 13 | MongoDB offers several advantages as a storage backend: 14 | 15 | 1. **Schema Flexibility**: Easier to evolve data models over time 16 | 2. **Horizontal Scalability**: Built-in sharding and replication 17 | 3. **Rich Indexing**: Support for TTL, compound, and geospatial indexes 18 | 4. **Cloud Native**: First-class support in major cloud providers 19 | 5. **Document Model**: Natural fit for user and session data 20 | 21 | ## Design 22 | 23 | ### Storage Models 24 | 25 | ```rust 26 | #[derive(Serialize, Deserialize)] 27 | pub struct MongoUser { 28 | #[serde(rename = "_id")] 29 | pub id: String, 30 | pub email: String, 31 | pub password_hash: Option, 32 | pub email_verified_at: Option, 33 | pub created_at: DateTime, 34 | pub updated_at: DateTime, 35 | #[serde(default)] 36 | pub metadata: Document, // Flexible metadata storage 37 | } 38 | 39 | #[derive(Serialize, Deserialize)] 40 | pub struct MongoSession { 41 | #[serde(rename = "_id")] 42 | pub id: String, 43 | pub user_id: String, 44 | pub expires_at: DateTime, 45 | pub created_at: DateTime, 46 | pub user_agent: Option, 47 | pub ip_address: Option, 48 | } 49 | ``` 50 | 51 | ### Storage Implementation 52 | 53 | ```rust 54 | pub struct MongoStorage { 55 | client: Client, 56 | database: String, 57 | } 58 | 59 | #[derive(Clone)] 60 | pub struct MongoHandle { 61 | db: Database, 62 | users: Collection, 63 | sessions: Collection, 64 | } 65 | 66 | #[async_trait] 67 | impl StoragePlugin for MongoStorage { 68 | type Config = MongoConfig; 69 | type Handle = MongoHandle; 70 | 71 | async fn initialize(&self, config: Self::Config) -> Result { 72 | let db = self.client.database(&self.database); 73 | 74 | // Create collections 75 | let users = db.collection("users"); 76 | let sessions = db.collection("sessions"); 77 | 78 | // Setup indexes 79 | self.setup_indexes(&users, &sessions).await?; 80 | 81 | Ok(MongoHandle { db, users, sessions }) 82 | } 83 | } 84 | ``` 85 | 86 | ### Indexes 87 | 88 | ```rust 89 | impl MongoStorage { 90 | async fn setup_indexes( 91 | &self, 92 | users: &Collection, 93 | sessions: &Collection, 94 | ) -> Result<(), Error> { 95 | // User indexes 96 | users.create_index( 97 | IndexModel::builder() 98 | .keys(doc! { "email": 1 }) 99 | .options(IndexOptions::builder().unique(true).build()) 100 | .build(), 101 | None, 102 | ).await?; 103 | 104 | // Session indexes 105 | sessions.create_index( 106 | IndexModel::builder() 107 | .keys(doc! { "expires_at": 1 }) 108 | .options(IndexOptions::builder() 109 | .expire_after(Duration::ZERO) 110 | .build()) 111 | .build(), 112 | None, 113 | ).await?; 114 | 115 | sessions.create_index( 116 | IndexModel::builder() 117 | .keys(doc! { "user_id": 1 }) 118 | .build(), 119 | None, 120 | ).await?; 121 | 122 | Ok(()) 123 | } 124 | } 125 | ``` 126 | 127 | ### Configuration 128 | 129 | ```rust 130 | #[derive(Debug, Clone)] 131 | pub struct MongoConfig { 132 | pub uri: String, 133 | pub database: String, 134 | pub max_pool_size: Option, 135 | pub min_pool_size: Option, 136 | pub timeout: Option, 137 | } 138 | 139 | impl Default for MongoConfig { 140 | fn default() -> Self { 141 | Self { 142 | uri: "mongodb://localhost:27017".to_string(), 143 | database: "torii".to_string(), 144 | max_pool_size: Some(10), 145 | min_pool_size: Some(1), 146 | timeout: Some(Duration::from_secs(30)), 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | ## Implementation Details 153 | 154 | ### 1. Connection Management 155 | 156 | - Use connection pooling for efficient resource usage 157 | - Support replica set configuration 158 | - Handle connection failures gracefully 159 | - Support TLS/SSL connections 160 | 161 | ### 2. Data Migration 162 | 163 | ```rust 164 | impl MongoStorage { 165 | pub async fn migrate(&self) -> Result<(), Error> { 166 | // Version collection for tracking migrations 167 | let versions = self.db.collection("versions"); 168 | 169 | // Run migrations in order 170 | self.migrate_v1().await?; 171 | self.migrate_v2().await?; 172 | 173 | Ok(()) 174 | } 175 | } 176 | ``` 177 | 178 | ### 3. Error Mapping 179 | 180 | ```rust 181 | impl From for Error { 182 | fn from(err: mongodb::error::Error) -> Self { 183 | match err.kind.as_ref() { 184 | ErrorKind::Write(WriteFailure::WriteError(e)) 185 | if e.code == 11000 => Error::Duplicate, 186 | ErrorKind::Authentication => Error::Authentication, 187 | _ => Error::Storage(err.to_string()), 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | ### 4. Transactions 194 | 195 | ```rust 196 | impl MongoHandle { 197 | pub async fn transaction(&self, f: F) -> Result 198 | where 199 | F: FnOnce(&MongoHandle) -> Future>, 200 | { 201 | let session = self.client.start_session().await?; 202 | session.start_transaction().await?; 203 | 204 | match f(self).await { 205 | Ok(result) => { 206 | session.commit_transaction().await?; 207 | Ok(result) 208 | } 209 | Err(e) => { 210 | session.abort_transaction().await?; 211 | Err(e) 212 | } 213 | } 214 | } 215 | } 216 | ``` 217 | 218 | ## Benefits 219 | 220 | 1. **Scalability**: Native support for horizontal scaling 221 | 2. **Flexibility**: Schema-less design for future extensions 222 | 3. **Performance**: Efficient indexing and query capabilities 223 | 4. **Operations**: Built-in monitoring and management tools 224 | 5. **Cloud Support**: Easy deployment to major cloud providers 225 | 226 | ## Migration Guide 227 | 228 | 1. **Install Dependencies**: 229 | 230 | ```toml 231 | [dependencies] 232 | mongodb = "2.8" 233 | ``` 234 | 235 | 2. **Configure Storage**: 236 | 237 | ```rust 238 | let config = MongoConfig { 239 | uri: "mongodb://localhost:27017", 240 | database: "torii", 241 | ..Default::default() 242 | }; 243 | 244 | let storage = MongoStorage::new(config)?; 245 | ``` 246 | 247 | 3. **Data Migration**: 248 | 249 | ```bash 250 | # Export from SQLite 251 | sqlite3 torii.db .dump > dump.sql 252 | 253 | # Import to MongoDB 254 | torii migrate --from sqlite --to mongodb 255 | ``` 256 | 257 | ## Questions 258 | 259 | 1. Should we support MongoDB-specific features like Change Streams? 260 | 2. How should we handle schema evolution? 261 | 3. Should we support MongoDB Atlas-specific features? 262 | 4. How do we handle connection pooling in serverless environments? 263 | 264 | ## Alternatives Considered 265 | 266 | 1. **Use ODM Layer** 267 | 268 | - More abstraction 269 | - Higher overhead 270 | - Less flexible queries 271 | 272 | 2. **Raw BSON Documents** 273 | 274 | - More flexible 275 | - Less type safety 276 | - More error-prone 277 | 278 | 3. **Hybrid Approach** 279 | - Use typed models for core fields 280 | - Raw BSON for extensible fields 281 | - Balance of safety and flexibility 282 | 283 | ## References 284 | 285 | - [MongoDB Rust Driver](https://docs.rs/mongodb) 286 | - [MongoDB Index Types](https://www.mongodb.com/docs/manual/indexes/) 287 | - [MongoDB Transactions](https://www.mongodb.com/docs/manual/core/transactions/) 288 | - [RFC 0002: Plugin Interfaces](./002-plugin-interfaces.md) 289 | -------------------------------------------------------------------------------- /rfcs/004-postgresql-storage.md: -------------------------------------------------------------------------------- 1 | # RFC 004: PostgreSQL Storage Provider 2 | 3 | | Date | Author | Status | 4 | | ---------- | ------------ | -------------- | 5 | | 2025-02-19 | @cmackenzie1 | ✅ Implemented | 6 | 7 | ## Summary 8 | 9 | Add PostgreSQL as a storage provider for Torii, implementing the core storage interfaces while leveraging PostgreSQL's features for improved reliability and performance. 10 | 11 | ## Motivation 12 | 13 | PostgreSQL offers several compelling advantages as a storage backend: 14 | 15 | 1. **ACID Compliance**: Full transaction support with strong consistency guarantees 16 | 2. **Reliability**: Mature, battle-tested database with excellent durability 17 | 3. **Performance**: Connection pooling and efficient query execution 18 | 4. **Schema Enforcement**: Strong typing and constraints for data integrity 19 | 5. **Ecosystem**: Wide hosting options and management tools 20 | 21 | ## Design 22 | 23 | ### Core Tables 24 | 25 | ```sql 26 | CREATE TABLE users ( 27 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 28 | name TEXT, 29 | email TEXT UNIQUE, 30 | email_verified_at TIMESTAMPTZ, 31 | password_hash TEXT, 32 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 33 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 34 | ); 35 | 36 | CREATE TABLE sessions ( 37 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 38 | user_id UUID, 39 | user_agent TEXT, 40 | ip_address TEXT, 41 | expires_at TIMESTAMPTZ, 42 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 43 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 44 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 45 | ); 46 | 47 | CREATE TABLE oauth_accounts ( 48 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 49 | user_id UUID NOT NULL, 50 | provider TEXT NOT NULL, 51 | subject TEXT NOT NULL, 52 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 53 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 54 | FOREIGN KEY(user_id) REFERENCES users(id), 55 | UNIQUE(user_id, provider, subject) 56 | ); 57 | 58 | CREATE TABLE oauth_state ( 59 | csrf_state TEXT PRIMARY KEY, 60 | pkce_verifier TEXT NOT NULL, 61 | expires_at TIMESTAMPTZ NOT NULL, 62 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 63 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 64 | ); 65 | ``` 66 | 67 | ### Storage Implementation 68 | 69 | The PostgreSQL storage provider implements the following core interfaces: 70 | 71 | - `UserStorage` - For managing user accounts 72 | - `SessionStorage` - For handling user sessions 73 | - `PasswordStorage` - For email/password authentication 74 | - `OAuthStorage` - For OAuth account linking 75 | - `PasskeyStorage` - For passkey authentication 76 | 77 | Key implementation details: 78 | 79 | ```rust 80 | pub struct PostgresStorage { 81 | pool: PgPool, 82 | } 83 | 84 | impl PostgresStorage { 85 | pub fn new(pool: PgPool) -> Self { 86 | Self { pool } 87 | } 88 | 89 | pub async fn migrate(&self) -> Result<(), sqlx::Error> { 90 | let migrations = sqlx::migrate!("./migrations"); 91 | migrations.run(&self.pool).await?; 92 | Ok(()) 93 | } 94 | } 95 | ``` 96 | 97 | ### Features 98 | 99 | 1. **Connection Pooling**: Efficient connection management via sqlx 100 | 2. **Migration Support**: Built-in schema migrations 101 | 3. **Strong Typing**: Type-safe database interactions 102 | 4. **Automatic Timestamps**: Created/updated timestamps handled by database 103 | 5. **Referential Integrity**: Foreign key constraints for data consistency 104 | 105 | ## Usage 106 | 107 | 1. Add dependencies: 108 | 109 | ```toml 110 | [dependencies] 111 | torii-storage-postgres = { version = "0.1" } 112 | sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls"] } 113 | ``` 114 | 115 | 2. Initialize storage: 116 | 117 | ```rust 118 | let pool = PgPool::connect("postgres://user:pass@localhost/db").await?; 119 | let storage = PostgresStorage::new(pool); 120 | storage.migrate().await?; 121 | ``` 122 | 123 | ## Benefits 124 | 125 | 1. **Production Ready**: Stable, tested implementation 126 | 2. **Type Safety**: Compile-time query checking 127 | 3. **Performance**: Efficient connection pooling 128 | 4. **Maintainability**: Clear schema migrations 129 | 5. **Security**: Built-in password hashing support 130 | 131 | ## Migration Guide 132 | 133 | For users migrating from another storage provider: 134 | 135 | 1. Set up PostgreSQL database 136 | 2. Update configuration to use PostgreSQL connection string 137 | 3. Run migrations via `storage.migrate()` 138 | 4. Data migration tools provided separately 139 | 140 | ## References 141 | 142 | - [SQLx Documentation](https://docs.rs/sqlx) 143 | - [PostgreSQL Documentation](https://www.postgresql.org/docs/) 144 | - [RFC 0002: Plugin Interfaces](./002-plugin-interfaces.md) 145 | 146 | ## Known Limitations 147 | 148 | 1. No built-in read replicas support 149 | 2. Connection pool sizing needs manual tuning 150 | 3. No automatic backup management 151 | -------------------------------------------------------------------------------- /rfcs/006-redis-storage.md: -------------------------------------------------------------------------------- 1 | # RFC 006: Redis/ValKey Storage Provider 2 | 3 | | Date | Author | Status | 4 | | ---------- | ------------ | -------- | 5 | | 2025-02-19 | @cmackenzie1 | 📝 Draft | 6 | 7 | ## Summary 8 | 9 | Add Redis support as a caching and session storage layer for Torii, with optional support for ValKey as a Redis-compatible alternative. This implementation will focus on high-performance session management and caching while maintaining compatibility with the storage interface defined in RFC 0002. 10 | 11 | ## Motivation 12 | 13 | Redis/ValKey offers several advantages for session and cache storage: 14 | 15 | 1. **Performance**: In-memory storage with sub-millisecond response times 16 | 2. **TTL Support**: Native expiration for sessions and cache entries 17 | 3. **Scalability**: Cluster support for horizontal scaling 18 | 4. **Persistence**: Optional durability with AOF/RDB 19 | 5. **Atomic Operations**: Built-in support for atomic operations 20 | 6. **Compatibility**: ValKey provides Redis compatibility with Rust implementation 21 | 22 | ## Design 23 | 24 | ### Storage Models 25 | 26 | ```rust 27 | use std::time::Duration; 28 | use serde::{Serialize, Deserialize}; 29 | 30 | #[derive(Debug, Serialize, Deserialize)] 31 | pub struct CachedUser { 32 | #[serde(flatten)] 33 | pub user: User, 34 | #[serde(with = "time::serde::timestamp")] 35 | pub cached_at: DateTime, 36 | } 37 | 38 | #[derive(Debug, Serialize, Deserialize)] 39 | pub struct CachedSession { 40 | #[serde(flatten)] 41 | pub session: Session, 42 | pub metadata: HashMap, 43 | } 44 | 45 | #[derive(Debug, Clone)] 46 | pub struct RedisConfig { 47 | pub urls: Vec, 48 | pub username: Option, 49 | pub password: Option, 50 | pub database: i32, 51 | pub cluster_mode: bool, 52 | pub tls_enabled: bool, 53 | pub key_prefix: String, 54 | pub pool_size: usize, 55 | pub cache_ttl: Duration, 56 | } 57 | 58 | pub struct RedisStorage { 59 | client: redis::Client, 60 | config: RedisConfig, 61 | } 62 | 63 | #[derive(Clone)] 64 | pub struct RedisHandle { 65 | pool: redis::aio::ConnectionManager, 66 | config: RedisConfig, 67 | } 68 | ``` 69 | 70 | ### Storage Implementation 71 | 72 | ```rust 73 | #[async_trait] 74 | impl StoragePlugin for RedisStorage { 75 | type Config = RedisConfig; 76 | type Handle = RedisHandle; 77 | 78 | async fn initialize(&self, config: Self::Config) -> Result { 79 | let pool = self.client.get_connection_manager().await?; 80 | 81 | // Test connection 82 | let mut conn = pool.clone(); 83 | redis::cmd("PING") 84 | .query_async(&mut conn) 85 | .await 86 | .map_err(Error::from)?; 87 | 88 | Ok(RedisHandle { pool, config }) 89 | } 90 | } 91 | 92 | impl RedisHandle { 93 | fn key(&self, kind: &str, id: &str) -> String { 94 | format!("{}:{}:{}", self.config.key_prefix, kind, id) 95 | } 96 | 97 | async fn get_json( 98 | &self, 99 | key: &str 100 | ) -> Result, Error> { 101 | let mut conn = self.pool.clone(); 102 | let data: Option = redis::cmd("GET") 103 | .arg(key) 104 | .query_async(&mut conn) 105 | .await?; 106 | 107 | match data { 108 | Some(json) => Ok(Some(serde_json::from_str(&json)?)), 109 | None => Ok(None), 110 | } 111 | } 112 | 113 | async fn set_json( 114 | &self, 115 | key: &str, 116 | value: &T, 117 | ttl: Option, 118 | ) -> Result<(), Error> { 119 | let json = serde_json::to_string(value)?; 120 | let mut conn = self.pool.clone(); 121 | 122 | match ttl { 123 | Some(ttl) => { 124 | redis::cmd("SETEX") 125 | .arg(key) 126 | .arg(ttl.as_secs()) 127 | .arg(json) 128 | .query_async(&mut conn) 129 | .await? 130 | } 131 | None => { 132 | redis::cmd("SET") 133 | .arg(key) 134 | .arg(json) 135 | .query_async(&mut conn) 136 | .await? 137 | } 138 | } 139 | 140 | Ok(()) 141 | } 142 | } 143 | ``` 144 | -------------------------------------------------------------------------------- /torii-auth-magic-link/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-auth-magic-link" 3 | description = "Magic Link authentication plugin for Torii" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2" } 11 | base64 = "0.22" 12 | chrono.workspace = true 13 | rand = "0.9" 14 | thiserror.workspace = true 15 | 16 | [dev-dependencies] 17 | torii-storage-sqlite = { path = "../torii-storage-sqlite" } 18 | 19 | axum = { version = "0.8", features = ["macros"] } 20 | axum-extra = { version = "0.10", features = ["cookie"] } 21 | serde_json.workspace = true 22 | serde.workspace = true 23 | sqlx.workspace = true 24 | tokio.workspace = true 25 | tracing-subscriber.workspace = true 26 | 27 | 28 | [[example]] 29 | name = "magic-link" 30 | path = "examples/magic-link.rs" 31 | -------------------------------------------------------------------------------- /torii-auth-magic-link/examples/README.md: -------------------------------------------------------------------------------- 1 | # Magic Link Example 2 | 3 | This example demonstrates how to use the Magic Link plugin to authenticate a user using an email and password. 4 | 5 | ## Running the example 6 | 7 | ```bash 8 | cargo run --example magic-link 9 | ``` 10 | 11 | ## Accessing the example 12 | 13 | The example will start a server on `http://localhost:4000`. You can access the example by opening a browser and navigating to `http://localhost:4000/` and completing the form to create a new user. 14 | 15 | Once you have created a user, you can access the example by navigating to `http://localhost:4000/` and clicking the "Get Magic Link" button. 16 | 17 | Once signed in, you will be redirected to `http://localhost:4000/whoami` where you can view the user's details. 18 | 19 | > [!IMPORTANT] 20 | > If you run the example multiple times, you will need to clear your cookies or use a different browser to test signing in with a different user. 21 | -------------------------------------------------------------------------------- /torii-auth-oauth/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-auth-oauth" 3 | description = "OAuth authentication plugin for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | 12 | oauth2 = { version = "5.0.0" } 13 | reqwest = { version = "0.12", features = ["json"] } 14 | chrono.workspace = true 15 | serde.workspace = true 16 | serde_json.workspace = true 17 | tracing.workspace = true 18 | 19 | [dev-dependencies] 20 | axum = { version = "0.8", features = ["macros"] } 21 | axum-extra = { version = "0.10", features = ["cookie"] } 22 | sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } 23 | tokio.workspace = true 24 | torii-storage-sqlite = { path = "../torii-storage-sqlite" } # don't specify version in dev-dependencies 25 | tracing-subscriber.workspace = true 26 | 27 | [[example]] 28 | name = "google" 29 | path = "examples/google/google.rs" 30 | 31 | [[example]] 32 | name = "github" 33 | path = "examples/github/github.rs" 34 | -------------------------------------------------------------------------------- /torii-auth-oauth/examples/github/README.md: -------------------------------------------------------------------------------- 1 | # GitHub OAuth Example 2 | 3 | This example demonstrates how to use the oauth plugin to authenticate a user using GitHub. 4 | 5 | ## Environment Variables 6 | 7 | To run the example, you must set the following environment variables: 8 | 9 | - `GITHUB_CLIENT_ID` 10 | - `GITHUB_CLIENT_SECRET` 11 | 12 | ## Running the example 13 | 14 | ```bash 15 | cargo run --example github 16 | ``` 17 | 18 | ## Accessing the example 19 | 20 | The example will start a server on `http://localhost:4000`. You can access the example by opening a browser and navigating to `http://localhost:4000`. 21 | -------------------------------------------------------------------------------- /torii-auth-oauth/examples/github/github.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | Json, Router, 5 | extract::{Query, State}, 6 | http::StatusCode, 7 | response::{IntoResponse, Redirect}, 8 | routing::get, 9 | }; 10 | use axum_extra::extract::{CookieJar, cookie::Cookie}; 11 | use serde::Deserialize; 12 | use sqlx::{Pool, Sqlite}; 13 | use torii_auth_oauth::OAuthPlugin; 14 | use torii_core::{DefaultUserManager, Session, plugin::PluginManager, storage::SessionStorage}; 15 | use torii_storage_sqlite::SqliteStorage; 16 | 17 | #[derive(Debug, Deserialize)] 18 | struct QueryParams { 19 | code: String, 20 | state: String, 21 | } 22 | 23 | #[derive(Clone)] 24 | struct AppState { 25 | plugin_manager: Arc>, 26 | } 27 | 28 | #[axum::debug_handler] 29 | async fn login_handler(State(state): State, jar: CookieJar) -> (CookieJar, Redirect) { 30 | let plugin = state 31 | .plugin_manager 32 | .get_plugin::, SqliteStorage>>("github") 33 | .unwrap(); 34 | 35 | let auth_url = plugin.get_authorization_url().await.unwrap(); 36 | 37 | let jar = jar.add( 38 | Cookie::build(("csrf_state", auth_url.csrf_state().to_string())) 39 | .path("/") 40 | .http_only(true), 41 | ); 42 | 43 | (jar, Redirect::to(auth_url.url())) 44 | } 45 | 46 | #[axum::debug_handler] 47 | async fn callback_handler( 48 | State(state): State, 49 | Query(params): Query, 50 | jar: CookieJar, 51 | ) -> impl IntoResponse { 52 | let csrf_state = jar.get("csrf_state").unwrap().value(); 53 | 54 | if csrf_state != params.state { 55 | return (StatusCode::BAD_REQUEST, "CSRF state mismatch").into_response(); 56 | } 57 | 58 | let plugin = state 59 | .plugin_manager 60 | .get_plugin::, SqliteStorage>>("github") 61 | .unwrap(); 62 | 63 | let user = plugin 64 | .exchange_code(params.code.to_string(), csrf_state.to_string()) 65 | .await 66 | .unwrap(); 67 | 68 | let session = state 69 | .plugin_manager 70 | .session_storage() 71 | .create_session( 72 | &Session::builder() 73 | .user_id(user.id.clone()) 74 | .build() 75 | .expect("Failed to build session"), 76 | ) 77 | .await 78 | .expect("Failed to create session"); 79 | 80 | // Set session cookie 81 | let jar = jar.add( 82 | Cookie::build(("session_id", session.token.to_string())) 83 | .path("/") 84 | .http_only(true), 85 | ); 86 | 87 | (jar, Json(user)).into_response() 88 | } 89 | 90 | #[tokio::main] 91 | async fn main() { 92 | tracing_subscriber::fmt::init(); 93 | let pool = Pool::::connect("sqlite:./github.db?mode=rwc") 94 | .await 95 | .unwrap(); 96 | 97 | let storage = Arc::new(SqliteStorage::new(pool.clone())); 98 | let session_storage = Arc::new(SqliteStorage::new(pool.clone())); 99 | 100 | storage.migrate().await.unwrap(); 101 | session_storage.migrate().await.unwrap(); 102 | 103 | // Create user manager 104 | let user_manager = Arc::new(DefaultUserManager::new(storage.clone())); 105 | 106 | let mut plugin_manager = PluginManager::new(storage.clone(), session_storage.clone()); 107 | plugin_manager.register_plugin(OAuthPlugin::github( 108 | &std::env::var("GITHUB_CLIENT_ID").expect("GITHUB_CLIENT_ID must be set"), 109 | &std::env::var("GITHUB_CLIENT_SECRET").expect("GITHUB_CLIENT_SECRET must be set"), 110 | "http://localhost:4000/auth/github/callback", 111 | user_manager.clone(), 112 | storage.clone(), 113 | )); 114 | 115 | let app = Router::new() 116 | .route("/", get(|| async { "Hello, World!" })) 117 | .route("/auth/github/login", get(login_handler)) 118 | .route("/auth/github/callback", get(callback_handler)) 119 | .with_state(AppState { 120 | plugin_manager: Arc::new(plugin_manager), 121 | }); 122 | 123 | tokio::spawn(async move { 124 | let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap(); 125 | println!("Listening on {}", listener.local_addr().unwrap()); 126 | axum::serve(listener, app).await.unwrap(); 127 | }); 128 | 129 | println!( 130 | "Please open the following URL in your browser: http://localhost:4000/auth/github/login" 131 | ); 132 | 133 | println!("Press Enter or Ctrl+C to exit..."); 134 | let _ = std::io::stdin().read_line(&mut String::new()); 135 | } 136 | -------------------------------------------------------------------------------- /torii-auth-oauth/examples/google/README.md: -------------------------------------------------------------------------------- 1 | # Google OAuth Example 2 | 3 | This example demonstrates how to use the oauth plugin to authenticate a user using Google. 4 | 5 | ## Environment Variables 6 | 7 | To run the example, you must set the following environment variables: 8 | 9 | - `GOOGLE_CLIENT_ID` 10 | - `GOOGLE_CLIENT_SECRET` 11 | 12 | ## Running the example 13 | 14 | ```bash 15 | cargo run --example google 16 | ``` 17 | 18 | ## Accessing the example 19 | 20 | The example will start a server on `http://localhost:4000`. You can access the example by opening a browser and navigating to `http://localhost:4000`. 21 | -------------------------------------------------------------------------------- /torii-auth-oauth/examples/google/google.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | Json, Router, 5 | extract::{Query, State}, 6 | http::StatusCode, 7 | response::{IntoResponse, Redirect}, 8 | routing::get, 9 | }; 10 | use axum_extra::extract::{CookieJar, cookie::Cookie}; 11 | use serde::Deserialize; 12 | use sqlx::{Pool, Sqlite}; 13 | use torii_auth_oauth::OAuthPlugin; 14 | use torii_core::{DefaultUserManager, Session, plugin::PluginManager, storage::SessionStorage}; 15 | use torii_storage_sqlite::SqliteStorage; 16 | 17 | #[derive(Debug, Deserialize)] 18 | struct QueryParams { 19 | code: String, 20 | state: String, 21 | } 22 | 23 | #[derive(Clone)] 24 | struct AppState { 25 | plugin_manager: Arc>, 26 | } 27 | 28 | #[axum::debug_handler] 29 | async fn login_handler(State(state): State, jar: CookieJar) -> (CookieJar, Redirect) { 30 | let plugin = state 31 | .plugin_manager 32 | .get_plugin::, SqliteStorage>>("google") 33 | .unwrap(); 34 | 35 | let auth_url = plugin.get_authorization_url().await.unwrap(); 36 | 37 | let jar = jar.add( 38 | Cookie::build(("csrf_state", auth_url.csrf_state().to_string())) 39 | .path("/") 40 | .http_only(true), 41 | ); 42 | 43 | (jar, Redirect::to(auth_url.url())) 44 | } 45 | 46 | #[axum::debug_handler] 47 | async fn callback_handler( 48 | State(state): State, 49 | Query(params): Query, 50 | jar: CookieJar, 51 | ) -> impl IntoResponse { 52 | let csrf_state = jar.get("csrf_state").unwrap().value(); 53 | 54 | if csrf_state != params.state { 55 | return (StatusCode::BAD_REQUEST, "CSRF state mismatch").into_response(); 56 | } 57 | 58 | let plugin = state 59 | .plugin_manager 60 | .get_plugin::, SqliteStorage>>("google") 61 | .unwrap(); 62 | 63 | let user = plugin 64 | .exchange_code(params.code.to_string(), csrf_state.to_string()) 65 | .await 66 | .unwrap(); 67 | 68 | let session = state 69 | .plugin_manager 70 | .session_storage() 71 | .create_session(&Session::builder().user_id(user.id.clone()).build().unwrap()) 72 | .await 73 | .unwrap(); 74 | 75 | // Set session cookie 76 | let jar = jar.add( 77 | Cookie::build(("session_id", session.token.to_string())) 78 | .path("/") 79 | .http_only(true), 80 | ); 81 | 82 | (jar, Json(user)).into_response() 83 | } 84 | 85 | #[tokio::main] 86 | async fn main() { 87 | tracing_subscriber::fmt::init(); 88 | let pool = Pool::::connect("sqlite:./google.db?mode=rwc") 89 | .await 90 | .unwrap(); 91 | 92 | let storage = Arc::new(SqliteStorage::new(pool.clone())); 93 | let session_storage = Arc::new(SqliteStorage::new(pool.clone())); 94 | 95 | storage.migrate().await.unwrap(); 96 | session_storage.migrate().await.unwrap(); 97 | 98 | // Create user manager 99 | let user_manager = Arc::new(DefaultUserManager::new(storage.clone())); 100 | 101 | let mut plugin_manager = PluginManager::new(storage.clone(), session_storage.clone()); 102 | plugin_manager.register_plugin(OAuthPlugin::google( 103 | &std::env::var("GOOGLE_CLIENT_ID").expect("GOOGLE_CLIENT_ID must be set"), 104 | &std::env::var("GOOGLE_CLIENT_SECRET").expect("GOOGLE_CLIENT_SECRET must be set"), 105 | "http://localhost:4000/auth/google/callback", 106 | user_manager.clone(), 107 | storage.clone(), 108 | )); 109 | 110 | let app = Router::new() 111 | .route("/", get(|| async { "Hello, World!" })) 112 | .route("/auth/google/login", get(login_handler)) 113 | .route("/auth/google/callback", get(callback_handler)) 114 | .with_state(AppState { 115 | plugin_manager: Arc::new(plugin_manager), 116 | }); 117 | 118 | tokio::spawn(async move { 119 | let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap(); 120 | println!("Listening on {}", listener.local_addr().unwrap()); 121 | axum::serve(listener, app).await.unwrap(); 122 | }); 123 | 124 | println!( 125 | "Please open the following URL in your browser: http://localhost:4000/auth/google/login" 126 | ); 127 | 128 | println!("Press Enter or Ctrl+C to exit..."); 129 | let _ = std::io::stdin().read_line(&mut String::new()); 130 | } 131 | -------------------------------------------------------------------------------- /torii-auth-oauth/src/providers/google.rs: -------------------------------------------------------------------------------- 1 | use oauth2::{ 2 | AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, 3 | PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, StandardTokenResponse, TokenUrl, 4 | basic::{BasicClient, BasicTokenType}, 5 | }; 6 | use serde::Deserialize; 7 | use torii_core::{Error, error::AuthError}; 8 | 9 | use super::{AuthorizationUrl, UserInfo}; 10 | 11 | pub struct Google { 12 | client_id: String, 13 | client_secret: String, 14 | redirect_uri: String, 15 | } 16 | 17 | const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; 18 | const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; 19 | const DEFAULT_SCOPES: &str = "openid email profile"; 20 | 21 | /// A limited subset of the user info response from Google. 22 | #[derive(Debug, Clone, Deserialize)] 23 | pub struct GoogleUserInfo { 24 | pub email: String, 25 | pub name: String, 26 | pub picture: String, 27 | pub sub: String, 28 | } 29 | 30 | impl Google { 31 | pub fn new(client_id: String, client_secret: String, redirect_uri: String) -> Self { 32 | Self { 33 | client_id, 34 | client_secret, 35 | redirect_uri, 36 | } 37 | } 38 | pub fn get_authorization_url(&self) -> Result<(AuthorizationUrl, String), Error> { 39 | // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and 40 | // token URL. 41 | let client = BasicClient::new(ClientId::new(self.client_id.clone())) 42 | .set_client_secret(ClientSecret::new(self.client_secret.clone())) 43 | .set_auth_uri(AuthUrl::new(GOOGLE_AUTH_URL.to_string()).expect("Invalid auth URL")) 44 | .set_token_uri(TokenUrl::new(GOOGLE_TOKEN_URL.to_string()).expect("Invalid token URL")) 45 | .set_redirect_uri( 46 | RedirectUrl::new(self.redirect_uri.clone()).expect("Invalid redirect URI"), 47 | ); 48 | 49 | let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); 50 | let (auth_url, csrf_state) = client 51 | .authorize_url(CsrfToken::new_random) 52 | .set_pkce_challenge(pkce_challenge) 53 | .add_scopes( 54 | DEFAULT_SCOPES 55 | .split_whitespace() 56 | .map(|s| Scope::new(s.to_string())) 57 | .collect::>(), 58 | ) 59 | .url(); 60 | 61 | Ok(( 62 | AuthorizationUrl { 63 | url: auth_url.to_string(), 64 | csrf_state: csrf_state.secret().to_string(), 65 | }, 66 | pkce_verifier.secret().to_string(), 67 | )) 68 | } 69 | 70 | pub async fn get_user_info(&self, access_token: &str) -> Result { 71 | let http_client = reqwest::ClientBuilder::new() 72 | // Following redirects opens the client up to SSRF vulnerabilities. 73 | .redirect(reqwest::redirect::Policy::none()) 74 | .build() 75 | .expect("Client should build"); 76 | 77 | // Get user info from Google's userinfo endpoint 78 | let user_info = http_client 79 | .get("https://openidconnect.googleapis.com/v1/userinfo") 80 | .bearer_auth(access_token) 81 | .send() 82 | .await 83 | .map_err(|e| { 84 | tracing::error!( 85 | error = ?e, 86 | "Error getting user info" 87 | ); 88 | Error::Auth(AuthError::InvalidCredentials) 89 | })? 90 | .json::() 91 | .await 92 | .map_err(|e| { 93 | tracing::error!( 94 | error = ?e, 95 | "Error parsing user info" 96 | ); 97 | Error::Auth(AuthError::InvalidCredentials) 98 | })?; 99 | 100 | Ok(UserInfo::Google(user_info)) 101 | } 102 | 103 | pub async fn exchange_code( 104 | &self, 105 | code: &str, 106 | pkce_verifier: &str, 107 | ) -> Result, Error> { 108 | let client = BasicClient::new(ClientId::new(self.client_id.clone())) 109 | .set_client_secret(ClientSecret::new(self.client_secret.clone())) 110 | .set_auth_uri(AuthUrl::new(GOOGLE_AUTH_URL.to_string()).expect("Invalid auth URL")) 111 | .set_token_uri(TokenUrl::new(GOOGLE_TOKEN_URL.to_string()).expect("Invalid token URL")) 112 | .set_redirect_uri( 113 | RedirectUrl::new(self.redirect_uri.clone()).expect("Invalid redirect URI"), 114 | ); 115 | 116 | let http_client = reqwest::ClientBuilder::new() 117 | // Following redirects opens the client up to SSRF vulnerabilities. 118 | .redirect(reqwest::redirect::Policy::none()) 119 | .build() 120 | .expect("Client should build"); 121 | 122 | let token_response = client 123 | .exchange_code(AuthorizationCode::new(code.to_string())) 124 | .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier.to_string())) 125 | .request_async(&http_client) 126 | .await 127 | .map_err(|_| Error::Auth(AuthError::InvalidCredentials))?; 128 | 129 | Ok(token_response) 130 | } 131 | } 132 | 133 | #[cfg(test)] 134 | mod tests { 135 | use super::*; 136 | 137 | #[tokio::test] 138 | async fn test_google_get_authorization_url() { 139 | let google = Google::new( 140 | "client_id".to_string(), 141 | "client_secret".to_string(), 142 | "http://localhost:8080/callback".to_string(), 143 | ); 144 | 145 | let (auth_url, pkce_verifier) = google.get_authorization_url().unwrap(); 146 | assert!(auth_url.url.contains("accounts.google.com")); 147 | assert!(auth_url.url.contains("client_id=client_id")); 148 | assert!(auth_url.url.contains("scope=openid+email+profile")); 149 | assert!(!auth_url.csrf_state.is_empty()); 150 | assert!(!pkce_verifier.is_empty()); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /torii-auth-oauth/src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | use oauth2::{EmptyExtraTokenFields, StandardTokenResponse, basic::BasicTokenType}; 2 | use torii_core::Error; 3 | 4 | use crate::AuthorizationUrl; 5 | 6 | mod github; 7 | mod google; 8 | 9 | pub enum Provider { 10 | Google(google::Google), 11 | Github(github::Github), 12 | } 13 | 14 | impl Provider { 15 | pub fn name(&self) -> &str { 16 | match self { 17 | Self::Google(_) => "google", 18 | Self::Github(_) => "github", 19 | } 20 | } 21 | 22 | pub fn google(client_id: &str, client_secret: &str, redirect_uri: &str) -> Self { 23 | Self::Google(google::Google::new( 24 | client_id.to_string(), 25 | client_secret.to_string(), 26 | redirect_uri.to_string(), 27 | )) 28 | } 29 | 30 | pub fn github(client_id: &str, client_secret: &str, redirect_uri: &str) -> Self { 31 | Self::Github(github::Github::new( 32 | client_id.to_string(), 33 | client_secret.to_string(), 34 | redirect_uri.to_string(), 35 | )) 36 | } 37 | 38 | pub fn get_authorization_url(&self) -> Result<(AuthorizationUrl, String), Error> { 39 | match self { 40 | Self::Google(google) => google.get_authorization_url(), 41 | Self::Github(github) => github.get_authorization_url(), 42 | } 43 | } 44 | 45 | pub async fn get_user_info(&self, access_token: &str) -> Result { 46 | match self { 47 | Self::Google(google) => google.get_user_info(access_token).await, 48 | Self::Github(github) => github.get_user_info(access_token).await, 49 | } 50 | } 51 | 52 | pub async fn exchange_code( 53 | &self, 54 | code: &str, 55 | pkce_verifier: &str, 56 | ) -> Result, Error> { 57 | let token_response = match self { 58 | Self::Google(google) => google.exchange_code(code, pkce_verifier).await, 59 | Self::Github(github) => github.exchange_code(code, pkce_verifier).await, 60 | }?; 61 | 62 | Ok(token_response) 63 | } 64 | } 65 | 66 | #[derive(Debug, Clone)] 67 | pub enum UserInfo { 68 | Google(google::GoogleUserInfo), 69 | Github(github::GithubUserInfo), 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | #[test] 77 | fn test_provider_name() { 78 | let google = Provider::google( 79 | "client_id", 80 | "client_secret", 81 | "http://localhost:8080/callback", 82 | ); 83 | assert_eq!(google.name(), "google"); 84 | 85 | let github = Provider::github( 86 | "client_id", 87 | "client_secret", 88 | "http://localhost:8080/callback", 89 | ); 90 | assert_eq!(github.name(), "github"); 91 | } 92 | 93 | #[tokio::test] 94 | async fn test_provider_get_authorization_url() { 95 | let google = Provider::google( 96 | "client_id", 97 | "client_secret", 98 | "http://localhost:8080/callback", 99 | ); 100 | let (auth_url, _) = google.get_authorization_url().unwrap(); 101 | assert!(auth_url.url().contains("accounts.google.com")); 102 | 103 | let github = Provider::github( 104 | "client_id", 105 | "client_secret", 106 | "http://localhost:8080/callback", 107 | ); 108 | let (auth_url, _) = github.get_authorization_url().unwrap(); 109 | assert!(auth_url.url().contains("github.com")); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /torii-auth-passkey/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-auth-passkey" 3 | description = "Passkey authentication plugin for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | 12 | async-trait.workspace = true 13 | chrono.workspace = true 14 | webauthn-rs = { version = "0.5.1", features = [ 15 | "danger-allow-state-serialisation", 16 | ] } 17 | serde.workspace = true 18 | serde_json.workspace = true 19 | tracing.workspace = true 20 | thiserror.workspace = true 21 | uuid = { workspace = true, features = ["v4", "serde"] } 22 | 23 | [dev-dependencies] 24 | axum = { version = "0.8", features = ["macros"] } 25 | axum-extra = { version = "0.10", features = ["cookie"] } 26 | sqlx.workspace = true 27 | tokio.workspace = true 28 | torii-storage-sqlite = { path = "../torii-storage-sqlite" } # don't specify version in dev-dependencies 29 | tracing-subscriber.workspace = true 30 | 31 | [[example]] 32 | name = "passkey" 33 | path = "examples/passkey.rs" 34 | -------------------------------------------------------------------------------- /torii-auth-password/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-auth-password" 3 | description = "Password authentication plugin for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | chrono.workspace = true 12 | password-auth = { version = "1", features = ["argon2"] } 13 | regex.workspace = true 14 | tracing.workspace = true 15 | 16 | [dev-dependencies] 17 | async-trait.workspace = true 18 | axum = { version = "0.8", features = ["macros"] } 19 | axum-extra = { version = "0.10", features = ["cookie"] } 20 | serde_json.workspace = true 21 | serde.workspace = true 22 | sqlx.workspace = true 23 | tokio.workspace = true 24 | torii-storage-sqlite = { path = "../torii-storage-sqlite" } # don't specify version in dev-dependencies 25 | tracing-subscriber.workspace = true 26 | 27 | 28 | [[example]] 29 | name = "password" 30 | path = "examples/password.rs" 31 | -------------------------------------------------------------------------------- /torii-auth-password/examples/README.md: -------------------------------------------------------------------------------- 1 | # Password Example 2 | 3 | This example demonstrates how to use the Password plugin to authenticate a user using an email and password. 4 | 5 | ## Running the example 6 | 7 | ```bash 8 | cargo run --example password 9 | ``` 10 | 11 | ## Key Concepts 12 | 13 | This example demonstrates: 14 | 15 | 1. Setting up a `PasswordPlugin` with a user manager and password storage 16 | 2. User registration with email and password 17 | 3. Login authentication with email and password 18 | 4. Session management for authenticated users 19 | 5. Protected routes that require authentication 20 | 21 | ## Accessing the example 22 | 23 | The example will start a server on `http://localhost:4000`. You can access the example by opening a browser and navigating to `http://localhost:4000/sign-up` and completing the form to create a new user. 24 | 25 | Once you have created a user, you can access the example by navigating to `http://localhost:4000/sign-in` and signing in with the email and password you created. 26 | 27 | Once signed in, you will be redirected to `http://localhost:4000/whoami` where you can view the user's details. 28 | 29 | > [!IMPORTANT] 30 | > If you run the example multiple times, you will need to clear your cookies or use a different browser to test signing in with a different user. -------------------------------------------------------------------------------- /torii-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-core" 3 | description = "Core functionality for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | async-trait.workspace = true 11 | base64.workspace = true 12 | chrono.workspace = true 13 | dashmap.workspace = true 14 | downcast-rs = "2.0.1" 15 | jsonwebtoken.workspace = true 16 | rand.workspace = true 17 | serde.workspace = true 18 | serde_json.workspace = true 19 | thiserror.workspace = true 20 | tracing.workspace = true 21 | uuid.workspace = true 22 | tokio.workspace = true 23 | 24 | [dev-dependencies] 25 | tokio.workspace = true 26 | 27 | [features] 28 | default = [] 29 | -------------------------------------------------------------------------------- /torii-core/README.md: -------------------------------------------------------------------------------- 1 | # torii-core 2 | 3 | Core functionality for the torii project. All plugins are built on top of this library and are responsible for handling the specific details of each authentication method. 4 | 5 | Plugins may use the core functionality for user management, and session management, but are otherwise free to implement the logic or storage in any way they want. 6 | 7 | ## Users 8 | 9 | Users are the core of the authentication system. They are responsible for storing user information and are used to identify users in the system. The core user struct is defined as follows: 10 | 11 | | Field | Type | Description | 12 | | ------------------- | ------------------ | ------------------------------------------------- | 13 | | `id` | `String` | The unique identifier for the user. | 14 | | `name` | `String` | The name of the user. | 15 | | `email` | `String` | The email of the user. | 16 | | `email_verified_at` | `Option` | The timestamp when the user's email was verified. | 17 | | `created_at` | `DateTime` | The timestamp when the user was created. | 18 | | `updated_at` | `DateTime` | The timestamp when the user was last updated. | 19 | 20 | ## Sessions 21 | 22 | Sessions are used to track user sessions and are used to authenticate users. The core session struct is defined as follows: 23 | 24 | | Field | Type | Description | 25 | | ------------ | ---------------- | ------------------------------------------------------ | 26 | | `id` | `String` | The unique identifier for the session. | 27 | | `user_id` | `String` | The unique identifier for the user. | 28 | | `user_agent` | `Option` | The user agent of the client that created the session. | 29 | | `ip_address` | `Option` | The IP address of the client that created the session. | 30 | | `created_at` | `DateTime` | The timestamp when the session was created. | 31 | | `updated_at` | `DateTime` | The timestamp when the session was last updated. | 32 | | `expires_at` | `DateTime` | The timestamp when the session will expire. | 33 | -------------------------------------------------------------------------------- /torii-core/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | pub enum Error { 5 | #[error("Authentication error: {0}")] 6 | Auth(#[from] AuthError), 7 | 8 | #[error("Storage error: {0}")] 9 | Storage(#[from] StorageError), 10 | 11 | #[error("Plugin error: {0}")] 12 | Plugin(#[from] PluginError), 13 | 14 | #[error("Validation error: {0}")] 15 | Validation(#[from] ValidationError), 16 | 17 | #[error("Event error: {0}")] 18 | Event(#[from] EventError), 19 | 20 | #[error("Session error: {0}")] 21 | Session(#[from] SessionError), 22 | } 23 | 24 | #[derive(Debug, Error)] 25 | pub enum AuthError { 26 | #[error("Invalid credentials")] 27 | InvalidCredentials, 28 | 29 | #[error("User not found")] 30 | UserNotFound, 31 | 32 | #[error("User already exists")] 33 | UserAlreadyExists, 34 | 35 | #[error("Email not verified")] 36 | EmailNotVerified, 37 | 38 | #[error("Unsupported authentication method: {0}")] 39 | UnsupportedMethod(String), 40 | } 41 | 42 | #[derive(Debug, Error)] 43 | pub enum SessionError { 44 | #[error("Session not found")] 45 | NotFound, 46 | 47 | #[error("Session expired")] 48 | Expired, 49 | 50 | #[error("Session already exists")] 51 | AlreadyExists, 52 | 53 | #[error("Invalid token: {0}")] 54 | InvalidToken(String), 55 | 56 | #[error("JWT verification failed: {0}")] 57 | JwtVerification(String), 58 | } 59 | 60 | #[derive(Debug, Error)] 61 | pub enum StorageError { 62 | #[error("Database error: {0}")] 63 | Database(String), 64 | 65 | #[error("Migration error: {0}")] 66 | Migration(String), 67 | 68 | #[error("Connection error: {0}")] 69 | Connection(String), 70 | 71 | #[error("Record not found")] 72 | NotFound, 73 | } 74 | 75 | #[derive(Debug, Error)] 76 | pub enum PluginError { 77 | #[error("Plugin not found: {0}")] 78 | NotFound(String), 79 | 80 | #[error("Plugin initialization failed: {0}")] 81 | InitializationFailed(String), 82 | 83 | #[error("Plugin type mismatch: {0}")] 84 | TypeMismatch(String), 85 | 86 | #[error("Plugin operation failed: {0}")] 87 | OperationFailed(String), 88 | } 89 | 90 | #[derive(Debug, Error)] 91 | pub enum ValidationError { 92 | #[error("Invalid email format")] 93 | InvalidEmail, 94 | 95 | #[error("Weak password")] 96 | WeakPassword, 97 | 98 | #[error("Invalid field: {0}")] 99 | InvalidField(String), 100 | 101 | #[error("Missing required field: {0}")] 102 | MissingField(String), 103 | } 104 | 105 | #[derive(Debug, Error)] 106 | pub enum EventError { 107 | #[error("Event bus error: {0}")] 108 | BusError(String), 109 | 110 | #[error("Event handler error: {0}")] 111 | HandlerError(String), 112 | } 113 | 114 | impl Error { 115 | pub fn is_auth_error(&self) -> bool { 116 | matches!( 117 | self, 118 | Error::Auth(AuthError::InvalidCredentials) 119 | | Error::Auth(AuthError::UserNotFound) 120 | | Error::Auth(AuthError::UserAlreadyExists) 121 | ) 122 | } 123 | 124 | pub fn is_validation_error(&self) -> bool { 125 | matches!( 126 | self, 127 | Error::Validation(ValidationError::InvalidEmail) 128 | | Error::Validation(ValidationError::WeakPassword) 129 | | Error::Validation(ValidationError::InvalidField(_)) 130 | | Error::Validation(ValidationError::MissingField(_)) 131 | ) 132 | } 133 | 134 | pub fn is_plugin_error(&self) -> bool { 135 | matches!( 136 | self, 137 | Error::Plugin(PluginError::NotFound(_)) 138 | | Error::Plugin(PluginError::TypeMismatch(_)) 139 | | Error::Plugin(PluginError::OperationFailed(_)) 140 | ) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /torii-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Core functionality for the torii project 2 | //! 3 | //! This module contains the core functionality for the torii project. 4 | //! 5 | //! It includes the core user and session structs, as well as the plugin system. 6 | //! 7 | //! The core module is designed to be used as a dependency for plugins and is not intended to be used directly by application code. 8 | //! 9 | //! See [`User`] for the core user struct, [`Session`] for the core session struct, and [`Plugin`] for the plugin system. 10 | //! 11 | pub mod error; 12 | pub mod events; 13 | pub mod plugin; 14 | pub mod session; 15 | pub mod storage; 16 | pub mod user; 17 | 18 | pub use error::Error; 19 | pub use plugin::{Plugin, PluginManager}; 20 | pub use session::Session; 21 | pub use storage::{NewUser, SessionStorage, UserStorage}; 22 | pub use user::{DefaultUserManager, OAuthAccount, User, UserId, UserManager}; 23 | -------------------------------------------------------------------------------- /torii-migration/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-migration" 3 | description = "Migration utilities for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | async-trait.workspace = true 12 | chrono.workspace = true 13 | sqlx = { workspace = true, features = ["any"] } 14 | thiserror.workspace = true 15 | -------------------------------------------------------------------------------- /torii-migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Migration management for SQL databases in Torii 2 | //! 3 | //! This module provides traits and utilities for managing SQL database migrations in Torii. 4 | //! It defines a common interface for writing migrations that can be used across different 5 | //! SQL database backends. 6 | //! 7 | //! The main traits are: 8 | //! - [`Migration`]: Defines a single SQL migration with up/down operations 9 | //! - [`MigrationManager`]: Manages the execution and tracking of migrations 10 | //! 11 | //! Migrations are tracked in a database table (default name: `_torii_migrations`) to record 12 | //! which migrations have been applied and when. 13 | use async_trait::async_trait; 14 | use chrono::{DateTime, Utc}; 15 | use sqlx::Database; 16 | use thiserror::Error; 17 | use torii_core::{Error, error::StorageError}; 18 | 19 | #[derive(Debug, Error)] 20 | pub enum MigrationError { 21 | #[error("Database error: {0}")] 22 | Sqlx(#[from] sqlx::Error), 23 | } 24 | 25 | impl From for Error { 26 | fn from(value: MigrationError) -> Self { 27 | match value { 28 | MigrationError::Sqlx(e) => Error::Storage(StorageError::Database(e.to_string())), 29 | } 30 | } 31 | } 32 | 33 | pub type Result = std::result::Result; 34 | 35 | #[async_trait] 36 | pub trait Migration: Send + Sync { 37 | /// Unique version number for ordering migrations 38 | fn version(&self) -> i64; 39 | 40 | /// Human readable name of the migration 41 | fn name(&self) -> &str; 42 | 43 | /// Execute the migration 44 | async fn up<'a>(&'a self, conn: &'a mut ::Connection) -> Result<()>; 45 | 46 | /// Rollback the migration 47 | async fn down<'a>(&'a self, conn: &'a mut ::Connection) -> Result<()>; 48 | } 49 | 50 | #[derive(Debug, Clone, sqlx::FromRow)] 51 | pub struct MigrationRecord { 52 | pub version: i64, 53 | pub name: String, 54 | pub applied_at: DateTime, 55 | } 56 | 57 | #[async_trait] 58 | pub trait MigrationManager: Send + Sync { 59 | fn get_migration_table_name(&self) -> &str { 60 | "_torii_migrations" 61 | } 62 | 63 | /// Initialize migration tracking table 64 | async fn initialize(&self) -> Result<()>; 65 | 66 | /// Apply pending migrations 67 | async fn up(&self, migrations: &[Box>]) -> Result<()>; 68 | 69 | /// Rollback migrations 70 | async fn down(&self, migrations: &[Box>]) -> Result<()>; 71 | 72 | /// Get list of applied migrations 73 | async fn get_applied_migrations(&self) -> Result>; 74 | 75 | /// Check if specific migration was applied 76 | async fn is_applied(&self, version: i64) -> Result; 77 | } 78 | -------------------------------------------------------------------------------- /torii-storage-postgres/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-storage-postgres" 3 | description = "Postgres storage backend for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | torii-migration = { path = "../torii-migration", version = "0.2.2" } 12 | async-trait.workspace = true 13 | chrono.workspace = true 14 | sqlx = { workspace = true, features = ["postgres", "uuid"] } 15 | tracing.workspace = true 16 | uuid.workspace = true 17 | 18 | [dev-dependencies] 19 | tokio.workspace = true 20 | tracing-subscriber.workspace = true 21 | rand = "0.9" 22 | -------------------------------------------------------------------------------- /torii-storage-postgres/README.md: -------------------------------------------------------------------------------- 1 | # torii-storage-postgres 2 | 3 | This crate provides a Postgres storage implementation for Torii. 4 | 5 | It provides implementations for the following traits: 6 | 7 | - `UserStorage` - for storing and retrieving users 8 | - `SessionStorage` - for storing and retrieving sessions 9 | - `EmailAuthStorage` - for use by the `torii-auth-password` plugin 10 | -------------------------------------------------------------------------------- /torii-storage-postgres/src/magic_link.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::{DateTime, Utc}; 3 | use torii_core::{ 4 | UserId, 5 | error::StorageError, 6 | storage::{MagicLinkStorage, MagicToken}, 7 | }; 8 | 9 | use crate::PostgresStorage; 10 | 11 | #[derive(Debug, Clone, sqlx::FromRow)] 12 | pub struct PostgresMagicToken { 13 | pub id: Option, 14 | pub user_id: String, 15 | pub token: String, 16 | pub used_at: Option>, 17 | pub expires_at: DateTime, 18 | pub created_at: DateTime, 19 | pub updated_at: DateTime, 20 | } 21 | 22 | impl From for MagicToken { 23 | fn from(row: PostgresMagicToken) -> Self { 24 | MagicToken::new( 25 | UserId::new(&row.user_id), 26 | row.token.clone(), 27 | row.used_at, 28 | row.expires_at, 29 | row.created_at, 30 | row.updated_at, 31 | ) 32 | } 33 | } 34 | 35 | impl From<&MagicToken> for PostgresMagicToken { 36 | fn from(token: &MagicToken) -> Self { 37 | PostgresMagicToken { 38 | id: None, 39 | user_id: token.user_id.as_str().to_string(), 40 | token: token.token.clone(), 41 | used_at: token.used_at, 42 | expires_at: token.expires_at, 43 | created_at: token.created_at, 44 | updated_at: token.updated_at, 45 | } 46 | } 47 | } 48 | 49 | #[async_trait] 50 | impl MagicLinkStorage for PostgresStorage { 51 | type Error = StorageError; 52 | 53 | async fn save_magic_token( 54 | &self, 55 | token: &MagicToken, 56 | ) -> Result<(), ::Error> { 57 | let row = PostgresMagicToken::from(token); 58 | 59 | sqlx::query("INSERT INTO magic_links (user_id, token, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)") 60 | .bind(row.user_id) 61 | .bind(row.token) 62 | .bind(row.expires_at) 63 | .bind(row.created_at) 64 | .bind(row.updated_at) 65 | .execute(&self.pool) 66 | .await 67 | .map_err(|e| StorageError::Database(e.to_string()))?; 68 | 69 | Ok(()) 70 | } 71 | 72 | async fn get_magic_token( 73 | &self, 74 | token: &str, 75 | ) -> Result, ::Error> { 76 | let row: Option = 77 | sqlx::query_as( 78 | "SELECT id, user_id, token, used_at, expires_at, created_at, updated_at FROM magic_links WHERE token = $1 AND expires_at > $2 AND used_at IS NULL", 79 | ) 80 | .bind(token) 81 | .bind(Utc::now()) 82 | .fetch_optional(&self.pool) 83 | .await 84 | .map_err(|e| StorageError::Database(e.to_string()))?; 85 | 86 | Ok(row.map(|row| row.into())) 87 | } 88 | 89 | async fn set_magic_token_used( 90 | &self, 91 | token: &str, 92 | ) -> Result<(), ::Error> { 93 | sqlx::query("UPDATE magic_links SET used_at = $1 WHERE token = $2") 94 | .bind(Utc::now()) 95 | .bind(token) 96 | .execute(&self.pool) 97 | .await 98 | .map_err(|e| StorageError::Database(e.to_string()))?; 99 | 100 | Ok(()) 101 | } 102 | } 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use super::*; 107 | 108 | use chrono::Duration; 109 | use torii_core::{NewUser, User, UserStorage, storage::MagicLinkStorage}; 110 | use uuid::Uuid; 111 | 112 | use crate::PostgresStorage; 113 | 114 | async fn create_test_user(storage: &PostgresStorage) -> User { 115 | let user = NewUser::builder() 116 | .email("test@test.com".to_string()) 117 | .build() 118 | .expect("Failed to build test user"); 119 | storage 120 | .create_user(&user) 121 | .await 122 | .expect("Failed to create test user") 123 | } 124 | 125 | #[tokio::test] 126 | async fn test_save_and_get_magic_token() { 127 | let storage = crate::tests::setup_test_db().await; 128 | 129 | // Create a user 130 | let user = create_test_user(&storage).await; 131 | 132 | let token = MagicToken::new( 133 | UserId::new(&user.id.to_string()), 134 | Uuid::new_v4().to_string(), 135 | None, 136 | Utc::now() + Duration::minutes(5), 137 | Utc::now(), 138 | Utc::now(), 139 | ); 140 | storage 141 | .save_magic_token(&token) 142 | .await 143 | .expect("Failed to save magic token"); 144 | 145 | let stored_token = storage 146 | .get_magic_token(&token.token) 147 | .await 148 | .expect("Failed to get magic token"); 149 | assert!(stored_token.is_some()); 150 | 151 | let stored_token = stored_token.unwrap(); 152 | assert_eq!(stored_token, token); 153 | } 154 | 155 | #[tokio::test] 156 | async fn test_get_nonexistent_magic_token() { 157 | let storage = crate::tests::setup_test_db().await; 158 | 159 | let token = Uuid::new_v4().to_string(); 160 | let result = storage 161 | .get_magic_token(&token) 162 | .await 163 | .expect("Failed to query magic token"); 164 | assert!(result.is_none()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /torii-storage-postgres/src/passkey.rs: -------------------------------------------------------------------------------- 1 | use crate::PostgresStorage; 2 | use async_trait::async_trait; 3 | use chrono::Utc; 4 | use torii_core::UserId; 5 | use torii_core::error::StorageError; 6 | use torii_core::storage::PasskeyStorage; 7 | 8 | #[async_trait] 9 | impl PasskeyStorage for PostgresStorage { 10 | type Error = StorageError; 11 | 12 | async fn add_passkey( 13 | &self, 14 | user_id: &UserId, 15 | credential_id: &str, 16 | passkey_json: &str, 17 | ) -> Result<(), ::Error> { 18 | sqlx::query( 19 | r#" 20 | INSERT INTO passkeys (credential_id, user_id, public_key) 21 | VALUES ($1, $2, $3) 22 | "#, 23 | ) 24 | .bind(credential_id) 25 | .bind(user_id.as_str()) 26 | .bind(passkey_json) 27 | .execute(&self.pool) 28 | .await 29 | .map_err(|e| StorageError::Database(e.to_string()))?; 30 | Ok(()) 31 | } 32 | 33 | async fn get_passkey_by_credential_id( 34 | &self, 35 | credential_id: &str, 36 | ) -> Result, ::Error> { 37 | let passkey: Option = sqlx::query_scalar( 38 | r#" 39 | SELECT public_key 40 | FROM passkeys 41 | WHERE credential_id = $1 42 | "#, 43 | ) 44 | .bind(credential_id) 45 | .fetch_optional(&self.pool) 46 | .await 47 | .map_err(|e| StorageError::Database(e.to_string()))?; 48 | Ok(passkey) 49 | } 50 | 51 | async fn get_passkeys( 52 | &self, 53 | user_id: &UserId, 54 | ) -> Result, ::Error> { 55 | let passkeys: Vec = sqlx::query_scalar( 56 | r#" 57 | SELECT public_key 58 | FROM passkeys 59 | WHERE user_id = $1 60 | "#, 61 | ) 62 | .bind(user_id.as_str()) 63 | .fetch_all(&self.pool) 64 | .await 65 | .map_err(|e| StorageError::Database(e.to_string()))?; 66 | Ok(passkeys) 67 | } 68 | 69 | async fn set_passkey_challenge( 70 | &self, 71 | challenge_id: &str, 72 | challenge: &str, 73 | expires_in: chrono::Duration, 74 | ) -> Result<(), ::Error> { 75 | sqlx::query( 76 | r#" 77 | INSERT INTO passkey_challenges (challenge_id, challenge, expires_at) 78 | VALUES ($1, $2, $3) 79 | "#, 80 | ) 81 | .bind(challenge_id) 82 | .bind(challenge) 83 | .bind(Utc::now() + expires_in) 84 | .execute(&self.pool) 85 | .await 86 | .map_err(|e| StorageError::Database(e.to_string()))?; 87 | Ok(()) 88 | } 89 | 90 | async fn get_passkey_challenge( 91 | &self, 92 | challenge_id: &str, 93 | ) -> Result, ::Error> { 94 | let challenge: Option = sqlx::query_scalar( 95 | r#" 96 | SELECT challenge 97 | FROM passkey_challenges 98 | WHERE challenge_id = $1 AND expires_at > $2 99 | "#, 100 | ) 101 | .bind(challenge_id) 102 | .bind(Utc::now()) 103 | .fetch_optional(&self.pool) 104 | .await 105 | .map_err(|e| StorageError::Database(e.to_string()))?; 106 | Ok(challenge) 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use chrono::Duration; 113 | use torii_core::{NewUser, User, UserStorage, storage::PasskeyStorage}; 114 | use uuid::Uuid; 115 | 116 | use crate::PostgresStorage; 117 | 118 | async fn create_test_user(storage: &PostgresStorage) -> User { 119 | let user = NewUser::builder() 120 | .email("test@test.com".to_string()) 121 | .build() 122 | .unwrap(); 123 | storage.create_user(&user).await.unwrap() 124 | } 125 | 126 | #[tokio::test] 127 | async fn test_add_and_get_passkey() { 128 | let storage = crate::tests::setup_test_db().await; 129 | 130 | // Create a user 131 | let user = create_test_user(&storage).await; 132 | 133 | let credential_id = Uuid::new_v4().to_string(); 134 | let passkey_json = "passkey_json"; 135 | storage 136 | .add_passkey(&user.id, &credential_id, passkey_json) 137 | .await 138 | .unwrap(); 139 | 140 | let passkeys = storage.get_passkeys(&user.id).await.unwrap(); 141 | assert_eq!(passkeys.len(), 1); 142 | assert_eq!(passkeys[0], passkey_json); 143 | } 144 | 145 | #[tokio::test] 146 | async fn test_set_and_get_passkey_challenge() { 147 | let storage = crate::tests::setup_test_db().await; 148 | 149 | let challenge_id = Uuid::new_v4().to_string(); 150 | let challenge = "challenge"; 151 | let expires_in = Duration::minutes(5); 152 | storage 153 | .set_passkey_challenge(&challenge_id, challenge, expires_in) 154 | .await 155 | .unwrap(); 156 | 157 | let stored_challenge = storage.get_passkey_challenge(&challenge_id).await.unwrap(); 158 | assert!(stored_challenge.is_some()); 159 | assert_eq!(stored_challenge.unwrap(), challenge); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /torii-storage-postgres/src/password.rs: -------------------------------------------------------------------------------- 1 | use crate::PostgresStorage; 2 | use async_trait::async_trait; 3 | use torii_core::UserId; 4 | use torii_core::error::StorageError; 5 | use torii_core::storage::PasswordStorage; 6 | 7 | #[async_trait] 8 | impl PasswordStorage for PostgresStorage { 9 | type Error = StorageError; 10 | 11 | async fn set_password_hash( 12 | &self, 13 | user_id: &UserId, 14 | hash: &str, 15 | ) -> Result<(), ::Error> { 16 | sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") 17 | .bind(hash) 18 | .bind(user_id.as_str()) 19 | .execute(&self.pool) 20 | .await 21 | .map_err(|_| StorageError::Database("Failed to set password hash".to_string()))?; 22 | Ok(()) 23 | } 24 | 25 | async fn get_password_hash( 26 | &self, 27 | user_id: &UserId, 28 | ) -> Result, ::Error> { 29 | let result = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") 30 | .bind(user_id.as_str()) 31 | .fetch_optional(&self.pool) 32 | .await 33 | .map_err(|_| StorageError::Database("Failed to get password hash".to_string()))?; 34 | Ok(result) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /torii-storage-seaorm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-storage-seaorm" 3 | description = "SeaORM storage plugin for Torii" 4 | version = "0.2.3" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2" } 11 | 12 | async-trait.workspace = true 13 | chrono.workspace = true 14 | sea-orm = { version = "1.1", features = [ 15 | "runtime-tokio-rustls", 16 | "macros", 17 | "with-chrono", 18 | "with-uuid", 19 | ] } 20 | sea-orm-migration = { version = "1.1", features = ["runtime-tokio-rustls"] } 21 | thiserror.workspace = true 22 | 23 | [dev-dependencies] 24 | tokio.workspace = true 25 | 26 | [features] 27 | default = ["sqlite"] 28 | sqlite = ["sea-orm/sqlx-sqlite", "sea-orm-migration/sqlx-sqlite"] 29 | postgres = ["sea-orm/sqlx-postgres", "sea-orm-migration/sqlx-postgres"] 30 | mysql = ["sea-orm/sqlx-mysql", "sea-orm-migration/sqlx-mysql"] 31 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/magic_link.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "magic_links")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub user_id: String, 10 | pub token: String, 11 | pub used_at: Option>, 12 | pub expires_at: DateTime, 13 | pub created_at: DateTime, 14 | pub updated_at: DateTime, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation {} 19 | 20 | impl ActiveModelBehavior for ActiveModel {} 21 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod magic_link; 2 | pub(crate) mod oauth; 3 | pub(crate) mod passkey; 4 | pub(crate) mod passkey_challenge; 5 | pub(crate) mod pkce_verifier; 6 | pub(crate) mod session; 7 | pub(crate) mod user; 8 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/oauth.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "oauth_accounts")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub user_id: String, 10 | pub provider: String, 11 | pub subject: String, 12 | pub created_at: DateTime, 13 | pub updated_at: DateTime, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation {} 18 | 19 | impl ActiveModelBehavior for ActiveModel {} 20 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/passkey.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "passkeys")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub user_id: String, 10 | pub credential_id: String, 11 | pub data_json: String, 12 | pub created_at: DateTime, 13 | pub updated_at: DateTime, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation {} 18 | 19 | impl ActiveModelBehavior for ActiveModel {} 20 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/passkey_challenge.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "passkey_challenges")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub challenge_id: String, 10 | pub challenge: String, 11 | pub expires_at: DateTime, 12 | pub created_at: DateTime, 13 | pub updated_at: DateTime, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation {} 18 | 19 | impl ActiveModelBehavior for ActiveModel {} 20 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/pkce_verifier.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "pkce_verifiers")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub csrf_state: String, 10 | pub verifier: String, 11 | pub expires_at: DateTime, 12 | pub created_at: DateTime, 13 | pub updated_at: DateTime, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation {} 18 | 19 | impl ActiveModelBehavior for ActiveModel {} 20 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/session.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::entity::prelude::*; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "sessions")] 6 | pub struct Model { 7 | #[sea_orm(primary_key)] 8 | pub id: i64, 9 | pub user_id: String, 10 | pub token: String, 11 | pub ip_address: Option, 12 | pub user_agent: Option, 13 | pub expires_at: DateTime, 14 | pub created_at: DateTime, 15 | pub updated_at: DateTime, 16 | } 17 | 18 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 19 | pub enum Relation {} 20 | 21 | impl ActiveModelBehavior for ActiveModel {} 22 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/entities/user.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sea_orm::{ActiveValue::Set, entity::prelude::*}; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 5 | #[sea_orm(table_name = "users")] 6 | pub struct Model { 7 | #[sea_orm(primary_key, auto_increment = false)] 8 | pub id: String, 9 | pub email: String, 10 | pub name: Option, 11 | pub password_hash: Option, 12 | pub email_verified_at: Option>, 13 | pub created_at: DateTime, 14 | pub updated_at: DateTime, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation {} 19 | 20 | impl ActiveModelBehavior for ActiveModel { 21 | fn new() -> Self { 22 | Self { 23 | id: Set(Uuid::new_v4().to_string()), 24 | ..ActiveModelTrait::default() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod entities; 2 | mod magic_link; 3 | mod migrations; 4 | mod oauth; 5 | mod passkey; 6 | mod password; 7 | mod session; 8 | mod user; 9 | 10 | use migrations::Migrator; 11 | use sea_orm::{Database, DatabaseConnection}; 12 | use sea_orm_migration::prelude::*; 13 | 14 | #[derive(Debug, thiserror::Error)] 15 | pub enum SeaORMStorageError { 16 | #[error(transparent)] 17 | Database(#[from] sea_orm::DbErr), 18 | #[error("User not found")] 19 | UserNotFound, 20 | } 21 | 22 | /// SeaORM storage backend 23 | /// 24 | /// This storage backend uses SeaORM to manage database connections and migrations. 25 | /// It provides a `connect` method to create a new storage instance from a database URL. 26 | /// It also provides a `migrate` method to apply pending migrations. 27 | /// 28 | /// # Example 29 | /// 30 | /// ```rust 31 | /// use torii_storage_seaorm::SeaORMStorage; 32 | /// let storage = SeaORMStorage::connect("sqlite://todos.db?mode=rwc").await.unwrap(); 33 | /// let _ = storage.migrate().await.unwrap(); 34 | /// ``` 35 | #[derive(Clone)] 36 | pub struct SeaORMStorage { 37 | pool: DatabaseConnection, 38 | } 39 | 40 | impl SeaORMStorage { 41 | pub fn new(pool: DatabaseConnection) -> Self { 42 | Self { pool } 43 | } 44 | 45 | pub async fn connect(url: &str) -> Result { 46 | let pool = Database::connect(url).await?; 47 | pool.ping().await?; 48 | 49 | Ok(Self::new(pool)) 50 | } 51 | 52 | pub async fn migrate(&self) -> Result<(), SeaORMStorageError> { 53 | Migrator::up(&self.pool, None).await.unwrap(); 54 | 55 | Ok(()) 56 | } 57 | } 58 | #[cfg(test)] 59 | mod tests { 60 | use sea_orm::Database; 61 | 62 | use crate::migrations::Migrator; 63 | 64 | use super::*; 65 | 66 | #[tokio::test] 67 | async fn test_migrations_up() { 68 | let pool = Database::connect("sqlite::memory:").await.unwrap(); 69 | let migrations = Migrator::get_pending_migrations(&pool).await.unwrap(); 70 | migrations.iter().for_each(|m| { 71 | println!("{}: {}", m.name(), m.status()); 72 | }); 73 | Migrator::up(&pool, None).await.unwrap(); 74 | let migrations = Migrator::get_pending_migrations(&pool).await.unwrap(); 75 | migrations.iter().for_each(|m| { 76 | println!("{}: {}", m.name(), m.status()); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/magic_link.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::Utc; 3 | use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter}; 4 | use torii_core::{ 5 | UserId, 6 | storage::{MagicLinkStorage, MagicToken}, 7 | }; 8 | 9 | use crate::entities::magic_link; 10 | 11 | use crate::{SeaORMStorage, SeaORMStorageError}; 12 | 13 | impl From for MagicToken { 14 | fn from(value: magic_link::Model) -> Self { 15 | MagicToken { 16 | user_id: UserId::new(&value.user_id), 17 | token: value.token, 18 | used_at: value.used_at, 19 | expires_at: value.expires_at, 20 | created_at: value.created_at, 21 | updated_at: value.updated_at, 22 | } 23 | } 24 | } 25 | 26 | #[async_trait] 27 | impl MagicLinkStorage for SeaORMStorage { 28 | type Error = SeaORMStorageError; 29 | 30 | async fn save_magic_token( 31 | &self, 32 | token: &MagicToken, 33 | ) -> Result<(), ::Error> { 34 | let magic_link = magic_link::ActiveModel { 35 | user_id: Set(token.user_id.to_string()), 36 | token: Set(token.token.clone()), 37 | used_at: Set(token.used_at), 38 | expires_at: Set(token.expires_at), 39 | ..Default::default() 40 | }; 41 | magic_link.insert(&self.pool).await?; 42 | 43 | Ok(()) 44 | } 45 | 46 | async fn get_magic_token( 47 | &self, 48 | token: &str, 49 | ) -> Result, ::Error> { 50 | let magic_link = magic_link::Entity::find() 51 | .filter(magic_link::Column::Token.eq(token)) 52 | .one(&self.pool) 53 | .await?; 54 | 55 | Ok(magic_link.map(|model| model.into())) 56 | } 57 | 58 | async fn set_magic_token_used( 59 | &self, 60 | token: &str, 61 | ) -> Result<(), ::Error> { 62 | let magic_link: Option = magic_link::Entity::find() 63 | .filter(magic_link::Column::Token.eq(token)) 64 | .one(&self.pool) 65 | .await? 66 | .map(|model| model.into()); 67 | 68 | if let Some(mut magic_link) = magic_link { 69 | magic_link.used_at = Set(Some(Utc::now())); 70 | magic_link.update(&self.pool).await?; 71 | } 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/m20250304_000001_create_user_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{ 2 | DbErr, DeriveMigrationName, 3 | prelude::*, 4 | sea_query::{Index, Table}, 5 | }; 6 | use sea_orm_migration::{ 7 | MigrationTrait, SchemaManager, 8 | schema::{string, string_null, timestamp_with_time_zone, timestamp_with_time_zone_null}, 9 | }; 10 | 11 | use super::Users; 12 | 13 | #[derive(DeriveMigrationName)] 14 | pub struct CreateUsers; 15 | 16 | #[async_trait::async_trait] 17 | impl MigrationTrait for CreateUsers { 18 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 19 | manager 20 | .create_table( 21 | Table::create() 22 | .table(Users::Table) 23 | .if_not_exists() 24 | .col(string(Users::Id).primary_key()) 25 | .col(string(Users::Email)) 26 | .col(string_null(Users::Name)) // Nullable since users may not have a name yet... 27 | .col(string_null(Users::PasswordHash)) // Nullable since users may not have a password (i.e. OAuth, Passkey, Magic Link) 28 | .col(timestamp_with_time_zone_null(Users::EmailVerifiedAt)) 29 | .col( 30 | timestamp_with_time_zone(Users::CreatedAt) 31 | .default(Expr::current_timestamp()), 32 | ) 33 | .col( 34 | timestamp_with_time_zone(Users::UpdatedAt) 35 | .default(Expr::current_timestamp()), 36 | ) 37 | .to_owned(), 38 | ) 39 | .await?; 40 | 41 | manager 42 | .create_index( 43 | Index::create() 44 | .table(Users::Table) 45 | .name("idx_users_email") 46 | .col(Users::Email) 47 | .unique() 48 | .to_owned(), 49 | ) 50 | .await?; 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/m20250304_000002_create_session_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{ 2 | DbErr, DeriveMigrationName, 3 | prelude::*, 4 | sea_query::{Index, Table}, 5 | }; 6 | use sea_orm_migration::{ 7 | MigrationTrait, SchemaManager, 8 | schema::{pk_auto, string, string_null, timestamp_with_time_zone}, 9 | }; 10 | 11 | use super::Sessions; 12 | 13 | #[derive(DeriveMigrationName)] 14 | pub struct CreateSessions; 15 | 16 | #[async_trait::async_trait] 17 | impl MigrationTrait for CreateSessions { 18 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 19 | manager 20 | .create_table( 21 | Table::create() 22 | .table(Sessions::Table) 23 | .if_not_exists() 24 | .col(pk_auto(Sessions::Id).big_integer()) 25 | .col(string(Sessions::UserId)) 26 | .col(string(Sessions::Token)) 27 | .col(string_null(Sessions::IpAddress)) 28 | .col(string_null(Sessions::UserAgent)) 29 | .col(timestamp_with_time_zone(Sessions::ExpiresAt)) 30 | .col( 31 | timestamp_with_time_zone(Sessions::CreatedAt) 32 | .default(Expr::current_timestamp()), 33 | ) 34 | .col( 35 | timestamp_with_time_zone(Sessions::UpdatedAt) 36 | .default(Expr::current_timestamp()), 37 | ) 38 | .to_owned(), 39 | ) 40 | .await?; 41 | 42 | manager 43 | .create_index( 44 | Index::create() 45 | .table(Sessions::Table) 46 | .name("idx_sessions_token") 47 | .col(Sessions::Token) 48 | .unique() 49 | .to_owned(), 50 | ) 51 | .await?; 52 | 53 | manager 54 | .create_index( 55 | Index::create() 56 | .table(Sessions::Table) 57 | .name("idx_sessions_user_id") 58 | .col(Sessions::UserId) 59 | .to_owned(), 60 | ) 61 | .await?; 62 | 63 | manager 64 | .create_index( 65 | Index::create() 66 | .table(Sessions::Table) 67 | .name("idx_sessions_expires_at") 68 | .col(Sessions::ExpiresAt) 69 | .to_owned(), 70 | ) 71 | .await?; 72 | 73 | manager 74 | .create_index( 75 | Index::create() 76 | .table(Sessions::Table) 77 | .name("idx_sessions_created_at") 78 | .col(Sessions::CreatedAt) 79 | .to_owned(), 80 | ) 81 | .await?; 82 | 83 | manager 84 | .create_index( 85 | Index::create() 86 | .table(Sessions::Table) 87 | .name("idx_sessions_updated_at") 88 | .col(Sessions::UpdatedAt) 89 | .to_owned(), 90 | ) 91 | .await?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/m20250304_000003_create_oauth_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{ 2 | DbErr, DeriveMigrationName, 3 | prelude::*, 4 | sea_query::{Index, Table}, 5 | }; 6 | use sea_orm_migration::{ 7 | MigrationTrait, SchemaManager, 8 | schema::{pk_auto, string, timestamp}, 9 | }; 10 | 11 | use super::OauthAccounts; 12 | use super::PkceVerifiers; 13 | #[derive(DeriveMigrationName)] 14 | pub struct CreateOAuthAccounts; 15 | 16 | #[async_trait::async_trait] 17 | impl MigrationTrait for CreateOAuthAccounts { 18 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 19 | manager 20 | .create_table( 21 | Table::create() 22 | .table(OauthAccounts::Table) 23 | .if_not_exists() 24 | .col(pk_auto(OauthAccounts::Id).big_integer()) 25 | .col(string(OauthAccounts::UserId)) 26 | .col(string(OauthAccounts::Provider)) 27 | .col(string(OauthAccounts::Subject)) 28 | .col(timestamp(OauthAccounts::CreatedAt).default(Expr::current_timestamp())) 29 | .col(timestamp(OauthAccounts::UpdatedAt).default(Expr::current_timestamp())) 30 | .to_owned(), 31 | ) 32 | .await?; 33 | 34 | manager 35 | .create_index( 36 | Index::create() 37 | .table(OauthAccounts::Table) 38 | .name("idx_oauth_accounts_user_id") 39 | .col(OauthAccounts::UserId) 40 | .to_owned(), 41 | ) 42 | .await?; 43 | 44 | manager 45 | .create_index( 46 | Index::create() 47 | .table(OauthAccounts::Table) 48 | .name("idx_oauth_accounts_provider") 49 | .col(OauthAccounts::Provider) 50 | .to_owned(), 51 | ) 52 | .await?; 53 | 54 | manager 55 | .create_index( 56 | Index::create() 57 | .table(OauthAccounts::Table) 58 | .name("idx_oauth_accounts_subject") 59 | .col(OauthAccounts::Subject) 60 | .to_owned(), 61 | ) 62 | .await?; 63 | 64 | manager 65 | .create_table( 66 | Table::create() 67 | .table(PkceVerifiers::Table) 68 | .if_not_exists() 69 | .col(pk_auto(PkceVerifiers::Id).big_integer()) 70 | .col(string(PkceVerifiers::CsrfState)) 71 | .col(string(PkceVerifiers::Verifier)) 72 | .col(timestamp(PkceVerifiers::ExpiresAt).default(Expr::current_timestamp())) 73 | .col(timestamp(PkceVerifiers::CreatedAt).default(Expr::current_timestamp())) 74 | .col(timestamp(PkceVerifiers::UpdatedAt).default(Expr::current_timestamp())) 75 | .to_owned(), 76 | ) 77 | .await?; 78 | 79 | manager 80 | .create_index( 81 | Index::create() 82 | .table(PkceVerifiers::Table) 83 | .name("idx_pkce_verifiers_csrf_state") 84 | .col(PkceVerifiers::CsrfState) 85 | .to_owned(), 86 | ) 87 | .await?; 88 | 89 | manager 90 | .create_index( 91 | Index::create() 92 | .table(PkceVerifiers::Table) 93 | .name("idx_pkce_verifiers_expires_at") 94 | .col(PkceVerifiers::ExpiresAt) 95 | .to_owned(), 96 | ) 97 | .await?; 98 | 99 | Ok(()) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/m20250304_000004_create_passkeys_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{ 2 | DbErr, DeriveMigrationName, 3 | prelude::*, 4 | sea_query::{Index, Table}, 5 | }; 6 | use sea_orm_migration::{ 7 | MigrationTrait, SchemaManager, 8 | schema::{pk_auto, string, timestamp}, 9 | }; 10 | 11 | use super::{PasskeyChallenges, Passkeys}; 12 | 13 | #[derive(DeriveMigrationName)] 14 | pub struct CreatePasskeys; 15 | 16 | #[async_trait::async_trait] 17 | impl MigrationTrait for CreatePasskeys { 18 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 19 | manager 20 | .create_table( 21 | Table::create() 22 | .table(Passkeys::Table) 23 | .if_not_exists() 24 | .col(pk_auto(Passkeys::Id).big_integer()) 25 | .col(string(Passkeys::UserId).not_null()) 26 | .col(string(Passkeys::CredentialId).not_null()) 27 | .col(string(Passkeys::DataJson).not_null()) 28 | .col( 29 | timestamp(Passkeys::CreatedAt) 30 | .not_null() 31 | .default(Expr::current_timestamp()), 32 | ) 33 | .col( 34 | timestamp(Passkeys::UpdatedAt) 35 | .not_null() 36 | .default(Expr::current_timestamp()), 37 | ) 38 | .to_owned(), 39 | ) 40 | .await?; 41 | 42 | manager 43 | .create_index( 44 | Index::create() 45 | .table(Passkeys::Table) 46 | .name("idx_passkeys_user_id") 47 | .col(Passkeys::UserId) 48 | .to_owned(), 49 | ) 50 | .await?; 51 | 52 | manager 53 | .create_index( 54 | Index::create() 55 | .table(Passkeys::Table) 56 | .name("idx_passkeys_credential_id") 57 | .col(Passkeys::CredentialId) 58 | .to_owned(), 59 | ) 60 | .await?; 61 | 62 | manager 63 | .create_table( 64 | Table::create() 65 | .table(PasskeyChallenges::Table) 66 | .if_not_exists() 67 | .col(pk_auto(PasskeyChallenges::Id).big_integer()) 68 | .col(string(PasskeyChallenges::ChallengeId)) 69 | .col(string(PasskeyChallenges::Challenge)) 70 | .col(timestamp(PasskeyChallenges::ExpiresAt).default(Expr::current_timestamp())) 71 | .col(timestamp(PasskeyChallenges::CreatedAt).default(Expr::current_timestamp())) 72 | .col(timestamp(PasskeyChallenges::UpdatedAt).default(Expr::current_timestamp())) 73 | .to_owned(), 74 | ) 75 | .await?; 76 | 77 | manager 78 | .create_index( 79 | Index::create() 80 | .table(PasskeyChallenges::Table) 81 | .name("idx_passkey_challenges_challenge_id") 82 | .col(PasskeyChallenges::ChallengeId) 83 | .to_owned(), 84 | ) 85 | .await?; 86 | 87 | manager 88 | .create_index( 89 | Index::create() 90 | .table(PasskeyChallenges::Table) 91 | .name("idx_passkey_challenges_expires_at") 92 | .col(PasskeyChallenges::ExpiresAt) 93 | .to_owned(), 94 | ) 95 | .await?; 96 | 97 | Ok(()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/m20250304_000005_create_magic_links.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{ 2 | DbErr, DeriveMigrationName, 3 | prelude::*, 4 | sea_query::{Index, Table}, 5 | }; 6 | use sea_orm_migration::{ 7 | MigrationTrait, SchemaManager, 8 | schema::{pk_auto, string, timestamp, timestamp_null}, 9 | }; 10 | 11 | use super::MagicLinks; 12 | 13 | #[derive(DeriveMigrationName)] 14 | pub struct CreateMagicLinks; 15 | 16 | #[async_trait::async_trait] 17 | impl MigrationTrait for CreateMagicLinks { 18 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 19 | manager 20 | .create_table( 21 | Table::create() 22 | .table(MagicLinks::Table) 23 | .if_not_exists() 24 | .col(pk_auto(MagicLinks::Id).big_integer()) 25 | .col(string(MagicLinks::UserId)) 26 | .col(string(MagicLinks::Token)) 27 | .col(timestamp_null(MagicLinks::UsedAt)) 28 | .col(timestamp(MagicLinks::ExpiresAt)) 29 | .col(timestamp(MagicLinks::CreatedAt).default(Expr::current_timestamp())) 30 | .col(timestamp(MagicLinks::UpdatedAt).default(Expr::current_timestamp())) 31 | .to_owned(), 32 | ) 33 | .await?; 34 | 35 | manager 36 | .create_index( 37 | Index::create() 38 | .table(MagicLinks::Table) 39 | .name("idx_magic_links_user_id") 40 | .col(MagicLinks::UserId) 41 | .to_owned(), 42 | ) 43 | .await?; 44 | 45 | manager 46 | .create_index( 47 | Index::create() 48 | .table(MagicLinks::Table) 49 | .name("idx_magic_links_token") 50 | .col(MagicLinks::Token) 51 | .to_owned(), 52 | ) 53 | .await?; 54 | 55 | manager 56 | .create_index( 57 | Index::create() 58 | .table(MagicLinks::Table) 59 | .name("idx_magic_links_expires_at") 60 | .col(MagicLinks::ExpiresAt) 61 | .to_owned(), 62 | ) 63 | .await?; 64 | 65 | Ok(()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/migrations/mod.rs: -------------------------------------------------------------------------------- 1 | use m20250304_000001_create_user_table::CreateUsers; 2 | use m20250304_000002_create_session_table::CreateSessions; 3 | 4 | use m20250304_000003_create_oauth_table::CreateOAuthAccounts; 5 | use sea_orm::{ 6 | DeriveIden, 7 | sea_query::{Alias, IntoIden}, 8 | }; 9 | use sea_orm_migration::{MigrationTrait, MigratorTrait}; 10 | 11 | mod m20250304_000001_create_user_table; 12 | mod m20250304_000002_create_session_table; 13 | mod m20250304_000003_create_oauth_table; 14 | mod m20250304_000004_create_passkeys_table; 15 | mod m20250304_000005_create_magic_links; 16 | 17 | #[allow(dead_code)] 18 | pub struct Migrator; 19 | 20 | #[async_trait::async_trait] 21 | impl MigratorTrait for Migrator { 22 | // Override the name of migration table 23 | fn migration_table_name() -> sea_orm::DynIden { 24 | Alias::new("torii_migrations").into_iden() 25 | } 26 | 27 | fn migrations() -> Vec> { 28 | vec![ 29 | Box::new(CreateUsers), 30 | Box::new(CreateSessions), 31 | Box::new(CreateOAuthAccounts), 32 | ] 33 | } 34 | } 35 | 36 | #[derive(DeriveIden)] 37 | pub enum Users { 38 | Table, 39 | Id, 40 | Email, 41 | Name, 42 | PasswordHash, 43 | EmailVerifiedAt, 44 | CreatedAt, 45 | UpdatedAt, 46 | } 47 | 48 | #[derive(DeriveIden)] 49 | pub enum Sessions { 50 | Table, 51 | Id, 52 | UserId, 53 | Token, 54 | IpAddress, 55 | UserAgent, 56 | ExpiresAt, 57 | CreatedAt, 58 | UpdatedAt, 59 | } 60 | 61 | #[derive(DeriveIden)] 62 | pub enum OauthAccounts { 63 | Table, 64 | Id, 65 | UserId, 66 | Provider, 67 | Subject, 68 | CreatedAt, 69 | UpdatedAt, 70 | } 71 | 72 | #[derive(DeriveIden)] 73 | pub enum PkceVerifiers { 74 | Table, 75 | Id, 76 | CsrfState, 77 | Verifier, 78 | ExpiresAt, 79 | CreatedAt, 80 | UpdatedAt, 81 | } 82 | 83 | #[derive(DeriveIden)] 84 | pub enum Passkeys { 85 | Table, 86 | Id, 87 | UserId, 88 | CredentialId, 89 | DataJson, 90 | CreatedAt, 91 | UpdatedAt, 92 | } 93 | 94 | #[derive(DeriveIden)] 95 | pub enum PasskeyChallenges { 96 | Table, 97 | Id, 98 | ChallengeId, 99 | Challenge, 100 | ExpiresAt, 101 | CreatedAt, 102 | UpdatedAt, 103 | } 104 | 105 | #[derive(DeriveIden)] 106 | pub enum MagicLinks { 107 | Table, 108 | Id, 109 | UserId, 110 | Token, 111 | UsedAt, 112 | ExpiresAt, 113 | CreatedAt, 114 | UpdatedAt, 115 | } 116 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/oauth.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sea_orm::ActiveValue::Set; 3 | use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; 4 | use torii_core::{OAuthAccount, User}; 5 | use torii_core::{UserId, storage::OAuthStorage}; 6 | 7 | use crate::{SeaORMStorage, SeaORMStorageError}; 8 | 9 | use crate::entities::oauth; 10 | use crate::entities::pkce_verifier; 11 | use crate::entities::user; 12 | 13 | impl From for OAuthAccount { 14 | fn from(value: oauth::Model) -> Self { 15 | OAuthAccount::builder() 16 | .user_id(UserId::new(&value.user_id)) 17 | .provider(value.provider) 18 | .subject(value.subject) 19 | .build() 20 | .expect("Failed to build OAuthAccount") 21 | } 22 | } 23 | 24 | #[async_trait::async_trait] 25 | impl OAuthStorage for SeaORMStorage { 26 | type Error = SeaORMStorageError; 27 | 28 | async fn create_oauth_account( 29 | &self, 30 | provider: &str, 31 | subject: &str, 32 | user_id: &UserId, 33 | ) -> Result::Error> { 34 | let user = user::Entity::find_by_id(user_id.to_string()) 35 | .one(&self.pool) 36 | .await?; 37 | 38 | if let Some(user) = user { 39 | let oauth_account = oauth::ActiveModel { 40 | user_id: Set(user.id), 41 | provider: Set(provider.to_string()), 42 | subject: Set(subject.to_string()), 43 | ..Default::default() 44 | }; 45 | let oauth_account = oauth_account.insert(&self.pool).await?; 46 | 47 | Ok(oauth_account.into()) 48 | } else { 49 | Err(SeaORMStorageError::UserNotFound) 50 | } 51 | } 52 | 53 | async fn get_user_by_provider_and_subject( 54 | &self, 55 | provider: &str, 56 | subject: &str, 57 | ) -> Result, ::Error> { 58 | let oauth_account = oauth::Entity::find() 59 | .filter(oauth::Column::Provider.eq(provider)) 60 | .filter(oauth::Column::Subject.eq(subject)) 61 | .one(&self.pool) 62 | .await?; 63 | 64 | match oauth_account { 65 | Some(oauth_account) => { 66 | let user = user::Entity::find_by_id(oauth_account.user_id) 67 | .one(&self.pool) 68 | .await?; 69 | let user = match user { 70 | Some(user) => user, 71 | None => return Err(SeaORMStorageError::UserNotFound), 72 | }; 73 | Ok(Some(user.into())) 74 | } 75 | _ => Ok(None), 76 | } 77 | } 78 | 79 | async fn get_oauth_account_by_provider_and_subject( 80 | &self, 81 | provider: &str, 82 | subject: &str, 83 | ) -> Result, ::Error> { 84 | let oauth_account = oauth::Entity::find() 85 | .filter(oauth::Column::Provider.eq(provider)) 86 | .filter(oauth::Column::Subject.eq(subject)) 87 | .one(&self.pool) 88 | .await?; 89 | 90 | match oauth_account { 91 | Some(oauth_account) => Ok(Some(oauth_account.into())), 92 | None => Ok(None), 93 | } 94 | } 95 | 96 | async fn link_oauth_account( 97 | &self, 98 | user_id: &UserId, 99 | provider: &str, 100 | subject: &str, 101 | ) -> Result<(), ::Error> { 102 | let user = user::Entity::find_by_id(user_id.to_string()) 103 | .one(&self.pool) 104 | .await?; 105 | 106 | let user = match user { 107 | Some(user) => user, 108 | None => return Err(SeaORMStorageError::UserNotFound), 109 | }; 110 | 111 | let oauth_account = oauth::ActiveModel { 112 | user_id: Set(user.id), 113 | provider: Set(provider.to_string()), 114 | subject: Set(subject.to_string()), 115 | ..Default::default() 116 | }; 117 | oauth_account.insert(&self.pool).await?; 118 | Ok(()) 119 | } 120 | 121 | async fn store_pkce_verifier( 122 | &self, 123 | csrf_state: &str, 124 | pkce_verifier: &str, 125 | expires_in: chrono::Duration, 126 | ) -> Result<(), ::Error> { 127 | let pkce_verifier = pkce_verifier::ActiveModel { 128 | csrf_state: Set(csrf_state.to_string()), 129 | verifier: Set(pkce_verifier.to_string()), 130 | expires_at: Set(Utc::now() + expires_in), 131 | ..Default::default() 132 | }; 133 | pkce_verifier.insert(&self.pool).await?; 134 | Ok(()) 135 | } 136 | 137 | async fn get_pkce_verifier( 138 | &self, 139 | csrf_state: &str, 140 | ) -> Result, ::Error> { 141 | let pkce_verifier = pkce_verifier::Entity::find() 142 | .filter(pkce_verifier::Column::CsrfState.eq(csrf_state)) 143 | .one(&self.pool) 144 | .await?; 145 | 146 | match pkce_verifier { 147 | Some(pkce_verifier) => Ok(Some(pkce_verifier.verifier)), 148 | None => Ok(None), 149 | } 150 | } 151 | } 152 | 153 | #[cfg(test)] 154 | mod tests { 155 | use chrono::Duration; 156 | use sea_orm::{Database, DatabaseConnection}; 157 | use sea_orm_migration::MigratorTrait; 158 | 159 | use super::*; 160 | use crate::migrations::Migrator; 161 | 162 | async fn setup_db() -> DatabaseConnection { 163 | let pool = Database::connect("sqlite::memory:") 164 | .await 165 | .expect("Failed to connect to test db"); 166 | Migrator::up(&pool, None) 167 | .await 168 | .expect("Failed to run migrations"); 169 | pool 170 | } 171 | 172 | #[tokio::test] 173 | async fn test_store_and_get_pkce_verifier() { 174 | let pool = setup_db().await; 175 | let storage = SeaORMStorage::new(pool); 176 | 177 | let csrf_state = "test_state"; 178 | let pkce_verifier = "test_verifier"; 179 | let expires_in = Duration::hours(1); 180 | 181 | // Store the verifier 182 | storage 183 | .store_pkce_verifier(csrf_state, pkce_verifier, expires_in) 184 | .await 185 | .expect("Failed to store PKCE verifier"); 186 | 187 | // Retrieve the verifier 188 | let retrieved = storage 189 | .get_pkce_verifier(csrf_state) 190 | .await 191 | .expect("Failed to get PKCE verifier"); 192 | 193 | assert_eq!(retrieved, Some(pkce_verifier.to_string())); 194 | 195 | // Test non-existent verifier 196 | let non_existent = storage 197 | .get_pkce_verifier("non_existent") 198 | .await 199 | .expect("Failed to query non-existent verifier"); 200 | 201 | assert_eq!(non_existent, None); 202 | } 203 | 204 | #[tokio::test] 205 | async fn test_store_oauth_account() { 206 | let pool = setup_db().await; 207 | let storage = SeaORMStorage::new(pool); 208 | 209 | // First create a user 210 | let user = user::ActiveModel { 211 | email: Set("test@example.com".to_string()), 212 | name: Set(Some("Test User".to_string())), 213 | password_hash: Set(None), 214 | email_verified_at: Set(None), 215 | ..Default::default() 216 | }; 217 | let user = user 218 | .insert(&storage.pool) 219 | .await 220 | .expect("Failed to create user"); 221 | 222 | // Store OAuth account 223 | storage 224 | .create_oauth_account("google", "123456", &UserId::new(&user.id)) 225 | .await 226 | .expect("Failed to store OAuth account"); 227 | 228 | // Verify OAuth account was stored 229 | let oauth_account = oauth::Entity::find() 230 | .filter(oauth::Column::UserId.eq(&user.id)) 231 | .one(&storage.pool) 232 | .await 233 | .expect("Failed to query OAuth account"); 234 | 235 | assert!(oauth_account.is_some()); 236 | let oauth_account = oauth_account.unwrap(); 237 | assert_eq!(oauth_account.provider, "google"); 238 | assert_eq!(oauth_account.subject, "123456"); 239 | assert_eq!(oauth_account.user_id, user.id); 240 | 241 | // Test storing OAuth account for non-existent user 242 | let result = storage 243 | .create_oauth_account("non_existent", "google", &UserId::new("non_existent")) 244 | .await; 245 | assert!(matches!(result, Err(SeaORMStorageError::UserNotFound))); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/passkey.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::Utc; 3 | use sea_orm::ActiveValue::Set; 4 | use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; 5 | use torii_core::{UserId, storage::PasskeyStorage}; 6 | 7 | use crate::{SeaORMStorage, SeaORMStorageError}; 8 | 9 | use crate::entities::{passkey, passkey_challenge}; 10 | 11 | #[async_trait] 12 | impl PasskeyStorage for SeaORMStorage { 13 | type Error = SeaORMStorageError; 14 | 15 | async fn add_passkey( 16 | &self, 17 | user_id: &UserId, 18 | credential_id: &str, 19 | passkey_json: &str, 20 | ) -> Result<(), ::Error> { 21 | let passkey = passkey::ActiveModel { 22 | user_id: Set(user_id.to_string()), 23 | credential_id: Set(credential_id.to_string()), 24 | data_json: Set(passkey_json.to_string()), 25 | ..Default::default() 26 | }; 27 | 28 | passkey.insert(&self.pool).await?; 29 | 30 | Ok(()) 31 | } 32 | 33 | async fn get_passkey_by_credential_id( 34 | &self, 35 | credential_id: &str, 36 | ) -> Result, ::Error> { 37 | let passkey = passkey::Entity::find() 38 | .filter(passkey::Column::CredentialId.eq(credential_id)) 39 | .one(&self.pool) 40 | .await?; 41 | 42 | Ok(passkey.map(|p| p.data_json)) 43 | } 44 | 45 | async fn get_passkeys( 46 | &self, 47 | user_id: &UserId, 48 | ) -> Result, ::Error> { 49 | let passkeys = passkey::Entity::find() 50 | .filter(passkey::Column::UserId.eq(user_id.to_string())) 51 | .all(&self.pool) 52 | .await?; 53 | 54 | Ok(passkeys.into_iter().map(|p| p.data_json).collect()) 55 | } 56 | 57 | async fn set_passkey_challenge( 58 | &self, 59 | challenge_id: &str, 60 | challenge: &str, 61 | expires_in: chrono::Duration, 62 | ) -> Result<(), ::Error> { 63 | let passkey_challenge = passkey_challenge::ActiveModel { 64 | challenge_id: Set(challenge_id.to_string()), 65 | challenge: Set(challenge.to_string()), 66 | expires_at: Set(Utc::now() + expires_in), 67 | ..Default::default() 68 | }; 69 | 70 | passkey_challenge.insert(&self.pool).await?; 71 | 72 | Ok(()) 73 | } 74 | 75 | async fn get_passkey_challenge( 76 | &self, 77 | challenge_id: &str, 78 | ) -> Result, ::Error> { 79 | let passkey_challenge = passkey_challenge::Entity::find() 80 | .filter(passkey_challenge::Column::ChallengeId.eq(challenge_id)) 81 | .one(&self.pool) 82 | .await?; 83 | 84 | Ok(passkey_challenge.map(|p| p.challenge)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/password.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::ActiveValue::Set; 2 | use sea_orm::{ActiveModelTrait, EntityTrait}; 3 | use torii_core::{UserId, storage::PasswordStorage}; 4 | 5 | use crate::{SeaORMStorage, SeaORMStorageError}; 6 | 7 | use crate::entities::user; 8 | 9 | #[async_trait::async_trait] 10 | impl PasswordStorage for SeaORMStorage { 11 | type Error = SeaORMStorageError; 12 | 13 | async fn set_password_hash( 14 | &self, 15 | user_id: &UserId, 16 | password_hash: &str, 17 | ) -> Result<(), ::Error> { 18 | let user: Option = user::Entity::find_by_id(user_id.as_str()) 19 | .one(&self.pool) 20 | .await? 21 | .map(|user| user.into()); 22 | 23 | if user.is_none() { 24 | return Err(SeaORMStorageError::UserNotFound); 25 | } 26 | 27 | if let Some(mut user) = user { 28 | user.password_hash = Set(Some(password_hash.to_string())); 29 | user.update(&self.pool).await?; 30 | } 31 | 32 | Ok(()) 33 | } 34 | 35 | async fn get_password_hash( 36 | &self, 37 | user_id: &UserId, 38 | ) -> Result, ::Error> { 39 | let user: Option = user::Entity::find_by_id(user_id.as_str()) 40 | .one(&self.pool) 41 | .await?; 42 | 43 | match user { 44 | Some(user) => Ok(user.password_hash), 45 | _ => Err(SeaORMStorageError::UserNotFound), 46 | } 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use sea_orm::Database; 53 | use sea_orm_migration::MigratorTrait; 54 | use torii_core::User; 55 | 56 | use crate::entities::user; 57 | use crate::migrations::Migrator; 58 | 59 | use super::*; 60 | 61 | #[tokio::test] 62 | async fn test_password_hash() { 63 | let storage = SeaORMStorage::new( 64 | Database::connect("sqlite::memory:") 65 | .await 66 | .expect("Failed to connect to db"), 67 | ); 68 | Migrator::up(&storage.pool, None) 69 | .await 70 | .expect("Failed to run migrations"); 71 | 72 | // Create test user 73 | let user = User::builder() 74 | .id(UserId::new("1")) 75 | .email("test@example.com".to_string()) 76 | .build() 77 | .expect("Failed to build user"); 78 | 79 | let user_model = user::ActiveModel { 80 | id: Set(user.id.as_str().to_owned()), 81 | email: Set(user.email.to_owned()), 82 | ..Default::default() 83 | }; 84 | user_model 85 | .insert(&storage.pool) 86 | .await 87 | .expect("Failed to create user"); 88 | 89 | // Set password hash 90 | let hash = "test_hash_123"; 91 | storage 92 | .set_password_hash(&user.id, hash) 93 | .await 94 | .expect("Failed to set password hash"); 95 | 96 | // Get password hash 97 | let stored_hash = storage 98 | .get_password_hash(&user.id) 99 | .await 100 | .expect("Failed to get password hash"); 101 | 102 | assert_eq!(stored_hash, Some(hash.to_string())); 103 | 104 | // Get password hash for non-existent user 105 | let result = storage 106 | .get_password_hash(&UserId::new("non_existent")) 107 | .await; 108 | 109 | assert!(matches!(result, Err(SeaORMStorageError::UserNotFound))); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/session.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sea_orm::ActiveValue::Set; 3 | use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; 4 | use torii_core::session::SessionToken; 5 | use torii_core::{Session, SessionStorage, UserId}; 6 | 7 | use crate::SeaORMStorage; 8 | use crate::entities::session; 9 | 10 | impl From for Session { 11 | fn from(value: session::Model) -> Self { 12 | Self { 13 | token: SessionToken::new(&value.token), 14 | user_id: UserId::new(&value.user_id), 15 | user_agent: value.user_agent.to_owned(), 16 | ip_address: value.ip_address.to_owned(), 17 | created_at: value.created_at.to_owned(), 18 | updated_at: value.updated_at.to_owned(), 19 | expires_at: value.expires_at.to_owned(), 20 | } 21 | } 22 | } 23 | 24 | #[async_trait::async_trait] 25 | impl SessionStorage for SeaORMStorage { 26 | type Error = sea_orm::DbErr; 27 | 28 | async fn create_session( 29 | &self, 30 | session: &Session, 31 | ) -> Result::Error> { 32 | let s = session::ActiveModel { 33 | user_id: Set(session.user_id.to_string()), 34 | token: Set(session.token.as_str().to_owned()), 35 | ip_address: Set(session.ip_address.to_owned()), 36 | user_agent: Set(session.user_agent.to_owned()), 37 | expires_at: Set(session.expires_at.to_owned()), 38 | ..Default::default() 39 | }; 40 | 41 | let result = s.insert(&self.pool).await?; 42 | 43 | Ok(result.into()) 44 | } 45 | 46 | async fn get_session( 47 | &self, 48 | id: &SessionToken, 49 | ) -> Result, ::Error> { 50 | let session = session::Entity::find() 51 | .filter(session::Column::Token.eq(id.as_str())) 52 | .one(&self.pool) 53 | .await? 54 | .map(|s| s.into()); 55 | 56 | Ok(session) 57 | } 58 | 59 | async fn delete_session( 60 | &self, 61 | id: &SessionToken, 62 | ) -> Result<(), ::Error> { 63 | session::Entity::delete_many() 64 | .filter(session::Column::Token.eq(id.as_str())) 65 | .exec(&self.pool) 66 | .await?; 67 | 68 | Ok(()) 69 | } 70 | 71 | async fn cleanup_expired_sessions(&self) -> Result<(), ::Error> { 72 | session::Entity::delete_many() 73 | .filter(session::Column::ExpiresAt.lt(Utc::now())) 74 | .exec(&self.pool) 75 | .await?; 76 | 77 | Ok(()) 78 | } 79 | 80 | async fn delete_sessions_for_user( 81 | &self, 82 | user_id: &UserId, 83 | ) -> Result<(), ::Error> { 84 | session::Entity::delete_many() 85 | .filter(session::Column::UserId.eq(user_id.as_str())) 86 | .exec(&self.pool) 87 | .await?; 88 | 89 | Ok(()) 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use sea_orm::Database; 96 | use sea_orm_migration::MigratorTrait; 97 | 98 | use crate::migrations::Migrator; 99 | 100 | use super::*; 101 | 102 | #[tokio::test] 103 | async fn test_create_session() { 104 | let storage = SeaORMStorage::new(Database::connect("sqlite::memory:").await.unwrap()); 105 | Migrator::up(&storage.pool, None).await.unwrap(); 106 | 107 | let session = Session::builder() 108 | .user_id(UserId::new("1")) 109 | .user_agent(Some("test".to_string())) 110 | .ip_address(Some("127.0.0.1".to_string())) 111 | .build() 112 | .unwrap(); 113 | 114 | let created_session = storage.create_session(&session).await.unwrap(); 115 | assert_eq!(created_session.token, session.token); 116 | assert_eq!(created_session.user_id, session.user_id); 117 | assert_eq!(created_session.user_agent, session.user_agent); 118 | assert_eq!(created_session.ip_address, session.ip_address); 119 | assert_eq!( 120 | created_session.created_at.timestamp(), 121 | session.created_at.timestamp() 122 | ); 123 | } 124 | 125 | #[tokio::test] 126 | async fn test_get_session() { 127 | let storage = SeaORMStorage::new(Database::connect("sqlite::memory:").await.unwrap()); 128 | Migrator::up(&storage.pool, None).await.unwrap(); 129 | 130 | let session = Session::builder() 131 | .user_id(UserId::new("1")) 132 | .user_agent(Some("test".to_string())) 133 | .ip_address(Some("127.0.0.1".to_string())) 134 | .build() 135 | .unwrap(); 136 | 137 | let created_session = storage.create_session(&session).await.unwrap(); 138 | assert_eq!(created_session.token, session.token); 139 | assert_eq!(created_session.user_id, session.user_id); 140 | assert_eq!(created_session.user_agent, session.user_agent); 141 | assert_eq!(created_session.ip_address, session.ip_address); 142 | 143 | let retrieved_session = storage.get_session(&session.token).await.unwrap().unwrap(); 144 | assert_eq!(retrieved_session.token, session.token); 145 | assert_eq!(retrieved_session.user_id, session.user_id); 146 | assert_eq!(retrieved_session.user_agent, session.user_agent); 147 | assert_eq!(retrieved_session.ip_address, session.ip_address); 148 | } 149 | 150 | #[tokio::test] 151 | async fn test_delete_session() { 152 | let storage = SeaORMStorage::new(Database::connect("sqlite::memory:").await.unwrap()); 153 | Migrator::up(&storage.pool, None).await.unwrap(); 154 | 155 | let session = Session::builder() 156 | .user_id(UserId::new("1")) 157 | .user_agent(Some("test".to_string())) 158 | .ip_address(Some("127.0.0.1".to_string())) 159 | .build() 160 | .unwrap(); 161 | 162 | let created_session = storage.create_session(&session).await.unwrap(); 163 | assert_eq!(created_session.token, session.token); 164 | assert_eq!(created_session.user_id, session.user_id); 165 | assert_eq!(created_session.user_agent, session.user_agent); 166 | assert_eq!(created_session.ip_address, session.ip_address); 167 | 168 | storage.delete_session(&session.token).await.unwrap(); 169 | 170 | let retrieved_session = storage.get_session(&session.token).await.unwrap(); 171 | assert!(retrieved_session.is_none()); 172 | } 173 | 174 | #[tokio::test] 175 | async fn test_cleanup_expired_sessions() { 176 | let storage = SeaORMStorage::new(Database::connect("sqlite::memory:").await.unwrap()); 177 | Migrator::up(&storage.pool, None).await.unwrap(); 178 | 179 | // Create valid session 180 | let valid_session = Session::builder() 181 | .user_id(UserId::new("1")) 182 | .user_agent(Some("test".to_string())) 183 | .ip_address(Some("127.0.0.1".to_string())) 184 | .expires_at(Utc::now() + chrono::Duration::days(1)) 185 | .build() 186 | .unwrap(); 187 | storage.create_session(&valid_session).await.unwrap(); 188 | 189 | // Create expired session 190 | let expired_session = Session::builder() 191 | .user_id(UserId::new("1")) 192 | .user_agent(Some("test".to_string())) 193 | .ip_address(Some("127.0.0.1".to_string())) 194 | .expires_at(Utc::now() - chrono::Duration::days(1)) 195 | .build() 196 | .unwrap(); 197 | storage.create_session(&expired_session).await.unwrap(); 198 | 199 | // Verify both sessions exist 200 | assert!( 201 | storage 202 | .get_session(&valid_session.token) 203 | .await 204 | .unwrap() 205 | .is_some() 206 | ); 207 | assert!( 208 | storage 209 | .get_session(&expired_session.token) 210 | .await 211 | .unwrap() 212 | .is_some() 213 | ); 214 | 215 | // Run cleanup 216 | storage.cleanup_expired_sessions().await.unwrap(); 217 | 218 | // Verify expired session was removed but valid session remains 219 | assert!( 220 | storage 221 | .get_session(&valid_session.token) 222 | .await 223 | .unwrap() 224 | .is_some() 225 | ); 226 | assert!( 227 | storage 228 | .get_session(&expired_session.token) 229 | .await 230 | .unwrap() 231 | .is_none() 232 | ); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /torii-storage-seaorm/src/user.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sea_orm::ColumnTrait; 3 | use sea_orm::QueryFilter; 4 | use sea_orm::{ActiveModelTrait, EntityTrait, Set}; 5 | use torii_core::{NewUser, User as ToriiUser, UserId, UserStorage}; 6 | 7 | use crate::SeaORMStorage; 8 | use crate::entities::user; 9 | 10 | impl From for ToriiUser { 11 | fn from(user: user::Model) -> Self { 12 | Self { 13 | id: UserId::new(&user.id), 14 | name: user.name.to_owned(), 15 | email: user.email.to_owned(), 16 | email_verified_at: user.email_verified_at.to_owned(), 17 | created_at: user.created_at.to_owned(), 18 | updated_at: user.updated_at.to_owned(), 19 | } 20 | } 21 | } 22 | 23 | #[async_trait::async_trait] 24 | impl UserStorage for SeaORMStorage { 25 | type Error = sea_orm::DbErr; 26 | 27 | async fn create_user(&self, user: &NewUser) -> Result { 28 | let model = user::ActiveModel { 29 | email: Set(user.email.to_owned()), 30 | name: Set(user.name.to_owned()), 31 | email_verified_at: Set(user.email_verified_at.to_owned()), 32 | ..Default::default() 33 | }; 34 | 35 | Ok(model.insert(&self.pool).await?.into()) 36 | } 37 | 38 | async fn get_user(&self, id: &UserId) -> Result, Self::Error> { 39 | let user = user::Entity::find_by_id(id.as_str()) 40 | .one(&self.pool) 41 | .await?; 42 | 43 | Ok(user.map(ToriiUser::from)) 44 | } 45 | 46 | async fn get_user_by_email(&self, email: &str) -> Result, Self::Error> { 47 | Ok(user::Entity::find() 48 | .filter(user::Column::Email.eq(email)) 49 | .one(&self.pool) 50 | .await? 51 | .map(ToriiUser::from)) 52 | } 53 | 54 | async fn get_or_create_user_by_email(&self, email: &str) -> Result { 55 | let user = self.get_user_by_email(email).await?; 56 | 57 | match user { 58 | Some(user) => Ok(user), 59 | None => { 60 | self.create_user(&NewUser::builder().email(email.to_string()).build().unwrap()) 61 | .await 62 | } 63 | } 64 | } 65 | 66 | async fn update_user(&self, user: &ToriiUser) -> Result { 67 | let model = user::ActiveModel { 68 | name: Set(user.name.to_owned()), 69 | ..Default::default() 70 | }; 71 | 72 | Ok(model.update(&self.pool).await?.into()) 73 | } 74 | 75 | async fn delete_user(&self, id: &UserId) -> Result<(), Self::Error> { 76 | let _ = user::Entity::delete_by_id(id.as_str()) 77 | .exec(&self.pool) 78 | .await?; 79 | 80 | Ok(()) 81 | } 82 | 83 | async fn set_user_email_verified(&self, user_id: &UserId) -> Result<(), Self::Error> { 84 | let mut user: user::ActiveModel = user::Entity::find_by_id(user_id.as_str()) 85 | .one(&self.pool) 86 | .await? 87 | .unwrap() 88 | .into(); 89 | 90 | user.email_verified_at = Set(Some(Utc::now())); 91 | user.update(&self.pool).await?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /torii-storage-sqlite/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii-storage-sqlite" 3 | description = "SQLite storage backend for the torii authentication ecosystem" 4 | version = "0.2.2" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | torii-migration = { path = "../torii-migration", version = "0.2.2" } 12 | async-trait.workspace = true 13 | chrono.workspace = true 14 | sqlx = { workspace = true, features = ["sqlite"] } 15 | tracing.workspace = true 16 | 17 | [dev-dependencies] 18 | tokio.workspace = true 19 | tracing-subscriber.workspace = true 20 | -------------------------------------------------------------------------------- /torii-storage-sqlite/README.md: -------------------------------------------------------------------------------- 1 | # torii-storage-sqlite 2 | 3 | This crate provides a SQLite storage implementation for Torii. 4 | 5 | It provides implementations for the following traits: 6 | 7 | - `UserStorage` - for storing and retrieving users 8 | - `SessionStorage` - for storing and retrieving sessions 9 | - `EmailAuthStorage` - for use by the `torii-auth-password` plugin 10 | -------------------------------------------------------------------------------- /torii-storage-sqlite/src/magic_link.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::{DateTime, Utc}; 3 | use torii_core::{ 4 | UserId, 5 | error::StorageError, 6 | storage::{MagicLinkStorage, MagicToken}, 7 | }; 8 | 9 | use crate::SqliteStorage; 10 | 11 | #[derive(Debug, Clone, sqlx::FromRow)] 12 | pub struct SqliteMagicToken { 13 | pub id: Option, 14 | pub user_id: String, 15 | pub token: String, 16 | pub used_at: Option, 17 | pub expires_at: i64, 18 | pub created_at: i64, 19 | pub updated_at: i64, 20 | } 21 | 22 | impl From for MagicToken { 23 | fn from(row: SqliteMagicToken) -> Self { 24 | MagicToken::new( 25 | UserId::new(&row.user_id), 26 | row.token.clone(), 27 | row.used_at 28 | .map(|timestamp| DateTime::from_timestamp(timestamp, 0).unwrap()), 29 | DateTime::from_timestamp(row.expires_at, 0).unwrap(), 30 | DateTime::from_timestamp(row.created_at, 0).unwrap(), 31 | DateTime::from_timestamp(row.updated_at, 0).unwrap(), 32 | ) 33 | } 34 | } 35 | 36 | impl From<&MagicToken> for SqliteMagicToken { 37 | fn from(token: &MagicToken) -> Self { 38 | SqliteMagicToken { 39 | id: None, 40 | user_id: token.user_id.as_str().to_string(), 41 | token: token.token.clone(), 42 | used_at: token.used_at.map(|dt| dt.timestamp()), 43 | expires_at: token.expires_at.timestamp(), 44 | created_at: token.created_at.timestamp(), 45 | updated_at: token.updated_at.timestamp(), 46 | } 47 | } 48 | } 49 | 50 | #[async_trait] 51 | impl MagicLinkStorage for SqliteStorage { 52 | type Error = StorageError; 53 | 54 | async fn save_magic_token( 55 | &self, 56 | token: &MagicToken, 57 | ) -> Result<(), ::Error> { 58 | let row = SqliteMagicToken::from(token); 59 | 60 | sqlx::query("INSERT INTO magic_links (user_id, token, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") 61 | .bind(row.user_id) 62 | .bind(row.token) 63 | .bind(row.expires_at) 64 | .bind(row.created_at) 65 | .bind(row.updated_at) 66 | .execute(&self.pool) 67 | .await 68 | .map_err(|e| StorageError::Database(e.to_string()))?; 69 | 70 | Ok(()) 71 | } 72 | 73 | async fn get_magic_token( 74 | &self, 75 | token: &str, 76 | ) -> Result, ::Error> { 77 | let row: Option = sqlx::query_as( 78 | r#" 79 | SELECT id, user_id, token, used_at, expires_at, created_at, updated_at 80 | FROM magic_links 81 | WHERE token = ? AND expires_at > ? AND used_at IS NULL 82 | "#, 83 | ) 84 | .bind(token) 85 | .bind(Utc::now().timestamp()) 86 | .fetch_optional(&self.pool) 87 | .await 88 | .map_err(|e| StorageError::Database(e.to_string()))?; 89 | 90 | Ok(row.map(|row| row.into())) 91 | } 92 | 93 | async fn set_magic_token_used( 94 | &self, 95 | token: &str, 96 | ) -> Result<(), ::Error> { 97 | sqlx::query("UPDATE magic_links SET used_at = ? WHERE token = ?") 98 | .bind(Utc::now().timestamp()) 99 | .bind(token) 100 | .execute(&self.pool) 101 | .await 102 | .map_err(|e| StorageError::Database(e.to_string()))?; 103 | 104 | Ok(()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /torii-storage-sqlite/src/passkey.rs: -------------------------------------------------------------------------------- 1 | use crate::SqliteStorage; 2 | use async_trait::async_trait; 3 | use chrono::Utc; 4 | use torii_core::UserId; 5 | use torii_core::error::StorageError; 6 | use torii_core::storage::PasskeyStorage; 7 | 8 | #[async_trait] 9 | impl PasskeyStorage for SqliteStorage { 10 | type Error = StorageError; 11 | 12 | async fn add_passkey( 13 | &self, 14 | user_id: &UserId, 15 | credential_id: &str, 16 | passkey_json: &str, 17 | ) -> Result<(), ::Error> { 18 | sqlx::query( 19 | r#" 20 | INSERT INTO passkeys (credential_id, user_id, public_key) 21 | VALUES (?, ?, ?) 22 | "#, 23 | ) 24 | .bind(credential_id) 25 | .bind(user_id.as_str()) 26 | .bind(passkey_json) 27 | .execute(&self.pool) 28 | .await 29 | .map_err(|e| StorageError::Database(e.to_string()))?; 30 | Ok(()) 31 | } 32 | 33 | async fn get_passkey_by_credential_id( 34 | &self, 35 | credential_id: &str, 36 | ) -> Result, ::Error> { 37 | let passkey: Option = sqlx::query_scalar( 38 | r#" 39 | SELECT public_key 40 | FROM passkeys 41 | WHERE credential_id = ? 42 | "#, 43 | ) 44 | .bind(credential_id) 45 | .fetch_optional(&self.pool) 46 | .await 47 | .map_err(|e| StorageError::Database(e.to_string()))?; 48 | Ok(passkey) 49 | } 50 | 51 | async fn get_passkeys( 52 | &self, 53 | user_id: &UserId, 54 | ) -> Result, ::Error> { 55 | let passkeys: Vec = sqlx::query_scalar( 56 | r#" 57 | SELECT public_key 58 | FROM passkeys 59 | WHERE user_id = ? 60 | "#, 61 | ) 62 | .bind(user_id.as_str()) 63 | .fetch_all(&self.pool) 64 | .await 65 | .map_err(|e| StorageError::Database(e.to_string()))?; 66 | Ok(passkeys) 67 | } 68 | 69 | async fn set_passkey_challenge( 70 | &self, 71 | challenge_id: &str, 72 | challenge: &str, 73 | expires_in: chrono::Duration, 74 | ) -> Result<(), ::Error> { 75 | sqlx::query( 76 | r#" 77 | INSERT INTO passkey_challenges (challenge_id, challenge, expires_at) 78 | VALUES (?, ?, ?) 79 | "#, 80 | ) 81 | .bind(challenge_id) 82 | .bind(challenge) 83 | .bind((Utc::now() + expires_in).timestamp()) 84 | .execute(&self.pool) 85 | .await 86 | .map_err(|e| StorageError::Database(e.to_string()))?; 87 | Ok(()) 88 | } 89 | 90 | async fn get_passkey_challenge( 91 | &self, 92 | challenge_id: &str, 93 | ) -> Result, ::Error> { 94 | let challenge: Option = sqlx::query_scalar( 95 | r#" 96 | SELECT challenge 97 | FROM passkey_challenges 98 | WHERE challenge_id = ? AND expires_at > ? 99 | "#, 100 | ) 101 | .bind(challenge_id) 102 | .bind(Utc::now().timestamp()) 103 | .fetch_optional(&self.pool) 104 | .await 105 | .map_err(|e| StorageError::Database(e.to_string()))?; 106 | Ok(challenge) 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use chrono::Duration; 113 | use sqlx::SqlitePool; 114 | use torii_core::{NewUser, User, UserStorage, storage::PasskeyStorage}; 115 | 116 | use crate::SqliteStorage; 117 | 118 | async fn setup_sqlite_storage() -> SqliteStorage { 119 | let _ = tracing_subscriber::fmt::try_init(); 120 | let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); 121 | let storage = SqliteStorage::new(pool); 122 | storage.migrate().await.unwrap(); 123 | storage 124 | } 125 | 126 | async fn create_test_user(storage: &SqliteStorage) -> User { 127 | let user = NewUser::builder() 128 | .email("test@test.com".to_string()) 129 | .build() 130 | .unwrap(); 131 | storage.create_user(&user).await.unwrap() 132 | } 133 | 134 | #[tokio::test] 135 | async fn test_add_and_get_passkey() { 136 | let storage = setup_sqlite_storage().await; 137 | 138 | // Create a user 139 | let user = create_test_user(&storage).await; 140 | 141 | let credential_id = "credential_id"; 142 | let passkey_json = "passkey_json"; 143 | storage 144 | .add_passkey(&user.id, credential_id, passkey_json) 145 | .await 146 | .unwrap(); 147 | 148 | let passkeys = storage.get_passkeys(&user.id).await.unwrap(); 149 | assert_eq!(passkeys.len(), 1); 150 | assert_eq!(passkeys[0], passkey_json); 151 | } 152 | 153 | #[tokio::test] 154 | async fn test_set_and_get_passkey_challenge() { 155 | let storage = setup_sqlite_storage().await; 156 | 157 | let challenge_id = "challenge_id"; 158 | let challenge = "challenge"; 159 | let expires_in = Duration::minutes(5); 160 | storage 161 | .set_passkey_challenge(challenge_id, challenge, expires_in) 162 | .await 163 | .unwrap(); 164 | 165 | let stored_challenge = storage.get_passkey_challenge(challenge_id).await.unwrap(); 166 | assert!(stored_challenge.is_some()); 167 | assert_eq!(stored_challenge.unwrap(), challenge); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /torii-storage-sqlite/src/password.rs: -------------------------------------------------------------------------------- 1 | use crate::SqliteStorage; 2 | use async_trait::async_trait; 3 | use torii_core::UserId; 4 | use torii_core::error::StorageError; 5 | use torii_core::storage::PasswordStorage; 6 | 7 | #[async_trait] 8 | impl PasswordStorage for SqliteStorage { 9 | type Error = StorageError; 10 | 11 | async fn set_password_hash( 12 | &self, 13 | user_id: &UserId, 14 | hash: &str, 15 | ) -> Result<(), ::Error> { 16 | sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") 17 | .bind(hash) 18 | .bind(user_id.as_str()) 19 | .execute(&self.pool) 20 | .await 21 | .map_err(|e| StorageError::Database(e.to_string()))?; 22 | Ok(()) 23 | } 24 | 25 | async fn get_password_hash( 26 | &self, 27 | user_id: &UserId, 28 | ) -> Result, ::Error> { 29 | let result = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") 30 | .bind(user_id.as_str()) 31 | .fetch_optional(&self.pool) 32 | .await 33 | .map_err(|e| StorageError::Database(e.to_string()))?; 34 | Ok(result) 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use super::*; 41 | use crate::tests::{create_test_user, setup_sqlite_storage}; 42 | 43 | #[tokio::test] 44 | async fn test_password_hash() { 45 | let storage = setup_sqlite_storage() 46 | .await 47 | .expect("Failed to setup storage"); 48 | 49 | // Create test user 50 | let user = create_test_user(&storage, "1") 51 | .await 52 | .expect("Failed to create user"); 53 | 54 | // Set password hash 55 | let hash = "test_hash_123"; 56 | storage 57 | .set_password_hash(&user.id, hash) 58 | .await 59 | .expect("Failed to set password hash"); 60 | 61 | // Get password hash 62 | let stored_hash = storage 63 | .get_password_hash(&user.id) 64 | .await 65 | .expect("Failed to get password hash"); 66 | 67 | assert_eq!(stored_hash, Some(hash.to_string())); 68 | 69 | // Get password hash for non-existent user 70 | let non_existent = storage 71 | .get_password_hash(&UserId::new("non_existent")) 72 | .await 73 | .expect("Failed to get password hash"); 74 | 75 | assert_eq!(non_existent, None); 76 | } 77 | 78 | #[tokio::test] 79 | async fn test_password_hash_update() { 80 | let storage = setup_sqlite_storage() 81 | .await 82 | .expect("Failed to setup storage"); 83 | 84 | // Create test user with initial password hash 85 | let user = create_test_user(&storage, "1") 86 | .await 87 | .expect("Failed to create user"); 88 | 89 | let initial_hash = "initial_hash_123"; 90 | storage 91 | .set_password_hash(&user.id, initial_hash) 92 | .await 93 | .expect("Failed to set initial password hash"); 94 | 95 | // Set updated password hash 96 | let updated_hash = "updated_hash_456"; 97 | storage 98 | .set_password_hash(&user.id, updated_hash) 99 | .await 100 | .expect("Failed to set updated password hash"); 101 | 102 | // Get updated password hash 103 | let stored_hash = storage 104 | .get_password_hash(&user.id) 105 | .await 106 | .expect("Failed to get updated password hash"); 107 | 108 | assert_eq!(stored_hash, Some(updated_hash.to_string())); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /torii/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "torii" 3 | version = "0.2.3" 4 | description = "A modular authentication ecosystem for Rust applications" 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | torii-core = { path = "../torii-core", version = "0.2.2" } 11 | # auth plugins 12 | torii-auth-password = { path = "../torii-auth-password", version = "0.2.2", optional = true } 13 | torii-auth-oauth = { path = "../torii-auth-oauth", version = "0.2.2", optional = true } 14 | torii-auth-passkey = { path = "../torii-auth-passkey", version = "0.2.2", optional = true } 15 | torii-auth-magic-link = { path = "../torii-auth-magic-link", version = "0.2.2", optional = true } 16 | # storage backends 17 | torii-storage-sqlite = { path = "../torii-storage-sqlite", version = "0.2.2", optional = true } 18 | torii-storage-postgres = { path = "../torii-storage-postgres", version = "0.2.2", optional = true } 19 | torii-storage-seaorm = { path = "../torii-storage-seaorm", version = "0.2.2", optional = true } 20 | 21 | # dependencies 22 | chrono.workspace = true # TODO: Make this optional and expose std::time::Duration in APIs 23 | tracing.workspace = true 24 | thiserror.workspace = true 25 | serde_json.workspace = true 26 | serde.workspace = true 27 | 28 | [dev-dependencies] 29 | tokio = { workspace = true, features = ["full"] } 30 | testcontainers-modules = {version = "0.12.0", features = ["postgres", "mysql", "mariadb"]} 31 | tracing-subscriber = { workspace = true } 32 | 33 | [features] 34 | default = ["password", "seaorm-sqlite"] 35 | 36 | # storage backends 37 | sqlite = ["dep:torii-storage-sqlite"] 38 | postgres = ["dep:torii-storage-postgres"] 39 | 40 | # seaorm storage backends 41 | seaorm-sqlite = ["dep:torii-storage-seaorm", "torii-storage-seaorm/sqlite"] 42 | seaorm-postgres = ["dep:torii-storage-seaorm", "torii-storage-seaorm/postgres"] 43 | seaorm-mysql = ["dep:torii-storage-seaorm", "torii-storage-seaorm/mysql"] 44 | seaorm = ["dep:torii-storage-seaorm", "torii-storage-seaorm/sqlite", "torii-storage-seaorm/postgres", "torii-storage-seaorm/mysql"] 45 | 46 | # auth plugins 47 | password = ["dep:torii-auth-password"] 48 | oauth = ["dep:torii-auth-oauth"] 49 | passkey = ["dep:torii-auth-passkey"] 50 | magic-link = ["dep:torii-auth-magic-link"] 51 | -------------------------------------------------------------------------------- /torii/README.md: -------------------------------------------------------------------------------- 1 | # Torii 2 | 3 | [![CI](https://github.com/cmackenzie1/torii-rs/actions/workflows/ci.yaml/badge.svg)](https://github.com/cmackenzie1/torii-rs/actions/workflows/ci.yaml) 4 | [![codecov](https://codecov.io/gh/cmackenzie1/torii-rs/branch/main/graph/badge.svg?token=MHF0G453L0)](https://codecov.io/gh/cmackenzie1/torii-rs) 5 | [![docs.rs](https://img.shields.io/docsrs/torii)](https://docs.rs/torii/latest/torii/) 6 | [![Crates.io Version](https://img.shields.io/crates/v/torii)](https://crates.io/crates/torii) 7 | 8 | Torii is a powerful authentication framework for Rust applications that gives you complete control over your users' data. Unlike hosted solutions like Auth0, Clerk, or WorkOS that store user information in their cloud, Torii lets you own and manage your authentication stack while providing modern auth features through a flexible plugin system. 9 | 10 | ## Features 11 | 12 | - Password-based authentication 13 | - Social OAuth/OpenID Connect 14 | - Passkey/WebAuthn support 15 | - Full data sovereignty - store user data where you want 16 | - Multiple storage backends: 17 | - SQLite 18 | - Postgres 19 | - MySQL 20 | 21 | ## Quick Start 22 | 23 | 1. Add dependencies to your `Cargo.toml`: 24 | 25 | ```toml 26 | [dependencies] 27 | torii = { version = "0.2.0", features = ["sqlite", "password"] } 28 | ``` 29 | 30 | 2. Initialize the database: 31 | 32 | ```rust 33 | let pool = SqliteStorage::connect("sqlite://todos.db?mode=rwc").await 34 | .expect("Failed to connect to database"); 35 | let user_storage = Arc::new(pool.clone()); 36 | let session_storage = Arc::new(pool.clone()); 37 | 38 | // Migrate the user storage 39 | user_storage 40 | .migrate() 41 | .await 42 | .expect("Failed to migrate user storage"); 43 | 44 | // Migrate the session storage 45 | session_storage 46 | .migrate() 47 | .await 48 | .expect("Failed to migrate session storage"); 49 | 50 | let torii = Torii::new(user_storage, session_storage).with_password_plugin(); 51 | ``` 52 | 53 | 3. Create a user: 54 | 55 | ```rust 56 | let user = torii.register_user_with_password("test@example.com", "password").await?; 57 | ``` 58 | 59 | 4. Login a user: 60 | 61 | ```rust 62 | let user = torii.login_user_with_password("test@example.com", "password").await?; 63 | ``` 64 | -------------------------------------------------------------------------------- /torii/tests/jwt_sessions.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use chrono::Duration; 4 | use torii::{SessionConfig, Torii}; 5 | use torii_core::session::{JwtConfig, SessionToken}; 6 | use torii_core::user::UserId; 7 | 8 | #[cfg(feature = "sqlite")] 9 | use torii::SqliteStorage; 10 | 11 | // Test secret for HS256 12 | const TEST_HS256_SECRET: &[u8] = b"this_is_a_test_secret_key_for_hs256_jwt_tokens_not_for_prod"; 13 | 14 | #[cfg(all(feature = "password", feature = "sqlite"))] 15 | #[tokio::test] 16 | async fn test_jwt_session_manager() { 17 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 18 | sqlite.migrate().await.unwrap(); 19 | 20 | // Create a JWT config with HS256 21 | let jwt_config = JwtConfig::new_hs256(TEST_HS256_SECRET.to_vec()) 22 | .with_issuer("torii-test-hs256") 23 | .with_metadata(true); 24 | 25 | // Create a Torii instance with JWT sessions 26 | let torii = Torii::new(sqlite.clone()).with_jwt_sessions(jwt_config.clone()); 27 | 28 | // Create a JWT session 29 | let user_id = UserId::new_random(); 30 | let session = torii 31 | .create_session( 32 | &user_id, 33 | Some("test-agent-hs256".to_string()), 34 | Some("127.0.0.2".to_string()), 35 | ) 36 | .await 37 | .unwrap(); 38 | 39 | // Verify the token is a JWT 40 | match &session.token { 41 | SessionToken::Jwt(_) => { 42 | // Expected 43 | } 44 | _ => panic!("Expected JWT token"), 45 | } 46 | 47 | // Retrieve the session 48 | let retrieved = torii.get_session(&session.token).await.unwrap(); 49 | assert_eq!(retrieved.user_id, user_id); 50 | } 51 | 52 | #[cfg(feature = "sqlite")] 53 | #[tokio::test] 54 | async fn test_jwt_expiration() { 55 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 56 | sqlite.migrate().await.unwrap(); 57 | 58 | // Create a JWT config with HS256 59 | let jwt_config = JwtConfig::new_hs256(TEST_HS256_SECRET.to_vec()); 60 | 61 | // Create Torii with a JWT session manager and short expiration 62 | let torii = Torii::new(sqlite.clone()) 63 | .with_jwt_sessions(jwt_config.clone()) 64 | .with_session_config(SessionConfig { 65 | expires_in: Duration::seconds(1), 66 | jwt_config: Some(jwt_config.clone()), 67 | }); 68 | 69 | // Create a JWT session with a very short expiration 70 | let user_id = UserId::new_random(); 71 | let session = torii.create_session(&user_id, None, None).await.unwrap(); 72 | 73 | // Verify we can get the session immediately 74 | let retrieved = torii.get_session(&session.token).await.unwrap(); 75 | assert_eq!(retrieved.user_id, user_id); 76 | 77 | // Wait for expiration 78 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 79 | 80 | // Try to get the expired session 81 | let result = torii.get_session(&session.token).await; 82 | assert!(result.is_err()); 83 | } 84 | 85 | #[cfg(all(feature = "password", feature = "sqlite"))] 86 | #[tokio::test] 87 | async fn test_password_auth_with_jwt() { 88 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 89 | sqlite.migrate().await.unwrap(); 90 | 91 | // Create a JWT config with HS256 92 | let jwt_config = JwtConfig::new_hs256(TEST_HS256_SECRET.to_vec()) 93 | .with_issuer("torii-test-hs256") 94 | .with_metadata(true); 95 | 96 | // Create Torii with JWT sessions 97 | let torii = Torii::new(sqlite.clone()) 98 | .with_jwt_sessions(jwt_config) 99 | .with_password_plugin(); 100 | 101 | // Register a user 102 | let user = torii 103 | .register_user_with_password("test@example.com", "password123") 104 | .await 105 | .unwrap(); 106 | 107 | // Verify email (required for login) 108 | torii.set_user_email_verified(&user.id).await.unwrap(); 109 | 110 | // Login with password 111 | let (user, session) = torii 112 | .login_user_with_password("test@example.com", "password123", None, None) 113 | .await 114 | .unwrap(); 115 | 116 | // Verify the token is a JWT 117 | match &session.token { 118 | SessionToken::Jwt(_) => { 119 | // Expected 120 | } 121 | _ => panic!("Expected JWT token"), 122 | } 123 | 124 | // Verify the session contains the user ID 125 | assert_eq!(session.user_id, user.id); 126 | 127 | // Validate the session 128 | let retrieved = torii.get_session(&session.token).await.unwrap(); 129 | assert_eq!(retrieved.user_id, user.id); 130 | 131 | // Try with incorrect password 132 | let result = torii 133 | .login_user_with_password("test@example.com", "wrong-password", None, None) 134 | .await; 135 | assert!(result.is_err()); 136 | } 137 | -------------------------------------------------------------------------------- /torii/tests/magic_link.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use torii::{SqliteStorage, Torii}; 4 | 5 | #[cfg(all(feature = "magic-link", feature = "sqlite"))] 6 | #[tokio::test] 7 | async fn test_magic_link_auth() { 8 | // Set up SQLite storage 9 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 10 | sqlite.migrate().await.unwrap(); 11 | 12 | // Create Torii instance with magic link plugin 13 | let torii = Torii::new(sqlite.clone()).with_magic_link_plugin(); 14 | 15 | // Generate a token for a test email 16 | let email = "test@example.com"; 17 | let magic_token = torii.generate_magic_token(email).await.unwrap(); 18 | 19 | // Verify the token contains expected data 20 | assert!(!magic_token.token.is_empty()); 21 | assert!(magic_token.token.len() > 32); // Should be a reasonably long token 22 | assert!(!magic_token.used()); // Token should not be used yet 23 | assert!(magic_token.expires_at > chrono::Utc::now()); // Should expire in the future 24 | 25 | // Verify the magic token 26 | let (user, session) = torii 27 | .verify_magic_token(&magic_token.token, None, None) 28 | .await 29 | .unwrap(); 30 | 31 | // Verify user details 32 | assert_eq!(user.email, email); 33 | 34 | // Verify session 35 | assert_eq!(session.user_id, user.id); 36 | assert!(!session.is_expired()); 37 | 38 | // Trying to verify the same token again should fail (one-time use) 39 | let result = torii 40 | .verify_magic_token(&magic_token.token, None, None) 41 | .await; 42 | assert!(result.is_err()); 43 | } 44 | 45 | #[cfg(all(feature = "magic-link", feature = "sqlite"))] 46 | #[tokio::test] 47 | async fn test_magic_link_expired_token() { 48 | use chrono::Duration; 49 | use std::time::Duration as StdDuration; 50 | use tokio::time::sleep; 51 | 52 | // Set up SQLite storage 53 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 54 | sqlite.migrate().await.unwrap(); 55 | 56 | // Create Torii instance with magic link plugin 57 | let torii = Torii::new(sqlite.clone()) 58 | .with_magic_link_plugin() 59 | .with_session_config(torii::SessionConfig { 60 | expires_in: Duration::seconds(2), // Short expiry for testing 61 | jwt_config: None, 62 | }); 63 | 64 | // Generate a token 65 | let email = "expired@example.com"; 66 | let magic_token = torii.generate_magic_token(email).await.unwrap(); 67 | 68 | // Wait for the token to expire (hardcoded in plugin to 10 minutes, 69 | // but we can't wait that long in a test, so we'll mock this by directly checking 70 | // the error message when trying to verify) 71 | sleep(StdDuration::from_secs(2)).await; 72 | 73 | // We can't easily test token expiration in a unit test since the expiry is hardcoded 74 | // in the MagicLinkPlugin implementation. In a real application, you would make the 75 | // expiry time configurable. 76 | // 77 | // Instead, we'll verify that session expiration works by creating a session with 78 | // a short lifetime and checking that it expires. 79 | 80 | // Verify the token first 81 | let (_, session) = torii 82 | .verify_magic_token(&magic_token.token, None, None) 83 | .await 84 | .unwrap(); 85 | 86 | // Wait for the session to expire 87 | sleep(StdDuration::from_secs(3)).await; 88 | 89 | // Get the session - should fail because it's expired 90 | let result = torii.get_session(&session.token).await; 91 | assert!(result.is_err()); 92 | } 93 | 94 | #[cfg(all(feature = "magic-link", feature = "sqlite"))] 95 | #[tokio::test] 96 | async fn test_magic_link_connection_info() { 97 | // Set up SQLite storage 98 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 99 | sqlite.migrate().await.unwrap(); 100 | 101 | // Create Torii instance with magic link plugin 102 | let torii = Torii::new(sqlite.clone()).with_magic_link_plugin(); 103 | 104 | // Generate a token 105 | let email = "connection@example.com"; 106 | let magic_token = torii.generate_magic_token(email).await.unwrap(); 107 | 108 | // Verify the token with connection info 109 | let user_agent = Some("Test User Agent".to_string()); 110 | let ip_address = Some("127.0.0.1".to_string()); 111 | 112 | let (_, session) = torii 113 | .verify_magic_token(&magic_token.token, user_agent.clone(), ip_address.clone()) 114 | .await 115 | .unwrap(); 116 | 117 | // Verify session has the connection info 118 | assert_eq!(session.user_agent, user_agent); 119 | assert_eq!(session.ip_address, ip_address); 120 | } 121 | -------------------------------------------------------------------------------- /torii/tests/postgres.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use testcontainers_modules::testcontainers::runners::AsyncRunner; 3 | use torii::{PostgresStorage, Torii}; 4 | 5 | #[tokio::test] 6 | async fn test_postgres_password_auth() { 7 | let container = testcontainers_modules::postgres::Postgres::default() 8 | .start() 9 | .await 10 | .unwrap(); 11 | let host_port = container.get_host_port_ipv4(5432).await.unwrap(); 12 | let connection_string = 13 | &format!("postgres://postgres:postgres@127.0.0.1:{host_port}/postgres",); 14 | 15 | let storage = Arc::new(PostgresStorage::connect(connection_string).await.unwrap()); 16 | let torii = Torii::new(storage.clone()).with_password_plugin(); 17 | storage.migrate().await.unwrap(); // TODO(now): Move this to Torii::initialize() 18 | 19 | let user = torii 20 | .register_user_with_password("test@example.com", "password") 21 | .await 22 | .unwrap(); 23 | assert_eq!(user.email, "test@example.com"); 24 | 25 | // Login the user without verifying the email, should fail 26 | let result = torii 27 | .login_user_with_password("test@example.com", "password", None, None) 28 | .await; 29 | assert!(result.is_err()); 30 | 31 | // Verify the email 32 | torii.set_user_email_verified(&user.id).await.unwrap(); 33 | 34 | // Login the user again, should succeed 35 | let (user, session) = torii 36 | .login_user_with_password("test@example.com", "password", None, None) 37 | .await 38 | .unwrap(); 39 | assert_eq!(user.email, "test@example.com"); 40 | assert!(!session.is_expired()); 41 | } 42 | -------------------------------------------------------------------------------- /torii/tests/seaorm.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use testcontainers_modules::testcontainers::{ImageExt, runners::AsyncRunner}; 3 | use torii::{SeaORMStorage, Torii}; 4 | 5 | /// Sets up Torii with password authentication and tests the basic authentication flow. 6 | async fn test_password_auth_flow(storage: Arc) { 7 | // Set up Torii with the storage 8 | let torii = Torii::new(storage.clone()).with_password_plugin(); 9 | 10 | // Ensure database is migrated 11 | storage.migrate().await.expect("Failed to migrate storage"); 12 | 13 | // Register a test user 14 | let user = torii 15 | .register_user_with_password("test@example.com", "password") 16 | .await 17 | .expect("Failed to register user"); 18 | assert_eq!(user.email, "test@example.com"); 19 | 20 | // Attempt to login without verifying email (should fail) 21 | let result = torii 22 | .login_user_with_password("test@example.com", "password", None, None) 23 | .await; 24 | assert!(result.is_err(), "Login should fail with unverified email"); 25 | 26 | // Verify the email 27 | torii 28 | .set_user_email_verified(&user.id) 29 | .await 30 | .expect("Failed to verify email"); 31 | 32 | // Login after email verification (should succeed) 33 | let (user, session) = torii 34 | .login_user_with_password("test@example.com", "password", None, None) 35 | .await 36 | .expect("Failed to login user"); 37 | assert_eq!(user.email, "test@example.com"); 38 | assert!(!session.is_expired()); 39 | } 40 | 41 | #[tokio::test] 42 | async fn test_seaorm_postgres_password_auth() { 43 | let container = testcontainers_modules::postgres::Postgres::default() 44 | .with_tag("17-alpine") 45 | .start() 46 | .await 47 | .expect("Failed to start Postgres container"); 48 | 49 | let host_port = container 50 | .get_host_port_ipv4(5432) 51 | .await 52 | .expect("Failed to get port"); 53 | let connection_string = format!("postgres://postgres:postgres@127.0.0.1:{host_port}/postgres"); 54 | 55 | let storage = Arc::new( 56 | SeaORMStorage::connect(&connection_string) 57 | .await 58 | .expect("Failed to connect to database"), 59 | ); 60 | 61 | test_password_auth_flow(storage).await; 62 | } 63 | 64 | #[tokio::test] 65 | async fn test_seaorm_mysql_password_auth() { 66 | let _ = tracing_subscriber::fmt::try_init(); 67 | 68 | let container = testcontainers_modules::mysql::Mysql::default() 69 | .with_tag("8") 70 | .start() 71 | .await 72 | .expect("Failed to start MySQL container"); 73 | 74 | let host_port = container 75 | .get_host_port_ipv4(3306) 76 | .await 77 | .expect("Failed to get port"); 78 | let connection_string = format!("mysql://127.0.0.1:{host_port}/test"); 79 | 80 | let storage = Arc::new( 81 | SeaORMStorage::connect(&connection_string) 82 | .await 83 | .expect("Failed to connect to database"), 84 | ); 85 | 86 | test_password_auth_flow(storage).await; 87 | } 88 | 89 | #[tokio::test] 90 | async fn test_seaorm_mariadb_password_auth() { 91 | let _ = tracing_subscriber::fmt::try_init(); 92 | 93 | let container = testcontainers_modules::mariadb::Mariadb::default() 94 | .with_tag("11") 95 | .start() 96 | .await 97 | .expect("Failed to start MariaDB container"); 98 | 99 | let host_port = container 100 | .get_host_port_ipv4(3306) 101 | .await 102 | .expect("Failed to get port"); 103 | let connection_string = format!("mysql://127.0.0.1:{host_port}/test"); 104 | 105 | let storage = Arc::new( 106 | SeaORMStorage::connect(&connection_string) 107 | .await 108 | .expect("Failed to connect to database"), 109 | ); 110 | 111 | test_password_auth_flow(storage).await; 112 | } 113 | 114 | #[tokio::test] 115 | async fn test_seaorm_sqlite_password_auth() { 116 | let storage = Arc::new( 117 | SeaORMStorage::connect("sqlite::memory:") 118 | .await 119 | .expect("Failed to connect to database"), 120 | ); 121 | 122 | test_password_auth_flow(storage).await; 123 | } 124 | -------------------------------------------------------------------------------- /torii/tests/sqlite.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use torii::{SqliteStorage, Torii}; 4 | 5 | #[tokio::test] 6 | async fn test_sqlite_password_auth() { 7 | let sqlite = Arc::new(SqliteStorage::connect("sqlite::memory:").await.unwrap()); 8 | let torii = Torii::new(sqlite.clone()).with_password_plugin(); 9 | sqlite.migrate().await.unwrap(); // TODO(now): Move this to Torii::initialize() 10 | 11 | let user = torii 12 | .register_user_with_password("test@example.com", "password") 13 | .await 14 | .unwrap(); 15 | assert_eq!(user.email, "test@example.com"); 16 | 17 | // Login the user without verifying the email, should fail 18 | let result = torii 19 | .login_user_with_password("test@example.com", "password", None, None) 20 | .await; 21 | assert!(result.is_err()); 22 | 23 | // Verify the email 24 | torii.set_user_email_verified(&user.id).await.unwrap(); 25 | 26 | // Login the user again, should succeed 27 | let (user, session) = torii 28 | .login_user_with_password("test@example.com", "password", None, None) 29 | .await 30 | .unwrap(); 31 | assert_eq!(user.email, "test@example.com"); 32 | assert!(!session.is_expired()); 33 | } 34 | --------------------------------------------------------------------------------