chore: full stack stability and migration fixes, plus react UI progress
Some checks failed
CI / podman-build (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-18 09:01:38 +02:00
parent 38cab8c246
commit a66d908eff
142 changed files with 12210 additions and 3402 deletions

View File

@@ -37,7 +37,7 @@ struct RefreshTokenGrant {
pub async fn logout(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(db): Extension<PgPool>,
Extension(auth_ctx): Extension<AuthContext>,
) -> Result<StatusCode, (StatusCode, String)> {
let claims = auth_ctx
@@ -45,9 +45,8 @@ pub async fn logout(
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?;
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1 AND revoked = false")
sqlx::query("UPDATE auth.refresh_tokens SET revoked = true WHERE user_id = $1 AND revoked = false")
.bind(user_id)
.execute(&db)
.await
@@ -82,15 +81,14 @@ pub async fn settings(
}
pub async fn magiclink(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
State(_state): State<AuthState>,
Extension(db): Extension<PgPool>,
Json(payload): Json<RecoverRequest>,
) -> Result<Json<Value>, (StatusCode, String)> {
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let token = generate_confirmation_token();
let hashed_token = hash_refresh_token(&token);
sqlx::query("UPDATE users SET confirmation_token = $1 WHERE email = $2")
sqlx::query("UPDATE auth.users SET confirmation_token = $1 WHERE email = $2")
.bind(&hashed_token)
.bind(&payload.email)
.execute(&db)
@@ -103,8 +101,8 @@ pub async fn magiclink(
}
pub async fn delete_user(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
State(_state): State<AuthState>,
Extension(db): Extension<PgPool>,
Extension(auth_ctx): Extension<AuthContext>,
) -> Result<StatusCode, (StatusCode, String)> {
let claims = auth_ctx
@@ -112,15 +110,14 @@ pub async fn delete_user(
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?;
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
sqlx::query("UPDATE users SET deleted_at = now() WHERE id = $1")
sqlx::query("UPDATE auth.users SET deleted_at = now() WHERE id = $1")
.bind(user_id)
.execute(&db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1")
sqlx::query("UPDATE auth.refresh_tokens SET revoked = true WHERE user_id = $1")
.bind(user_id)
.execute(&db)
.await
@@ -131,16 +128,15 @@ pub async fn delete_user(
pub async fn signup(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(db): Extension<PgPool>,
project_ctx: Option<Extension<ProjectContext>>,
Json(payload): Json<SignUpRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
payload
.validate()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let user_exists = sqlx::query("SELECT id FROM users WHERE email = $1")
let user_exists = sqlx::query("SELECT id FROM auth.users WHERE email = $1")
.bind(&payload.email)
.fetch_optional(&db)
.await
@@ -158,7 +154,7 @@ pub async fn signup(
let user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (email, encrypted_password, raw_user_meta_data, confirmation_token, confirmed_at)
INSERT INTO auth.users (email, encrypted_password, raw_user_meta_data, confirmation_token, confirmed_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
@@ -179,7 +175,7 @@ pub async fn signup(
.unwrap_or(false);
if auto_confirm {
sqlx::query("UPDATE users SET email_confirmed_at = now(), confirmation_token = NULL WHERE id = $1")
sqlx::query("UPDATE auth.users SET email_confirmed_at = now(), confirmation_token = NULL WHERE id = $1")
.bind(user.id)
.execute(&db)
.await
@@ -215,12 +211,11 @@ pub async fn signup(
pub async fn login(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(db): Extension<PgPool>,
project_ctx: Option<Extension<ProjectContext>>,
Json(payload): Json<SignInRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE email = $1")
.bind(&payload.email)
.fetch_optional(&db)
.await
@@ -281,11 +276,10 @@ pub async fn login(
}
pub async fn get_user(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
State(_state): State<AuthState>,
Extension(db): Extension<PgPool>,
Extension(auth_ctx): Extension<AuthContext>,
) -> Result<Json<User>, (StatusCode, String)> {
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let claims = auth_ctx
.claims
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
@@ -293,7 +287,7 @@ pub async fn get_user(
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?;
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(&db)
.await
@@ -342,7 +336,7 @@ pub async fn token(
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
req.validate()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
login(State(state), Some(Extension(db)), project_ctx, Json(req)).await
login(State(state), Extension(db), project_ctx, Json(req)).await
}
"refresh_token" => {
let req: RefreshTokenGrant = serde_json::from_value(payload)
@@ -358,7 +352,7 @@ pub async fn token(
let (revoked_token_hash, user_id, session_id) =
sqlx::query_as::<_, (String, Uuid, Option<Uuid>)>(
r#"
UPDATE refresh_tokens
UPDATE auth.refresh_tokens
SET revoked = true, updated_at = now()
WHERE token = $1 AND revoked = false
RETURNING token, user_id, session_id
@@ -386,7 +380,7 @@ pub async fn token(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(&db)
.await
@@ -419,20 +413,19 @@ pub async fn token(
}
pub async fn recover(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
State(_state): State<AuthState>,
Extension(db): Extension<PgPool>,
Json(payload): Json<RecoverRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
payload
.validate()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let token = generate_recovery_token();
let user = sqlx::query_as::<_, User>(
r#"
UPDATE users
UPDATE auth.users
SET recovery_token = $1
WHERE email = $2
RETURNING *
@@ -455,18 +448,17 @@ pub async fn recover(
pub async fn verify(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(db): Extension<PgPool>,
project_ctx: Option<Extension<ProjectContext>>,
Json(payload): Json<VerifyRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
let user = match payload.r#type.as_str() {
"signup" => {
let hashed_input = hash_refresh_token(&payload.token);
sqlx::query_as::<_, User>(
r#"
UPDATE users
UPDATE auth.users
SET email_confirmed_at = now(), confirmation_token = NULL
WHERE confirmation_token = $1
RETURNING *
@@ -481,7 +473,7 @@ pub async fn verify(
"recovery" => {
let hashed_input = hash_refresh_token(&payload.token);
let user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE recovery_token = $1"
"SELECT * FROM auth.users WHERE recovery_token = $1"
)
.bind(&hashed_input)
.fetch_optional(&db)
@@ -492,14 +484,14 @@ pub async fn verify(
if let Some(new_password) = &payload.password {
let hashed = hash_password(new_password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query("UPDATE users SET encrypted_password = $1, recovery_token = NULL WHERE id = $2")
sqlx::query("UPDATE auth.users SET encrypted_password = $1, recovery_token = NULL WHERE id = $2")
.bind(&hashed)
.bind(user.id)
.execute(&db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
} else {
sqlx::query("UPDATE users SET recovery_token = NULL WHERE id = $1")
sqlx::query("UPDATE auth.users SET recovery_token = NULL WHERE id = $1")
.bind(user.id)
.execute(&db)
.await
@@ -510,7 +502,7 @@ pub async fn verify(
"email_change" => {
let hashed_input = hash_refresh_token(&payload.token);
sqlx::query_as::<_, User>(
"UPDATE users SET email = email_change, email_change = NULL, email_change_token_new = NULL WHERE email_change_token_new = $1 RETURNING *"
"UPDATE auth.users SET email = email_change, email_change = NULL, email_change_token_new = NULL WHERE email_change_token_new = $1 RETURNING *"
)
.bind(&hashed_input)
.fetch_optional(&db)
@@ -522,7 +514,7 @@ pub async fn verify(
let hashed_input = hash_refresh_token(&payload.token);
sqlx::query_as::<_, User>(
r#"
UPDATE users
UPDATE auth.users
SET email_confirmed_at = now(), confirmation_token = NULL
WHERE confirmation_token = $1
RETURNING *
@@ -558,11 +550,10 @@ pub async fn verify(
pub async fn update_user(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(db): Extension<PgPool>,
Extension(auth_ctx): Extension<AuthContext>,
Json(payload): Json<UserUpdateRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
payload
.validate()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
@@ -579,7 +570,7 @@ pub async fn update_user(
let token = generate_confirmation_token();
let hashed_token = hash_refresh_token(&token);
sqlx::query(
"UPDATE users SET email_change = now(), email_change_token_new = $1 WHERE id = $2"
"UPDATE auth.users SET email_change = now(), email_change_token_new = $1 WHERE id = $2"
)
.bind(&hashed_token)
.bind(user_id)
@@ -591,7 +582,7 @@ pub async fn update_user(
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(&db)
.await
@@ -604,7 +595,7 @@ pub async fn update_user(
if let Some(password) = &payload.password {
let hashed = hash_password(password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query("UPDATE users SET encrypted_password = $1 WHERE id = $2")
sqlx::query("UPDATE auth.users SET encrypted_password = $1 WHERE id = $2")
.bind(hashed)
.bind(user_id)
.execute(&mut *tx)
@@ -613,7 +604,7 @@ pub async fn update_user(
}
if let Some(data) = &payload.data {
sqlx::query("UPDATE users SET raw_user_meta_data = $1 WHERE id = $2")
sqlx::query("UPDATE auth.users SET raw_user_meta_data = $1 WHERE id = $2")
.bind(data)
.bind(user_id)
.execute(&mut *tx)
@@ -623,7 +614,7 @@ pub async fn update_user(
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(&db)
.await

View File

@@ -175,7 +175,7 @@ pub async fn verify(
};
let jwt_secret = project_ctx.jwt_secret.as_str();
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(&state.db)
.await

View File

@@ -4,28 +4,17 @@ use axum::{
middleware::Next,
response::Response,
};
use common::{Config, ProjectContext};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use common::{Config, ProjectContext, JwtConfig, JwtClaims};
#[derive(Clone)]
pub struct AuthMiddlewareState {
pub config: Config,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub email: Option<String>,
pub role: String,
pub exp: usize,
pub iss: String,
pub aud: Option<String>,
pub jwt_config: JwtConfig,
}
#[derive(Clone)]
pub struct AuthContext {
pub claims: Option<Claims>,
pub claims: Option<JwtClaims>,
pub role: String,
}
@@ -44,19 +33,36 @@ pub async fn auth_middleware(
if path.contains("/authorize") || path.contains("/callback") {
return Ok(next.run(req).await);
}
// Allow public Signed URL access (GET only)
if path.contains("/object/sign/") && req.method() == axum::http::Method::GET {
return Ok(next.run(req).await);
}
// Determine the secret to use
let jwt_secret = if let Some(ctx) = &project_ctx {
tracing::debug!("Using project-specific JWT secret");
ctx.jwt_secret.clone()
// Allow public WebSocket endpoint (JWT validation is handled in phx_join payload)
if path.contains("/realtime/v1/websocket") {
return Ok(next.run(req).await);
}
// Determine the JWT config to use
let jwt_config = if let Some(ctx) = &project_ctx {
tracing::debug!(
secret_source = "project",
secret_preview = &ctx.jwt_secret[..ctx.jwt_secret.len().min(8)],
"Using project-specific JWT secret"
);
JwtConfig {
secret: ctx.jwt_secret.clone(),
issuer: state.jwt_config.issuer.clone(),
algorithm: state.jwt_config.algorithm,
}
} else {
tracing::debug!("ProjectContext not found, using global JWT secret");
state.config.jwt_secret.clone()
tracing::debug!(
secret_source = "global",
secret_preview = &state.jwt_config.secret[..state.jwt_config.secret.len().min(8)],
"ProjectContext not found, using global JWT secret"
);
state.jwt_config.clone()
};
let auth_header = req
@@ -84,18 +90,19 @@ pub async fn auth_middleware(
};
if let Some(token) = token {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
validation.validate_aud = false;
// validation.set_audience(&["authenticated"]); // If we used audience
tracing::debug!(
token_preview = &token[..token.len().min(16)],
token_length = token.len(),
"Attempting JWT validation"
);
match decode::<Claims>(
&token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&validation,
) {
Ok(token_data) => {
let claims = token_data.claims;
match jwt_config.validate_token(&token) {
Ok(claims) => {
tracing::debug!(
role = &claims.role,
sub = &claims.sub,
"Token validated successfully"
);
let role = claims.role.clone();
let ctx = AuthContext {
@@ -107,7 +114,11 @@ pub async fn auth_middleware(
}
Err(e) => {
// Invalid token
tracing::error!("Token validation failed: {}", e);
tracing::error!(
error = %e,
secret_source = if project_ctx.is_some() { "project" } else { "global" },
"Token validation failed"
);
return Err(StatusCode::UNAUTHORIZED);
}
}

View File

@@ -225,7 +225,7 @@ pub async fn callback(
return Err((StatusCode::BAD_REQUEST, "Missing OAuth state parameter".to_string()));
}
let existing_user = sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE email = $1")
let existing_user = sqlx::query_as::<_, crate::models::User>("SELECT * FROM auth.users WHERE email = $1")
.bind(&user_profile.email)
.fetch_optional(&db)
.await
@@ -253,7 +253,7 @@ pub async fn callback(
sqlx::query_as::<_, crate::models::User>(
r#"
INSERT INTO users (email, encrypted_password, raw_user_meta_data, email_confirmed_at)
INSERT INTO auth.users (email, encrypted_password, raw_user_meta_data, email_confirmed_at)
VALUES ($1, $2, $3, now())
RETURNING *
"#,

View File

@@ -166,7 +166,7 @@ pub async fn sso_callback(
let sub = claims.subject().as_str();
// 5. Create/Update User
let existing_user = sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE email = $1")
let existing_user = sqlx::query_as::<_, crate::models::User>("SELECT * FROM auth.users WHERE email = $1")
.bind(email)
.fetch_optional(&db)
.await
@@ -185,7 +185,7 @@ pub async fn sso_callback(
sqlx::query_as::<_, crate::models::User>(
r#"
INSERT INTO users (email, encrypted_password, raw_user_meta_data)
INSERT INTO auth.users (email, encrypted_password, raw_user_meta_data)
VALUES ($1, $2, $3)
RETURNING *
"#,

View File

@@ -162,7 +162,7 @@ pub async fn issue_refresh_token(
sqlx::query(
r#"
INSERT INTO refresh_tokens (token, user_id, session_id, parent)
INSERT INTO auth.refresh_tokens (token, user_id, session_id, parent)
VALUES ($1, $2, $3, $4)
"#,
)