Skip to content

Commit d553d0b

Browse files
pilartomasclaude
andcommitted
feat(gateway): add OAuth resource server support via OIDC JWKS
Make the gateway accept OIDC access tokens (Bearer JWT validated against the provider's JWKS) in addition to the existing NextAuth session cookies and API keys. Auth methods are tried in order: oc_ API key, OIDC Bearer JWT, NextAuth cookie. JWKS is optional — enabled when OAUTH_ISSUER is set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 355a19e commit d553d0b

6 files changed

Lines changed: 372 additions & 30 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ DATABASE_URL=postgresql://onecli:onecli@localhost:5432/onecli
66
# Generate with: openssl rand -hex 32
77
NEXTAUTH_SECRET=
88

9-
# Gateway auth mode — "local" skips JWT validation (single-user dev), "oauth" validates NextAuth cookies.
9+
# Gateway auth mode — "local" skips JWT validation (single-user dev), "oauth" validates OIDC access tokens.
1010
AUTH_MODE=local
1111

1212
# OIDC login provider (Keycloak, Okta, Auth0, etc.)

apps/gateway/src/auth.rs

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
//! Gateway authentication for browser requests.
1+
//! Gateway authentication for browser and API requests.
22
//!
33
//! Supports two modes controlled by the `AUTH_MODE` env var:
44
//! - `local`: bypasses JWT validation, looks up the "local-admin" user directly.
5-
//! - `oauth` (default): validates a NextAuth session cookie JWT (HS256).
5+
//! - `oauth` (default): accepts three auth methods (tried in order):
6+
//! 1. API key: `Authorization: Bearer oc_...`
7+
//! 2. OIDC access token: `Authorization: Bearer <jwt>` (validated via JWKS)
8+
//! 3. NextAuth session cookie: `authjs.session-token` (HS256 via NEXTAUTH_SECRET)
69
710
use std::sync::OnceLock;
811

@@ -18,12 +21,13 @@ use tracing::warn;
1821

1922
use crate::db;
2023
use crate::gateway::GatewayState;
24+
use crate::jwks::JwksManager;
2125

2226
// ── AuthError ────────────────────────────────────────────────────────────
2327

2428
/// Authentication error — always returns 401 Unauthorized.
2529
#[derive(Debug)]
26-
pub(crate) struct AuthError(String);
30+
pub(crate) struct AuthError(pub(crate) String);
2731

2832
impl std::fmt::Display for AuthError {
2933
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -37,7 +41,7 @@ impl IntoResponse for AuthError {
3741
}
3842
}
3943

40-
// ── JWT claims ───────────────────────────────────────────────────────────
44+
// ── NextAuth cookie claims ──────────────────────────────────────────────
4145

4246
#[derive(Debug, Deserialize)]
4347
struct SessionClaims {
@@ -60,7 +64,13 @@ fn nextauth_secret() -> Option<&'static str> {
6064

6165
// ── Extractor ────────────────────────────────────────────────────────────
6266

63-
/// Authenticated user extracted from browser session cookies.
67+
/// Authenticated user extracted from the request.
68+
///
69+
/// Authentication methods (tried in order):
70+
/// 1. API key: `Authorization: Bearer oc_...` (OneCLI API key)
71+
/// 2. OIDC access token: `Authorization: Bearer <jwt>` (validated via JWKS)
72+
/// 3. NextAuth session cookie: `authjs.session-token` (HS256 via NEXTAUTH_SECRET)
73+
/// 4. Local mode: bypasses auth, returns the "local-admin" user
6474
///
6575
/// Add as an Axum handler parameter to require authentication:
6676
/// ```ignore
@@ -78,18 +88,19 @@ impl FromRequestParts<GatewayState> for AuthUser {
7888
parts: &mut Parts,
7989
state: &GatewayState,
8090
) -> Result<Self, Self::Rejection> {
91+
let pool = &state.policy_engine.pool;
92+
8193
// Try API key auth first (Authorization: Bearer oc_...)
82-
if let Some(api_key_user) =
83-
validate_api_key(&state.policy_engine.pool, &parts.headers).await
84-
{
94+
if let Some(api_key_user) = validate_api_key(pool, &parts.headers).await {
8595
return Ok(api_key_user);
8696
}
8797

88-
// Fall back to session auth (cookies / JWT)
89-
let user_id = validate_request(&state.policy_engine.pool, &parts.headers).await?;
98+
// Fall back to session auth (OIDC JWT, NextAuth cookie, or local mode)
99+
let user_id =
100+
validate_request(pool, &parts.headers, state.jwks.as_ref()).await?;
90101

91102
// Resolve account from membership
92-
let account_id = db::find_account_id_by_user(&state.policy_engine.pool, &user_id)
103+
let account_id = db::find_account_id_by_user(pool, &user_id)
93104
.await
94105
.map_err(|e| {
95106
warn!(error = %e, "auth: failed to resolve account");
@@ -134,12 +145,15 @@ async fn validate_api_key(pool: &PgPool, headers: &HeaderMap) -> Option<AuthUser
134145

135146
// ── Session auth ─────────────────────────────────────────────────────────
136147

137-
/// Validate an incoming browser request and return the internal user ID.
138-
/// The caller resolves the account ID from the user's membership.
139-
async fn validate_request(pool: &PgPool, headers: &HeaderMap) -> Result<String, AuthError> {
148+
/// Validate an incoming request and return the internal user ID.
149+
async fn validate_request(
150+
pool: &PgPool,
151+
headers: &HeaderMap,
152+
jwks: Option<&JwksManager>,
153+
) -> Result<String, AuthError> {
140154
match auth_mode() {
141155
"local" => validate_local(pool).await,
142-
_ => validate_oauth(pool, headers).await,
156+
_ => validate_oauth(pool, headers, jwks).await,
143157
}
144158
}
145159

@@ -162,28 +176,75 @@ async fn validate_local(pool: &PgPool) -> Result<String, AuthError> {
162176

163177
// ── OAuth mode ───────────────────────────────────────────────────────────
164178

165-
async fn validate_oauth(pool: &PgPool, headers: &HeaderMap) -> Result<String, AuthError> {
166-
// 1. Extract session token from cookies
179+
/// Authenticate via OIDC access token (Bearer header) or NextAuth session cookie.
180+
///
181+
/// Tries the Bearer token first (via JWKS validation), then falls back to
182+
/// the NextAuth session cookie (HS256 with NEXTAUTH_SECRET).
183+
async fn validate_oauth(
184+
pool: &PgPool,
185+
headers: &HeaderMap,
186+
jwks: Option<&JwksManager>,
187+
) -> Result<String, AuthError> {
188+
// 1. Try OIDC access token from Authorization header
189+
if let Some(sub) = try_bearer_jwt(headers, jwks).await {
190+
return lookup_user(pool, &sub).await;
191+
}
192+
193+
// 2. Fall back to NextAuth session cookie
194+
validate_nextauth_cookie(pool, headers).await
195+
}
196+
197+
/// Try to validate a non-`oc_` Bearer token as an OIDC access token.
198+
/// Returns the `sub` claim on success, `None` if no valid token found.
199+
async fn try_bearer_jwt(headers: &HeaderMap, jwks: Option<&JwksManager>) -> Option<String> {
200+
let jwks = jwks?;
201+
202+
let auth_header = headers
203+
.get(hyper::header::AUTHORIZATION)?
204+
.to_str()
205+
.ok()?;
206+
207+
let token = auth_header
208+
.strip_prefix("Bearer ")
209+
.or_else(|| auth_header.strip_prefix("bearer "))?;
210+
211+
// Skip oc_ tokens — those are API keys handled elsewhere
212+
if token.starts_with("oc_") {
213+
return None;
214+
}
215+
216+
match jwks.validate(token).await {
217+
Ok(claims) => Some(claims.sub),
218+
Err(e) => {
219+
warn!(error = %e, "OIDC bearer auth: JWT validation failed");
220+
None
221+
}
222+
}
223+
}
224+
225+
/// Validate a NextAuth session cookie (HS256 JWT signed with NEXTAUTH_SECRET).
226+
async fn validate_nextauth_cookie(
227+
pool: &PgPool,
228+
headers: &HeaderMap,
229+
) -> Result<String, AuthError> {
167230
let cookie_header = headers
168231
.get(hyper::header::COOKIE)
169232
.and_then(|v| v.to_str().ok())
170233
.ok_or_else(|| {
171234
warn!("oauth auth: no cookie header");
172-
AuthError("missing cookie".to_string())
235+
AuthError("missing authentication".to_string())
173236
})?;
174237

175238
let token = parse_cookie(cookie_header, "authjs.session-token").ok_or_else(|| {
176239
warn!("oauth auth: session token cookie not found");
177240
AuthError("missing session token".to_string())
178241
})?;
179242

180-
// 2. Read NEXTAUTH_SECRET
181243
let secret = nextauth_secret().ok_or_else(|| {
182244
warn!("oauth auth: NEXTAUTH_SECRET not set");
183245
AuthError("server misconfigured".to_string())
184246
})?;
185247

186-
// 3. Decode JWT (HS256)
187248
let mut validation = Validation::new(Algorithm::HS256);
188249
validation.required_spec_claims.clear();
189250
validation.validate_exp = false;
@@ -194,29 +255,31 @@ async fn validate_oauth(pool: &PgPool, headers: &HeaderMap) -> Result<String, Au
194255
&validation,
195256
)
196257
.map_err(|e| {
197-
warn!(error = %e, "oauth auth: JWT decode failed");
258+
warn!(error = %e, "oauth auth: NextAuth JWT decode failed");
198259
AuthError("invalid session token".to_string())
199260
})?;
200261

201-
let sub = &token_data.claims.sub;
262+
lookup_user(pool, &token_data.claims.sub).await
263+
}
264+
265+
// ── Helpers ──────────────────────────────────────────────────────────────
202266

203-
// 4. Look up user by external auth ID
204-
let user = db::find_user_by_external_auth_id(pool, sub)
267+
/// Look up an internal user ID from an external auth ID (OIDC `sub` or NextAuth subject).
268+
async fn lookup_user(pool: &PgPool, external_auth_id: &str) -> Result<String, AuthError> {
269+
let user = db::find_user_by_external_auth_id(pool, external_auth_id)
205270
.await
206271
.map_err(|e| {
207272
warn!(error = %e, "oauth auth: db error");
208273
AuthError("internal error".to_string())
209274
})?
210275
.ok_or_else(|| {
211-
warn!(sub = %sub, "oauth auth: user not found");
276+
warn!(sub = %external_auth_id, "oauth auth: user not found");
212277
AuthError("user not found".to_string())
213278
})?;
214279

215280
Ok(user.id)
216281
}
217282

218-
// ── Helpers ──────────────────────────────────────────────────────────────
219-
220283
/// Parse a specific cookie value from a Cookie header string.
221284
fn parse_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
222285
cookie_header.split(';').find_map(|pair| {

apps/gateway/src/gateway.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use crate::ca::CertificateAuthority;
4040
use crate::cache::CacheStore;
4141
use crate::connect::{self, ConnectError, PolicyEngine};
4242
use crate::inject;
43+
use crate::jwks::JwksManager;
4344
use crate::vault;
4445

4546
// ── GatewayState ───────────────────────────────────────────────────────
@@ -61,6 +62,9 @@ pub(crate) struct GatewayState {
6162
pub cache: Arc<dyn CacheStore>,
6263
/// Provider-agnostic vault service for credential fetching.
6364
pub vault_service: Arc<vault::VaultService>,
65+
/// OIDC JWKS manager for OAuth access token validation.
66+
/// `None` in local auth mode (no OIDC provider configured).
67+
pub jwks: Option<JwksManager>,
6468
}
6569

6670
// ── GatewayServer ───────────────────────────────────────────────────────
@@ -123,6 +127,7 @@ impl GatewayServer {
123127
policy_engine: Arc<PolicyEngine>,
124128
vault_service: Arc<vault::VaultService>,
125129
cache: Arc<dyn CacheStore>,
130+
jwks: Option<JwksManager>,
126131
) -> Self {
127132
let global_skip = std::env::var("GATEWAY_DANGER_ACCEPT_INVALID_CERTS").is_ok();
128133
let skip_verify_hosts = Arc::new(parse_skip_verify_hosts());
@@ -141,6 +146,7 @@ impl GatewayServer {
141146
policy_engine,
142147
cache,
143148
vault_service,
149+
jwks,
144150
};
145151

146152
Self { state, port }

0 commit comments

Comments
 (0)