solo_api/auth/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Pluggable auth for Solo's HTTP transport (v0.8.0 P3).
4//!
5//! Two modes (configured via `[auth]` block in `solo.config.toml`):
6//! * **Bearer** — single shared token; one tenant per daemon. Identical
7//! wire-behavior to v0.7.x bearer auth, re-implemented here so the
8//! middleware that emits `AuthenticatedPrincipal` covers both modes.
9//! * **OIDC** — standard OpenID Connect; any provider via discovery URL.
10//! JWKS keys are cached (TTL honors `Cache-Control: max-age=` from the
11//! discovery doc, falls back to 1 hour). A cache miss on an unknown
12//! `kid` triggers an immediate refetch (handles IdP key rotation
13//! without operator intervention).
14//!
15//! **MCP uses bearer-only at v0.8.0** — the MCP spec has no story for OIDC.
16//! **CLI is implicitly trusted** (no auth — admin tier).
17//!
18//! Wire shape:
19//! 1. Axum middleware (`auth_middleware`) runs ahead of the
20//! `TenantExtractor`. It validates the `Authorization` header and
21//! inserts an [`AuthenticatedPrincipal`] into the request extensions.
22//! 2. `TenantExtractor` then prefers `principal.tenant_claim` (set in
23//! OIDC mode from the configured JWT claim) over the
24//! `X-Solo-Tenant` header. Bearer-mode principals carry the daemon's
25//! default tenant.
26//!
27//! See `docs/dev-log/0090-v0.8.0-implementation-plan.md` Section 2 P3
28//! for the spec. ADR-0004 (added in P7) documents how auth ties into
29//! per-tenant writer-actor isolation.
30
31pub mod bearer;
32pub mod middleware;
33pub mod oidc;
34
35use serde::{Deserialize, Serialize};
36use solo_core::TenantId;
37
38/// Configuration for one auth mode. Stored in [`solo_storage::SoloConfig`]
39/// under the `[auth]` block.
40///
41/// Backward compatibility: when the `[auth]` block is absent from
42/// `solo.config.toml`, the runtime falls through to the
43/// `--bearer-token-file` CLI flag (v0.7.x behavior). Operators opt into
44/// the v0.8.0 config-driven path by writing an `[auth]` block.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(tag = "mode", rename_all = "snake_case")]
47pub enum AuthConfig {
48 /// Single shared bearer token; one tenant per daemon.
49 Bearer { token: String },
50 /// OIDC via any provider's discovery URL (`https://<host>/.well-known/openid-configuration`).
51 /// `audience` matches the JWT `aud` claim. `tenant_claim_name` names
52 /// the custom JWT claim that carries the target tenant id (defaults
53 /// to `solo_tenant`).
54 Oidc {
55 discovery_url: String,
56 audience: String,
57 #[serde(default = "default_tenant_claim_name")]
58 tenant_claim_name: String,
59 },
60}
61
62fn default_tenant_claim_name() -> String {
63 "solo_tenant".to_string()
64}
65
66impl Default for AuthConfig {
67 fn default() -> Self {
68 // Default = empty-bearer (effectively a "no-auth" mode for dev).
69 // Operators must opt in explicitly by setting an `[auth]` block;
70 // the daemon's `--bearer-token-file` flag still works for the
71 // v0.7.x path when the config block is absent.
72 AuthConfig::Bearer {
73 token: String::new(),
74 }
75 }
76}
77
78impl From<solo_storage::AuthSettings> for AuthConfig {
79 /// Convert the on-disk config block (`SoloConfig.auth`) into the
80 /// transport-side `AuthConfig`. Same wire shape — the duplication
81 /// is intentional so `solo-storage` doesn't depend on `solo-api`.
82 fn from(s: solo_storage::AuthSettings) -> Self {
83 match s {
84 solo_storage::AuthSettings::Bearer { token } => AuthConfig::Bearer { token },
85 solo_storage::AuthSettings::Oidc {
86 discovery_url,
87 audience,
88 tenant_claim_name,
89 } => AuthConfig::Oidc {
90 discovery_url,
91 audience,
92 tenant_claim_name,
93 },
94 }
95 }
96}
97
98/// Result of a successful auth check, attached to the request as an
99/// `axum::Extension`. The `TenantExtractor` in `http.rs` reads this to
100/// resolve the request's target tenant ahead of the `X-Solo-Tenant`
101/// header. P4 (audit log) reads `principal.subject` for the
102/// audit-log "who" field.
103#[derive(Debug, Clone)]
104pub struct AuthenticatedPrincipal {
105 /// JWT `sub` claim, or `"bearer"` for bearer-mode requests.
106 pub subject: String,
107 /// Tenant claimed by the JWT (`tenant_claim_name`), if any. In
108 /// bearer mode this is set to the daemon's default tenant. In OIDC
109 /// mode it's the validated value of the configured custom claim.
110 pub tenant_claim: Option<TenantId>,
111 /// Scopes advertised by the JWT (`scope` claim, space-split).
112 /// Empty for bearer-mode principals.
113 pub scopes: Vec<String>,
114 /// Raw JWT claims (`serde_json::Value`) for downstream inspection.
115 /// `Null` for bearer-mode principals.
116 pub claims: serde_json::Value,
117}
118
119impl AuthenticatedPrincipal {
120 /// For bearer mode: synthesize a principal with the daemon's
121 /// default tenant. No JWT, no claims, no scopes.
122 pub fn bearer(default_tenant: TenantId) -> Self {
123 Self {
124 subject: "bearer".to_string(),
125 tenant_claim: Some(default_tenant),
126 scopes: Vec::new(),
127 claims: serde_json::Value::Null,
128 }
129 }
130}
131
132/// Failure modes for both bearer and OIDC validation. The middleware
133/// maps these to HTTP status codes (401 for client-supplied-credential
134/// failures, 403 for tenant-claim issues, 500 for upstream IdP issues).
135#[derive(Debug, thiserror::Error)]
136pub enum AuthError {
137 #[error("missing Authorization header")]
138 MissingAuthHeader,
139 #[error("malformed Authorization header (expected `Bearer <token>`)")]
140 MalformedAuthHeader,
141 #[error("invalid bearer token")]
142 InvalidBearer,
143 #[error("invalid OIDC token: {reason}")]
144 InvalidOidcToken { reason: String },
145 #[error("OIDC token missing tenant claim '{claim_name}'")]
146 MissingTenantClaim { claim_name: String },
147 #[error("OIDC token has invalid tenant_id: {0}")]
148 InvalidTenantClaim(#[from] solo_core::TenantIdError),
149 #[error("OIDC discovery error: {0}")]
150 Discovery(String),
151 #[error("JWKS error: {0}")]
152 Jwks(String),
153}