init
This commit is contained in:
13
.dev.vars
Normal file
13
.dev.vars
Normal file
@@ -0,0 +1,13 @@
|
||||
CLIENT_ID="your-value-here"
|
||||
|
||||
CLIENT_SECRET="your-value-here"
|
||||
|
||||
AUTH_SERVER_URL="your-value-here"
|
||||
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
DEV_MODE="true"
|
||||
|
||||
ZITADEL_ORG_ID="your-value-here"
|
||||
|
||||
ZITADEL_PROJECT_ID="your-value-here"
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/target/
|
||||
/node_modules/
|
||||
/.wrangler/
|
||||
/.idea/
|
||||
/build/
|
3868
Cargo.lock
generated
Normal file
3868
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
76
Cargo.toml
Normal file
76
Cargo.toml
Normal file
@@ -0,0 +1,76 @@
|
||||
[package]
|
||||
name = "zitadel-session-worker"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = [ "Geoff Seemueller <28698553+geoffsee@users.noreply.github.com>" ]
|
||||
|
||||
[package.metadata.release]
|
||||
release = false
|
||||
|
||||
# https://github.com/rustwasm/wasm-pack/issues/1247
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
worker = { version="0.5.0", features=['http', 'axum', 'd1', 'timezone'] }
|
||||
worker-macros = { version="0.5.0", features=['http'] }
|
||||
axum = { version = "0.7.9", default-features = false, features = ["macros", "json", "query", "original-uri", "tracing", "http1", "matched-path"] }
|
||||
tower-service = "0.3.2"
|
||||
console_error_panic_hook = { version = "0.1.1" }
|
||||
reqwest = { version = "0.11.27", features = ["json", "rustls-tls"], default-features = false }
|
||||
thiserror = "1.0.69"
|
||||
openidconnect = { version = "3.5.0", features = ["reqwest"]}
|
||||
serde_urlencoded = {version = "0.7.1"}
|
||||
url = "2.5.4"
|
||||
oauth2 = { version = "=5.0.0", optional = false, default-features = false, features = ["reqwest"] }
|
||||
custom_error = {version = "1.9.2"}
|
||||
serde_json = { version = "1.0.116" }
|
||||
# this is set to version 1.0.200 in zitadel rust library
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
jsonwebtoken = { version = "9.3.0"}
|
||||
axum-extra = { version = "0.10.0", features = ["typed-header", "cookie"] }
|
||||
base64 = "0.22.1"
|
||||
js-sys = "0.3"
|
||||
time = { version = "0.3" , default-features = false, features = ["wasm-bindgen", "serde"], optional = false}
|
||||
async-trait = { version = "0.1.80"}
|
||||
tokio = { version = "1.43.0", default-features = false, features = ["macros","rt"] }
|
||||
wasm-bindgen-futures = "0.4.50"
|
||||
serde-wasm-bindgen = "0.5"
|
||||
openid = "0.16.1"
|
||||
anyhow = "1.0.95"
|
||||
tower-sessions = { version = "=0.13", default-features = false, features = ["memory-store", "signed", "private", "axum-core"] }
|
||||
tower = { version = "0.5.2", features = ["tokio", "tracing"] }
|
||||
tower-http = { git = "https://github.com/tower-rs/tower-http", default-features = false, features = ["cors", "set-header", "sensitive-headers", "trace", "propagate-header", "follow-redirect", "request-id"] }
|
||||
chrono = { version = "0.4.40", features = ["wasmbind"] }
|
||||
tower-sessions-core = { version = "0.13"}
|
||||
worker-kv = "0.8.0"
|
||||
tracing = { version = "0.1" }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ['json', 'time'] }
|
||||
tracing-web = "0.1"
|
||||
axum-core = { version = "0.4.5", features = ["tracing"] }
|
||||
http = "1.3.1"
|
||||
bytes = "1.9.0"
|
||||
mini-moka = "0.10.3"
|
||||
tower-cookies = "0.10.0"
|
||||
uuid = {version = "1.12.1", features = ["v4"]}
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
ring = { version = "0.17.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
ring = { version = "0.17.4", features = ["std", "wasm32_unknown_unknown_js"] }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4.38"
|
||||
tower = { version = "0.5.2" }
|
||||
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
|
||||
http-body-util = {version = "0.1.2"}
|
||||
|
||||
|
||||
[profile.release]
|
||||
allow-unsafe = true
|
222
README.md
Normal file
222
README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# zitadel-session-worker
|
||||
|
||||
> ⚠️ **WARNING**: This project is currently in development and **NOT** production-ready. Use at your own risk. It may
|
||||
> contain bugs, security vulnerabilities, or incomplete features. This should
|
||||
> serve as a starting point for anyone building similar technology. All feedback is welcome.
|
||||
|
||||
A Rust Cloudflare Worker that provides authentication and session management for web applications using ZITADEL as the identity provider. It adopts the implementation for oauth2 token introspection from [smartive/zitadel-rs](https://github.com/smartive/zitadel-rust).
|
||||
|
||||
## Overview
|
||||
|
||||
This project is a Rust-based Cloudflare Worker that acts as an authentication proxy for web applications. It handles:
|
||||
|
||||
- oauth2/oidc w/PKCE via Zitadel
|
||||
- Session management using Cloudflare KV storage
|
||||
- Token introspection and validation
|
||||
- Proxying authenticated requests to backend services
|
||||
|
||||
When deployed, the worker sits between your users and your application services. It:
|
||||
1. Intercepts incoming requests
|
||||
2. Verifies if the user has a valid session
|
||||
3. If not, redirects to ZITADEL for authentication
|
||||
4. After successful authentication, creates a session and proxies the request to your service
|
||||
5. For subsequent requests, validates the session and proxies authenticated requests
|
||||
|
||||
|
||||
> **Note**: Caches are used by the introspection and session modules. They prevent excessive r/w.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Rust](https://www.rust-lang.org/tools/install) (latest stable version)
|
||||
- LLVM and clang
|
||||
- [Bun](https://bun.sh/) JavaScript runtime
|
||||
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) for Cloudflare Workers development
|
||||
- ZITADEL Administrator Access
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd zitadel-session-worker
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
# Install JavaScript dependencies
|
||||
bun install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
> **Note**: There is a docker compose file with Zitadel in this repository that can be used for testing.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.dev.vars` file in the project root with the following variables:
|
||||
|
||||
```
|
||||
CLIENT_ID="your-client-id"
|
||||
CLIENT_SECRET="your-client-secret"
|
||||
AUTH_SERVER_URL="your-zitadel-instance-url"
|
||||
ZITADEL_ORG_ID="your-organization-id"
|
||||
ZITADEL_PROJECT_ID="your-project-id"
|
||||
APP_URL="http://localhost:3000"
|
||||
DEV_MODE="true"
|
||||
```
|
||||
|
||||
### Wrangler Configuration
|
||||
|
||||
- `wrangler.jsonc` - Base configuration
|
||||
|
||||
## Development
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Start the development server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will start the worker on `localhost:3000`.
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build the project
|
||||
cargo clean && cargo install -q worker-build && worker-build --release
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploying to Cloudflare
|
||||
|
||||
```bash
|
||||
# Deploy to development environment
|
||||
bun run deploy:dev
|
||||
|
||||
# Deploy with updated secrets
|
||||
bun run deploy:dev:secrets
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# View logs from the deployed worker
|
||||
bun run tail:dev
|
||||
```
|
||||
|
||||
## Integration with Your Application
|
||||
|
||||
To integrate this worker with your existing application:
|
||||
|
||||
1. **Configure Cloudflare**:
|
||||
- Set up a Cloudflare Worker route that points to your application domain
|
||||
- Deploy this worker to that route
|
||||
|
||||
2. **Configure ZITADEL**:
|
||||
- Create an application in ZITADEL
|
||||
- Configure the redirect URI to `https://your-worker-domain/login/callback`
|
||||
- Get the client ID and client secret
|
||||
|
||||
3. **Configure this Worker**:
|
||||
- Update the environment variables with your ZITADEL credentials
|
||||
- Set the `APP_URL` to your application's URL
|
||||
- Set an http route in `wrangler.jsonc`
|
||||
|
||||
4. **Access Control**:
|
||||
- The worker will automatically handle authentication
|
||||
- Your application will receive authenticated requests with user information
|
||||
- You can access user information via the `/api/whoami` endpoint
|
||||
|
||||
## Testing
|
||||
|
||||
The project uses Rust's built-in testing framework with tokio for async tests.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. For unit tests, add them to the `tests` module in the relevant source file
|
||||
2. For async tests, use the `#[tokio::test]` attribute
|
||||
3. Follow the existing pattern of testing both success and error cases
|
||||
4. Mock external dependencies when necessary
|
||||
|
||||
## Debugging
|
||||
|
||||
1. For local development, use `console_log!` macros to output debug information
|
||||
2. View logs in the wrangler development console
|
||||
3. For deployed workers, use `bun run tail:dev` to stream logs
|
||||
4. Check the `/api/whoami` endpoint to verify user authentication and session data
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/` - Rust source code
|
||||
- `api/` - API endpoints and routing
|
||||
- `axum_introspector/` - Axum framework integration for token introspection
|
||||
- `credentials/` - Credential management
|
||||
- `oidc/` - OpenID Connect implementation
|
||||
- `session_storage/` - Session storage implementations
|
||||
- `utilities.rs` - Utility functions
|
||||
- `lib.rs` - Main entry point and worker setup
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to this project are welcome! Here are some guidelines:
|
||||
|
||||
1. **Fork the repository** and create your branch from `main`
|
||||
2. **Install dependencies** and ensure you can build the project
|
||||
3. **Make your changes** and add or update tests as necessary
|
||||
4. **Ensure tests pass** by running `cargo test`
|
||||
5. **Format your code** with `cargo fmt`
|
||||
6. **Submit a pull request** with a clear description of your changes
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow Rust's standard code style and idioms
|
||||
- Use `cargo fmt` to format code
|
||||
- Use `cargo clippy` for linting
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
This project is made possible thanks to:
|
||||
|
||||
- **ZITADEL**: For providing the robust identity management platform that powers this authentication proxy
|
||||
- **Smartive**: For [zitadel-rs](https://github.com/smartive/zitadel-rust)
|
||||
- **Cloudflare**: For their Workers platform and KV storage solution
|
||||
- **Open Source Community**: For the various dependencies and tools that make this project possible:
|
||||
- The Rust ecosystem and its crates
|
||||
- The Axum web framework
|
||||
- The Tower middleware ecosystem
|
||||
- Various other open-source projects listed in our dependencies
|
||||
|
||||
I appreciate the hard work and dedication of all the developers and organizations that contribute to the open-source
|
||||
ecosystem.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Geoff Seemueller
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
207
bun.lock
Normal file
207
bun.lock
Normal file
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"wrangler": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250320.0" }, "optionalPeers": ["workerd"] }, "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250428.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6nVe9oV4Hdec6ctzMtW80TiDvNTd2oFPi3VsKqSDVaJSJbL+4b6seyJ7G/UEPI+si6JhHBSLV2/9lNXNGLjClA=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250428.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/TB7bh7SIJ5f+6r4PHsAz7+9Qal/TK1cJuKFkUno1kqGlZbdrMwH0ATYwlWC/nBFeu2FB3NUolsTntEuy23hnQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250428.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9eCbj+R3CKqpiXP6DfAA20DxKge+OTj7Hyw3ZewiEhWH9INIHiJwJQYybu4iq9kJEGjnGvxgguLFjSCWm26hgg=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250428.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-D9NRBnW46nl1EQsP13qfkYb5lbt4C6nxl38SBKY/NOcZAUoHzNB5K0GaK8LxvpkM7X/97ySojlMfR5jh5DNXYQ=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250428.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RQCRj28eitjKD0tmei6iFOuWqMuHMHdNGEigRmbkmuTlpbWHNAoHikgCzZQ/dkKDdatA76TmcpbyECNf31oaTA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
|
||||
|
||||
"as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20250428.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250428.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-M3qcJXjeAEimHrEeWXEhrJiC3YHB5M3QSqqK67pOTI+lHn0QyVG/2iFUjVJ/nv+i10uxeAEva8GRGeu+tKRCmQ=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="],
|
||||
|
||||
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
|
||||
|
||||
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
|
||||
|
||||
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="],
|
||||
|
||||
"workerd": ["workerd@1.20250428.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250428.0", "@cloudflare/workerd-darwin-arm64": "1.20250428.0", "@cloudflare/workerd-linux-64": "1.20250428.0", "@cloudflare/workerd-linux-arm64": "1.20250428.0", "@cloudflare/workerd-windows-64": "1.20250428.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-JJNWkHkwPQKQdvtM9UORijgYdcdJsihA4SfYjwh02IUQsdMyZ9jizV1sX9yWi9B9ptlohTW8UNHJEATuphGgdg=="],
|
||||
|
||||
"wrangler": ["wrangler@4.14.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.1", "blake3-wasm": "2.1.5", "esbuild": "0.25.2", "miniflare": "4.20250428.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250428.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250428.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EU7IThP7i68TBftJJSveogvWZ5k/WRijcJh3UclDWiWWhDZTPbL6LOJEFhHKqFzHOaC4Y2Aewt48rfTz0e7oCw=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
|
||||
|
||||
"zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
}
|
||||
}
|
48
compose.yml
Normal file
48
compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zitadel:
|
||||
restart: 'always'
|
||||
networks:
|
||||
- 'zitadel'
|
||||
image: 'ghcr.io/zitadel/zitadel:latest'
|
||||
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
|
||||
environment:
|
||||
# - 'ZITADEL_INITIAL_USER=zitadel-admin@zitadel.localhost'
|
||||
# - 'ZITADEL_INITIAL_PASSWORD=Password1!'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_HOST=db'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_PORT=5432'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=postgres'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=postgres'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable'
|
||||
- 'ZITADEL_EXTERNALSECURE=false'
|
||||
depends_on:
|
||||
db:
|
||||
condition: 'service_healthy'
|
||||
ports:
|
||||
- '8080:8080'
|
||||
|
||||
db:
|
||||
restart: 'always'
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=zitadel
|
||||
networks:
|
||||
- 'zitadel'
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"]
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
|
||||
networks:
|
||||
zitadel:
|
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "bun operate:env:local dev",
|
||||
"dev:example-service": "(cd ../_sample_workers/example-service && bunx wrangler dev)",
|
||||
"deploy:dev": "wrangler deploy",
|
||||
"deploy:dev:secrets": "wrangler versions secret bulk secrets.json && bun run deploy:dev",
|
||||
"tail:dev": "wrangler tail"
|
||||
},
|
||||
"dependencies": {
|
||||
"wrangler": "latest"
|
||||
}
|
||||
}
|
9
secrets.json
Normal file
9
secrets.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"CLIENT_ID": "",
|
||||
"CLIENT_SECRET": "",
|
||||
"AUTH_SERVER_URL": "",
|
||||
"ZITADEL_ORG_ID": "",
|
||||
"ZITADEL_PROJECT_ID": "",
|
||||
"APP_URL": "https://your-worker.workers.dev",
|
||||
"DEV_MODE": "false"
|
||||
}
|
20
src/api/authenticated.rs
Normal file
20
src/api/authenticated.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crate::axum_introspector::introspection::IntrospectedUser;
|
||||
use crate::AppState;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::response::IntoResponse;
|
||||
use tower::Layer;
|
||||
use tower_service::Service;
|
||||
use worker::*;
|
||||
|
||||
pub struct AuthenticatedApi;
|
||||
|
||||
impl AuthenticatedApi {
|
||||
#[worker::send]
|
||||
pub async fn proxy(session: tower_sessions::Session, State(state): State<AppState>, user: IntrospectedUser, mut request: Request) -> impl IntoResponse {
|
||||
let worker_request = worker::Request::try_from(request).unwrap();
|
||||
let http_request = http::Request::try_from(worker_request).unwrap();
|
||||
|
||||
let proxy_target = state.env.service("PROXY_TARGET").unwrap();
|
||||
<http::Response<worker::Body> as Into<HttpResponse>>::into(proxy_target.fetch_request(http_request).await.expect("failed to proxy request"))
|
||||
}
|
||||
}
|
2
src/api/mod.rs
Normal file
2
src/api/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod public;
|
||||
pub mod authenticated;
|
353
src/api/public.rs
Normal file
353
src/api/public.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use crate::utilities::Utilities;
|
||||
use crate::{AppState, Callback};
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::response::IntoResponse;
|
||||
use oauth2::basic::BasicClient;
|
||||
use oauth2::{
|
||||
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
|
||||
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tower::Layer;
|
||||
use tower_service::Service;
|
||||
use tower_sessions_core::Session;
|
||||
use worker::*;
|
||||
|
||||
pub struct PublicApi;
|
||||
|
||||
impl PublicApi {
|
||||
#[worker::send]
|
||||
pub async fn fallback() -> impl IntoResponse {
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
pub async fn login_page(session: Session, request: Request) -> impl IntoResponse {
|
||||
session
|
||||
.insert("last_visited", chrono::Local::now().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
session.save().await.unwrap();
|
||||
|
||||
axum::response::Html(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form[action="/login/authorize"]');
|
||||
if (form) {
|
||||
form.submit();
|
||||
} else {
|
||||
console.error("Login form not found.");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to login...</p>
|
||||
<form action="/login/authorize" method="GET" style="display:none;">
|
||||
<button type="submit">Login with ZITADEL</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
pub async fn authorize(
|
||||
session: tower_sessions::Session,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let oauth_base_url = state.env.secret("AUTH_SERVER_URL").unwrap().to_string();
|
||||
let app_host = state.env.secret("APP_URL").unwrap().to_string();
|
||||
|
||||
let redirect_uri = format!("{}{}", app_host, "/login/callback");
|
||||
|
||||
let client = BasicClient::new(ClientId::new(
|
||||
state.env.secret("CLIENT_ID").unwrap().to_string(),
|
||||
))
|
||||
.set_client_secret(ClientSecret::new(
|
||||
state.env.secret("CLIENT_SECRET").unwrap().to_string(),
|
||||
))
|
||||
.set_auth_uri(AuthUrl::new(format!("{}{}", oauth_base_url, "/oauth/v2/authorize")).unwrap())
|
||||
.set_token_uri(TokenUrl::new(format!("{}{}", oauth_base_url, "/oauth/v2/token")).unwrap())
|
||||
.set_redirect_uri(RedirectUrl::new(redirect_uri).unwrap());
|
||||
|
||||
// Generate a PKCE challenge.
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let org_scope: String = if let Ok(org_id) = state.env.secret("ZITADEL_ORG_ID") {
|
||||
format!("urn:zitadel:iam:org:id:{}", org_id.to_string())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let project_scope: String = if let Ok(project_id) = state.env.secret("ZITADEL_PROJECT_ID") {
|
||||
format!(
|
||||
"urn:zitadel:iam:org:project:id:{}:aud",
|
||||
project_id.to_string()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut scopes = vec![
|
||||
Scope::new("openid".to_string()),
|
||||
Scope::new("email".to_string()),
|
||||
// Scope::new("profile".to_string()),
|
||||
// Scope::new("offline_access".to_string())
|
||||
];
|
||||
|
||||
if (!org_scope.is_empty()) {
|
||||
scopes.push(Scope::new(org_scope));
|
||||
}
|
||||
if (!project_scope.is_empty()) {
|
||||
scopes.push(Scope::new(project_scope));
|
||||
}
|
||||
|
||||
let (auth_url, csrf_token) = client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.add_scopes(scopes)
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
let csrf_string = csrf_token.secret().to_string();
|
||||
let verifier_storage_key = Utilities::get_pkce_verifier_storage_key(&csrf_string); // Use a key tied to the state param
|
||||
|
||||
if let Some(csrf_state) = session.get::<String>("csrf_state").await.unwrap() {
|
||||
if csrf_state != csrf_string {
|
||||
console_error!("CSRF state mismatch.");
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
session
|
||||
.insert(
|
||||
verifier_storage_key.as_str(),
|
||||
pkce_verifier.secret().as_str(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.insert("csrf_state", csrf_string.as_str())
|
||||
.await
|
||||
.unwrap();
|
||||
session.save().await.unwrap();
|
||||
}
|
||||
let csrf_store = state.env.kv("KV_STORAGE").unwrap();
|
||||
|
||||
let session_csrf_key = Utilities::get_auth_session_key(csrf_string.as_str());
|
||||
|
||||
csrf_store
|
||||
.put(session_csrf_key.as_str(), session.id().unwrap().to_string())
|
||||
.unwrap()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let final_auth_url = auth_url.as_str();
|
||||
|
||||
let redirect_response = http::Response::builder()
|
||||
.status(http::StatusCode::FOUND)
|
||||
.header(http::header::LOCATION, final_auth_url)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
redirect_response.into_response()
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
mut session: tower_sessions::Session,
|
||||
callback: Query<Callback>,
|
||||
request: Request,
|
||||
) -> impl IntoResponse {
|
||||
let code = &callback.code;
|
||||
let state_param = &callback.state;
|
||||
|
||||
if code.is_empty() {
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::from("Invalid authorization code"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let verifier_storage_key = Utilities::get_pkce_verifier_storage_key(state_param);
|
||||
|
||||
let csrf_store = state.env.kv("KV_STORAGE").unwrap();
|
||||
|
||||
let csrf_key = Utilities::get_auth_session_key(state_param);
|
||||
|
||||
let get_auth_session_id = csrf_store
|
||||
.get(csrf_key.as_str())
|
||||
.text()
|
||||
.await
|
||||
.expect("failed to get auth session id");
|
||||
|
||||
csrf_store.delete(csrf_key.as_str()).await.unwrap();
|
||||
|
||||
let asi = get_auth_session_id.map(|data| data).unwrap();
|
||||
|
||||
let auth_session_id = tower_sessions_core::session::Id::from_str(asi.as_str()).unwrap();
|
||||
|
||||
let mut auth_session =
|
||||
Session::new(Some(auth_session_id), Arc::new(state.session_store), None);
|
||||
|
||||
let verifier_string: String = match auth_session.get(verifier_storage_key.as_str()).await {
|
||||
Ok(Some(v)) => v,
|
||||
Ok(None) => {
|
||||
console_error!(
|
||||
"PKCE verifier not found in session for key: {:?}",
|
||||
verifier_storage_key
|
||||
);
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::from("Session state mismatch or expired."))
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
console_error!("Error retrieving PKCE verifier from session: {:?}", e);
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::from(
|
||||
"Internal server error retrieving session data.",
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let stored_csrf_state: String = match auth_session.get("csrf_state").await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
console_error!("CSRF state not found in session.");
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::from("CSRF state mismatch or missing."))
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
console_error!("Error retrieving CSRF state from session: {:?}", e);
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::from(
|
||||
"Internal server error retrieving session data.",
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Basic CSRF state verification
|
||||
if &stored_csrf_state != state_param {
|
||||
console_error!(
|
||||
"CSRF state mismatch. Expected: {:?}, Received: {:?}",
|
||||
stored_csrf_state,
|
||||
state_param
|
||||
);
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
} else {
|
||||
auth_session.remove::<String>("csrf_state").await.unwrap();
|
||||
}
|
||||
|
||||
let pkce_verifier = PkceCodeVerifier::new(verifier_string);
|
||||
// console_log!("callback::pkce_verifier: {:?}", pkce_verifier.secret().to_string());
|
||||
auth_session
|
||||
.remove::<String>(verifier_storage_key.as_str())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let oauth_base_url = state.env.secret("AUTH_SERVER_URL").unwrap().to_string();
|
||||
let app_host = state.env.secret("HOST").unwrap().to_string();
|
||||
let redirect_uri = format!("{}{}", app_host, "/login/callback");
|
||||
|
||||
let redirect_url = RedirectUrl::new(redirect_uri).unwrap();
|
||||
|
||||
let client = BasicClient::new(ClientId::new(
|
||||
state.env.secret("CLIENT_ID").unwrap().to_string(),
|
||||
))
|
||||
.set_client_secret(ClientSecret::new(
|
||||
state
|
||||
.env
|
||||
.secret("CLIENT_SECRET")
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
))
|
||||
.set_auth_uri(AuthUrl::new(format!("{}{}", oauth_base_url, "/oauth/v2/authorize")).unwrap())
|
||||
.set_token_uri(TokenUrl::new(format!("{}{}", oauth_base_url, "/oauth/v2/token")).unwrap())
|
||||
.set_redirect_uri(redirect_url);
|
||||
|
||||
let http_client = oauth2::reqwest::ClientBuilder::new()
|
||||
.build()
|
||||
.expect("Client should build");
|
||||
|
||||
match client
|
||||
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(&http_client)
|
||||
.await
|
||||
{
|
||||
Ok(token_result) => {
|
||||
session
|
||||
.insert("token", token_result.access_token().secret().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
session.save().await.unwrap();
|
||||
|
||||
let url = request.uri();
|
||||
let mut redirect_location = Url::parse(url.to_string().as_str()).unwrap();
|
||||
redirect_location.set_path("/");
|
||||
redirect_location.set_query(None);
|
||||
|
||||
console_log!("redirecting to : {:?}", redirect_location);
|
||||
|
||||
let session_response = Session::from(session).save().await.unwrap().into_response();
|
||||
let session_headers = session_response.headers();
|
||||
|
||||
let mut redirect_response = axum::response::Response::builder()
|
||||
.status(http::StatusCode::FOUND)
|
||||
.header(http::header::LOCATION, redirect_location.as_str())
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
|
||||
for (key, value) in session_headers.iter() {
|
||||
redirect_response.headers_mut().insert(key, value.clone());
|
||||
}
|
||||
redirect_response.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
console_log!("Token request failed: {:?}", e);
|
||||
let error_message = match e {
|
||||
oauth2::RequestTokenError::ServerResponse(server_error) => {
|
||||
format!("Server error: {:?}", server_error)
|
||||
}
|
||||
_ => format!("Unknown error: {:?}", e),
|
||||
};
|
||||
return axum::response::Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::from(format!(
|
||||
"OAuth2 Token Error: {}",
|
||||
error_message
|
||||
)))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
src/axum_introspector/introspection/mod.rs
Normal file
7
src/axum_introspector/introspection/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod state;
|
||||
mod state_builder;
|
||||
mod user;
|
||||
|
||||
pub use state::IntrospectionState;
|
||||
pub use state_builder::{IntrospectionStateBuilder, IntrospectionStateBuilderError};
|
||||
pub use user::{IntrospectedUser, IntrospectionGuardError};
|
18
src/axum_introspector/introspection/state.rs
Normal file
18
src/axum_introspector/introspection/state.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use openidconnect::IntrospectionUrl;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::oidc::introspection::cache::IntrospectionCache;
|
||||
use crate::oidc::introspection::AuthorityAuthentication;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IntrospectionState {
|
||||
pub(crate) config: Arc<IntrospectionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct IntrospectionConfig {
|
||||
pub(crate) authority: String,
|
||||
pub(crate) authentication: AuthorityAuthentication,
|
||||
pub(crate) introspection_uri: IntrospectionUrl,
|
||||
pub(crate) cache: Option<Box<dyn IntrospectionCache>>,
|
||||
}
|
91
src/axum_introspector/introspection/state_builder.rs
Normal file
91
src/axum_introspector/introspection/state_builder.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use custom_error::custom_error;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::axum_introspector::introspection::state::IntrospectionConfig;
|
||||
use crate::credentials::Application;
|
||||
use crate::oidc::discovery::{discover, DiscoveryError};
|
||||
use crate::oidc::introspection::AuthorityAuthentication;
|
||||
|
||||
use crate::oidc::introspection::cache::IntrospectionCache;
|
||||
|
||||
use super::state::IntrospectionState;
|
||||
|
||||
custom_error! {
|
||||
pub IntrospectionStateBuilderError
|
||||
NoAuthSchema = "no authentication for authority defined",
|
||||
Discovery{source: DiscoveryError} = "could not fetch discovery document: {source}",
|
||||
NoIntrospectionUrl = "discovery document did not contain an introspection url",
|
||||
}
|
||||
|
||||
pub struct IntrospectionStateBuilder {
|
||||
authority: String,
|
||||
authentication: Option<AuthorityAuthentication>,
|
||||
cache: Option<Box<dyn IntrospectionCache>>,
|
||||
}
|
||||
|
||||
impl IntrospectionStateBuilder {
|
||||
pub fn new(authority: &str) -> Self {
|
||||
Self {
|
||||
authority: authority.to_string(),
|
||||
authentication: None,
|
||||
cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_basic_auth(
|
||||
&mut self,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> &mut IntrospectionStateBuilder {
|
||||
self.authentication = Some(AuthorityAuthentication::Basic {
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
});
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_jwt_profile(&mut self, application: Application) -> &mut IntrospectionStateBuilder {
|
||||
self.authentication = Some(AuthorityAuthentication::JWTProfile { application });
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_introspection_cache(
|
||||
&mut self,
|
||||
cache: impl IntrospectionCache + 'static,
|
||||
) -> &mut IntrospectionStateBuilder {
|
||||
self.cache = Some(Box::new(cache));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build(&mut self) -> Result<IntrospectionState, IntrospectionStateBuilderError> {
|
||||
if self.authentication.is_none() {
|
||||
return Err(IntrospectionStateBuilderError::NoAuthSchema);
|
||||
}
|
||||
|
||||
let metadata = discover(&self.authority)
|
||||
.await
|
||||
.map_err(|source| IntrospectionStateBuilderError::Discovery { source })?;
|
||||
|
||||
let introspection_uri = metadata
|
||||
.additional_metadata()
|
||||
.introspection_endpoint
|
||||
.clone();
|
||||
|
||||
if introspection_uri.is_none() {
|
||||
return Err(IntrospectionStateBuilderError::NoIntrospectionUrl);
|
||||
}
|
||||
|
||||
Ok(IntrospectionState {
|
||||
config: Arc::new(IntrospectionConfig {
|
||||
authority: self.authority.clone(),
|
||||
introspection_uri: introspection_uri.unwrap(),
|
||||
authentication: self.authentication.as_ref().unwrap().clone(),
|
||||
// #[cfg(feature = "introspection_cache")]
|
||||
cache: self.cache.take(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
497
src/axum_introspector/introspection/user.rs
Normal file
497
src/axum_introspector/introspection/user.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, RequestPartsExt};
|
||||
use custom_error::custom_error;
|
||||
use openidconnect::TokenIntrospectionResponse;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use worker::console_log;
|
||||
|
||||
use crate::axum_introspector::introspection::IntrospectionState;
|
||||
use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse};
|
||||
|
||||
custom_error! {
|
||||
pub IntrospectionGuardError
|
||||
MissingConfig = "no introspection cdktf given to rocket managed state",
|
||||
Unauthorized = "no HTTP authorization header found",
|
||||
InvalidHeader = "authorization header is invalid",
|
||||
WrongScheme = "Authorization header is not a bearer token",
|
||||
Introspection{source: IntrospectionError} = "introspection returned an error: {source}",
|
||||
Inactive = "access token is inactive",
|
||||
NoUserId = "introspection result contained no user id",
|
||||
}
|
||||
|
||||
impl IntoResponse for IntrospectionGuardError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
let (status, error_message) = match self {
|
||||
IntrospectionGuardError::MissingConfig => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "missing config")
|
||||
}
|
||||
IntrospectionGuardError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
IntrospectionGuardError::InvalidHeader => (StatusCode::BAD_REQUEST, "invalid header"),
|
||||
IntrospectionGuardError::WrongScheme => (StatusCode::BAD_REQUEST, "invalid schema"),
|
||||
IntrospectionGuardError::Introspection { source: _ } => {
|
||||
(StatusCode::BAD_REQUEST, "introspection error")
|
||||
}
|
||||
IntrospectionGuardError::Inactive => (StatusCode::FORBIDDEN, "user is inactive"),
|
||||
IntrospectionGuardError::NoUserId => (StatusCode::NOT_FOUND, "user was not found"),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": error_message,
|
||||
}));
|
||||
|
||||
(
|
||||
status,
|
||||
[("x-introspection-error", error_message)],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize,Debug)]
|
||||
pub struct IntrospectedUser {
|
||||
pub user_id: String,
|
||||
pub username: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub preferred_username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: Option<bool>,
|
||||
pub locale: Option<String>,
|
||||
pub project_roles: Option<HashMap<String, HashMap<String, String>>>,
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
//
|
||||
// On wasm32, define a newtype that wraps a future and unsafely marks it as Send.
|
||||
// This is safe on single-threaded targets.
|
||||
//
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
struct NonSendFuture<F>(F);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::task::Poll;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::task::Context;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<F: Future> Future for NonSendFuture<F> {
|
||||
type Output = F::Output;
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
// SAFETY: Delegate polling to the inner future.
|
||||
unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
unsafe impl<F> Send for NonSendFuture<F> {}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn wrap_future<F>(f: F) -> Pin<Box<dyn Future<Output = F::Output> + Send>>
|
||||
where
|
||||
F: Future + 'static,
|
||||
{
|
||||
Box::pin(NonSendFuture(f))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn wrap_future<F>(f: F) -> Pin<Box<dyn Future<Output = F::Output> + Send>>
|
||||
where
|
||||
F: Future + Send + 'static,
|
||||
{
|
||||
Box::pin(f)
|
||||
}
|
||||
//
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for IntrospectedUser
|
||||
where
|
||||
S: 'static + Sync,
|
||||
IntrospectionState: FromRef<S>,
|
||||
tower_sessions_core::Session: FromRequestParts<S>,
|
||||
{
|
||||
type Rejection = IntrospectionGuardError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let mut parts_clone = parts.clone();
|
||||
|
||||
let session = tower_sessions_core::Session::from_request_parts(&mut parts_clone, state)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let unwrapped_session = session.unwrap();
|
||||
|
||||
let _ = unwrapped_session.load().await.unwrap();
|
||||
|
||||
let token = if let Some(tok) = unwrapped_session.get::<String>("token").await.ok() {
|
||||
if !tok.is_none() {
|
||||
Some(tok.unwrap())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
let token_from_header = Self::token_from_header(parts)?;
|
||||
|
||||
Some(token_from_header)
|
||||
};
|
||||
|
||||
let introspection_state = IntrospectionState::from_ref(state);
|
||||
let config = Arc::clone(&introspection_state.config);
|
||||
|
||||
let fut = async move {
|
||||
let result = match introspection_state.config.cache.as_deref() {
|
||||
None => {
|
||||
introspect(
|
||||
&config.introspection_uri,
|
||||
&config.authority,
|
||||
&config.authentication,
|
||||
&token.unwrap(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(cache) => match cache.get(token.clone().unwrap_or(String::new()).as_str()).await {
|
||||
Some(cached_response) => Ok(cached_response),
|
||||
None => {
|
||||
let res = introspect(
|
||||
&config.introspection_uri,
|
||||
&config.authority,
|
||||
&config.authentication,
|
||||
token.clone().unwrap_or(String::new()).as_str(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(res) = &res {
|
||||
cache
|
||||
.set(token.clone().unwrap().as_str(), res.clone())
|
||||
.await;
|
||||
}
|
||||
res
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let user: Result<IntrospectedUser, IntrospectionGuardError> = match result {
|
||||
Ok(res) => match res.active() {
|
||||
true if res.sub().is_some() => Ok(res.into()),
|
||||
false => Err(IntrospectionGuardError::Inactive),
|
||||
_ => Err(IntrospectionGuardError::NoUserId),
|
||||
},
|
||||
Err(source) => return Err(IntrospectionGuardError::Introspection { source }),
|
||||
};
|
||||
user
|
||||
};
|
||||
|
||||
wrap_future(fut).await
|
||||
}
|
||||
}
|
||||
|
||||
impl IntrospectedUser {
|
||||
fn token_from_header(parts: &mut Parts) -> Result<String, IntrospectionGuardError> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.ok_or(IntrospectionGuardError::InvalidHeader)
|
||||
.unwrap();
|
||||
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| IntrospectionGuardError::InvalidHeader)
|
||||
.unwrap();
|
||||
|
||||
if !auth_str.starts_with("Bearer ") {
|
||||
return Err(IntrospectionGuardError::WrongScheme);
|
||||
}
|
||||
|
||||
let token = auth_str.trim_start_matches("Bearer ").trim().to_string();
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
|
||||
fn from(response: ZitadelIntrospectionResponse) -> Self {
|
||||
Self {
|
||||
user_id: response.sub().unwrap().to_string(),
|
||||
username: response.username().map(|s| s.to_string()),
|
||||
name: response.extra_fields().name.clone(),
|
||||
given_name: response.extra_fields().given_name.clone(),
|
||||
family_name: response.extra_fields().family_name.clone(),
|
||||
preferred_username: response.extra_fields().preferred_username.clone(),
|
||||
email: response.extra_fields().email.clone(),
|
||||
email_verified: response.extra_fields().email_verified,
|
||||
locale: response.extra_fields().locale.clone(),
|
||||
project_roles: response.extra_fields().project_roles.clone(),
|
||||
metadata: response.extra_fields().metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::axum_introspector::introspection::{IntrospectionState, IntrospectionStateBuilder};
|
||||
use crate::credentials::Application;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
|
||||
const PERSONAL_ACCESS_TOKEN: &str =
|
||||
"dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA";
|
||||
const APPLICATION: &str = r#"
|
||||
{
|
||||
"type": "application",
|
||||
"keyId": "181963758610940161",
|
||||
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwT2YZJytkkZ1DDM3dcu1OA8YPzHu6XR8HotdMNRnV75GhOT4\nB7zDtdtoP8w/1NHHPEJ859e0kYhrrnKikOKLS6fS1KRsmqR5ZvTq8SlZ2mq3RcX2\nebZx5dQt36INij/WXdsBmjM/yfWvqqWBSb0L/186DaWwmmIxoXWe873vxRmlzblg\nGd8Nu07s9YTREbGPbtFVHEUM6xI4oIe8HJ0e1+JBkiGqk31Cogo0FoAxrOAg0Sf4\n5XiUMYIjzqh8673F9SC4IpVxG22mpFk3vDFuAITaStWYbiH2hPJNKWyX9HDCZb1D\nDqa3wZBDiLqWxh22hNZ6ZIe+3UoSGWsPBH+E1wIDAQABAoIBAD2v5QsRPRN57HmF\njAnNir8nimz6CrN53Pl/MbOZypenBSn9UfReXPeb3+6lzCarBPgGnYsBQAJJU16v\n95daym7PVy1Mg+Ll6F9mhe2Qbr+b23+pj2IRTNC6aB6Aw+PDNzJk7GEGRTG6fWZz\nSQ96Cu9tvcGHiBXwjLlnK+PRWU5IsCiLsjT4xBXsMLMw3YOdMK5z58sqr+SnNEyq\nRHoEvi9aC94WrargVB45Yx+81YNW8uQ5rMDmYaJC5a7ENz522SlAuf4T+fAGJ/HE\n/qbZGD4YwlLqAFDgewQ+5tEWEus3zgY2MIR7vN2zXU1Ptk+mQkXZl/Pxdp7q1xU+\nvr/kcykCgYEAy7MiIAzc1ctQDvkk3HiespzdQ/sC7+CGsBzkyubRc9Oq/YR7GfVK\nGTuDEDlWwx92VAvJGDWRa3T426YDyqiPj66uo836sgL15Uigg5afZun2bqGC78le\nBhSy9b+0YDHPa87GxtKt9UmMoB6WdmoPzOkLEEGS7eesmk2DDgY+QSUCgYEA8tr/\n3PawigL1cxuFpcO1lH6XUspGeAo5yB8FXvfW5g50e37LgooIvOFgUlYuchxwr6uh\nW+CUAWmm4farsgvMBMPYw+PbkCTi/xemiiDmMHUYd7sJkTl0JXApq3pZsNMg4Fw/\n29RynmcG8TGe2dkwrWp1aBYjvIHwEHuNHHTTA0sCgYBtSUFAwsXkaj0cm2y8YHZ8\nS46mv1AXFHYOnKHffjDXnLN7ao2FIsXLfdNWa/zxmLqqYtxUAcFwToSJi6szGnZT\nVxvZRFSBFveIOQvtLW1+EH4nYr3WGko4pvhQwrZqea7YH0skNrogBILPEToWc9bg\nUBOgeB31R7uh2X47kvvphQKBgQDWc60dYnniZVp5mwQZrQjbaC4YXaZ8ugrsPPhx\nNEoAPSN/KihrzZiJsjtsec3p1lNrzRNgHqCT3sgPIdPcFa7DRm5UDRIF54zL1gaq\nUwLyJ3TDxdZc928o4DLryc8J5mZRuSRq6t+MIU5wDnFHzhK+EBQ9Jc/I1rU22ONz\nDXaIoQKBgH14Apggo0o4Eo+OnEBRFbbDulaOfVLPTK9rktikbwO1vzDch8kdcwCU\nsvtRXHjDQL93Ih/8S9aDJZoSDulwr3VUsuDiDEb4jfYmP2sbNO4nIJt+SBMhVOXV\nt7E/uWK28X0GL/bIUzSMMgTfdjhXEtJW+s6hQU1fG+9U1qVTQ2R/\n-----END RSA PRIVATE KEY-----\n",
|
||||
"appId": "181963751145079041",
|
||||
"clientId": "181963751145144577@zitadel_rust_test"
|
||||
}"#;
|
||||
|
||||
async fn authed(user: IntrospectedUser) -> impl IntoResponse {
|
||||
format!(
|
||||
"Hello authorized user: {:?} with id {}",
|
||||
user.username, user.user_id
|
||||
)
|
||||
}
|
||||
|
||||
async fn unauthed() -> impl IntoResponse {
|
||||
"Hello unauthorized"
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SomeUserState {
|
||||
introspection_state: IntrospectionState,
|
||||
}
|
||||
|
||||
impl FromRef<SomeUserState> for IntrospectionState {
|
||||
fn from_ref(input: &SomeUserState) -> Self {
|
||||
input.introspection_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn app() -> Router {
|
||||
let introspection_state = IntrospectionStateBuilder::new(ZITADEL_URL)
|
||||
.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = SomeUserState {
|
||||
introspection_state,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/unauthed", get(unauthed))
|
||||
.route("/authed", get(authed))
|
||||
.with_state(state);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_guard() {
|
||||
let app = app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_protects_if_non_bearer_present() {
|
||||
let app = app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", "Basic Something")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_protects_if_multiple_auth_headers_present() {
|
||||
let app = app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", "something one")
|
||||
.header("authorization", "something two")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_protects_if_invalid_token() {
|
||||
let app = app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", "Bearer something")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_allows_valid_token() {
|
||||
let app = app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", format!("Bearer {PERSONAL_ACCESS_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// #[cfg(feature = "introspection_cache")]
|
||||
mod introspection_cache {
|
||||
use super::*;
|
||||
use crate::oidc::introspection::cache::in_memory::InMemoryIntrospectionCache;
|
||||
use crate::oidc::introspection::cache::IntrospectionCache;
|
||||
use crate::oidc::introspection::ZitadelIntrospectionExtraTokenFields;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use http_body_util::BodyExt;
|
||||
use std::ops::Add;
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn app_witch_cache(cache: impl IntrospectionCache + 'static) -> Router {
|
||||
let introspection_state = IntrospectionStateBuilder::new(ZITADEL_URL)
|
||||
.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
|
||||
.with_introspection_cache(cache)
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = SomeUserState {
|
||||
introspection_state,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/unauthed", get(unauthed))
|
||||
.route("/authed", get(authed))
|
||||
.with_state(state);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_uses_cached_response() {
|
||||
let cache = Arc::new(InMemoryIntrospectionCache::default());
|
||||
let app = app_witch_cache(cache.clone()).await;
|
||||
|
||||
let mut res = ZitadelIntrospectionResponse::new(
|
||||
true,
|
||||
ZitadelIntrospectionExtraTokenFields::default(),
|
||||
);
|
||||
res.set_sub(Some("cached_sub".to_string()));
|
||||
res.set_exp(Some(Utc::now().add(TimeDelta::days(1))));
|
||||
cache.set(PERSONAL_ACCESS_TOKEN, res).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", format!("Bearer {PERSONAL_ACCESS_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(text.contains("cached_sub"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_caches_response() {
|
||||
let cache = Arc::new(InMemoryIntrospectionCache::default());
|
||||
let app = app_witch_cache(cache.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/authed")
|
||||
.header("authorization", format!("Bearer {PERSONAL_ACCESS_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cached_response = cache.get(PERSONAL_ACCESS_TOKEN).await.unwrap();
|
||||
|
||||
assert!(text.contains(cached_response.sub().unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
1
src/axum_introspector/mod.rs
Normal file
1
src/axum_introspector/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod introspection;
|
116
src/credentials/application.rs
Normal file
116
src/credentials/application.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use custom_error::custom_error;
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::read_to_string;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::credentials::jwt::JwtClaims;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Application {
|
||||
client_id: String,
|
||||
app_id: String,
|
||||
key_id: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
custom_error! {
|
||||
pub ApplicationError
|
||||
Io{source: std::io::Error} = "unable to read from file: {source}",
|
||||
Json{source: serde_json::Error} = "could not parse json: {source}",
|
||||
Key{source: jsonwebtoken::errors::Error} = "could not parse RSA key: {source}",
|
||||
}
|
||||
|
||||
impl Application {
|
||||
pub fn load_from_file<P: AsRef<Path>>(file_path: P) -> Result<Self, ApplicationError> {
|
||||
let data = read_to_string(file_path).map_err(|e| ApplicationError::Io { source: e })?;
|
||||
Application::load_from_json(data.as_str())
|
||||
}
|
||||
|
||||
pub fn load_from_json(json: &str) -> Result<Self, ApplicationError> {
|
||||
let sa: Application =
|
||||
serde_json::from_str(json).map_err(|e| ApplicationError::Json { source: e })?;
|
||||
Ok(sa)
|
||||
}
|
||||
|
||||
pub fn create_signed_jwt(&self, audience: &str) -> Result<String, ApplicationError> {
|
||||
let key = EncodingKey::from_rsa_pem(self.key.as_bytes())
|
||||
.map_err(|e| ApplicationError::Key { source: e })?;
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some(self.key_id.to_string());
|
||||
let claims = JwtClaims::new(&self.client_id, audience);
|
||||
let jwt = encode(&header, &claims, &key)?;
|
||||
|
||||
Ok(jwt)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
const APPLICATION: &str = r#"
|
||||
{
|
||||
"type": "application",
|
||||
"keyId": "181963758610940161",
|
||||
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwT2YZJytkkZ1DDM3dcu1OA8YPzHu6XR8HotdMNRnV75GhOT4\nB7zDtdtoP8w/1NHHPEJ859e0kYhrrnKikOKLS6fS1KRsmqR5ZvTq8SlZ2mq3RcX2\nebZx5dQt36INij/WXdsBmjM/yfWvqqWBSb0L/186DaWwmmIxoXWe873vxRmlzblg\nGd8Nu07s9YTREbGPbtFVHEUM6xI4oIe8HJ0e1+JBkiGqk31Cogo0FoAxrOAg0Sf4\n5XiUMYIjzqh8673F9SC4IpVxG22mpFk3vDFuAITaStWYbiH2hPJNKWyX9HDCZb1D\nDqa3wZBDiLqWxh22hNZ6ZIe+3UoSGWsPBH+E1wIDAQABAoIBAD2v5QsRPRN57HmF\njAnNir8nimz6CrN53Pl/MbOZypenBSn9UfReXPeb3+6lzCarBPgGnYsBQAJJU16v\n95daym7PVy1Mg+Ll6F9mhe2Qbr+b23+pj2IRTNC6aB6Aw+PDNzJk7GEGRTG6fWZz\nSQ96Cu9tvcGHiBXwjLlnK+PRWU5IsCiLsjT4xBXsMLMw3YOdMK5z58sqr+SnNEyq\nRHoEvi9aC94WrargVB45Yx+81YNW8uQ5rMDmYaJC5a7ENz522SlAuf4T+fAGJ/HE\n/qbZGD4YwlLqAFDgewQ+5tEWEus3zgY2MIR7vN2zXU1Ptk+mQkXZl/Pxdp7q1xU+\nvr/kcykCgYEAy7MiIAzc1ctQDvkk3HiespzdQ/sC7+CGsBzkyubRc9Oq/YR7GfVK\nGTuDEDlWwx92VAvJGDWRa3T426YDyqiPj66uo836sgL15Uigg5afZun2bqGC78le\nBhSy9b+0YDHPa87GxtKt9UmMoB6WdmoPzOkLEEGS7eesmk2DDgY+QSUCgYEA8tr/\n3PawigL1cxuFpcO1lH6XUspGeAo5yB8FXvfW5g50e37LgooIvOFgUlYuchxwr6uh\nW+CUAWmm4farsgvMBMPYw+PbkCTi/xemiiDmMHUYd7sJkTl0JXApq3pZsNMg4Fw/\n29RynmcG8TGe2dkwrWp1aBYjvIHwEHuNHHTTA0sCgYBtSUFAwsXkaj0cm2y8YHZ8\nS46mv1AXFHYOnKHffjDXnLN7ao2FIsXLfdNWa/zxmLqqYtxUAcFwToSJi6szGnZT\nVxvZRFSBFveIOQvtLW1+EH4nYr3WGko4pvhQwrZqea7YH0skNrogBILPEToWc9bg\nUBOgeB31R7uh2X47kvvphQKBgQDWc60dYnniZVp5mwQZrQjbaC4YXaZ8ugrsPPhx\nNEoAPSN/KihrzZiJsjtsec3p1lNrzRNgHqCT3sgPIdPcFa7DRm5UDRIF54zL1gaq\nUwLyJ3TDxdZc928o4DLryc8J5mZRuSRq6t+MIU5wDnFHzhK+EBQ9Jc/I1rU22ONz\nDXaIoQKBgH14Apggo0o4Eo+OnEBRFbbDulaOfVLPTK9rktikbwO1vzDch8kdcwCU\nsvtRXHjDQL93Ih/8S9aDJZoSDulwr3VUsuDiDEb4jfYmP2sbNO4nIJt+SBMhVOXV\nt7E/uWK28X0GL/bIUzSMMgTfdjhXEtJW+s6hQU1fG+9U1qVTQ2R/\n-----END RSA PRIVATE KEY-----\n",
|
||||
"appId": "181963751145079041",
|
||||
"clientId": "181963751145144577@zitadel_rust_test"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn load_successfully_from_json() {
|
||||
let sa = Application::load_from_json(APPLICATION).unwrap();
|
||||
|
||||
assert_eq!(sa.client_id, "181963751145144577@zitadel_rust_test");
|
||||
assert_eq!(sa.key_id, "181963758610940161");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_successfully_from_file() {
|
||||
let mut file = File::create("./temp_app").unwrap();
|
||||
file.write_all(APPLICATION.as_bytes())
|
||||
.expect("Could not write temp.");
|
||||
|
||||
let sa = Application::load_from_file("./temp_app").unwrap();
|
||||
|
||||
assert_eq!(sa.client_id, "181963751145144577@zitadel_rust_test");
|
||||
assert_eq!(sa.key_id, "181963758610940161");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_faulty_from_json() {
|
||||
let err = Application::load_from_json("{1234}").unwrap_err();
|
||||
|
||||
if let ApplicationError::Json { source: _ } = err {
|
||||
assert!(true);
|
||||
} else {
|
||||
assert!(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_faulty_from_file() {
|
||||
let err = Application::load_from_file("./foobar").unwrap_err();
|
||||
|
||||
if let ApplicationError::Io { source: _ } = err {
|
||||
assert!(true);
|
||||
} else {
|
||||
assert!(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_a_signed_jwt() {
|
||||
let sa = Application::load_from_json(APPLICATION).unwrap();
|
||||
let claims = sa.create_signed_jwt("https://zitadel.cloud").unwrap();
|
||||
|
||||
assert_eq!(&claims[0..5], "eyJ0e");
|
||||
}
|
||||
}
|
24
src/credentials/jwt.rs
Normal file
24
src/credentials/jwt.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct JwtClaims {
|
||||
iss: String,
|
||||
sub: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
aud: String,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub(super) fn new(sub_and_iss: &str, audience: &str) -> Self {
|
||||
let iat = time::OffsetDateTime::now_utc();
|
||||
let exp = iat + time::Duration::hours(1);
|
||||
Self {
|
||||
iss: sub_and_iss.to_string(),
|
||||
sub: sub_and_iss.to_string(),
|
||||
iat: iat.unix_timestamp(),
|
||||
exp: exp.unix_timestamp(),
|
||||
aud: audience.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
6
src/credentials/mod.rs
Normal file
6
src/credentials/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod application;
|
||||
mod jwt;
|
||||
mod service_account;
|
||||
|
||||
pub use application::*;
|
||||
pub use service_account::*;
|
232
src/credentials/service_account.rs
Normal file
232
src/credentials/service_account.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use custom_error::custom_error;
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use openidconnect::{
|
||||
core::{CoreProviderMetadata, CoreTokenType},
|
||||
http::HeaderMap,
|
||||
reqwest::async_http_client,
|
||||
EmptyExtraTokenFields, HttpRequest, IssuerUrl, OAuth2TokenResponse, StandardTokenResponse,
|
||||
};
|
||||
use reqwest::{
|
||||
header::{ACCEPT, CONTENT_TYPE},
|
||||
Method, Url,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use crate::credentials::jwt::JwtClaims;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceAccount {
|
||||
user_id: String,
|
||||
key_id: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AuthenticationOptions {
|
||||
pub api_access: bool,
|
||||
|
||||
pub scopes: Vec<String>,
|
||||
|
||||
pub roles: Vec<String>,
|
||||
|
||||
pub project_audiences: Vec<String>,
|
||||
}
|
||||
|
||||
custom_error! {
|
||||
pub ServiceAccountError
|
||||
Io{source: std::io::Error} = "unable to read from file: {source}",
|
||||
Json{source: serde_json::Error} = "could not parse json: {source}",
|
||||
Key{source: jsonwebtoken::errors::Error} = "could not parse RSA key: {source}",
|
||||
AudienceUrl{source: openidconnect::url::ParseError} = "audience url could not be parsed: {source}",
|
||||
DiscoveryError{source: Box<dyn std::error::Error>} = "could not discover OIDC document: {source}",
|
||||
TokenEndpointMissing = "OIDC document does not contain token endpoint",
|
||||
HttpError{source: openidconnect::reqwest::Error<reqwest::Error>} = "http error: {source}",
|
||||
UrlEncodeError = "could not encode url params for token request",
|
||||
TokenError = "could not fetch token from endpoint",
|
||||
AccessTokenMissing = "token response does not contain access token",
|
||||
}
|
||||
|
||||
impl ServiceAccount {
|
||||
pub fn load_from_file(file_path: &str) -> Result<Self, ServiceAccountError> {
|
||||
let data = read_to_string(file_path).map_err(|e| ServiceAccountError::Io { source: e })?;
|
||||
ServiceAccount::load_from_json(data.as_str())
|
||||
}
|
||||
|
||||
pub fn load_from_json(json: &str) -> Result<Self, ServiceAccountError> {
|
||||
let sa: ServiceAccount =
|
||||
serde_json::from_str(json).map_err(|e| ServiceAccountError::Json { source: e })?;
|
||||
Ok(sa)
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self, audience: &str) -> Result<String, ServiceAccountError> {
|
||||
self.authenticate_with_options(audience, &Default::default())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn authenticate_with_options(
|
||||
&self,
|
||||
audience: &str,
|
||||
options: &AuthenticationOptions,
|
||||
) -> Result<String, ServiceAccountError> {
|
||||
let issuer = IssuerUrl::new(audience.to_string())
|
||||
.map_err(|e| ServiceAccountError::AudienceUrl { source: e })?;
|
||||
let metadata = CoreProviderMetadata::discover_async(issuer, async_http_client)
|
||||
.await
|
||||
.map_err(|e| ServiceAccountError::DiscoveryError {
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
|
||||
let jwt = self.create_signed_jwt(audience)?;
|
||||
let url = metadata
|
||||
.token_endpoint()
|
||||
.ok_or(ServiceAccountError::TokenEndpointMissing)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(ACCEPT, "application/json".parse().unwrap());
|
||||
headers.append(
|
||||
CONTENT_TYPE,
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
let body = serde_urlencoded::to_string([
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||||
("assertion", &jwt),
|
||||
("scope", &options.create_scopes()),
|
||||
])
|
||||
.map_err(|_| ServiceAccountError::UrlEncodeError)?;
|
||||
|
||||
let url =
|
||||
Url::parse(url.as_str()).map_err(|_| ServiceAccountError::TokenEndpointMissing)?;
|
||||
let response = async_http_client(HttpRequest {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers,
|
||||
body: body.into_bytes(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ServiceAccountError::HttpError { source: e })?;
|
||||
|
||||
serde_json::from_slice(response.body.as_slice())
|
||||
.map_err(|e| ServiceAccountError::Json { source: e })
|
||||
.map(
|
||||
|response: StandardTokenResponse<EmptyExtraTokenFields, CoreTokenType>| {
|
||||
response.access_token().secret().clone()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn create_signed_jwt(&self, audience: &str) -> Result<String, ServiceAccountError> {
|
||||
let key = EncodingKey::from_rsa_pem(self.key.as_bytes())
|
||||
.map_err(|e| ServiceAccountError::Key { source: e })?;
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some(self.key_id.to_string());
|
||||
let claims = JwtClaims::new(&self.user_id, audience);
|
||||
let jwt = encode(&header, &claims, &key)?;
|
||||
|
||||
Ok(jwt)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticationOptions {
|
||||
fn create_scopes(&self) -> String {
|
||||
let mut result = vec!["openid".to_string()];
|
||||
|
||||
for role in &self.roles {
|
||||
let scope = format!("urn:zitadel:iam:org:project:role:{}", role);
|
||||
if !result.contains(&scope) {
|
||||
result.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
for p_id in &self.project_audiences {
|
||||
let scope = format!("urn:zitadel:iam:org:project:id:{}:aud", p_id);
|
||||
if !result.contains(&scope) {
|
||||
result.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
for scope in &self.scopes {
|
||||
if !result.contains(scope) {
|
||||
result.push(scope.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let api_scope = "urn:zitadel:iam:org:project:id:zitadel:aud".to_string();
|
||||
if self.api_access && !result.contains(&api_scope) {
|
||||
result.push(api_scope);
|
||||
}
|
||||
|
||||
result.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
|
||||
const SERVICE_ACCOUNT: &str = r#"
|
||||
{
|
||||
"type": "serviceaccount",
|
||||
"keyId": "181828078849229057",
|
||||
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA9VIWALQqzx1ypi42t7MG4KSOMldD10brsEUjTcjqxhl6TJrP\nsjaNKWArnV/XH+6ZKRd55mUEFFx9VflqdwQtMVPjZKXpV4cFDiPwf1Z1h1DS6im4\nSo7eKR7OGb7TLBhwt7i2UPF4WnxBhTp/M6pG5kCJ1t8glIo5yRbrILXObRmvNWMz\nVIFAyw68NDZGYNhnR8AT43zjeJTFXG/suuEoXO/mMmMjsYY8kS0BbiQeq5t5hIrr\na/odswkDPn5Zd4P91iJHDnYlgfJuo3oRmgpOj/dDsl+vTol+vveeMO4TXPwZcl36\ngUNPok7nd6BA3gqmOS+fMImzmZB42trghARXXwIDAQABAoIBAQCbMOGQcml+ep+T\ntzqQPWYFaLQ37nKRVmE1Mpeh1o+G4Ik4utrXX6EvYpJUzVN29ObZUuufr5nEE7qK\nT+1k+zRntyzr9/VElLrC9kNnGtfg0WWMEvZt3DF4i+9P5CMNCy0LXIOhcxBzFZYR\nZS8hDQArGvrX/nFK5qKlrqTyHXFIHDFa6z59ErhXEnsTgRvx/Mo+6UkdBkHsKnlJ\nAbXqXFbfz6nDsF1DgRra5ODn1k8nZqnC/YcssE7/dlbuByz10ECkOSzqYcfufnsb\n9N1Ld4Xlj3yzsqPFzEJyHHm9eEHQXsPavaXiM64/+zpsksLscEIE/0KtIy5tngpZ\nSCqZAcj5AoGBAPb1bQFWUBmmUuSTtSymsxgXghJiJ3r+jJgdGbkv2IsRTs4En5Sz\n0SbPE1YWmMDDgTacJlB4/XiaojQ/j1EEY17inxYomE72UL6/ET7ycsEw3e9ALuD5\np0y2Sdzes2biH30bw5jD8kJ+hV18T745KtzrwSH4I0lAjnkmiH+0S67VAoGBAP5N\nTtAp/Qdxh9GjNSw1J7KRLtJrrr0pPrJ9av4GoFoWlz+Qw2X3dl8rjG3Bqz9LPV7A\ngiHMel8WTmdIM/S3F4Q3ufEfE+VzG+gncWd9SJfX5/LVhatPzTGLNsY7AYGEpSwT\n5/0anS1mHrLwsVcPrZnigekr5A5mfZl6nxtOnE9jAoGBALACqacbUkmFrmy1DZp+\nUQSptI3PoR3bEG9VxkCjZi1vr3/L8cS1CCslyT1BK6uva4d1cSVHpjfv1g1xA38V\nppE46XOMiUk16sSYPv1jJQCmCHd9givcIy3cefZOTwTTwueTAyv888wKipjfgaIs\n8my0JllEljmeJi0Ylo6V/J7lAoGBAIFqRlmZhLNtC3mcXUsKIhG14OYk9uA9RTMA\nsJpmNOSj6oTm3wndTdhRCT4x+TxUxf6aaZ9ZuEz7xRq6m/ZF1ynqUi5ramyyj9kt\neYD5OSBNODVUhJoSGpLEDjQDg1iucIBmAQHFsYeRGL5nz1hHGkneA87uDzlk3zZk\nOORktReRAoGAGUfU2UfaniAlqrZsSma3ZTlvJWs1x8cbVDyKTYMX5ShHhp+cA86H\nYjSSol6GI2wQPP+qIvZ1E8XyzD2miMJabl92/WY0tHejNNBEHwD8uBZKrtMoFWM7\nWJNl+Xneu/sT8s4pP2ng6QE7jpHXi2TUNmSlgQry9JN2AmA9TuSTW2Y=\n-----END RSA PRIVATE KEY-----\n",
|
||||
"userId": "181828061098934529"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn load_successfully_from_json() {
|
||||
let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap();
|
||||
|
||||
assert_eq!(sa.user_id, "181828061098934529");
|
||||
assert_eq!(sa.key_id, "181828078849229057");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_successfully_from_file() {
|
||||
let mut file = File::create("./temp_sa").unwrap();
|
||||
file.write_all(SERVICE_ACCOUNT.as_bytes())
|
||||
.expect("Could not write temp.");
|
||||
|
||||
let sa = ServiceAccount::load_from_file("./temp_sa").unwrap();
|
||||
|
||||
assert_eq!(sa.user_id, "181828061098934529");
|
||||
assert_eq!(sa.key_id, "181828078849229057");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_faulty_from_json() {
|
||||
let err = ServiceAccount::load_from_json("{1234}").unwrap_err();
|
||||
|
||||
if let ServiceAccountError::Json { source: _ } = err {
|
||||
assert!(true);
|
||||
} else {
|
||||
assert!(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_faulty_from_file() {
|
||||
let err = ServiceAccount::load_from_file("./foobar").unwrap_err();
|
||||
|
||||
if let ServiceAccountError::Io { source: _ } = err {
|
||||
assert!(true);
|
||||
} else {
|
||||
assert!(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_a_signed_jwt() {
|
||||
let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap();
|
||||
let claims = sa.create_signed_jwt(ZITADEL_URL).unwrap();
|
||||
|
||||
assert_eq!(&claims[0..5], "eyJ0e");
|
||||
}
|
||||
}
|
280
src/lib.rs
Normal file
280
src/lib.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
mod api;
|
||||
mod axum_introspector;
|
||||
mod credentials;
|
||||
mod oidc;
|
||||
mod session_storage;
|
||||
mod utilities;
|
||||
mod zitadel_http;
|
||||
|
||||
use crate::api::authenticated::AuthenticatedApi;
|
||||
use crate::api::public::PublicApi;
|
||||
use crate::axum_introspector::introspection::{
|
||||
IntrospectedUser, IntrospectionState, IntrospectionStateBuilder,
|
||||
};
|
||||
use crate::oidc::introspection::cache::cloudflare::CloudflareIntrospectionCache;
|
||||
use crate::session_storage::cloudflare::CloudflareKvStore;
|
||||
use axum::extract::FromRef;
|
||||
use axum::response::{IntoResponse, Redirect};
|
||||
use axum::routing::{any, get};
|
||||
use axum::{Router, ServiceExt};
|
||||
use bytes::Bytes;
|
||||
use http::HeaderName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::to_string;
|
||||
use std::fmt::Debug;
|
||||
use std::iter::once;
|
||||
use std::ops::Deref;
|
||||
use tower::ServiceExt as TowerServiceExt;
|
||||
use tower_cookies::cookie::SameSite;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::propagate_header::PropagateHeaderLayer;
|
||||
use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
|
||||
use tower_service::Service;
|
||||
use tower_sessions::cookie::Key;
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use tower_sessions_core::Expiry;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use worker::*;
|
||||
|
||||
#[event(start)]
|
||||
fn start() {
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.without_time()
|
||||
.with_ansi(false) // Only partially supported across JavaScript runtimes
|
||||
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339()); // std::time is not available in browsers
|
||||
let perf_layer = tracing_web::performance_layer();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(perf_layer)
|
||||
.init()
|
||||
}
|
||||
|
||||
const SIGNING_KEY: &str = "keystore::sig";
|
||||
const ENCRYPTION_KEY: &str = "keystore::enc";
|
||||
|
||||
// main entrypoint
|
||||
|
||||
#[event(fetch)]
|
||||
async fn fetch(
|
||||
req: HttpRequest,
|
||||
_env: Env,
|
||||
_ctx: Context,
|
||||
) -> Result<axum::http::Response<axum::body::Body>> {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
Ok(route(req, _env).await)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
struct Callback {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
introspection_state: IntrospectionState,
|
||||
env: Env,
|
||||
session_store: CloudflareKvStore,
|
||||
}
|
||||
impl FromRef<AppState> for IntrospectionState {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.introspection_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn route(req: HttpRequest, _env: Env) -> axum_core::response::Response {
|
||||
let kv = _env.kv("KV_STORAGE").unwrap();
|
||||
let cache = CloudflareIntrospectionCache::new(kv.clone());
|
||||
|
||||
let introspection_state = IntrospectionStateBuilder::new(
|
||||
_env.secret("AUTH_SERVER_URL")
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
.with_basic_auth(
|
||||
_env.secret("CLIENT_ID")
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.as_str(),
|
||||
_env.secret("CLIENT_SECRET")
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
.with_introspection_cache(cache)
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let session_store = CloudflareKvStore::new(kv.clone());
|
||||
|
||||
let state = AppState {
|
||||
introspection_state,
|
||||
session_store: session_store.clone(),
|
||||
env: _env.clone(),
|
||||
};
|
||||
|
||||
let dev_mode = _env.var("DEV_MODE").unwrap().to_string(); // Example check
|
||||
|
||||
let is_dev = dev_mode == "true";
|
||||
|
||||
let keystore = _env.kv("KV_STORAGE").unwrap();
|
||||
|
||||
let signing = if let Some(bytes) = keystore.get(SIGNING_KEY).bytes().await.unwrap() {
|
||||
Key::derive_from(bytes.as_slice())
|
||||
} else {
|
||||
let key = Key::generate();
|
||||
keystore
|
||||
.put_bytes(SIGNING_KEY, key.master())
|
||||
.unwrap()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
key
|
||||
};
|
||||
|
||||
let encryption = if let Some(bytes) = keystore.get(ENCRYPTION_KEY).bytes().await.unwrap() {
|
||||
Key::derive_from(bytes.as_slice())
|
||||
} else {
|
||||
let key = Key::generate();
|
||||
keystore
|
||||
.put_bytes(ENCRYPTION_KEY, key.master())
|
||||
.unwrap()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
key
|
||||
};
|
||||
|
||||
let host_string = _env.secret("APP_URL").unwrap().to_string().as_str().to_owned();
|
||||
|
||||
let cookie_host_uri = host_string.parse::<http::Uri>().unwrap();
|
||||
|
||||
let mut cookie_host = cookie_host_uri.authority().unwrap().to_string();
|
||||
|
||||
if cookie_host.starts_with("localhost:") {
|
||||
cookie_host = "localhost".to_string();
|
||||
}
|
||||
|
||||
let session_layer = SessionManagerLayer::new(state.session_store.clone())
|
||||
.with_name("session")
|
||||
.with_expiry(Expiry::OnSessionEnd)
|
||||
.with_domain(cookie_host)
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_signed(signing)
|
||||
.with_private(encryption)
|
||||
.with_path("/")
|
||||
.with_secure(!is_dev)
|
||||
.with_always_save(false);
|
||||
|
||||
async fn handle_introspection_errors(
|
||||
mut response: axum_core::response::Response,
|
||||
) -> axum_core::response::Response {
|
||||
let x_error_header_value = response
|
||||
.headers()
|
||||
.get("x-introspection-error")
|
||||
.and_then(|header_value| header_value.to_str().ok());
|
||||
|
||||
// not used but is available
|
||||
let x_session_header_value = response
|
||||
.headers()
|
||||
.get("x-session")
|
||||
.and_then(|header_value| header_value.to_str().ok());
|
||||
|
||||
match response.status() {
|
||||
http::StatusCode::UNAUTHORIZED => {
|
||||
if let Some(x_error) = x_error_header_value {
|
||||
if x_error == "unauthorized" {
|
||||
return Redirect::to("/login").into_response();
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
http::StatusCode::BAD_REQUEST => {
|
||||
if let Some(x_error) = x_error_header_value {
|
||||
if x_error == "invalid schema"
|
||||
|| x_error == "invalid header"
|
||||
|| x_error == "introspection error"
|
||||
{
|
||||
return Redirect::to("/login").into_response();
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
http::StatusCode::FORBIDDEN => {
|
||||
if let Some(x_error) = x_error_header_value {
|
||||
if x_error == "user is inactive" {
|
||||
return Redirect::to("/login").into_response();
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
http::StatusCode::NOT_FOUND => {
|
||||
if let Some(x_error) = x_error_header_value {
|
||||
if x_error == "user was not found" {
|
||||
return Redirect::to("/login").into_response();
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR => {
|
||||
if let Some(x_error) = x_error_header_value {
|
||||
if x_error == "missing config" {
|
||||
return Redirect::to("/login").into_response();
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
_ => response,
|
||||
}
|
||||
}
|
||||
|
||||
let mut router = Router::new()
|
||||
.route("/", any(AuthenticatedApi::proxy))
|
||||
.route("/login", get(PublicApi::login_page)) // Add the login page route
|
||||
.route("/login/callback", get(PublicApi::callback))
|
||||
.route("/login/authorize", get(PublicApi::authorize))
|
||||
.route("/api/whoami", get(whoami))
|
||||
.route("/*path", any(AuthenticatedApi::proxy))
|
||||
.layer(PropagateHeaderLayer::new(HeaderName::from_static(
|
||||
"x-request-id",
|
||||
)))
|
||||
.layer(axum::middleware::map_response(handle_introspection_errors))
|
||||
.with_state(state)
|
||||
.layer(session_layer)
|
||||
.layer(CookieManagerLayer::new())
|
||||
.layer(CorsLayer::very_permissive())
|
||||
.layer(SetSensitiveRequestHeadersLayer::new(once(
|
||||
http::header::AUTHORIZATION,
|
||||
)));
|
||||
|
||||
router
|
||||
.as_service()
|
||||
.ready()
|
||||
.await
|
||||
.unwrap()
|
||||
.oneshot(req)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn whoami(
|
||||
session: tower_sessions::Session,
|
||||
introspected_user: IntrospectedUser,
|
||||
) -> impl IntoResponse {
|
||||
console_log!("calling whoami");
|
||||
to_string(&introspected_user).unwrap()
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for CloudflareKvStore {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.session_store.clone()
|
||||
}
|
||||
}
|
94
src/oidc/discovery.rs
Normal file
94
src/oidc/discovery.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use custom_error::custom_error;
|
||||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::{
|
||||
core::{
|
||||
CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClientAuthMethod, CoreGrantType,
|
||||
CoreJsonWebKey, CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm,
|
||||
CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType,
|
||||
CoreSubjectIdentifierType,
|
||||
},
|
||||
url, AdditionalProviderMetadata, IntrospectionUrl, IssuerUrl, ProviderMetadata, RevocationUrl,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
custom_error! {
|
||||
pub DiscoveryError
|
||||
IssuerUrl{source: url::ParseError} = "could not parse issuer url: {source}",
|
||||
DiscoveryDocument = "could not discover OIDC document",
|
||||
}
|
||||
|
||||
pub async fn discover(authority: &str) -> Result<ZitadelProviderMetadata, DiscoveryError> {
|
||||
let issuer = IssuerUrl::new(authority.to_string())
|
||||
.map_err(|source| DiscoveryError::IssuerUrl { source })?;
|
||||
ZitadelProviderMetadata::discover_async(issuer, async_http_client)
|
||||
.await
|
||||
.map_err(|_| DiscoveryError::DiscoveryDocument)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ZitadelAdditionalMetadata {
|
||||
pub introspection_endpoint: Option<IntrospectionUrl>,
|
||||
pub revocation_endpoint: Option<RevocationUrl>,
|
||||
}
|
||||
|
||||
impl AdditionalProviderMetadata for ZitadelAdditionalMetadata {}
|
||||
|
||||
|
||||
pub type ZitadelProviderMetadata = ProviderMetadata<
|
||||
ZitadelAdditionalMetadata,
|
||||
CoreAuthDisplay,
|
||||
CoreClientAuthMethod,
|
||||
CoreClaimName,
|
||||
CoreClaimType,
|
||||
CoreGrantType,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJweKeyManagementAlgorithm,
|
||||
CoreJwsSigningAlgorithm,
|
||||
CoreJsonWebKeyType,
|
||||
CoreJsonWebKeyUse,
|
||||
CoreJsonWebKey,
|
||||
CoreResponseMode,
|
||||
CoreResponseType,
|
||||
CoreSubjectIdentifierType,
|
||||
>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use super::*;
|
||||
|
||||
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_fails_with_invalid_url() {
|
||||
let result = discover("foobar").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
DiscoveryError::IssuerUrl { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_fails_with_invalid_discovery() {
|
||||
let result = discover("https://smartive.ch").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
DiscoveryError::DiscoveryDocument
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_succeeds() {
|
||||
let result = discover(ZITADEL_URL).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.token_endpoint().unwrap().to_string(),
|
||||
"https://zitadel-libraries-l8boqa.zitadel.cloud/oauth/v2/token".to_string()
|
||||
);
|
||||
}
|
||||
}
|
81
src/oidc/introspection/cache/cloudflare.rs
vendored
Normal file
81
src/oidc/introspection/cache/cloudflare.rs
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use async_trait::async_trait;
|
||||
// use axum_core::response::IntoResponse;
|
||||
use openidconnect::TokenIntrospectionResponse;
|
||||
use crate::oidc::introspection::cache::{IntrospectionCache, Response};
|
||||
// use crate::session_storage::cloudflare::CloudflareKvStore;
|
||||
|
||||
|
||||
/// for storing introspection results.
|
||||
pub struct CloudflareIntrospectionCache {
|
||||
kv: worker::kv::KvStore,
|
||||
}
|
||||
|
||||
impl CloudflareIntrospectionCache {
|
||||
/// Creates a new instance of `CloudflareIntrospectionCache` with the given KV namespace.
|
||||
pub fn new(kv: worker::kv::KvStore) -> Self {
|
||||
Self { kv }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CloudflareIntrospectionCache {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CloudflareKvStore")
|
||||
.finish_non_exhaustive()
|
||||
// Probably want to handle this differently
|
||||
// .field("kvstore", "KVStorePlaceholder")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn prefixed_key(token: &str) -> String {
|
||||
format!("introspectioncache::{}", token)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntrospectionCache for CloudflareIntrospectionCache {
|
||||
async fn get(&self, token: &str) -> Option<Response> {
|
||||
get(self.kv.clone(), token).await
|
||||
}
|
||||
|
||||
async fn set(&self, token: &str, response: Response) {
|
||||
// Check if the token is active and has an expiration time
|
||||
set(self.kv.clone(), token, response).await;
|
||||
}
|
||||
|
||||
async fn clear(&self) {
|
||||
wrapped_clear(self.kv.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn set(kv: worker::kv::KvStore, token: &str, response: Response) {
|
||||
if response.active() && response.exp().is_some() {
|
||||
// Serialize the response to JSON
|
||||
if let Ok(json) = serde_json::to_string(&response) {
|
||||
// Set the expiration time
|
||||
let expiration = response.exp().unwrap();
|
||||
// Store the serialized response in the KV store with expiration
|
||||
kv.put(prefixed_key(token).as_str(), json).unwrap().expiration(expiration.timestamp().unsigned_abs()).execute().await.unwrap_or(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[worker::send]
|
||||
async fn get(kv: worker::kv::KvStore, token: &str) -> Option<Response> {
|
||||
if let Some(data) = kv.get(prefixed_key(token).as_str()).text().await.unwrap_or(None) {
|
||||
serde_json::from_str(&data).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn wrapped_clear(kv: worker::kv::KvStore) {
|
||||
let keys = kv.list().execute().await.unwrap().keys;
|
||||
|
||||
for key in keys.iter().filter(|key| key.name.starts_with("introspectioncache::")) {
|
||||
kv.delete(&key.name).await.unwrap_or(());
|
||||
}
|
||||
}
|
132
src/oidc/introspection/cache/in_memory.rs
vendored
Normal file
132
src/oidc/introspection/cache/in_memory.rs
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use openidconnect::TokenIntrospectionResponse;
|
||||
use time::Duration;
|
||||
|
||||
type Response = super::super::ZitadelIntrospectionResponse;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InMemoryIntrospectionCache {
|
||||
cache: Arc<RwLock<HashMap<String, (Response, i64)>>>,
|
||||
}
|
||||
|
||||
impl InMemoryIntrospectionCache {
|
||||
/// Creates a new in memory cache backed by a HashMap.
|
||||
/// No max capacity limit is enforced, but entries are cleared based on expiry.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryIntrospectionCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl super::IntrospectionCache for InMemoryIntrospectionCache {
|
||||
async fn get(&self, token: &str) -> Option<Response> {
|
||||
let mut cache = self.cache.write().await;
|
||||
match cache.get(token) {
|
||||
Some((response, expires_at))
|
||||
if *expires_at < chrono::Utc::now().timestamp() => {
|
||||
cache.remove(token);
|
||||
None
|
||||
}
|
||||
Some((response, _)) => Some(response.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(&self, token: &str, response: Response) {
|
||||
if !response.active() || response.exp().is_none() {
|
||||
return;
|
||||
}
|
||||
let expires_at = response.exp().unwrap().timestamp();
|
||||
self.cache.write().await.insert(token.to_string(), (response, expires_at));
|
||||
}
|
||||
|
||||
async fn clear(&self) {
|
||||
self.cache.write().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use crate::oidc::introspection::cache::IntrospectionCache;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_set() {
|
||||
let c = InMemoryIntrospectionCache::new();
|
||||
let t = &c as &dyn IntrospectionCache;
|
||||
|
||||
let mut response = Response::new(true, Default::default());
|
||||
response.set_exp(Some(Utc::now()));
|
||||
|
||||
t.set("token1", response.clone()).await;
|
||||
t.set("token2", response.clone()).await;
|
||||
|
||||
assert!(t.get("token1").await.is_some());
|
||||
assert!(t.get("token2").await.is_some());
|
||||
assert!(t.get("token3").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_exp_response() {
|
||||
let c = InMemoryIntrospectionCache::new();
|
||||
let t = &c as &dyn IntrospectionCache;
|
||||
|
||||
let response = Response::new(true, Default::default());
|
||||
|
||||
t.set("token1", response.clone()).await;
|
||||
t.set("token2", response.clone()).await;
|
||||
|
||||
assert!(t.get("token1").await.is_none());
|
||||
assert!(t.get("token2").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let c = InMemoryIntrospectionCache::new();
|
||||
let t = &c as &dyn IntrospectionCache;
|
||||
|
||||
let mut response = Response::new(true, Default::default());
|
||||
response.set_exp(Some(Utc::now()));
|
||||
|
||||
t.set("token1", response.clone()).await;
|
||||
t.set("token2", response.clone()).await;
|
||||
|
||||
t.clear().await;
|
||||
|
||||
assert!(t.get("token1").await.is_none());
|
||||
assert!(t.get("token2").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_expired_token() {
|
||||
let c = InMemoryIntrospectionCache::new();
|
||||
let t = &c as &dyn IntrospectionCache;
|
||||
|
||||
let mut response = Response::new(true, Default::default());
|
||||
response.set_exp(Some(Utc::now() - TimeDelta::try_seconds(10).unwrap()));
|
||||
|
||||
t.set("token1", response.clone()).await;
|
||||
t.set("token2", response.clone()).await;
|
||||
|
||||
let _ = t.get("token1").await;
|
||||
let _ = t.get("token2").await;
|
||||
|
||||
assert!(t.get("token1").await.is_none());
|
||||
assert!(t.get("token2").await.is_none());
|
||||
}
|
||||
}
|
37
src/oidc/introspection/cache/mod.rs
vendored
Normal file
37
src/oidc/introspection/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
use async_trait::async_trait;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
|
||||
pub mod in_memory;
|
||||
pub mod cloudflare;
|
||||
|
||||
pub type Response = super::ZitadelIntrospectionResponse;
|
||||
|
||||
|
||||
#[async_trait]
|
||||
pub trait IntrospectionCache: Send + Sync + std::fmt::Debug {
|
||||
async fn get(&self, token: &str) -> Option<Response>;
|
||||
|
||||
async fn set(&self, token: &str, response: Response);
|
||||
|
||||
async fn clear(&self);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, V> IntrospectionCache for T
|
||||
where
|
||||
T: Deref<Target = V> + Send + Sync + Debug,
|
||||
V: IntrospectionCache,
|
||||
{
|
||||
async fn get(&self, token: &str) -> Option<Response> {
|
||||
self.deref().get(token).await
|
||||
}
|
||||
|
||||
async fn set(&self, token: &str, response: Response) {
|
||||
self.deref().set(token, response).await
|
||||
}
|
||||
|
||||
async fn clear(&self) {
|
||||
self.deref().clear().await
|
||||
}
|
||||
}
|
260
src/oidc/introspection/mod.rs
Normal file
260
src/oidc/introspection/mod.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use custom_error::custom_error;
|
||||
use openidconnect::http::Method;
|
||||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::url::{ParseError, Url};
|
||||
use openidconnect::HttpResponse;
|
||||
use openidconnect::{
|
||||
core::CoreTokenType, ExtraTokenFields, HttpRequest, StandardTokenIntrospectionResponse,
|
||||
};
|
||||
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display};
|
||||
use base64::Engine;
|
||||
use crate::credentials::{Application, ApplicationError};
|
||||
|
||||
pub mod cache;
|
||||
|
||||
custom_error! {
|
||||
pub IntrospectionError
|
||||
RequestFailed{source: openidconnect::reqwest::Error<reqwest::Error>} = "the introspection request did fail: {source}",
|
||||
PayloadSerialization = "could not correctly serialize introspection payload",
|
||||
JWTProfile{source: ApplicationError} = "could not create signed jwt key: {source}",
|
||||
ParseUrl{source: ParseError} = "could not parse url: {source}",
|
||||
ParseResponse{source: serde_json::Error} = "could not parse introspection response: {source}",
|
||||
DecodeResponse{source: base64::DecodeError} = "could not decode base64 metadata: {source}",
|
||||
ResponseError{source: ZitadelResponseError} = "received error response from Zitadel: {source}",
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ZitadelIntrospectionExtraTokenFields {
|
||||
pub name: Option<String>,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub preferred_username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: Option<bool>,
|
||||
pub locale: Option<String>,
|
||||
#[serde(rename = "urn:zitadel:iam:user:resourceowner:id")]
|
||||
pub resource_owner_id: Option<String>,
|
||||
#[serde(rename = "urn:zitadel:iam:user:resourceowner:name")]
|
||||
pub resource_owner_name: Option<String>,
|
||||
#[serde(rename = "urn:zitadel:iam:user:resourceowner:primary_domain")]
|
||||
pub resource_owner_primary_domain: Option<String>,
|
||||
#[serde(rename = "urn:zitadel:iam:org:project:roles")]
|
||||
pub project_roles: Option<HashMap<String, HashMap<String, String>>>,
|
||||
#[serde(rename = "urn:zitadel:iam:user:metadata")]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl ExtraTokenFields for ZitadelIntrospectionExtraTokenFields {}
|
||||
|
||||
pub type ZitadelIntrospectionResponse =
|
||||
StandardTokenIntrospectionResponse<ZitadelIntrospectionExtraTokenFields, CoreTokenType>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthorityAuthentication {
|
||||
Basic {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
},
|
||||
JWTProfile { application: Application },
|
||||
}
|
||||
|
||||
fn headers(auth: &AuthorityAuthentication) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(ACCEPT, "application/json".parse().unwrap());
|
||||
headers.append(
|
||||
CONTENT_TYPE,
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
|
||||
match auth {
|
||||
AuthorityAuthentication::Basic {
|
||||
client_id,
|
||||
client_secret,
|
||||
} => {
|
||||
headers.append(
|
||||
AUTHORIZATION,
|
||||
format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&format!("{}:{}", client_id, client_secret))
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
AuthorityAuthentication::JWTProfile { .. } => headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload(
|
||||
authority: &str,
|
||||
auth: &AuthorityAuthentication,
|
||||
token: &str,
|
||||
) -> Result<String, IntrospectionError> {
|
||||
match auth {
|
||||
AuthorityAuthentication::Basic { .. } => serde_urlencoded::to_string([("token", token)])
|
||||
.map_err(|_| IntrospectionError::PayloadSerialization),
|
||||
AuthorityAuthentication::JWTProfile { application } => {
|
||||
let jwt = application
|
||||
.create_signed_jwt(authority)
|
||||
.map_err(|source| IntrospectionError::JWTProfile { source })?;
|
||||
|
||||
serde_urlencoded::to_string([
|
||||
(
|
||||
"client_assertion_type",
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
),
|
||||
("client_assertion", &jwt),
|
||||
("token", token),
|
||||
])
|
||||
.map_err(|_| IntrospectionError::PayloadSerialization)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn introspect(
|
||||
introspection_uri: &str,
|
||||
authority: &str,
|
||||
authentication: &AuthorityAuthentication,
|
||||
token: &str,
|
||||
) -> Result<ZitadelIntrospectionResponse, IntrospectionError> {
|
||||
let response = async_http_client(HttpRequest {
|
||||
url: Url::parse(introspection_uri)
|
||||
.map_err(|source| IntrospectionError::ParseUrl { source })?,
|
||||
method: Method::POST,
|
||||
headers: headers(authentication),
|
||||
body: payload(authority, authentication, token)?.into_bytes(),
|
||||
})
|
||||
.await
|
||||
.map_err(|source| IntrospectionError::RequestFailed { source })?;
|
||||
|
||||
if !response.status_code.is_success() {
|
||||
return Err(IntrospectionError::ResponseError {
|
||||
source: ZitadelResponseError::from_response(&response),
|
||||
});
|
||||
}
|
||||
|
||||
let mut response: ZitadelIntrospectionResponse =
|
||||
serde_json::from_slice(response.body.as_slice())
|
||||
.map_err(|source| IntrospectionError::ParseResponse { source })?;
|
||||
decode_metadata(&mut response)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ZitadelResponseError {
|
||||
status_code: String,
|
||||
body: String,
|
||||
}
|
||||
impl ZitadelResponseError {
|
||||
fn from_response(response: &HttpResponse) -> Self {
|
||||
Self {
|
||||
status_code: response.status_code.to_string(),
|
||||
body: String::from_utf8_lossy(response.body.as_slice()).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for ZitadelResponseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "status code: {}, body: {}", self.status_code, self.body)
|
||||
}
|
||||
}
|
||||
impl Error for ZitadelResponseError {}
|
||||
|
||||
// Metadata values are base64 encoded.
|
||||
fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), IntrospectionError> {
|
||||
|
||||
if let Some(h) = &response.extra_fields().metadata {
|
||||
let mut extra: ZitadelIntrospectionExtraTokenFields = response.extra_fields().clone();
|
||||
let mut metadata = HashMap::new();
|
||||
for (k, v) in h {
|
||||
let decoded_v = base64::engine::general_purpose::STANDARD.decode(v)
|
||||
.map_err(|source| IntrospectionError::DecodeResponse { source })?;
|
||||
let decoded_v = String::from_utf8_lossy(&decoded_v).into_owned();
|
||||
metadata.insert(k.clone(), decoded_v);
|
||||
}
|
||||
extra.metadata.replace(metadata);
|
||||
response.set_extra_fields(extra)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use crate::oidc::discovery::discover;
|
||||
use openidconnect::TokenIntrospectionResponse;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
|
||||
const PERSONAL_ACCESS_TOKEN: &str =
|
||||
"dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA";
|
||||
|
||||
#[tokio::test]
|
||||
async fn introspect_fails_with_invalid_url() {
|
||||
let result = introspect(
|
||||
"foobar",
|
||||
"foobar",
|
||||
&AuthorityAuthentication::Basic {
|
||||
client_id: "".to_string(),
|
||||
client_secret: "".to_string(),
|
||||
},
|
||||
"token",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
IntrospectionError::ParseUrl { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn introspect_fails_with_invalid_endpoint() {
|
||||
let meta = discover(ZITADEL_URL).await.unwrap();
|
||||
let result = introspect(
|
||||
&meta.token_endpoint().unwrap().to_string(),
|
||||
ZITADEL_URL,
|
||||
&AuthorityAuthentication::Basic {
|
||||
client_id: "".to_string(),
|
||||
client_secret: "".to_string(),
|
||||
},
|
||||
"token",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn introspect_succeeds() {
|
||||
let meta = discover(ZITADEL_URL).await.unwrap();
|
||||
let result = introspect(
|
||||
&meta
|
||||
.additional_metadata()
|
||||
.introspection_endpoint
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
ZITADEL_URL,
|
||||
&AuthorityAuthentication::Basic {
|
||||
client_id: "194339055499018497@zitadel_rust_test".to_string(),
|
||||
client_secret: "Ip56oGzxKL1rJ8JaleUVKL7qUlpZ1tqHQYRSd6JE1mTlTJ3pDkDzoObHdZsOg88B"
|
||||
.to_string(),
|
||||
},
|
||||
PERSONAL_ACCESS_TOKEN,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.active());
|
||||
}
|
||||
}
|
2
src/oidc/mod.rs
Normal file
2
src/oidc/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod discovery;
|
||||
pub mod introspection;
|
201
src/session_storage/cloudflare.rs
Normal file
201
src/session_storage/cloudflare.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use async_trait::async_trait;
|
||||
use std::fmt::Debug;
|
||||
use time::OffsetDateTime;
|
||||
use tower_sessions::{
|
||||
session::{Id, Record},
|
||||
session_store, SessionStore,
|
||||
};
|
||||
use worker::console_error;
|
||||
use worker::kv::KvStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CloudflareKvStore {
|
||||
kv_storage: KvStore,
|
||||
}
|
||||
|
||||
impl CloudflareKvStore {
|
||||
pub(crate) fn new(kv_storage: KvStore) -> Self {
|
||||
Self { kv_storage }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CloudflareKvStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kv_storage: KvStore::create("KV_STORAGE").expect("Failed to create KV store"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CloudflareKvStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CloudflareKvStore").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn get_rec(kv_store: KvStore, session_id: String) -> Option<Record> {
|
||||
match kv_store.get(&session_id).text().await {
|
||||
Ok(record) => {
|
||||
serde_json::de::from_str(record.unwrap_or_default().as_str()).unwrap_or_default()
|
||||
}
|
||||
Err(err) => {
|
||||
console_error!("{:?}", err.to_string().as_str());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn delete_rec(kv_store: KvStore, session_id: String) -> Option<()> {
|
||||
kv_store
|
||||
.delete(&session_id.to_string())
|
||||
.await
|
||||
.expect("Failed to delete session");
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn create_record_handler(kv_storage: KvStore, record: &mut Record) {
|
||||
let id = record.id.to_string();
|
||||
let serialized_record = serde_json::to_string(record).expect("Failed to serialize record");
|
||||
let request = kv_storage
|
||||
.put(&id, serialized_record)
|
||||
.expect("Failed to create session");
|
||||
if let Err(err) = request.execute().await {
|
||||
panic!("Failed to execute create request");
|
||||
}
|
||||
}
|
||||
|
||||
#[worker::send]
|
||||
async fn save_record_handler(kv_storage: KvStore, record: &Record) {
|
||||
let id = record.id.to_string();
|
||||
let serialized_record = serde_json::to_string(record).expect("Failed to serialize record");
|
||||
let request = kv_storage.put(&id, serialized_record).unwrap();
|
||||
if let Err(err) = request.execute().await {
|
||||
panic!("Failed to execute save request");
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionStore for CloudflareKvStore {
|
||||
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
|
||||
if record.id.to_string().is_empty() {
|
||||
record.id = Id::default();
|
||||
}
|
||||
|
||||
create_record_handler(self.kv_storage.clone(), record).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save(&self, record: &Record) -> session_store::Result<()> {
|
||||
save_record_handler(self.kv_storage.clone(), record).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
|
||||
let id = session_id.to_string();
|
||||
|
||||
match get_rec(self.kv_storage.clone(), id).await {
|
||||
Some(record) => {
|
||||
let is_active = is_active(record.expiry_date);
|
||||
|
||||
if is_active {
|
||||
Ok(Some(record))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
|
||||
delete_rec(self.kv_storage.clone(), session_id.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(expiry_date: OffsetDateTime) -> bool {
|
||||
expiry_date > OffsetDateTime::now_utc()
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use time::Duration;
|
||||
//
|
||||
// use super::*;
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn test_create() {
|
||||
// let store = CloudflareKvStore::default();
|
||||
// let mut record = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
// };
|
||||
// assert!(store.create(&mut record).await.is_ok());
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn test_save() {
|
||||
// let store = CloudflareKvStore::default();
|
||||
// let record = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
// };
|
||||
// assert!(store.save(&record).await.is_ok());
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn test_load() {
|
||||
// let store = CloudflareKvStore::default();
|
||||
// let mut record = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
// };
|
||||
// store.create(&mut record).await.unwrap();
|
||||
// let loaded_record = store.load(&record.id).await.unwrap();
|
||||
// assert_eq!(Some(record), loaded_record);
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn test_delete() {
|
||||
// let store = CloudflareKvStore::default();
|
||||
// let mut record = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
// };
|
||||
// store.create(&mut record).await.unwrap();
|
||||
// assert!(store.delete(&record.id).await.is_ok());
|
||||
// assert_eq!(None, store.load(&record.id).await.unwrap());
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn test_create_id_collision() {
|
||||
// let store = CloudflareKvStore::default();
|
||||
// let expiry_date = OffsetDateTime::now_utc() + Duration::minutes(30);
|
||||
// let mut record1 = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date,
|
||||
// };
|
||||
// let mut record2 = Record {
|
||||
// id: Default::default(),
|
||||
// data: Default::default(),
|
||||
// expiry_date,
|
||||
// };
|
||||
// store.create(&mut record1).await.unwrap();
|
||||
// record2.id = record1.id; // Set the same ID for record2
|
||||
// store.create(&mut record2).await.unwrap();
|
||||
// assert_ne!(record1.id, record2.id); // IDs should be different
|
||||
// }
|
||||
// }
|
134
src/session_storage/in_memory.rs
Normal file
134
src/session_storage/in_memory.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Mutex;
|
||||
use tower_sessions_core::{
|
||||
session::{Id, Record},
|
||||
session_store, SessionStore,
|
||||
};
|
||||
|
||||
/// A session store that lives only in memory.
|
||||
///
|
||||
/// This is useful for testing but not recommended for real applications.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use tower_sessions::MemoryStore;
|
||||
/// MemoryStore::default();
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryStore(Arc<Mutex<HashMap<Id, Record>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl SessionStore for MemoryStore {
|
||||
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
|
||||
let mut store_guard = self.0.lock().await;
|
||||
while store_guard.contains_key(&record.id) {
|
||||
// Session ID collision mitigation.
|
||||
record.id = Id::default();
|
||||
}
|
||||
store_guard.insert(record.id, record.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save(&self, record: &Record) -> session_store::Result<()> {
|
||||
self.0.lock().await.insert(record.id, record.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.await
|
||||
.get(session_id)
|
||||
.filter(|Record { expiry_date, .. }| is_active(*expiry_date))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
|
||||
self.0.lock().await.remove(session_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(expiry_date: OffsetDateTime) -> bool {
|
||||
expiry_date > OffsetDateTime::now_utc()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create() {
|
||||
let store = MemoryStore::default();
|
||||
let mut record = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
};
|
||||
assert!(store.create(&mut record).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save() {
|
||||
let store = MemoryStore::default();
|
||||
let record = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
};
|
||||
assert!(store.save(&record).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load() {
|
||||
let store = MemoryStore::default();
|
||||
let mut record = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
};
|
||||
store.create(&mut record).await.unwrap();
|
||||
let loaded_record = store.load(&record.id).await.unwrap();
|
||||
assert_eq!(Some(record), loaded_record);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete() {
|
||||
let store = MemoryStore::default();
|
||||
let mut record = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: OffsetDateTime::now_utc() + Duration::minutes(30),
|
||||
};
|
||||
store.create(&mut record).await.unwrap();
|
||||
assert!(store.delete(&record.id).await.is_ok());
|
||||
assert_eq!(None, store.load(&record.id).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_id_collision() {
|
||||
let store = MemoryStore::default();
|
||||
let expiry_date = OffsetDateTime::now_utc() + Duration::minutes(30);
|
||||
let mut record1 = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date,
|
||||
};
|
||||
let mut record2 = Record {
|
||||
id: Default::default(),
|
||||
data: Default::default(),
|
||||
expiry_date,
|
||||
};
|
||||
store.create(&mut record1).await.unwrap();
|
||||
record2.id = record1.id; // Set the same ID for record2
|
||||
store.create(&mut record2).await.unwrap();
|
||||
assert_ne!(record1.id, record2.id); // IDs should be different
|
||||
}
|
||||
}
|
2
src/session_storage/mod.rs
Normal file
2
src/session_storage/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod cloudflare;
|
||||
pub mod in_memory;
|
30
src/utilities.rs
Normal file
30
src/utilities.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
pub struct Utilities;
|
||||
|
||||
impl Utilities {
|
||||
pub fn get_pkce_verifier_storage_key(csrf_string: &str) -> String {
|
||||
format!("pkce_verifier_{}", csrf_string)
|
||||
}
|
||||
|
||||
pub fn get_auth_session_key(csrf_string: &str) -> String {
|
||||
format!("csrf_session_{}", csrf_string)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_pkce_verifier_storage_key() {
|
||||
let csrf_string = "test_csrf";
|
||||
let key = Utilities::get_pkce_verifier_storage_key(csrf_string);
|
||||
assert_eq!(key, "pkce_verifier_test_csrf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_auth_session_key() {
|
||||
let csrf_string = "test_csrf";
|
||||
let key = Utilities::get_auth_session_key(csrf_string);
|
||||
assert_eq!(key, "csrf_session_test_csrf");
|
||||
}
|
||||
}
|
39
src/zitadel_http.rs
Normal file
39
src/zitadel_http.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct OidcMetadata {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub introspection_endpoint: Option<String>,
|
||||
pub userinfo_endpoint: Option<String>,
|
||||
pub revocation_endpoint: Option<String>,
|
||||
pub end_session_endpoint: Option<String>,
|
||||
pub device_authorization_endpoint: Option<String>,
|
||||
pub jwks_uri: String,
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
pub response_types_supported: Option<Vec<String>>,
|
||||
pub response_modes_supported: Option<Vec<String>>,
|
||||
pub grant_types_supported: Option<Vec<String>>,
|
||||
pub subject_types_supported: Option<Vec<String>>,
|
||||
pub id_token_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub request_object_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
|
||||
pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub revocation_endpoint_auth_methods_supported: Option<Vec<String>>,
|
||||
pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub introspection_endpoint_auth_methods_supported: Option<Vec<String>>,
|
||||
pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub claims_supported: Option<Vec<String>>,
|
||||
pub code_challenge_methods_supported: Option<Vec<String>>,
|
||||
pub ui_locales_supported: Option<Vec<String>>,
|
||||
pub request_parameter_supported: Option<bool>,
|
||||
pub request_uri_parameter_supported: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn fetch_oidc_metadata(issuer_url: &str) -> OidcMetadata {
|
||||
let issuer_url = issuer_url.trim_end_matches('/');
|
||||
let metadata_url = format!("{}/.well-known/openid-configuration", issuer_url);
|
||||
|
||||
let response = reqwest::get(&metadata_url).await.expect("Failed to fetch metadata");
|
||||
|
||||
response.json::<OidcMetadata>().await.expect("Failed to parse metadata")
|
||||
}
|
8
temp_app
Normal file
8
temp_app
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
{
|
||||
"type": "application",
|
||||
"keyId": "181963758610940161",
|
||||
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwT2YZJytkkZ1DDM3dcu1OA8YPzHu6XR8HotdMNRnV75GhOT4\nB7zDtdtoP8w/1NHHPEJ859e0kYhrrnKikOKLS6fS1KRsmqR5ZvTq8SlZ2mq3RcX2\nebZx5dQt36INij/WXdsBmjM/yfWvqqWBSb0L/186DaWwmmIxoXWe873vxRmlzblg\nGd8Nu07s9YTREbGPbtFVHEUM6xI4oIe8HJ0e1+JBkiGqk31Cogo0FoAxrOAg0Sf4\n5XiUMYIjzqh8673F9SC4IpVxG22mpFk3vDFuAITaStWYbiH2hPJNKWyX9HDCZb1D\nDqa3wZBDiLqWxh22hNZ6ZIe+3UoSGWsPBH+E1wIDAQABAoIBAD2v5QsRPRN57HmF\njAnNir8nimz6CrN53Pl/MbOZypenBSn9UfReXPeb3+6lzCarBPgGnYsBQAJJU16v\n95daym7PVy1Mg+Ll6F9mhe2Qbr+b23+pj2IRTNC6aB6Aw+PDNzJk7GEGRTG6fWZz\nSQ96Cu9tvcGHiBXwjLlnK+PRWU5IsCiLsjT4xBXsMLMw3YOdMK5z58sqr+SnNEyq\nRHoEvi9aC94WrargVB45Yx+81YNW8uQ5rMDmYaJC5a7ENz522SlAuf4T+fAGJ/HE\n/qbZGD4YwlLqAFDgewQ+5tEWEus3zgY2MIR7vN2zXU1Ptk+mQkXZl/Pxdp7q1xU+\nvr/kcykCgYEAy7MiIAzc1ctQDvkk3HiespzdQ/sC7+CGsBzkyubRc9Oq/YR7GfVK\nGTuDEDlWwx92VAvJGDWRa3T426YDyqiPj66uo836sgL15Uigg5afZun2bqGC78le\nBhSy9b+0YDHPa87GxtKt9UmMoB6WdmoPzOkLEEGS7eesmk2DDgY+QSUCgYEA8tr/\n3PawigL1cxuFpcO1lH6XUspGeAo5yB8FXvfW5g50e37LgooIvOFgUlYuchxwr6uh\nW+CUAWmm4farsgvMBMPYw+PbkCTi/xemiiDmMHUYd7sJkTl0JXApq3pZsNMg4Fw/\n29RynmcG8TGe2dkwrWp1aBYjvIHwEHuNHHTTA0sCgYBtSUFAwsXkaj0cm2y8YHZ8\nS46mv1AXFHYOnKHffjDXnLN7ao2FIsXLfdNWa/zxmLqqYtxUAcFwToSJi6szGnZT\nVxvZRFSBFveIOQvtLW1+EH4nYr3WGko4pvhQwrZqea7YH0skNrogBILPEToWc9bg\nUBOgeB31R7uh2X47kvvphQKBgQDWc60dYnniZVp5mwQZrQjbaC4YXaZ8ugrsPPhx\nNEoAPSN/KihrzZiJsjtsec3p1lNrzRNgHqCT3sgPIdPcFa7DRm5UDRIF54zL1gaq\nUwLyJ3TDxdZc928o4DLryc8J5mZRuSRq6t+MIU5wDnFHzhK+EBQ9Jc/I1rU22ONz\nDXaIoQKBgH14Apggo0o4Eo+OnEBRFbbDulaOfVLPTK9rktikbwO1vzDch8kdcwCU\nsvtRXHjDQL93Ih/8S9aDJZoSDulwr3VUsuDiDEb4jfYmP2sbNO4nIJt+SBMhVOXV\nt7E/uWK28X0GL/bIUzSMMgTfdjhXEtJW+s6hQU1fG+9U1qVTQ2R/\n-----END RSA PRIVATE KEY-----\n",
|
||||
"appId": "181963751145079041",
|
||||
"clientId": "181963751145144577@zitadel_rust_test"
|
||||
}
|
7
temp_sa
Normal file
7
temp_sa
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
{
|
||||
"type": "serviceaccount",
|
||||
"keyId": "181828078849229057",
|
||||
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA9VIWALQqzx1ypi42t7MG4KSOMldD10brsEUjTcjqxhl6TJrP\nsjaNKWArnV/XH+6ZKRd55mUEFFx9VflqdwQtMVPjZKXpV4cFDiPwf1Z1h1DS6im4\nSo7eKR7OGb7TLBhwt7i2UPF4WnxBhTp/M6pG5kCJ1t8glIo5yRbrILXObRmvNWMz\nVIFAyw68NDZGYNhnR8AT43zjeJTFXG/suuEoXO/mMmMjsYY8kS0BbiQeq5t5hIrr\na/odswkDPn5Zd4P91iJHDnYlgfJuo3oRmgpOj/dDsl+vTol+vveeMO4TXPwZcl36\ngUNPok7nd6BA3gqmOS+fMImzmZB42trghARXXwIDAQABAoIBAQCbMOGQcml+ep+T\ntzqQPWYFaLQ37nKRVmE1Mpeh1o+G4Ik4utrXX6EvYpJUzVN29ObZUuufr5nEE7qK\nT+1k+zRntyzr9/VElLrC9kNnGtfg0WWMEvZt3DF4i+9P5CMNCy0LXIOhcxBzFZYR\nZS8hDQArGvrX/nFK5qKlrqTyHXFIHDFa6z59ErhXEnsTgRvx/Mo+6UkdBkHsKnlJ\nAbXqXFbfz6nDsF1DgRra5ODn1k8nZqnC/YcssE7/dlbuByz10ECkOSzqYcfufnsb\n9N1Ld4Xlj3yzsqPFzEJyHHm9eEHQXsPavaXiM64/+zpsksLscEIE/0KtIy5tngpZ\nSCqZAcj5AoGBAPb1bQFWUBmmUuSTtSymsxgXghJiJ3r+jJgdGbkv2IsRTs4En5Sz\n0SbPE1YWmMDDgTacJlB4/XiaojQ/j1EEY17inxYomE72UL6/ET7ycsEw3e9ALuD5\np0y2Sdzes2biH30bw5jD8kJ+hV18T745KtzrwSH4I0lAjnkmiH+0S67VAoGBAP5N\nTtAp/Qdxh9GjNSw1J7KRLtJrrr0pPrJ9av4GoFoWlz+Qw2X3dl8rjG3Bqz9LPV7A\ngiHMel8WTmdIM/S3F4Q3ufEfE+VzG+gncWd9SJfX5/LVhatPzTGLNsY7AYGEpSwT\n5/0anS1mHrLwsVcPrZnigekr5A5mfZl6nxtOnE9jAoGBALACqacbUkmFrmy1DZp+\nUQSptI3PoR3bEG9VxkCjZi1vr3/L8cS1CCslyT1BK6uva4d1cSVHpjfv1g1xA38V\nppE46XOMiUk16sSYPv1jJQCmCHd9givcIy3cefZOTwTTwueTAyv888wKipjfgaIs\n8my0JllEljmeJi0Ylo6V/J7lAoGBAIFqRlmZhLNtC3mcXUsKIhG14OYk9uA9RTMA\nsJpmNOSj6oTm3wndTdhRCT4x+TxUxf6aaZ9ZuEz7xRq6m/ZF1ynqUi5ramyyj9kt\neYD5OSBNODVUhJoSGpLEDjQDg1iucIBmAQHFsYeRGL5nz1hHGkneA87uDzlk3zZk\nOORktReRAoGAGUfU2UfaniAlqrZsSma3ZTlvJWs1x8cbVDyKTYMX5ShHhp+cA86H\nYjSSol6GI2wQPP+qIvZ1E8XyzD2miMJabl92/WY0tHejNNBEHwD8uBZKrtMoFWM7\nWJNl+Xneu/sT8s4pP2ng6QE7jpHXi2TUNmSlgQry9JN2AmA9TuSTW2Y=\n-----END RSA PRIVATE KEY-----\n",
|
||||
"userId": "181828061098934529"
|
||||
}
|
27
wrangler.jsonc
Normal file
27
wrangler.jsonc
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"compatibility_date": "2025-01-31",
|
||||
"main": "build/worker/shim.mjs",
|
||||
"name": "zitadel-session-worker",
|
||||
"workers_dev": true,
|
||||
"build": {
|
||||
"command": "cargo install -q worker-build && worker-build --release"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "PROXY_TARGET",
|
||||
"service": "example-service"
|
||||
}
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "KV_STORAGE",
|
||||
"id": "your-id",
|
||||
"preview_id": "your-preview-id"
|
||||
}
|
||||
],
|
||||
"dev": {
|
||||
"port": 3000,
|
||||
"ip": "localhost"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user