parent
b3242b84ff
commit
1ec1ba0028
4 changed files with 192 additions and 30 deletions
50
src/auth.rs
50
src/auth.rs
|
|
@ -14,13 +14,15 @@ use uuid::Uuid;
|
|||
use crate::{AppState, db};
|
||||
|
||||
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
|
||||
const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes
|
||||
const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct SessionClaims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
pub struct SessionClaims {
|
||||
pub sub: String,
|
||||
pub kind: String, // "access" or "refresh"
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -94,8 +96,8 @@ where
|
|||
.or_else(|| read_query_token(parts))
|
||||
.ok_or_else(|| ApiError::unauthorized("missing jwt token"))?;
|
||||
|
||||
let user_id = verify_session(&token, &app.settings.session_secret)
|
||||
.map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
let user_id = verify_session(&token, &app.settings.session_secret, "access")
|
||||
.map_err(|_| ApiError::unauthorized("invalid or expired token"))?;
|
||||
|
||||
let exists = db::user_exists(&app.db, user_id)
|
||||
.await
|
||||
|
|
@ -138,25 +140,41 @@ pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<
|
|||
})
|
||||
}
|
||||
|
||||
pub fn new_jwt_token(user_id: Uuid, secret: &str) -> Result<String> {
|
||||
pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> {
|
||||
let now = now_ts();
|
||||
let claims = SessionClaims {
|
||||
|
||||
let access_claims = SessionClaims {
|
||||
sub: user_id.to_string(),
|
||||
kind: "access".to_string(),
|
||||
iat: now as usize,
|
||||
exp: (now + SESSION_TTL_SECS) as usize,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
let refresh_claims = SessionClaims {
|
||||
sub: user_id.to_string(),
|
||||
kind: "refresh".to_string(),
|
||||
iat: now as usize,
|
||||
exp: (now + REFRESH_TTL_SECS) as usize,
|
||||
};
|
||||
|
||||
let access_token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&access_claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.context("failed to encode session token")?;
|
||||
.context("failed to encode access token")?;
|
||||
|
||||
Ok(token)
|
||||
let refresh_token = encode(
|
||||
&Header::default(),
|
||||
&refresh_claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.context("failed to encode refresh token")?;
|
||||
|
||||
Ok((access_token, refresh_token))
|
||||
}
|
||||
|
||||
fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
||||
pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result<Uuid> {
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
|
|
@ -167,6 +185,10 @@ fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
|||
)
|
||||
.context("failed to decode session token")?;
|
||||
|
||||
if data.claims.kind != expected_kind {
|
||||
return Err(anyhow!("invalid token kind"));
|
||||
}
|
||||
|
||||
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
|
|||
.route("/", get(index))
|
||||
.route("/auth/login", get(auth_login))
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
.route("/auth/refresh", post(auth_refresh))
|
||||
.route("/auth/logout", post(auth_logout))
|
||||
.route("/me", get(me))
|
||||
.route("/dms", get(list_dm_conversations))
|
||||
|
|
@ -213,8 +214,9 @@ async fn auth_callback(
|
|||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||
|
||||
let jwt_token = auth::new_jwt_token(user.id, &state.settings.session_secret)
|
||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||
let (access_token, refresh_token) =
|
||||
auth::new_jwt_tokens(user.id, &state.settings.session_secret)
|
||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
|
|
@ -224,7 +226,53 @@ async fn auth_callback(
|
|||
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
||||
);
|
||||
|
||||
Ok((headers, Redirect::to(&format!("/?token={}", jwt_token))))
|
||||
Ok((
|
||||
headers,
|
||||
Redirect::to(&format!(
|
||||
"/?token={}&refresh_token={}",
|
||||
access_token, refresh_token
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthRefreshBody {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthRefreshResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
async fn auth_refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<AuthRefreshBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user_id = auth::verify_session(
|
||||
&body.refresh_token,
|
||||
&state.settings.session_secret,
|
||||
"refresh",
|
||||
)
|
||||
.map_err(|_| ApiError::unauthorized("invalid or expired refresh token"))?;
|
||||
|
||||
let exists = db::user_exists(&state.db, user_id)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("user verification failed"))?;
|
||||
|
||||
if !exists {
|
||||
return Err(ApiError::unauthorized("user not found"));
|
||||
}
|
||||
|
||||
let (access_token, refresh_token) =
|
||||
auth::new_jwt_tokens(user_id, &state.settings.session_secret)
|
||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||
|
||||
Ok(Json(AuthRefreshResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn auth_logout() -> Result<impl IntoResponse, ApiError> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue