Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1cc9b1d
Add agent context
justin13888 Mar 2, 2026
0b41858
Init sidecar
justin13888 Mar 2, 2026
5c4ea4c
feat(auth): implement GET /auth/devices endpoint
justin13888 Mar 3, 2026
08e4fdf
feat(users): add registered_via column to track account creation origin
justin13888 Mar 3, 2026
23bc289
fix(auth): handle unique constraint violation in user registration
justin13888 Mar 3, 2026
5f7a956
drop email service and registration verification feature
justin13888 Mar 3, 2026
5b9f66d
Merge branch 'v1-wip' into v1-stabilize-auth
justin13888 Mar 3, 2026
fe1eaeb
feat(auth): add rate limiting and account lockout to auth endpoints
justin13888 Mar 3, 2026
d0099e7
feat(auth): implement integration test suite using salvo test infrast…
justin13888 Mar 3, 2026
06f4ff3
feat(auth): externalize TOTP issuer config and accept passkey name fr…
justin13888 Mar 3, 2026
f792eea
feat(web): implement auth context and token management
justin13888 Mar 4, 2026
1520c69
feat(web): connect login page to API and add auth guard
justin13888 Mar 4, 2026
88423a5
feat(web): implement MFA verification UIs (TOTP and passkey)
justin13888 Mar 4, 2026
ccd01e8
feat(web): implement password reset flows
justin13888 Mar 4, 2026
321affb
feat(web): add profile/security settings pages and interactive header
justin13888 Mar 4, 2026
9886223
chore(auth): restrict CORS to configurable origins via ALLOWED_ORIGIN…
justin13888 Mar 4, 2026
3884b7d
test(auth): add integration test suite for rate limiting, account loc…
justin13888 Mar 4, 2026
c6e200a
Lint web
justin13888 Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ target/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# sidecar/td
.todos/
.sidecar/
.sidecar-agent
.sidecar-task
.sidecar-pr
.sidecar-start.sh
.sidecar-base
.td-root
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Pixles

## MANDATORY: Use td for Task Management

You must run td usage --new-session at conversation start (or after /clear) to see current work.
Use td usage -q for subsequent reads.

1 change: 1 addition & 0 deletions CLAUDE.md
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pixles-api/auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ tracing-subscriber = { workspace = true }
base64 = "0.22.1"
sea-orm-migration = { workspace = true }
salvo = { workspace = true, features = ["test"] }
totp-rs = { version = "5.7.0", features = ["gen_secret", "otpauth"] }
5 changes: 2 additions & 3 deletions pixles-api/auth/src/claims/issuer.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/// Returns the JWT token issuer. Reads from PIXLES_ISSUER env var if set.
pub fn get_auth_issuer() -> String {
// TODO: Fetch this from some application config instead of a constant

"pixles-api".to_string()
std::env::var("PIXLES_ISSUER").unwrap_or_else(|_| crate::constants::ISSUER.to_string())
}
6 changes: 6 additions & 0 deletions pixles-api/auth/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ pub struct AuthConfig {

/// Valkey URL
pub valkey_url: String,
/// TOTP issuer string shown in authenticator apps
pub totp_issuer: String,
/// Allowed CORS origins. Use `["*"]` to allow all origins (development only).
pub allowed_origins: Vec<String>,
}

impl From<&ServerConfig> for AuthConfig {
Expand All @@ -35,6 +39,8 @@ impl From<&ServerConfig> for AuthConfig {
jwt_refresh_token_duration_seconds: config.jwt_refresh_token_duration_seconds,
jwt_access_token_duration_seconds: config.jwt_access_token_duration_seconds,
valkey_url: config.valkey_url.clone(),
totp_issuer: config.totp_issuer.clone(),
allowed_origins: config.allowed_origins.clone(),
}
}
}
20 changes: 15 additions & 5 deletions pixles-api/auth/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,17 @@ impl Writer for RegisterError {
#[derive(Debug)]
pub enum LoginError {
InvalidCredentials,
AccountNotVerified,
AccountLocked,
RateLimited(u64),
Unexpected(InternalServerError),
}

impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCredentials => write!(f, "User not found or invalid credentials"),
Self::AccountNotVerified => write!(f, "Account not verified"),
Self::AccountLocked => write!(f, "Account temporarily locked due to too many failed login attempts"),
Self::RateLimited(retry_after) => write!(f, "Too many requests. Retry after {} seconds", retry_after),
Self::Unexpected(e) => write!(f, "Internal server error: {}", e),
}
}
Expand All @@ -92,9 +94,17 @@ impl Writer for LoginError {
res.status_code(StatusCode::UNAUTHORIZED);
res.render(Text::Plain("Invalid credentials"));
}
LoginError::AccountNotVerified => {
res.status_code(StatusCode::FORBIDDEN);
res.render(Text::Plain("Account not verified"));
LoginError::AccountLocked => {
res.status_code(StatusCode::LOCKED);
res.render(Text::Plain("Account locked due to too many failed login attempts"));
}
LoginError::RateLimited(retry_after) => {
res.status_code(StatusCode::TOO_MANY_REQUESTS);
res.headers_mut().insert(
salvo::http::header::RETRY_AFTER,
retry_after.to_string().parse().unwrap(),
);
res.render(Text::Plain("Too many requests"));
}
LoginError::Unexpected(e) => {
e.write(req, depot, res).await;
Expand Down
28 changes: 15 additions & 13 deletions pixles-api/auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use config::AuthConfig;
use salvo::cors::Cors;
use salvo::cors::{AllowOrigin, Cors};
use salvo::http::Method;
use salvo::prelude::*;
use sea_orm::DatabaseConnection;
Expand All @@ -24,6 +24,12 @@ pub mod utils;
#[cfg(feature = "server")]
pub mod validation;

/// Build a router from an existing `AppState` (useful for testing).
#[cfg(feature = "server")]
pub fn get_router_with_state(state: AppState) -> Router {
routes::get_router(state)
}

#[cfg(feature = "server")]
pub async fn get_router<C: Into<AuthConfig>>(
conn: DatabaseConnection,
Expand All @@ -38,9 +44,6 @@ pub async fn get_router<C: Into<AuthConfig>>(
.await
.map_err(|e| e.0)?;

// Initialize Email Service
let email_service = service::EmailService::new();

// Initialize Passkey Service
let rp_id = config.domain.clone();
let rp_origin = webauthn_rs::prelude::Url::parse(&format!("https://{}", config.domain))?;
Expand All @@ -50,9 +53,14 @@ pub async fn get_router<C: Into<AuthConfig>>(
let webauthn = std::sync::Arc::new(builder.build().map_err(|e| eyre::eyre!(e))?);
let passkey_service = service::PasskeyService::new(conn.clone(), webauthn);

// CORS configuration - TODO: Restrict later
// CORS configuration
let allow_origin = if config.allowed_origins.iter().any(|o| o == "*") {
AllowOrigin::any()
} else {
AllowOrigin::from(&config.allowed_origins)
};
let cors = Cors::new()
.allow_origin("*")
.allow_origin(allow_origin)
.allow_methods(vec![
Method::GET,
Method::POST,
Expand All @@ -63,13 +71,7 @@ pub async fn get_router<C: Into<AuthConfig>>(
.allow_headers("*")
.into_handler();

let state = AppState::new(
conn,
config,
session_manager,
email_service,
passkey_service,
);
let state = AppState::new(conn, config, session_manager, passkey_service);

let router = routes::get_router(state);

Expand Down
4 changes: 4 additions & 0 deletions pixles-api/auth/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub struct UserProfile {
pub is_admin: bool,
pub created_at: String,
pub updated_at: String,
/// How the account was created (e.g. 'invitation'); null for seeded/legacy/unknown
pub registered_via: Option<String>,
}

impl From<User> for UserProfile {
Expand All @@ -88,6 +90,7 @@ impl From<User> for UserProfile {
created_at,
modified_at,
deleted_at: _,
registered_via,
} = user;
Self {
user_id: id,
Expand All @@ -97,6 +100,7 @@ impl From<User> for UserProfile {
is_admin,
created_at: created_at.to_rfc3339(),
updated_at: modified_at.to_rfc3339(),
registered_via,
}
}
}
40 changes: 34 additions & 6 deletions pixles-api/auth/src/models/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
pub struct Device {
pub id: String,
pub created_at: i64,
pub last_active_at: i64,
pub user_agent: Option<String>,
pub ip_address: Option<String>,
pub is_current: bool,
Expand All @@ -41,6 +42,7 @@ pub enum RegisterUserResponses {
Success(TokenResponse),
BadRequest(BadRegisterUserRequestError),
UserAlreadyExists,
RateLimited(u64),
InternalServerError(InternalServerError),
}

Expand Down Expand Up @@ -79,6 +81,14 @@ impl Writer for RegisterUserResponses {
res.status_code(StatusCode::CONFLICT);
res.render(Json(ApiError::new("User already exists")));
}
Self::RateLimited(retry_after) => {
res.status_code(StatusCode::TOO_MANY_REQUESTS);
res.headers_mut().insert(
salvo::http::header::RETRY_AFTER,
retry_after.to_string().parse().unwrap(),
);
res.render(Json(ApiError::new("Too many requests")));
}
Self::InternalServerError(e) => e.write(req, depot, res).await,
}
}
Expand Down Expand Up @@ -114,7 +124,8 @@ pub enum LoginResponses {
Success(TokenResponse),
BadRequest,
InvalidCredentials,
AccountNotVerified,
AccountLocked,
RateLimited(u64),
InternalServerError(InternalServerError),
}

Expand All @@ -131,7 +142,8 @@ impl From<LoginError> for LoginResponses {
fn from(e: LoginError) -> Self {
match e {
LoginError::InvalidCredentials => Self::InvalidCredentials,
LoginError::AccountNotVerified => Self::AccountNotVerified,
LoginError::AccountLocked => Self::AccountLocked,
LoginError::RateLimited(r) => Self::RateLimited(r),
LoginError::Unexpected(e) => Self::InternalServerError(e),
}
}
Expand All @@ -153,13 +165,20 @@ impl Writer for LoginResponses {
res.status_code(StatusCode::UNAUTHORIZED);
res.render(Json(ApiError::new("Invalid credentials")));
}
Self::AccountNotVerified => {
res.status_code(StatusCode::FORBIDDEN);
res.render(Json(ApiError::new("Account not verified")));
Self::AccountLocked => {
res.status_code(StatusCode::LOCKED);
res.render(Json(ApiError::new("Account locked due to too many failed login attempts")));
}
Self::RateLimited(retry_after) => {
res.status_code(StatusCode::TOO_MANY_REQUESTS);
res.headers_mut().insert(
salvo::http::header::RETRY_AFTER,
retry_after.to_string().parse().unwrap(),
);
res.render(Json(ApiError::new("Too many requests")));
}
Self::InternalServerError(e) => {
e.write(_req, _depot, res).await;
return;
}
}
}
Expand Down Expand Up @@ -299,6 +318,7 @@ impl EndpointOutRegister for ValidateTokenResponses {
pub enum ResetPasswordRequestResponses {
Success,
BadRequest,
RateLimited(u64),
InternalServerError(InternalServerError),
}

Expand All @@ -314,6 +334,14 @@ impl Writer for ResetPasswordRequestResponses {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Json(ApiError::new("Invalid request")));
}
Self::RateLimited(retry_after) => {
res.status_code(StatusCode::TOO_MANY_REQUESTS);
res.headers_mut().insert(
salvo::http::header::RETRY_AFTER,
retry_after.to_string().parse().unwrap(),
);
res.render(Json(ApiError::new("Too many requests")));
}
Self::InternalServerError(e) => e.write(req, depot, res).await,
}
}
Expand Down
Loading