feat(admin-auth): add POST /api/admin/auth/refresh for sliding session window
All checks were successful
build-and-release / build (gateway) (push) Successful in 1m33s
build-and-release / build (graphic-designers) (push) Successful in 2m13s
build-and-release / build (ugc-content-creators) (push) Successful in 2m12s
build-and-release / build (users) (push) Successful in 4m48s
build-and-release / build (catering-services) (push) Successful in 1m22s
build-and-release / build (job-seekers) (push) Successful in 3m20s
build-and-release / build (companies) (push) Successful in 2m36s
build-and-release / build (social-media-managers) (push) Successful in 2m18s
build-and-release / build (tutors) (push) Successful in 2m41s
build-and-release / build (customers) (push) Successful in 1m38s
build-and-release / build (employees) (push) Successful in 3m16s
build-and-release / build (makeup-artists) (push) Successful in 1m46s
build-and-release / build (photographers) (push) Successful in 2m39s
build-and-release / build (jobs) (push) Successful in 1m5s
build-and-release / build (cron) (push) Successful in 1m40s
build-and-release / build (leads) (push) Successful in 2m18s
build-and-release / build (payments) (push) Successful in 3m10s
build-and-release / build (fitness-trainers) (push) Successful in 1m25s
build-and-release / build (video-editors) (push) Successful in 2m11s
build-and-release / build (developers) (push) Successful in 1m43s

Admin access tokens expire after 15 minutes with no way to renew one, so
active admins got logged out mid-work with no warning (silent 401s, now
surfaced by admin-solid's session-expired dialog). Add a refresh endpoint
that exchanges the HttpOnly nxtgauge_admin_token cookie for a new 15-minute
access token, rotating the employee_sessions row (revoke old, store new) -
mirrors the existing pattern in apps/users/src/handlers/auth.rs, but against
the DB-backed employee_sessions table instead of Redis.

Add EmployeeRepository::get_by_id / get_valid_session_by_token / revoke_session
to support it.

The admin-solid frontend calls this on a timer while the admin is active and
skips it once idle for 15 minutes, so the session now extends while active
and expires on inactivity as intended, instead of on a fixed wall-clock timer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-19 00:14:08 +05:30
parent f56251374a
commit 8a29b81c84
2 changed files with 102 additions and 0 deletions

View file

@ -19,6 +19,7 @@ pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/refresh", post(refresh))
.route("/session", get(session))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
@ -122,6 +123,74 @@ async fn logout(
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
}
/// POST /api/admin/auth/refresh
///
/// Exchanges the HttpOnly refresh-token cookie for a new 15-minute access
/// token, rotating the underlying session row (revoke old, store new).
/// Called by the admin frontend on user activity to implement a sliding
/// session window: active admins never see a 15-minute timeout, idle ones do.
async fn refresh(
State(state): State<AppState>,
req: axum::http::Request<axum::body::Body>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let cookie_header = req
.headers()
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = cookie_header
.split(';')
.map(str::trim)
.find_map(|p| p.strip_prefix("nxtgauge_admin_token="))
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;
let session = EmployeeRepository::get_valid_session_by_token(&state.pool, token)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;
let employee = EmployeeRepository::get_by_id(&state.pool, session.employee_id)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Employee not found", "INVALID_CREDENTIALS"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Employee not found", "INVALID_CREDENTIALS"))?;
if employee.status != "ACTIVE" {
return Err(err(StatusCode::FORBIDDEN, "Account not active", "ACCOUNT_INACTIVE"));
}
// Rotate: revoke old session, issue a new one.
EmployeeRepository::revoke_session(&state.pool, session.id)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let roles = vec![employee.role_code.clone()];
let tokens = generate_tokens(
employee.id.to_string(),
employee.email.clone(),
roles.clone(),
roles.first().cloned(),
&jwt_secret,
)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
EmployeeRepository::store_session(&state.pool, employee.id, &tokens.refresh_token, chrono::Utc::now() + chrono::Duration::days(30))
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let new_cookie = format!(
"nxtgauge_admin_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
tokens.refresh_token
);
Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
"access_token": tokens.access_token,
"expires_in": 900
}))))
}
async fn session(
auth: AuthUser,
_state: State<AppState>,

View file

@ -136,6 +136,15 @@ impl EmployeeRepository {
Ok(())
}
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Employee>, sqlx::Error> {
sqlx::query_as::<_, Employee>(
"SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE id = $1"
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
sqlx::query_as::<_, Employee>(
"SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1"
@ -180,6 +189,30 @@ impl EmployeeRepository {
.await
}
pub async fn get_valid_session_by_token(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<EmployeeSession>, sqlx::Error> {
sqlx::query_as::<_, EmployeeSession>(
r#"
SELECT id, employee_id, token_hash, expires_at, revoked, created_at
FROM employee_sessions
WHERE token_hash = $1 AND revoked = false AND expires_at > NOW()
"#
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
pub async fn revoke_session(pool: &PgPool, session_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE employee_sessions SET revoked = true WHERE id = $1")
.bind(session_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn change_password(
pool: &PgPool,
id: Uuid,