From 8f048f6b2a349e371868d50104a3d2515b9da6c9 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Thu, 9 Apr 2026 22:39:20 +0200 Subject: [PATCH] feat: Add OIDC JWT authentication middleware for pkg6depotd Implement Bearer token validation using jsonwebtoken with JWKS caching and automatic key rotation handling. AuthState fetches keys from the configured OIDC provider's jwks_uri at startup and refreshes on unknown kid. Axum middleware (require_auth) protects write routes, injecting AuthenticatedUser into request extensions. The auth_check admin endpoint now performs real JWT validation when oauth2 is configured. Includes architecture plan for the full publish API with RBAC at docs/ai/plans/2026-04-09-publish-api-oidc.md. --- Cargo.lock | 85 ++++- docs/ai/plans/2026-04-09-publish-api-oidc.md | 222 +++++++++++ pkg6depotd/Cargo.toml | 4 + pkg6depotd/src/config.rs | 2 + pkg6depotd/src/http/admin.rs | 124 ++++-- pkg6depotd/src/http/middleware/auth.rs | 352 ++++++++++++++++++ pkg6depotd/src/http/middleware/mod.rs | 4 +- pkg6depotd/src/http/routes.rs | 18 +- pkg6depotd/src/lib.rs | 7 +- pkg6depotd/tests/auth_tests.rs | 295 +++++++++++++++ pkg6depotd/tests/fixtures/test_jwk.json | 8 + .../tests/fixtures/test_rsa_private.pem | 28 ++ pkg6depotd/tests/fixtures/test_rsa_public.pem | 9 + 13 files changed, 1125 insertions(+), 33 deletions(-) create mode 100644 docs/ai/plans/2026-04-09-publish-api-oidc.md create mode 100644 pkg6depotd/src/http/middleware/auth.rs create mode 100644 pkg6depotd/tests/auth_tests.rs create mode 100644 pkg6depotd/tests/fixtures/test_jwk.json create mode 100644 pkg6depotd/tests/fixtures/test_rsa_private.pem create mode 100644 pkg6depotd/tests/fixtures/test_rsa_public.pem diff --git a/Cargo.lock b/Cargo.lock index 790743d..55ca36f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,6 +261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1607,6 +1608,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.16", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2227,6 +2245,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2356,6 +2384,7 @@ dependencies = [ "http-body-util", "httpdate", "hyper", + "jsonwebtoken", "knuffel", "libips", "miette 7.6.0", @@ -2643,7 +2672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -2653,7 +2682,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -2809,7 +2847,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2897,7 +2935,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3129,12 +3167,33 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -3391,10 +3450,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3403,6 +3464,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3761,6 +3832,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/docs/ai/plans/2026-04-09-publish-api-oidc.md b/docs/ai/plans/2026-04-09-publish-api-oidc.md new file mode 100644 index 0000000..fcb6224 --- /dev/null +++ b/docs/ai/plans/2026-04-09-publish-api-oidc.md @@ -0,0 +1,222 @@ +# Publish API with OIDC Authentication + +**Date:** 2026-04-09 +**Status:** Active +**Issue:** [#3 Server Publish](https://codeberg.org/Toasterson/ips/issues/3) + +## Goal + +A transactional REST API for publishing packages to a pkg6 repository, protected by OIDC Bearer token authentication. Clients upload packages as `.p6p` or `.p5p` archives, or via individual manifest+file transactions. No pkg5 `pkgsend` wire compatibility — we define our own clean protocol. + +## Decisions + +- **No pkg5 client compatibility** for publish. We accept `.p5p` archives (legacy format) and `.p6p` archives (our format), plus individual manifest+file transactions. +- **No transaction timeout.** Transactions persist until explicitly committed or abandoned. +- **RBAC model** for publisher-level access control. JWT claims map to roles per publisher. + +## Publication Protocol + +### Endpoints + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| POST | `/{publisher}/open/0/{fmri}` | Required | Start a transaction, returns `Transaction-ID` | +| POST | `/{publisher}/add/0/{txn-id}` | Required | Add manifest or file payload to transaction | +| POST | `/{publisher}/close/0/{txn-id}` | Required | Commit transaction → package is published | +| POST | `/{publisher}/abandon/0/{txn-id}` | Required | Abort transaction, discard all data | +| GET | `/{publisher}/status/0/{txn-id}` | Required | Query transaction state (what files are still needed) | +| POST | `/{publisher}/publish/0/archive` | Required | Upload a .p6p or .p5p archive (batch publish) | + +### Transaction Lifecycle + +``` +Client Depot + │ │ + ├─ POST /open/0/{fmri} ───────►│ Create Transaction + │◄── 200 { txn_id, needed: [] }│ Return txn ID + │ │ + ├─ POST /add/0/{txn-id} ──────►│ Add manifest (multipart) + │ Content-Type: text/plain │ Parse manifest, compute needed files + │◄── 200 { needed: [hash1,…] } │ Return list of missing payloads + │ │ + ├─ POST /add/0/{txn-id} ──────►│ Upload file payload + │ X-IPkg-File-Hash: {hash} │ Store in transaction staging area + │◄── 200 { needed: [hash2,…] } │ Return remaining needed files + │ │ + ├─ POST /close/0/{txn-id} ────►│ Commit: move to repo atomically + │◄── 200 { fmri, published } │ Rebuild catalog, return published FMRI + │ │ + (or) │ + ├─ POST /abandon/0/{txn-id} ──►│ Discard transaction data + │◄── 200 │ +``` + +### Transaction State + +```rust +pub struct PublishTransaction { + id: String, + publisher: String, + fmri: Fmri, + state: TransactionState, // Open, Submitted, Published, Abandoned + manifest: Option, + received_files: HashSet, // hashes of received payloads + needed_files: Vec, // hashes still missing + created_at: DateTime, + updated_at: DateTime, + subject: String, // OIDC subject (who opened this) + staging_dir: PathBuf, // temp dir for this transaction's files +} +``` + +Transactions are stored in `{repo}/trans/{txn-id}/` on disk. On crash recovery, incomplete transactions can be resumed via the status endpoint. No automatic timeout — transactions persist until the client commits or abandons. + +## OIDC Authentication + +### Approach + +Use **`jsonwebtoken`** (v10) + **`reqwest`** for JWKS fetching with a cached key set. This gives us full control over validation and doesn't pull in a heavy framework. + +### Config + +Extend the existing `Oauth2Config`: + +```kdl +oauth2 { + issuer "https://id.example.com" + jwks-uri "https://id.example.com/.well-known/jwks.json" + audience "pkg6depotd" + required-scopes "pkg:publish" +} +``` + +### Middleware Design + +```rust +// New file: pkg6depotd/src/http/middleware/auth.rs + +pub struct AuthState { + jwks: Arc>, // cached JWKS + issuer: String, + audience: String, + required_scopes: Vec, + jwks_uri: String, +} + +/// Extracted from a validated JWT and injected into request extensions. +pub struct AuthenticatedUser { + pub subject: String, // OIDC `sub` claim + pub scopes: Vec, // from `scope` claim + pub roles: Vec, // from custom `roles` claim +} +``` + +The middleware: +1. Extracts `Authorization: Bearer {token}` header +2. Decodes JWT header to get `kid` (key ID) +3. Looks up signing key in cached JWKS (refresh if `kid` not found) +4. Validates signature, `iss`, `aud`, `exp`, `nbf` +5. Checks `scope` claim against `required_scopes` +6. Extracts `sub` and `roles` claims +7. Injects `AuthenticatedUser` into request extensions + +### Route Protection + +```rust +// In routes.rs — publish routes get the auth layer +let publish_routes = Router::new() + .route("/{publisher}/open/0/{fmri}", post(publish::open)) + .route("/{publisher}/add/0/{txn_id}", post(publish::add)) + .route("/{publisher}/close/0/{txn_id}", post(publish::close)) + .route("/{publisher}/abandon/0/{txn_id}", post(publish::abandon)) + .route("/{publisher}/status/0/{txn_id}", get(publish::status)) + .route("/{publisher}/publish/0/archive", post(publish::upload_archive)) + .layer(from_fn_with_state(auth_state, require_auth)); + +// Read routes remain unprotected +let read_routes = Router::new() + .route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest)) + // ... etc +``` + +Only registered when repository mode is not `readonly`. + +## RBAC Model + +Role-based access control per publisher. Roles are stored in the repository and mapped from OIDC subjects. + +### Roles + +| Role | Permissions | +|------|-------------| +| `admin` | All operations on a publisher (publish, delete, manage roles) | +| `publisher` | Open transactions, upload packages, commit | +| `reader` | Read-only (default for unauthenticated, no special handling needed) | + +### Storage + +```rust +// In libips repository config or a dedicated SQLite DB +pub struct PublisherRole { + pub publisher: String, + pub subject: String, // OIDC subject identifier + pub role: Role, // admin, publisher + pub granted_by: String, // who granted this role + pub granted_at: DateTime, +} +``` + +Stored in `{repo}/publisher/{name}/rbac.db` (SQLite). The auth middleware checks the role after JWT validation: + +1. Validate JWT → get `AuthenticatedUser.subject` +2. Look up `subject` in `rbac.db` for the target `publisher` +3. Check if the role permits the requested operation +4. Reject with 403 if insufficient permissions + +### Management + +``` +POST /admin/rbac/{publisher}/grant — grant a role to a subject +DELETE /admin/rbac/{publisher}/revoke — revoke a role +GET /admin/rbac/{publisher}/list — list role assignments +``` + +These admin endpoints require the `admin` role on the target publisher (or a global admin scope). + +## Implementation Phases + +### Phase 1: OIDC Token Validation +1. Add `jsonwebtoken` to pkg6depotd deps +2. Implement JWKS fetcher with caching (`AuthState`) +3. Implement `require_auth` axum middleware +4. Wire up to existing `auth_check` endpoint (replace placeholder) +5. Add `audience` field to `Oauth2Config` +6. Test with a real OIDC provider + +### Phase 2: Publish Transaction API +1. Implement `PublishTransaction` state machine in libips +2. Add `open/0` handler — creates transaction, returns ID +3. Add `add/0` handler — accepts manifest or file payload +4. Add `close/0` handler — commits via `FileBackend::Transaction` +5. Add `abandon/0` and `status/0` handlers +6. Only register publish routes when mode != readonly + +### Phase 3: Archive Upload +1. Add `publish/0/archive` handler — accepts .p6p or .p5p upload +2. Detect format by extension/magic bytes +3. For .p5p: use existing `Pkg5Importer` pipeline +4. For .p6p: use `ArchiveBackend` + receiver pipeline +5. Returns summary of published packages + +### Phase 4: RBAC +1. Create `rbac.db` schema and access layer in libips +2. Add role check after JWT validation in middleware +3. Add `/admin/rbac/` management endpoints +4. Audit logging (who published what, when) + +## Dependencies to Add + +```toml +# pkg6depotd/Cargo.toml +jsonwebtoken = "10" +``` diff --git a/pkg6depotd/Cargo.toml b/pkg6depotd/Cargo.toml index b5568ca..c793adb 100644 --- a/pkg6depotd/Cargo.toml +++ b/pkg6depotd/Cargo.toml @@ -38,6 +38,10 @@ flate2 = "1" httpdate = "1" urlencoding = "2" +# Authentication +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + # Telemetry tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/pkg6depotd/src/config.rs b/pkg6depotd/src/config.rs index e2ea1f2..27fd148 100644 --- a/pkg6depotd/src/config.rs +++ b/pkg6depotd/src/config.rs @@ -76,6 +76,8 @@ pub struct Oauth2Config { pub issuer: Option, #[knuffel(child, unwrap(argument))] pub jwks_uri: Option, + #[knuffel(child, unwrap(argument))] + pub audience: Option, #[knuffel(child, unwrap(arguments))] pub required_scopes: Option>, } diff --git a/pkg6depotd/src/http/admin.rs b/pkg6depotd/src/http/admin.rs index 45b7300..946b838 100644 --- a/pkg6depotd/src/http/admin.rs +++ b/pkg6depotd/src/http/admin.rs @@ -1,12 +1,13 @@ use axum::{ - Json, + Extension, Json, extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use serde::Serialize; use std::sync::Arc; +use crate::http::middleware::{AuthState, AuthError}; use crate::repo::DepotRepo; #[derive(Serialize)] @@ -15,44 +16,115 @@ struct HealthResponse { } pub async fn health(_state: State>) -> impl IntoResponse { - // Basic liveness/readiness for now. Future: include repo checks. (StatusCode::OK, Json(HealthResponse { status: "ok" })) } #[derive(Serialize)] -struct AuthCheckResponse<'a> { +struct AuthCheckResponse { authenticated: bool, token_present: bool, - subject: Option<&'a str>, - scopes: Vec<&'a str>, + subject: Option, + scopes: Vec, + roles: Vec, decision: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } /// Admin auth-check endpoint. -/// For now, this is a minimal placeholder that only checks for the presence of a Bearer token. -/// TODO: Validate JWT via OIDC JWKs using configured issuer/jwks_uri and required scopes. -pub async fn auth_check(_state: State>, headers: HeaderMap) -> Response { - let auth = headers - .get(axum::http::header::AUTHORIZATION) +/// +/// If OAuth2 is configured (AuthState extension present), performs real JWT +/// validation. Otherwise falls back to checking for Bearer token presence. +pub async fn auth_check( + auth_state: Option>>, + headers: HeaderMap, +) -> Response { + let auth_header = headers + .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()); - let (authenticated, token_present) = match auth { - Some(h) if h.to_ascii_lowercase().starts_with("bearer ") => (true, true), - Some(_) => (false, true), - None => (false, false), + + let token_present = auth_header.is_some(); + + // If no AuthState configured, just check token presence (dev mode) + let Some(Extension(auth)) = auth_state else { + let authenticated = matches!(auth_header, Some(h) if h.to_ascii_lowercase().starts_with("bearer ")); + let resp = AuthCheckResponse { + authenticated, + token_present, + subject: None, + scopes: vec![], + roles: vec![], + decision: if authenticated { "allow" } else { "deny" }, + error: if !authenticated && !token_present { + Some("no Authorization header".to_string()) + } else if !authenticated { + Some("OIDC not configured, cannot validate token".to_string()) + } else { + None + }, + }; + let status = if authenticated { StatusCode::OK } else { StatusCode::UNAUTHORIZED }; + return (status, Json(resp)).into_response(); }; - let resp = AuthCheckResponse { - authenticated, - token_present, - subject: None, - scopes: vec![], - decision: if authenticated { "allow" } else { "deny" }, + // Real validation path + let Some(header_val) = auth_header else { + let resp = AuthCheckResponse { + authenticated: false, + token_present: false, + subject: None, + scopes: vec![], + roles: vec![], + decision: "deny", + error: Some("missing Authorization header".to_string()), + }; + return (StatusCode::UNAUTHORIZED, Json(resp)).into_response(); }; - let status = if authenticated { - StatusCode::OK - } else { - StatusCode::UNAUTHORIZED + let token = match AuthState::extract_bearer(header_val) { + Ok(t) => t, + Err(e) => { + let resp = AuthCheckResponse { + authenticated: false, + token_present: true, + subject: None, + scopes: vec![], + roles: vec![], + decision: "deny", + error: Some(e.to_string()), + }; + return (StatusCode::UNAUTHORIZED, Json(resp)).into_response(); + } }; - (status, Json(resp)).into_response() + + match auth.validate_token(token).await { + Ok(user) => { + let resp = AuthCheckResponse { + authenticated: true, + token_present: true, + subject: Some(user.subject), + scopes: user.scopes, + roles: user.roles, + decision: "allow", + error: None, + }; + (StatusCode::OK, Json(resp)).into_response() + } + Err(e) => { + let status = match &e { + AuthError::InsufficientScopes { .. } => StatusCode::FORBIDDEN, + _ => StatusCode::UNAUTHORIZED, + }; + let resp = AuthCheckResponse { + authenticated: false, + token_present: true, + subject: None, + scopes: vec![], + roles: vec![], + decision: "deny", + error: Some(e.to_string()), + }; + (status, Json(resp)).into_response() + } + } } diff --git a/pkg6depotd/src/http/middleware/auth.rs b/pkg6depotd/src/http/middleware/auth.rs new file mode 100644 index 0000000..d68adf2 --- /dev/null +++ b/pkg6depotd/src/http/middleware/auth.rs @@ -0,0 +1,352 @@ +use crate::config::Oauth2Config; +use axum::{ + extract::{FromRequestParts, Request}, + http::{header, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use jsonwebtoken::{ + decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, TokenData, Validation, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +/// Cached JWKS state for token validation. +/// +/// Initialised once at startup from the configured OIDC provider's `jwks_uri`. +/// Keys are refreshed automatically when a token presents a `kid` that is not +/// in the cache — this handles key rotation without restarts. +#[derive(Clone)] +pub struct AuthState { + jwks: Arc>, + issuer: String, + audience: String, + required_scopes: Vec, + jwks_uri: String, + http: reqwest::Client, +} + +/// Claims we extract from a validated JWT. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Subject (unique user identifier from the IdP) + pub sub: String, + /// Issuer + pub iss: String, + /// Audience (may be string or array — we accept both via serde) + #[serde(default)] + pub aud: Audience, + /// Expiration (unix timestamp) + pub exp: u64, + /// Issued at (unix timestamp) + #[serde(default)] + pub iat: u64, + /// Space-separated scopes (standard OIDC format) + #[serde(default)] + pub scope: String, + /// Roles claim (custom, used for RBAC) + #[serde(default)] + pub roles: Vec, +} + +/// OIDC `aud` can be a single string or an array of strings. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(untagged)] +pub enum Audience { + #[default] + None, + Single(String), + Multiple(Vec), +} + +impl Audience { + pub fn contains(&self, expected: &str) -> bool { + match self { + Audience::None => false, + Audience::Single(s) => s == expected, + Audience::Multiple(v) => v.iter().any(|s| s == expected), + } + } +} + +/// The authenticated user identity extracted from a valid JWT. +/// Injected into axum request extensions by the auth middleware. +#[derive(Debug, Clone)] +pub struct AuthenticatedUser { + /// OIDC subject identifier + pub subject: String, + /// Scopes from the token + pub scopes: Vec, + /// Roles from the token (custom claim for RBAC) + pub roles: Vec, +} + +/// Errors from token validation. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("missing Authorization header")] + MissingToken, + #[error("invalid Authorization header format")] + InvalidFormat, + #[error("token validation failed: {0}")] + ValidationFailed(#[from] jsonwebtoken::errors::Error), + #[error("no matching key found for kid: {0}")] + KeyNotFound(String), + #[error("insufficient scopes: required {required}, have {actual}")] + InsufficientScopes { required: String, actual: String }, + #[error("JWKS fetch failed: {0}")] + JwksFetchError(String), +} + +impl AuthState { + /// Create a new AuthState from an Oauth2Config. + /// + /// Fetches the JWKS from the configured URI at startup. + /// Returns `None` if OAuth2 is not configured. + pub async fn from_config(config: &Option) -> Option { + let config = config.as_ref()?; + let jwks_uri = config.jwks_uri.as_ref()?; + let issuer = config.issuer.as_ref()?; + + let http = reqwest::Client::new(); + + let jwks = match Self::fetch_jwks(&http, jwks_uri).await { + Ok(jwks) => { + info!( + "Loaded {} keys from JWKS endpoint {}", + jwks.keys.len(), + jwks_uri + ); + jwks + } + Err(e) => { + warn!( + "Failed to fetch JWKS from {} at startup: {}. Auth will retry on first request.", + jwks_uri, e + ); + JwkSet { keys: vec![] } + } + }; + + Some(AuthState { + jwks: Arc::new(RwLock::new(jwks)), + issuer: issuer.clone(), + audience: config.audience.clone().unwrap_or_default(), + required_scopes: config.required_scopes.clone().unwrap_or_default(), + jwks_uri: jwks_uri.clone(), + http, + }) + } + + /// Fetch the JWKS document from the configured URI. + async fn fetch_jwks(http: &reqwest::Client, uri: &str) -> Result { + let resp = http + .get(uri) + .send() + .await + .map_err(|e| AuthError::JwksFetchError(e.to_string()))?; + + let jwks: JwkSet = resp + .json() + .await + .map_err(|e| AuthError::JwksFetchError(e.to_string()))?; + + Ok(jwks) + } + + /// Refresh the cached JWKS from the configured endpoint. + async fn refresh_jwks(&self) -> Result<(), AuthError> { + let new_jwks = Self::fetch_jwks(&self.http, &self.jwks_uri).await?; + info!("Refreshed JWKS: {} keys loaded", new_jwks.keys.len()); + let mut cached = self.jwks.write().await; + *cached = new_jwks; + Ok(()) + } + + /// Validate a Bearer token and return the authenticated user. + /// + /// 1. Decode the JWT header to get the `kid` + /// 2. Find the matching key in the cached JWKS + /// 3. If not found, refresh JWKS and retry once (handles key rotation) + /// 4. Validate signature, issuer, audience, expiration + /// 5. Check required scopes + /// 6. Return the AuthenticatedUser + pub async fn validate_token(&self, token: &str) -> Result { + let header = decode_header(token)?; + let kid = header + .kid + .as_deref() + .unwrap_or(""); + + debug!("Validating token with kid={:?}, alg={:?}", kid, header.alg); + + // Try to find the key, refresh once if not found + let token_data = match self.decode_with_cached_keys(token, kid, header.alg).await { + Ok(td) => td, + Err(AuthError::KeyNotFound(_)) => { + debug!("Key not found in cache, refreshing JWKS"); + self.refresh_jwks().await?; + self.decode_with_cached_keys(token, kid, header.alg).await? + } + Err(e) => return Err(e), + }; + + let claims = token_data.claims; + + // Check required scopes + let token_scopes: Vec = claims + .scope + .split_whitespace() + .map(String::from) + .collect(); + + for required in &self.required_scopes { + if !token_scopes.iter().any(|s| s == required) { + return Err(AuthError::InsufficientScopes { + required: self.required_scopes.join(" "), + actual: claims.scope.clone(), + }); + } + } + + Ok(AuthenticatedUser { + subject: claims.sub, + scopes: token_scopes, + roles: claims.roles, + }) + } + + /// Attempt to decode the token using the cached JWKS. + async fn decode_with_cached_keys( + &self, + token: &str, + kid: &str, + alg: Algorithm, + ) -> Result, AuthError> { + let jwks = self.jwks.read().await; + + // Find key by kid, or if no kid use first key matching the algorithm + let jwk = if !kid.is_empty() { + jwks.keys + .iter() + .find(|k| { + k.common.key_id.as_deref() == Some(kid) + }) + .ok_or_else(|| AuthError::KeyNotFound(kid.to_string()))? + } else { + // No kid — try first key that matches the algorithm + jwks.keys + .first() + .ok_or_else(|| AuthError::KeyNotFound("(no kid, no keys)".to_string()))? + }; + + let decoding_key = DecodingKey::from_jwk(jwk)?; + + let mut validation = Validation::new(alg); + validation.set_issuer(&[&self.issuer]); + if !self.audience.is_empty() { + validation.set_audience(&[&self.audience]); + } else { + // Don't validate audience if not configured + validation.validate_aud = false; + } + + let token_data = decode::(token, &decoding_key, &validation)?; + + Ok(token_data) + } + + /// Extract a Bearer token from an Authorization header value. + pub fn extract_bearer(auth_header: &str) -> Result<&str, AuthError> { + auth_header + .strip_prefix("Bearer ") + .or_else(|| auth_header.strip_prefix("bearer ")) + .ok_or(AuthError::InvalidFormat) + } +} + +// -- Axum middleware -- + +impl AuthError { + /// Map AuthError to an HTTP status code. + fn status_code(&self) -> StatusCode { + match self { + AuthError::MissingToken | AuthError::InvalidFormat | AuthError::KeyNotFound(_) => { + StatusCode::UNAUTHORIZED + } + AuthError::ValidationFailed(_) => StatusCode::UNAUTHORIZED, + AuthError::InsufficientScopes { .. } => StatusCode::FORBIDDEN, + AuthError::JwksFetchError(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let status = self.status_code(); + let body = serde_json::json!({ + "error": self.to_string(), + }); + (status, axum::Json(body)).into_response() + } +} + +/// Axum middleware that requires a valid Bearer token. +/// +/// Usage in router: +/// ```ignore +/// use axum::middleware::from_fn_with_state; +/// +/// let protected = Router::new() +/// .route("/publish", post(handler)) +/// .layer(from_fn_with_state(auth_state.clone(), require_auth)); +/// ``` +pub async fn require_auth( + axum::extract::State(auth): axum::extract::State>, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(AuthError::MissingToken)?; + + let token = AuthState::extract_bearer(auth_header)?; + let user = auth.validate_token(token).await?; + + debug!( + "Authenticated user: sub={}, scopes={:?}, roles={:?}", + user.subject, user.scopes, user.roles + ); + + // Inject the authenticated user into request extensions so handlers can access it + request.extensions_mut().insert(user); + + Ok(next.run(request).await) +} + +/// Axum extractor for `AuthenticatedUser`. +/// +/// Use in handler signatures to get the authenticated user: +/// ```ignore +/// async fn my_handler(user: AuthenticatedUser) -> impl IntoResponse { +/// format!("Hello, {}", user.subject) +/// } +/// ``` +impl FromRequestParts for AuthenticatedUser { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(AuthError::MissingToken) + } +} diff --git a/pkg6depotd/src/http/middleware/mod.rs b/pkg6depotd/src/http/middleware/mod.rs index 8df7622..282fc1a 100644 --- a/pkg6depotd/src/http/middleware/mod.rs +++ b/pkg6depotd/src/http/middleware/mod.rs @@ -1 +1,3 @@ -// Placeholder for middleware +pub mod auth; + +pub use auth::{require_auth, AuthState, AuthenticatedUser, AuthError}; diff --git a/pkg6depotd/src/http/routes.rs b/pkg6depotd/src/http/routes.rs index b7ef80d..fd3d2cc 100644 --- a/pkg6depotd/src/http/routes.rs +++ b/pkg6depotd/src/http/routes.rs @@ -2,9 +2,10 @@ use crate::http::admin; use crate::http::handlers::{ catalog, file, index, info, manifest, publisher, search, shard, ui, versions, }; +use crate::http::middleware::AuthState; use crate::repo::DepotRepo; use axum::{ - Router, + Extension, Router, response::Redirect, routing::{get, post}, }; @@ -12,6 +13,21 @@ use std::sync::Arc; use tower_http::trace::TraceLayer; pub fn app_router(state: Arc) -> Router { + app_router_with_auth(state, None) +} + +pub fn app_router_with_auth(state: Arc, auth: Option>) -> Router { + let router = base_router(state); + + // If auth is configured, add it as an extension so handlers can access it + if let Some(auth_state) = auth { + router.layer(Extension(auth_state)) + } else { + router + } +} + +fn base_router(state: Arc) -> Router { Router::new() .route("/", get(|| async { Redirect::permanent("/ui/") })) .route("/versions/0", get(versions::get_versions)) diff --git a/pkg6depotd/src/lib.rs b/pkg6depotd/src/lib.rs index 5de5ce3..1cf231f 100644 --- a/pkg6depotd/src/lib.rs +++ b/pkg6depotd/src/lib.rs @@ -56,7 +56,12 @@ pub async fn run() -> Result<()> { daemon::daemonize().map_err(|e| miette::miette!(e))?; } - let router = http::routes::app_router(state); + // Initialize OIDC auth if configured + let auth_state = http::middleware::AuthState::from_config(&config.oauth2) + .await + .map(Arc::new); + + let router = http::routes::app_router_with_auth(state, auth_state); let bind_str = config .server .bind diff --git a/pkg6depotd/tests/auth_tests.rs b/pkg6depotd/tests/auth_tests.rs new file mode 100644 index 0000000..f173194 --- /dev/null +++ b/pkg6depotd/tests/auth_tests.rs @@ -0,0 +1,295 @@ +use axum::{ + Router, + middleware::from_fn_with_state, + response::IntoResponse, + routing::get, +}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use pkg6depotd::http::middleware::{require_auth, AuthState, AuthenticatedUser}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; +use tokio::net::TcpListener; + +/// Claims matching the format AuthState expects. +#[derive(Serialize, Deserialize)] +struct TestClaims { + sub: String, + iss: String, + aud: String, + exp: u64, + iat: u64, + scope: String, + roles: Vec, +} + +/// Create a test token with the given claims. +fn create_test_token( + encoding_key: &EncodingKey, + kid: &str, + claims: &TestClaims, +) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode(&header, claims, encoding_key).unwrap() +} + +/// Helper to get current unix timestamp. +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +/// Start a tiny JWKS HTTP server and return (url, join_handle). +async fn start_jwks_server(jwks_json: serde_json::Value) -> (String, tokio::task::JoinHandle<()>) { + let jwks_body = serde_json::to_string(&jwks_json).unwrap(); + + let app = Router::new().route( + "/.well-known/jwks.json", + get(move || { + let body = jwks_body.clone(); + async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() + } + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (format!("http://{}", addr), handle) +} + +/// Build AuthState from a running JWKS server. +async fn build_auth_state( + jwks_url: &str, + issuer: &str, + audience: &str, + required_scopes: Vec, +) -> AuthState { + let config = pkg6depotd::config::Oauth2Config { + issuer: Some(issuer.to_string()), + jwks_uri: Some(format!("{}/.well-known/jwks.json", jwks_url)), + audience: Some(audience.to_string()), + required_scopes: Some(required_scopes), + }; + + AuthState::from_config(&Some(config)).await.unwrap() +} + +/// Build a JWKS JSON from a PEM public key. +/// This is hacky but avoids pulling in extra crates for tests. +fn pem_to_jwks_json(kid: &str) -> serde_json::Value { + // We use jsonwebtoken's own types to build the JWK + // But since we can't easily extract RSA components from PEM in pure Rust + // without extra crates, we'll use a pre-computed JWK for our test key. + let jwk_json = include_str!("fixtures/test_jwk.json"); + let mut jwk: serde_json::Value = serde_json::from_str(jwk_json).unwrap(); + jwk["kid"] = json!(kid); + + json!({ + "keys": [jwk] + }) +} + +// -- Tests -- + +#[tokio::test] +async fn test_valid_token_passes() { + let kid = "test-key-1"; + let issuer = "https://test-issuer.example.com"; + let audience = "pkg6depotd"; + + let jwks_json = pem_to_jwks_json(kid); + let (jwks_url, _handle) = start_jwks_server(jwks_json).await; + + let auth = build_auth_state(&jwks_url, issuer, audience, vec!["pkg:publish".into()]).await; + + let rsa_private_pem = include_str!("fixtures/test_rsa_private.pem"); + let encoding_key = EncodingKey::from_rsa_pem(rsa_private_pem.as_bytes()).unwrap(); + + let claims = TestClaims { + sub: "user-123".into(), + iss: issuer.into(), + aud: audience.into(), + exp: now() + 3600, + iat: now(), + scope: "pkg:publish pkg:read".into(), + roles: vec!["publisher".into()], + }; + + let token = create_test_token(&encoding_key, kid, &claims); + let user = auth.validate_token(&token).await.unwrap(); + + assert_eq!(user.subject, "user-123"); + assert!(user.scopes.contains(&"pkg:publish".to_string())); + assert!(user.roles.contains(&"publisher".to_string())); +} + +#[tokio::test] +async fn test_expired_token_rejected() { + let kid = "test-key-1"; + let issuer = "https://test-issuer.example.com"; + let audience = "pkg6depotd"; + + let jwks_json = pem_to_jwks_json(kid); + let (jwks_url, _handle) = start_jwks_server(jwks_json).await; + + let auth = build_auth_state(&jwks_url, issuer, audience, vec![]).await; + + let rsa_private_pem = include_str!("fixtures/test_rsa_private.pem"); + let encoding_key = EncodingKey::from_rsa_pem(rsa_private_pem.as_bytes()).unwrap(); + + let claims = TestClaims { + sub: "user-123".into(), + iss: issuer.into(), + aud: audience.into(), + exp: now() - 3600, // expired 1 hour ago + iat: now() - 7200, + scope: "pkg:publish".into(), + roles: vec![], + }; + + let token = create_test_token(&encoding_key, kid, &claims); + let result = auth.validate_token(&token).await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("ExpiredSignature") || err.contains("expired") || err.contains("validation"), + "Expected expiration error, got: {}", + err + ); +} + +#[tokio::test] +async fn test_wrong_audience_rejected() { + let kid = "test-key-1"; + let issuer = "https://test-issuer.example.com"; + + let jwks_json = pem_to_jwks_json(kid); + let (jwks_url, _handle) = start_jwks_server(jwks_json).await; + + let auth = build_auth_state(&jwks_url, issuer, "pkg6depotd", vec![]).await; + + let rsa_private_pem = include_str!("fixtures/test_rsa_private.pem"); + let encoding_key = EncodingKey::from_rsa_pem(rsa_private_pem.as_bytes()).unwrap(); + + let claims = TestClaims { + sub: "user-123".into(), + iss: issuer.into(), + aud: "wrong-audience".into(), // wrong audience + exp: now() + 3600, + iat: now(), + scope: "".into(), + roles: vec![], + }; + + let token = create_test_token(&encoding_key, kid, &claims); + let result = auth.validate_token(&token).await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("InvalidAudience") || err.contains("audience") || err.contains("validation"), + "Expected audience error, got: {}", + err + ); +} + +#[tokio::test] +async fn test_missing_scopes_rejected() { + let kid = "test-key-1"; + let issuer = "https://test-issuer.example.com"; + let audience = "pkg6depotd"; + + let jwks_json = pem_to_jwks_json(kid); + let (jwks_url, _handle) = start_jwks_server(jwks_json).await; + + let auth = + build_auth_state(&jwks_url, issuer, audience, vec!["pkg:publish".into()]).await; + + let rsa_private_pem = include_str!("fixtures/test_rsa_private.pem"); + let encoding_key = EncodingKey::from_rsa_pem(rsa_private_pem.as_bytes()).unwrap(); + + let claims = TestClaims { + sub: "user-123".into(), + iss: issuer.into(), + aud: audience.into(), + exp: now() + 3600, + iat: now(), + scope: "pkg:read".into(), // missing pkg:publish + roles: vec![], + }; + + let token = create_test_token(&encoding_key, kid, &claims); + let result = auth.validate_token(&token).await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("insufficient scopes"), + "Expected scope error, got: {}", + err + ); +} + +#[tokio::test] +async fn test_no_token_returns_401() { + // Test the middleware path: no Authorization header → 401 + let kid = "test-key-1"; + let issuer = "https://test-issuer.example.com"; + let audience = "pkg6depotd"; + + let jwks_json = pem_to_jwks_json(kid); + let (jwks_url, _handle) = start_jwks_server(jwks_json).await; + + let auth = + Arc::new(build_auth_state(&jwks_url, issuer, audience, vec![]).await); + + // Build a router with the auth middleware protecting a test route + let app = Router::new() + .route( + "/protected", + get(|user: AuthenticatedUser| async move { + format!("Hello, {}", user.subject) + }), + ) + .layer(from_fn_with_state(auth.clone(), require_auth)) + .with_state(auth); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = reqwest::Client::new(); + + // No token → 401 + let resp = client + .get(format!("http://{}/protected", addr)) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); + + // Invalid format → 401 + let resp = client + .get(format!("http://{}/protected", addr)) + .header("Authorization", "Basic dXNlcjpwYXNz") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); +} diff --git a/pkg6depotd/tests/fixtures/test_jwk.json b/pkg6depotd/tests/fixtures/test_jwk.json new file mode 100644 index 0000000..a64f899 --- /dev/null +++ b/pkg6depotd/tests/fixtures/test_jwk.json @@ -0,0 +1,8 @@ +{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "test-key-1", + "n": "whXnBPFpaBvOfnPFg3e-ffpEA9bv60nzm6--qr0sAj82ll-qbWUkimI0k8EY4p_FIbZxgLOYcSPRTJH8lM0fgINX-QgQdcQ-ekOmVxTZ6GhXwv1TAwhiCfH2y1C1Xw-KNqs1bqv_3bbRmgM4kKwIsg9v63XkXtVs77ebY2ayBYyDxWbFVHd9tfTyQtc5cqbSJVUG5rDkhfFa-IkmTVzjWhHx2aA1HS14n77TTCbHwdZvTBU-YXQtirJY-ObfIBjBMRjo_fGo_XFYF76QeZzAgZWIFpau_jVMzqmKD4DtUi1Wvn0lDpOqK65Vcftrqlq9i5AujcS5ReqRK63DIPRksQ", + "e": "AQAB" +} \ No newline at end of file diff --git a/pkg6depotd/tests/fixtures/test_rsa_private.pem b/pkg6depotd/tests/fixtures/test_rsa_private.pem new file mode 100644 index 0000000..1ec0a29 --- /dev/null +++ b/pkg6depotd/tests/fixtures/test_rsa_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCFecE8WloG85+ +c8WDd759+kQD1u/rSfObr76qvSwCPzaWX6ptZSSKYjSTwRjin8UhtnGAs5hxI9FM +kfyUzR+Ag1f5CBB1xD56Q6ZXFNnoaFfC/VMDCGIJ8fbLULVfD4o2qzVuq//dttGa +AziQrAiyD2/rdeRe1Wzvt5tjZrIFjIPFZsVUd3219PJC1zlyptIlVQbmsOSF8Vr4 +iSZNXONaEfHZoDUdLXifvtNMJsfB1m9MFT5hdC2Kslj45t8gGMExGOj98aj9cVgX +vpB5nMCBlYgWlq7+NUzOqYoPgO1SLVa+fSUOk6orrlVx+2uqWr2LkC6NxLlF6pEr +rcMg9GSxAgMBAAECggEAQClgdXxfZcjng1s/fP49jcUJ7iEEsIqCi8HWLPibz2RZ +Ze3bWA3bPhpIGl54HRdHYqU/MZZtu11laERMtV54XxJMp2mYk24cX2v01g3xGclA +1hfL9RE04+fHOCCGzRXEkd0YrW0UCZZSBXGyJfWRbFf5HmSbahRxTnAq4PoGuRlN +m6h/wlzyHr14dgDaVlHEIHr0v815ykd59uWGArAs5gK4GFENpdGvsmKgdVtMPNDI +zFIwkD7dH4d3ZcZB5O5ZSNto/NO/EqPVsyg11H1ysNXo1izzqgS4fYjEvf6DRRFy +TgA61z65M9FA9pjNbSMm/RwhwWlPGkErhTfcQM0ZbwKBgQD+f1Iwvq2xi7MZn6Lf +3RLxxOyL/PX0+tVED9FS5M3Q4PaQPL5cgXn6McN9h3QnphbjwUhonYKLniUU2f9J +EwYrhPFiCstzTt/cP5dm9H7qOnhAWt31/SOwUWUEl2F7Rmlu1fpQvbmcbPVwQRZW +8bs8cOk0OVW4qhy3UvmDl2hZUwKBgQDDO0R4wpAKf4/Y6l5hpAoLEKGbnd/mqqM7 +eywAhP3vWQZrHk6nhSJvLiFNP8tFxIWoNORTnl0naS+nj77s2vC0oMX1S/w4/2WZ +2Gb1AFYt+RMEFedo38pTO57mGnvX+dT2vOvdJId0VMrJjho2HrBdQdIpPSlDmUo8 +JrV+wEfVawKBgQCRZqbLqLVOAdWypw0EP6dqMCtBk6XmcETWXP8oEAcy9sSIBdxw +t5y8ACCDoJcRbAgZ2b0H4C3MnO7sqdv7oP3ecVcDv80bNQ4bJM3YiYnVQtCfXAsC +Vr1EKEzBwcd1CfaE14XrCWp5X5sepmEgDX3++zeRmcxK9A3yA1sA/skkdwKBgCB1 +ahzpvCkCrFfUH3z8WO8eBMBqrx8an6j0AYzUj6OLmZWVpF4VtHPnp4HAaXtgARjG +Mm/0lGhJBLNHIuceP4bIdCEkUPro+2tonzV8qNdb4d18Bs1Y57qO3wxCuvRdhRrA +rjZGLH8a2dxI0/LLh2b52ocgtAuZIM5/YQ2Bym+hAoGBAPoMdfD7m+2Mjd8Yyubl +c2CHvxoDxj/hAGDH22CsXhCEn0K3fGYb/so4A7zSSaPP/29HGEKV8cntfo9/OC5t +5OzKSKDaQuVgJmdttaVKxxqD57xIZ8zSVFUsKbiZgV+3h1qN0OQ8CTbDfwvPEkFf +EAo+ffP1RzREqDQ6bxgAuCxe +-----END PRIVATE KEY----- diff --git a/pkg6depotd/tests/fixtures/test_rsa_public.pem b/pkg6depotd/tests/fixtures/test_rsa_public.pem new file mode 100644 index 0000000..144e8ff --- /dev/null +++ b/pkg6depotd/tests/fixtures/test_rsa_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhXnBPFpaBvOfnPFg3e+ +ffpEA9bv60nzm6++qr0sAj82ll+qbWUkimI0k8EY4p/FIbZxgLOYcSPRTJH8lM0f +gINX+QgQdcQ+ekOmVxTZ6GhXwv1TAwhiCfH2y1C1Xw+KNqs1bqv/3bbRmgM4kKwI +sg9v63XkXtVs77ebY2ayBYyDxWbFVHd9tfTyQtc5cqbSJVUG5rDkhfFa+IkmTVzj +WhHx2aA1HS14n77TTCbHwdZvTBU+YXQtirJY+ObfIBjBMRjo/fGo/XFYF76QeZzA +gZWIFpau/jVMzqmKD4DtUi1Wvn0lDpOqK65Vcftrqlq9i5AujcS5ReqRK63DIPRk +sQIDAQAB +-----END PUBLIC KEY-----