Skip to main content

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}