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
710use std:: sync:: OnceLock ;
811
@@ -18,12 +21,13 @@ use tracing::warn;
1821
1922use crate :: db;
2023use 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
2832impl 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 ) ]
4347struct 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.
221284fn parse_cookie < ' a > ( cookie_header : & ' a str , name : & str ) -> Option < & ' a str > {
222285 cookie_header. split ( ';' ) . find_map ( |pair| {
0 commit comments