feat: add user support with play event tracking

Backend (Rust API):
- Add users and play_events tables (migration 0005)
- Extract full user identity from JWT (sub, username, email, name)
  and pass AuthUser via request extensions to all handlers
- Auto-upsert user in background on every authenticated request
- POST /api/tracks/:slug/play endpoint to record play events
- Allow POST method in CORS

Frontend (Node player):
- Call recordPlay() when a track starts playing
- Add user profile avatar with dropdown menu (name, email, sign out)
- Pass user info from App through FurumiPlayer to Header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ultradesu
2026-04-08 16:51:53 +01:00
parent 5bc2b55ffd
commit 4fdd56dae4
10 changed files with 283 additions and 25 deletions
+61 -19
View File
@@ -109,12 +109,23 @@ impl OidcState {
}
}
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: String,
pub username: String,
pub display_name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct BearerClaims {
sub: String,
preferred_username: Option<String>,
name: Option<String>,
email: Option<String>,
}
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<AuthUser> {
let header = decode_header(token).ok()?;
let kid = header.kid.as_ref()?;
@@ -134,7 +145,13 @@ async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String>
validation.validate_aud = false;
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
Some(data.claims.sub)
let c = data.claims;
Some(AuthUser {
id: c.sub.clone(),
username: c.preferred_username.unwrap_or(c.sub),
display_name: c.name,
email: c.email,
})
}
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
@@ -164,11 +181,14 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
}
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
/// Inserts AuthUser into request extensions and upserts user in DB.
pub async fn require_auth(
State(state): State<Arc<AppState>>,
req: Request,
mut req: Request,
next: Next,
) -> Response {
let mut auth_user: Option<AuthUser> = None;
// 1. Check Bearer token — JWT from OIDC provider
if let Some(ref oidc) = state.oidc {
if let Some(token) = req
@@ -177,32 +197,54 @@ pub async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
if let Some(user_id) = validate_bearer_token(oidc, token).await {
tracing::debug!("Bearer auth OK for user: {}", user_id);
return next.run(req).await;
}
auth_user = validate_bearer_token(oidc, token).await;
}
}
// 2. Check SSO session cookie (if OIDC configured)
if let Some(ref oidc) = state.oidc {
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if auth_user.is_none() {
if let Some(ref oidc) = state.oidc {
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await;
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
auth_user = Some(AuthUser {
id: user_id.clone(),
username: user_id,
display_name: None,
email: None,
});
break;
}
}
}
}
}
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
match auth_user {
Some(user) => {
tracing::debug!("Auth OK for user: {}", user.username);
// Upsert user in background
let pool = state.pool.clone();
let u = user.clone();
tokio::spawn(async move {
if let Err(e) = crate::db::upsert_user(
&pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(),
).await {
tracing::warn!("Failed to upsert user: {}", e);
}
});
req.extensions_mut().insert(user);
next.run(req).await
}
None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
}
}
#[derive(Deserialize)]