feat: Add OIDC JWT authentication middleware for pkg6depotd
Some checks are pending
Rust CI / Format (push) Waiting to run
Rust CI / Clippy (push) Waiting to run
Rust CI / Build (Linux) (push) Blocked by required conditions
Rust CI / Build (Illumos) (push) Blocked by required conditions
Rust CI / Test (push) Blocked by required conditions
Rust CI / End-to-End Tests (push) Blocked by required conditions
Rust CI / Documentation (push) Blocked by required conditions

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.
This commit is contained in:
Till Wegmueller 2026-04-09 22:39:20 +02:00
parent 96b7207194
commit 8f048f6b2a
13 changed files with 1125 additions and 33 deletions

85
Cargo.lock generated
View file

@ -261,6 +261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1",
"zeroize", "zeroize",
] ]
@ -1607,6 +1608,23 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "keccak" name = "keccak"
version = "0.1.5" version = "0.1.5"
@ -2227,6 +2245,16 @@ dependencies = [
"hmac", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -2356,6 +2384,7 @@ dependencies = [
"http-body-util", "http-body-util",
"httpdate", "httpdate",
"hyper", "hyper",
"jsonwebtoken",
"knuffel", "knuffel",
"libips", "libips",
"miette 7.6.0", "miette 7.6.0",
@ -2643,7 +2672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core", "rand_core 0.9.3",
] ]
[[package]] [[package]]
@ -2653,7 +2682,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@ -2809,7 +2847,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.2.16", "getrandom 0.2.16",
"libc", "libc",
"untrusted", "untrusted 0.9.0",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@ -2897,7 +2935,7 @@ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted 0.9.0",
] ]
[[package]] [[package]]
@ -3129,12 +3167,33 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.11" version = "0.4.11"
@ -3391,10 +3450,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@ -3403,6 +3464,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" 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]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"
@ -3761,6 +3832,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View file

@ -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<Manifest>,
received_files: HashSet<String>, // hashes of received payloads
needed_files: Vec<String>, // hashes still missing
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
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<RwLock<JwkSet>>, // cached JWKS
issuer: String,
audience: String,
required_scopes: Vec<String>,
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<String>, // from `scope` claim
pub roles: Vec<String>, // 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<Utc>,
}
```
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"
```

View file

@ -38,6 +38,10 @@ flate2 = "1"
httpdate = "1" httpdate = "1"
urlencoding = "2" urlencoding = "2"
# Authentication
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Telemetry # Telemetry
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

View file

@ -76,6 +76,8 @@ pub struct Oauth2Config {
pub issuer: Option<String>, pub issuer: Option<String>,
#[knuffel(child, unwrap(argument))] #[knuffel(child, unwrap(argument))]
pub jwks_uri: Option<String>, pub jwks_uri: Option<String>,
#[knuffel(child, unwrap(argument))]
pub audience: Option<String>,
#[knuffel(child, unwrap(arguments))] #[knuffel(child, unwrap(arguments))]
pub required_scopes: Option<Vec<String>>, pub required_scopes: Option<Vec<String>>,
} }

View file

@ -1,12 +1,13 @@
use axum::{ use axum::{
Json, Extension, Json,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Serialize; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use crate::http::middleware::{AuthState, AuthError};
use crate::repo::DepotRepo; use crate::repo::DepotRepo;
#[derive(Serialize)] #[derive(Serialize)]
@ -15,44 +16,115 @@ struct HealthResponse {
} }
pub async fn health(_state: State<Arc<DepotRepo>>) -> impl IntoResponse { pub async fn health(_state: State<Arc<DepotRepo>>) -> impl IntoResponse {
// Basic liveness/readiness for now. Future: include repo checks.
(StatusCode::OK, Json(HealthResponse { status: "ok" })) (StatusCode::OK, Json(HealthResponse { status: "ok" }))
} }
#[derive(Serialize)] #[derive(Serialize)]
struct AuthCheckResponse<'a> { struct AuthCheckResponse {
authenticated: bool, authenticated: bool,
token_present: bool, token_present: bool,
subject: Option<&'a str>, subject: Option<String>,
scopes: Vec<&'a str>, scopes: Vec<String>,
roles: Vec<String>,
decision: &'static str, decision: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
} }
/// Admin auth-check endpoint. /// 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. /// If OAuth2 is configured (AuthState extension present), performs real JWT
pub async fn auth_check(_state: State<Arc<DepotRepo>>, headers: HeaderMap) -> Response { /// validation. Otherwise falls back to checking for Bearer token presence.
let auth = headers pub async fn auth_check(
.get(axum::http::header::AUTHORIZATION) auth_state: Option<Extension<Arc<AuthState>>>,
headers: HeaderMap,
) -> Response {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok()); .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 { let resp = AuthCheckResponse {
authenticated, authenticated,
token_present, token_present,
subject: None, subject: None,
scopes: vec![], scopes: vec![],
roles: vec![],
decision: if authenticated { "allow" } else { "deny" }, 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 status = if authenticated { // Real validation path
StatusCode::OK let Some(header_val) = auth_header else {
} else { let resp = AuthCheckResponse {
StatusCode::UNAUTHORIZED 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 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();
}
};
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() (status, Json(resp)).into_response()
}
}
} }

View file

@ -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<RwLock<JwkSet>>,
issuer: String,
audience: String,
required_scopes: Vec<String>,
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<String>,
}
/// 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<String>),
}
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<String>,
/// Roles from the token (custom claim for RBAC)
pub roles: Vec<String>,
}
/// 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<Oauth2Config>) -> Option<Self> {
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<JwkSet, AuthError> {
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<AuthenticatedUser, AuthError> {
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<String> = 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<TokenData<Claims>, 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::<Claims>(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<Arc<AuthState>>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
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<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AuthError::MissingToken)
}
}

View file

@ -1 +1,3 @@
// Placeholder for middleware pub mod auth;
pub use auth::{require_auth, AuthState, AuthenticatedUser, AuthError};

View file

@ -2,9 +2,10 @@ use crate::http::admin;
use crate::http::handlers::{ use crate::http::handlers::{
catalog, file, index, info, manifest, publisher, search, shard, ui, versions, catalog, file, index, info, manifest, publisher, search, shard, ui, versions,
}; };
use crate::http::middleware::AuthState;
use crate::repo::DepotRepo; use crate::repo::DepotRepo;
use axum::{ use axum::{
Router, Extension, Router,
response::Redirect, response::Redirect,
routing::{get, post}, routing::{get, post},
}; };
@ -12,6 +13,21 @@ use std::sync::Arc;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
pub fn app_router(state: Arc<DepotRepo>) -> Router { pub fn app_router(state: Arc<DepotRepo>) -> Router {
app_router_with_auth(state, None)
}
pub fn app_router_with_auth(state: Arc<DepotRepo>, auth: Option<Arc<AuthState>>) -> 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<DepotRepo>) -> Router {
Router::new() Router::new()
.route("/", get(|| async { Redirect::permanent("/ui/") })) .route("/", get(|| async { Redirect::permanent("/ui/") }))
.route("/versions/0", get(versions::get_versions)) .route("/versions/0", get(versions::get_versions))

View file

@ -56,7 +56,12 @@ pub async fn run() -> Result<()> {
daemon::daemonize().map_err(|e| miette::miette!(e))?; 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 let bind_str = config
.server .server
.bind .bind

View file

@ -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<String>,
}
/// 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<String>,
) -> 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);
}

View file

@ -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"
}

View file

@ -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-----

View file

@ -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-----