mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
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
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:
parent
96b7207194
commit
8f048f6b2a
13 changed files with 1125 additions and 33 deletions
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
222
docs/ai/plans/2026-04-09-publish-api-oidc.md
Normal file
222
docs/ai/plans/2026-04-09-publish-api-oidc.md
Normal 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"
|
||||||
|
```
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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>>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
352
pkg6depotd/src/http/middleware/auth.rs
Normal file
352
pkg6depotd/src/http/middleware/auth.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
// Placeholder for middleware
|
pub mod auth;
|
||||||
|
|
||||||
|
pub use auth::{require_auth, AuthState, AuthenticatedUser, AuthError};
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
295
pkg6depotd/tests/auth_tests.rs
Normal file
295
pkg6depotd/tests/auth_tests.rs
Normal 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);
|
||||||
|
}
|
||||||
8
pkg6depotd/tests/fixtures/test_jwk.json
vendored
Normal file
8
pkg6depotd/tests/fixtures/test_jwk.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
28
pkg6depotd/tests/fixtures/test_rsa_private.pem
vendored
Normal file
28
pkg6depotd/tests/fixtures/test_rsa_private.pem
vendored
Normal 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-----
|
||||||
9
pkg6depotd/tests/fixtures/test_rsa_public.pem
vendored
Normal file
9
pkg6depotd/tests/fixtures/test_rsa_public.pem
vendored
Normal 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-----
|
||||||
Loading…
Add table
Reference in a new issue