feat: Implement OIDC authentication with JWT token handling and dynamic auth configuration

This commit is contained in:
2026-01-06 21:10:57 +01:00
parent a5f9e8ae9e
commit 3d9c72a7ef
17 changed files with 265 additions and 75 deletions

View File

@@ -5,7 +5,7 @@ edition = "2024"
default-run = "notes-api"
[features]
default = ["sqlite", "smart-features", "auth-oidc", "auth-jwt"]
default = ["sqlite", "smart-features"]
sqlite = ["notes-infra/sqlite"]
postgres = ["notes-infra/postgres"]
smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"]

View File

@@ -1,10 +1,10 @@
#[cfg(feature = "smart-features")]
use notes_infra::factory::{EmbeddingProvider, VectorProvider};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::env;
/// Authentication mode - determines how the API authenticates requests
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
/// Session-based authentication using cookies (default for backward compatibility)
@@ -66,6 +66,9 @@ pub struct Config {
/// Whether the application is running in production mode
pub is_production: bool,
/// Frontend URL for OIDC redirect (defaults to first CORS origin)
pub frontend_url: String,
}
impl Default for Config {
@@ -100,6 +103,7 @@ impl Default for Config {
jwt_audience: None,
jwt_expiry_hours: 24,
is_production: false,
frontend_url: "http://localhost:5173".to_string(),
}
}
}
@@ -219,6 +223,8 @@ impl Config {
jwt_audience,
jwt_expiry_hours,
is_production,
frontend_url: env::var("FRONTEND_URL")
.unwrap_or_else(|_| "http://localhost:5173".to_string()),
}
}
}

View File

@@ -7,6 +7,8 @@ use validator::Validate;
use notes_domain::{Email, Note, Password, Tag};
use crate::config::AuthMode;
/// Request to create a new note
#[derive(Debug, Deserialize, Validate)]
pub struct CreateNoteRequest {
@@ -165,6 +167,9 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
pub auth_mode: AuthMode,
pub oidc_enabled: bool,
pub password_login_enabled: bool,
}
/// Note Link response DTO

View File

@@ -387,25 +387,19 @@ async fn oidc_callback(
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
// In JWT mode, return token as JSON
// In JWT mode, redirect to frontend with token in URL fragment
#[cfg(feature = "auth-jwt")]
if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) {
let token = create_jwt_for_user(&user, &state)?;
return Ok(Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
})
.into_response());
let redirect_url = format!(
"{}/auth/callback#access_token={}",
state.config.frontend_url, token
);
return Ok(axum::response::Redirect::to(&redirect_url).into_response());
}
// Session mode: return user info
Ok(Json(UserResponse {
id: user.id,
email: user.email,
created_at: user.created_at,
})
.into_response())
// Session mode: redirect to frontend (session cookie already set)
Ok(axum::response::Redirect::to(&state.config.frontend_url).into_response())
}
/// Fallback OIDC callback when auth-axum-login is not enabled
@@ -470,15 +464,15 @@ async fn oidc_callback(
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
// Return token as JSON
// Redirect to frontend with token in URL fragment
#[cfg(feature = "auth-jwt")]
{
let token = create_jwt_for_user(&user, &state)?;
return Ok(Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}));
let redirect_url = format!(
"{}/auth/callback#access_token={}",
state.config.frontend_url, token
);
return Ok(axum::response::Redirect::to(&redirect_url));
}
#[cfg(not(feature = "auth-jwt"))]

View File

@@ -10,5 +10,11 @@ use crate::state::AppState;
pub async fn get_config(State(state): State<AppState>) -> ApiResult<Json<ConfigResponse>> {
Ok(Json(ConfigResponse {
allow_registration: state.config.allow_registration,
auth_mode: state.config.auth_mode,
#[cfg(feature = "auth-oidc")]
oidc_enabled: state.oidc_service.is_some(),
#[cfg(not(feature = "auth-oidc"))]
oidc_enabled: false,
password_login_enabled: cfg!(feature = "auth-axum-login"),
}))
}