This commit is contained in:
geoffsee
2025-05-11 17:04:52 -04:00
commit 958d9a19df
36 changed files with 7161 additions and 0 deletions

13
.dev.vars Normal file
View 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
View File

@@ -0,0 +1,5 @@
/target/
/node_modules/
/.wrangler/
/.idea/
/build/

3868
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

76
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod public;
pub mod authenticated;

353
src/api/public.rs Normal file
View 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();
}
}
}
}

View 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};

View 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>>,
}

View 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(),
}),
})
}
}

View 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()));
}
}
}

View File

@@ -0,0 +1 @@
pub mod introspection;

View 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
View 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
View File

@@ -0,0 +1,6 @@
mod application;
mod jwt;
mod service_account;
pub use application::*;
pub use service_account::*;

View 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
View 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
View 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()
);
}
}

View 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(());
}
}

View 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
View 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
}
}

View 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
View File

@@ -0,0 +1,2 @@
pub mod discovery;
pub mod introspection;

View 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
// }
// }

View 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
}
}

View File

@@ -0,0 +1,2 @@
pub mod cloudflare;
pub mod in_memory;

30
src/utilities.rs Normal file
View 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
View 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
View 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
View 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
View 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"
}
}