added more support for supabase-js
This commit is contained in:
@@ -15,9 +15,13 @@ argon2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
totp-rs = { version = "5.5", features = ["qr", "gen_secret"] }
|
||||
uuid = { version = "1.8", features = ["v4", "serde"] }
|
||||
base32 = "0.4"
|
||||
openidconnect = { version = "3.5", features = ["accept-rfc3339-timestamps"] }
|
||||
anyhow = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
oauth2 = "5.0.0"
|
||||
reqwest = { version = "0.13.2", features = ["json"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
hex = "0.4.3"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use crate::middleware::AuthContext;
|
||||
use crate::models::{AuthResponse, SignInRequest, SignUpRequest, User};
|
||||
use crate::models::{
|
||||
AuthResponse, RecoverRequest, SignInRequest, SignUpRequest, User, UserUpdateRequest,
|
||||
VerifyRequest,
|
||||
};
|
||||
use crate::utils::{
|
||||
generate_refresh_token, generate_token, hash_password, hash_refresh_token, issue_refresh_token, verify_password,
|
||||
generate_confirmation_token, generate_recovery_token, generate_refresh_token, generate_token,
|
||||
hash_password, hash_refresh_token, issue_refresh_token, verify_password,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
@@ -34,7 +38,9 @@ pub async fn signup(
|
||||
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()))?;
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
|
||||
// Check if user exists
|
||||
let user_exists = sqlx::query("SELECT id FROM users WHERE email = $1")
|
||||
@@ -50,27 +56,41 @@ pub async fn signup(
|
||||
let hashed_password = hash_password(&payload.password)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let confirmation_token = generate_confirmation_token();
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (email, encrypted_password, raw_user_meta_data)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO users (email, encrypted_password, raw_user_meta_data, confirmation_token, confirmed_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.bind(hashed_password)
|
||||
.bind(payload.data.unwrap_or(serde_json::json!({})))
|
||||
.bind(&confirmation_token)
|
||||
.bind(None::<chrono::DateTime<chrono::Utc>>) // Initially unconfirmed? Or auto-confirmed for MVP?
|
||||
// For now, let's keep auto-confirm logic if no email service, OR implement proper flow.
|
||||
// The requirement is "Email Confirmation: Implement email verification flow".
|
||||
// So we should NOT set confirmed_at yet.
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Mock Email Sending
|
||||
tracing::info!(
|
||||
"Sending confirmation email to {}: token={}",
|
||||
user.email,
|
||||
confirmation_token
|
||||
);
|
||||
|
||||
let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() {
|
||||
ctx.jwt_secret.as_str()
|
||||
} else {
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
let (token, expires_in, _) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let refresh_token = issue_refresh_token(&db, user.id, Uuid::new_v4(), None).await?;
|
||||
@@ -115,7 +135,7 @@ pub async fn login(
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
let (token, expires_in, _) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let refresh_token = issue_refresh_token(&db, user.id, Uuid::new_v4(), None).await?;
|
||||
@@ -168,7 +188,8 @@ pub async fn token(
|
||||
"password" => {
|
||||
let req: SignInRequest = serde_json::from_value(payload)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
req.validate().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
|
||||
}
|
||||
"refresh_token" => {
|
||||
@@ -204,13 +225,9 @@ pub async fn token(
|
||||
"Missing session".to_string(),
|
||||
))?;
|
||||
|
||||
let new_refresh_token = issue_refresh_token(
|
||||
&mut *tx,
|
||||
user_id,
|
||||
session_id,
|
||||
Some(revoked_token_hash.as_str()),
|
||||
)
|
||||
.await?;
|
||||
let new_refresh_token =
|
||||
issue_refresh_token(&mut *tx, user_id, session_id, Some(revoked_token_hash.as_str()))
|
||||
.await?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
@@ -229,7 +246,7 @@ pub async fn token(
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (access_token, expires_in) =
|
||||
let (access_token, expires_in, _) =
|
||||
generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -247,3 +264,170 @@ pub async fn token(
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recover(
|
||||
State(state): State<AuthState>,
|
||||
db: Option<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
|
||||
SET recovery_token = $1
|
||||
WHERE email = $2
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&token)
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// We don't want to leak whether the user exists or not, so we always return OK
|
||||
if let Some(u) = user {
|
||||
// Mock Email Sending
|
||||
tracing::info!(
|
||||
"Sending recovery email to {}: token={}",
|
||||
u.email,
|
||||
token
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Recovery requested for non-existent email: {}",
|
||||
payload.email
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "message": "If the email exists, a recovery link has been sent." })))
|
||||
}
|
||||
|
||||
pub async fn verify(
|
||||
State(state): State<AuthState>,
|
||||
db: Option<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" => {
|
||||
sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET email_confirmed_at = now(), confirmation_token = NULL
|
||||
WHERE confirmation_token = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&payload.token)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
}
|
||||
"recovery" => {
|
||||
sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET recovery_token = NULL
|
||||
WHERE recovery_token = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&payload.token)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
}
|
||||
_ => return Err((StatusCode::BAD_REQUEST, "Unsupported verification type".to_string())),
|
||||
};
|
||||
|
||||
let user = user.ok_or((StatusCode::BAD_REQUEST, "Invalid token".to_string()))?;
|
||||
|
||||
let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() {
|
||||
ctx.jwt_secret.as_str()
|
||||
} else {
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (token, expires_in, _) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let refresh_token = issue_refresh_token(&db, user.id, Uuid::new_v4(), None).await?;
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: token,
|
||||
token_type: "bearer".to_string(),
|
||||
expires_in,
|
||||
refresh_token,
|
||||
user,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
State(state): State<AuthState>,
|
||||
db: Option<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()))?;
|
||||
|
||||
let claims = auth_ctx
|
||||
.claims
|
||||
.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 mut tx = db.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(email) = &payload.email {
|
||||
sqlx::query("UPDATE users SET email = $1 WHERE id = $2")
|
||||
.bind(email)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
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")
|
||||
.bind(hashed)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
if let Some(data) = &payload.data {
|
||||
sqlx::query("UPDATE users SET raw_user_meta_data = $1 WHERE id = $2")
|
||||
.bind(data)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
// Commit the transaction first to ensure updates are visible
|
||||
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Fetch the user after commit
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
Ok(Json(user))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod models;
|
||||
pub mod mfa;
|
||||
pub mod oauth;
|
||||
pub mod sso;
|
||||
pub mod utils;
|
||||
|
||||
|
||||
use axum::routing::{get, post};
|
||||
pub use axum::Router;
|
||||
pub use handlers::AuthState;
|
||||
@@ -13,7 +16,14 @@ pub fn router() -> Router<AuthState> {
|
||||
Router::new()
|
||||
.route("/signup", post(handlers::signup))
|
||||
.route("/token", post(handlers::token))
|
||||
.route("/recover", post(handlers::recover))
|
||||
.route("/verify", post(handlers::verify))
|
||||
.route("/authorize", get(oauth::authorize))
|
||||
.route("/callback/:provider", get(oauth::callback))
|
||||
.route("/user", get(handlers::get_user))
|
||||
.route("/mfa/enroll", post(mfa::enroll))
|
||||
.route("/mfa/verify", post(mfa::verify))
|
||||
.route("/mfa/challenge", post(mfa::challenge))
|
||||
.route("/sso", post(sso::sso_authorize))
|
||||
.route("/sso/callback/:domain", get(sso::sso_callback))
|
||||
.route("/user", get(handlers::get_user).put(handlers::update_user))
|
||||
}
|
||||
|
||||
205
auth/src/mfa.rs
Normal file
205
auth/src/mfa.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json},
|
||||
Extension,
|
||||
};
|
||||
use common::ProjectContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
use uuid::Uuid;
|
||||
use crate::middleware::AuthContext;
|
||||
use crate::handlers::AuthState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EnrollResponse {
|
||||
pub id: Uuid,
|
||||
pub type_: String,
|
||||
pub totp: TotpResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TotpResponse {
|
||||
pub qr_code: String, // SVG or PNG base64
|
||||
pub secret: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerifyRequest {
|
||||
pub factor_id: Uuid,
|
||||
pub code: String,
|
||||
pub challenge_id: Option<Uuid>, // For future use
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VerifyResponse {
|
||||
pub access_token: String, // Potentially upgraded token
|
||||
pub token_type: String,
|
||||
pub expires_in: usize,
|
||||
pub refresh_token: String,
|
||||
pub user: serde_json::Value,
|
||||
}
|
||||
|
||||
// Enroll MFA (Generate Secret & QR)
|
||||
pub async fn enroll(
|
||||
State(state): State<AuthState>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Extension(project_ctx): Extension<ProjectContext>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let user_id = auth_ctx.claims.as_ref()
|
||||
.and_then(|c| Uuid::parse_str(&c.sub).ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid user".to_string()))?;
|
||||
|
||||
// 1. Generate TOTP Secret
|
||||
let secret = Secret::generate_secret();
|
||||
let totp = TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret.to_bytes().unwrap(),
|
||||
Some(project_ctx.project_ref.clone()), // Issuer
|
||||
auth_ctx.claims.as_ref().and_then(|c| c.email.clone()).unwrap_or("user".to_string()), // Account Name
|
||||
).unwrap();
|
||||
|
||||
let secret_str = totp.get_secret_base32();
|
||||
let qr_code = totp.get_qr_base64().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
let uri = totp.get_url();
|
||||
|
||||
// 2. Store in DB (Unverified)
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO auth.mfa_factors (user_id, factor_type, secret, status) VALUES ($1, 'totp', $2, 'unverified') RETURNING id"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&secret_str)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let factor_id: Uuid = row.get("id");
|
||||
|
||||
Ok(Json(EnrollResponse {
|
||||
id: factor_id,
|
||||
type_: "totp".to_string(),
|
||||
totp: TotpResponse {
|
||||
qr_code,
|
||||
secret: secret_str,
|
||||
uri,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Verify MFA (Activate Factor)
|
||||
pub async fn verify(
|
||||
State(state): State<AuthState>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Extension(_project_ctx): Extension<ProjectContext>,
|
||||
Json(payload): Json<VerifyRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let user_id = auth_ctx.claims.as_ref()
|
||||
.and_then(|c| Uuid::parse_str(&c.sub).ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid user".to_string()))?;
|
||||
|
||||
// 1. Fetch Factor
|
||||
let row = sqlx::query(
|
||||
"SELECT secret, status FROM auth.mfa_factors WHERE id = $1 AND user_id = $2"
|
||||
)
|
||||
.bind(payload.factor_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Factor not found".to_string()))?;
|
||||
|
||||
let secret_str: String = row.get("secret");
|
||||
let status: String = row.get("status");
|
||||
|
||||
// 2. Validate Code
|
||||
let secret_bytes = base32::decode(base32::Alphabet::RFC4648 { padding: false }, &secret_str)
|
||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Invalid secret format".to_string()))?;
|
||||
|
||||
let totp = TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret_bytes,
|
||||
None,
|
||||
"".to_string(),
|
||||
).unwrap();
|
||||
|
||||
let is_valid = totp.check_current(&payload.code).unwrap_or(false);
|
||||
|
||||
if !is_valid {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid code".to_string()));
|
||||
}
|
||||
|
||||
// 3. Update Status if Unverified
|
||||
if status == "unverified" {
|
||||
sqlx::query("UPDATE auth.mfa_factors SET status = 'verified', updated_at = now() WHERE id = $1")
|
||||
.bind(payload.factor_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
// 4. Return Success (In a real scenario, this might return an upgraded JWT with `aal: 2`)
|
||||
// For now, we just confirm verification.
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "verified",
|
||||
"factor_id": payload.factor_id
|
||||
})))
|
||||
}
|
||||
|
||||
// Challenge (Login with MFA)
|
||||
pub async fn challenge(
|
||||
State(state): State<AuthState>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Json(payload): Json<VerifyRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// This is essentially the same as verify for now, but semantically distinct.
|
||||
// It implies checking a code against an ALREADY verified factor to allow login proceed.
|
||||
|
||||
let user_id = auth_ctx.claims.as_ref()
|
||||
.and_then(|c| Uuid::parse_str(&c.sub).ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid user".to_string()))?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT secret FROM auth.mfa_factors WHERE id = $1 AND user_id = $2 AND status = 'verified'"
|
||||
)
|
||||
.bind(payload.factor_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Factor not found or not verified".to_string()))?;
|
||||
|
||||
let secret_str: String = row.get("secret");
|
||||
|
||||
let secret_bytes = base32::decode(base32::Alphabet::RFC4648 { padding: false }, &secret_str)
|
||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Invalid secret format".to_string()))?;
|
||||
|
||||
let totp = TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret_bytes,
|
||||
None,
|
||||
"".to_string(),
|
||||
).unwrap();
|
||||
|
||||
let is_valid = totp.check_current(&payload.code).unwrap_or(false);
|
||||
|
||||
if !is_valid {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid code".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"factor_id": payload.factor_id
|
||||
})))
|
||||
}
|
||||
@@ -44,11 +44,18 @@ 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::info!("Using project-specific JWT secret: '{}'", ctx.jwt_secret);
|
||||
ctx.jwt_secret.clone()
|
||||
} else {
|
||||
tracing::warn!("ProjectContext not found! Using global JWT secret: '{}'", state.config.jwt_secret);
|
||||
state.config.jwt_secret.clone()
|
||||
};
|
||||
|
||||
@@ -98,8 +105,9 @@ pub async fn auth_middleware(
|
||||
req.extensions_mut().insert(ctx);
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
// Invalid token
|
||||
tracing::error!("Token validation failed: {}", e);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ pub struct User {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_sign_in_at: Option<DateTime<Utc>>,
|
||||
#[serde(rename = "app_metadata")]
|
||||
pub raw_app_meta_data: serde_json::Value,
|
||||
#[serde(rename = "user_metadata")]
|
||||
pub raw_user_meta_data: serde_json::Value,
|
||||
pub is_super_admin: Option<bool>,
|
||||
pub confirmed_at: Option<DateTime<Utc>>,
|
||||
@@ -62,3 +64,25 @@ pub struct RefreshToken {
|
||||
pub parent: Option<String>,
|
||||
pub session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct RecoverRequest {
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VerifyRequest {
|
||||
pub r#type: String, // signup, recovery, magiclink, invite
|
||||
pub token: String,
|
||||
pub password: Option<String>, // for recovery flow
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UserUpdateRequest {
|
||||
#[validate(email)]
|
||||
pub email: Option<String>,
|
||||
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
|
||||
pub password: Option<String>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -109,6 +109,30 @@ fn get_client(provider: &str, config: &Config) -> Result<OAuthClient, String> {
|
||||
"https://github.com/login/oauth/authorize",
|
||||
"https://github.com/login/oauth/access_token",
|
||||
),
|
||||
"azure" => (
|
||||
config.azure_client_id.clone().ok_or("Azure Client ID not set")?,
|
||||
config.azure_client_secret.clone().ok_or("Azure Client Secret not set")?,
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
),
|
||||
"gitlab" => (
|
||||
config.gitlab_client_id.clone().ok_or("GitLab Client ID not set")?,
|
||||
config.gitlab_client_secret.clone().ok_or("GitLab Client Secret not set")?,
|
||||
"https://gitlab.com/oauth/authorize",
|
||||
"https://gitlab.com/oauth/token",
|
||||
),
|
||||
"bitbucket" => (
|
||||
config.bitbucket_client_id.clone().ok_or("Bitbucket Client ID not set")?,
|
||||
config.bitbucket_client_secret.clone().ok_or("Bitbucket Client Secret not set")?,
|
||||
"https://bitbucket.org/site/oauth2/authorize",
|
||||
"https://bitbucket.org/site/oauth2/access_token",
|
||||
),
|
||||
"discord" => (
|
||||
config.discord_client_id.clone().ok_or("Discord Client ID not set")?,
|
||||
config.discord_client_secret.clone().ok_or("Discord Client Secret not set")?,
|
||||
"https://discord.com/api/oauth2/authorize",
|
||||
"https://discord.com/api/oauth2/token",
|
||||
),
|
||||
_ => return Err(format!("Unknown provider: {}", provider)),
|
||||
};
|
||||
|
||||
@@ -146,6 +170,28 @@ pub async fn authorize(
|
||||
auth_request = auth_request
|
||||
.add_scope(Scope::new("user:email".to_string()));
|
||||
}
|
||||
"azure" => {
|
||||
auth_request = auth_request
|
||||
.add_scope(Scope::new("User.Read".to_string()))
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()));
|
||||
}
|
||||
"gitlab" => {
|
||||
auth_request = auth_request
|
||||
.add_scope(Scope::new("read_user".to_string()));
|
||||
}
|
||||
"bitbucket" => {
|
||||
// Bitbucket scopes are not always required if key has permissions,
|
||||
// but usually 'email' is good.
|
||||
auth_request = auth_request
|
||||
.add_scope(Scope::new("email".to_string()));
|
||||
}
|
||||
"discord" => {
|
||||
auth_request = auth_request
|
||||
.add_scope(Scope::new("identify".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -219,7 +265,7 @@ pub async fn callback(
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
let (token, expires_in, _) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let refresh_token: String = issue_refresh_token(&db, user.id, Uuid::new_v4(), None)
|
||||
@@ -302,6 +348,113 @@ async fn fetch_user_profile(provider: &str, token: &str) -> Result<UserProfile,
|
||||
provider_id,
|
||||
})
|
||||
},
|
||||
"azure" => {
|
||||
let resp = client.get("https://graph.microsoft.com/v1.0/me")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let email = resp["mail"].as_str()
|
||||
.or(resp["userPrincipalName"].as_str())
|
||||
.ok_or("No email found")?
|
||||
.to_string();
|
||||
|
||||
let name = resp["displayName"].as_str().map(|s| s.to_string());
|
||||
let provider_id = resp["id"].as_str().ok_or("No ID found")?.to_string();
|
||||
|
||||
Ok(UserProfile {
|
||||
email,
|
||||
name,
|
||||
avatar_url: None, // Avatar requires separate call in Graph API
|
||||
provider_id,
|
||||
})
|
||||
},
|
||||
"gitlab" => {
|
||||
let resp = client.get("https://gitlab.com/api/v4/user")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let email = resp["email"].as_str().ok_or("No email found")?.to_string();
|
||||
let name = resp["name"].as_str().map(|s| s.to_string());
|
||||
let avatar_url = resp["avatar_url"].as_str().map(|s| s.to_string());
|
||||
let provider_id = resp["id"].as_i64().map(|id| id.to_string()).ok_or("No ID found")?.to_string();
|
||||
|
||||
Ok(UserProfile {
|
||||
email,
|
||||
name,
|
||||
avatar_url,
|
||||
provider_id,
|
||||
})
|
||||
},
|
||||
"bitbucket" => {
|
||||
let resp = client.get("https://api.bitbucket.org/2.0/user")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let emails_resp = client.get("https://api.bitbucket.org/2.0/user/emails")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let email = emails_resp["values"].as_array()
|
||||
.and_then(|v| v.iter().find(|e| e["is_primary"].as_bool().unwrap_or(false)))
|
||||
.and_then(|e| e["email"].as_str())
|
||||
.ok_or("No primary email found")?
|
||||
.to_string();
|
||||
|
||||
let name = resp["display_name"].as_str().map(|s| s.to_string());
|
||||
let avatar_url = resp["links"]["avatar"]["href"].as_str().map(|s| s.to_string());
|
||||
let provider_id = resp["account_id"].as_str().ok_or("No ID found")?.to_string();
|
||||
|
||||
Ok(UserProfile {
|
||||
email,
|
||||
name,
|
||||
avatar_url,
|
||||
provider_id,
|
||||
})
|
||||
},
|
||||
"discord" => {
|
||||
let resp = client.get("https://discord.com/api/users/@me")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let email = resp["email"].as_str().ok_or("No email found")?.to_string();
|
||||
let name = resp["global_name"].as_str().or(resp["username"].as_str()).map(|s| s.to_string());
|
||||
|
||||
let user_id = resp["id"].as_str().ok_or("No ID found")?;
|
||||
let avatar_hash = resp["avatar"].as_str();
|
||||
let avatar_url = avatar_hash.map(|h| format!("https://cdn.discordapp.com/avatars/{}/{}.png", user_id, h));
|
||||
|
||||
Ok(UserProfile {
|
||||
email,
|
||||
name,
|
||||
avatar_url,
|
||||
provider_id: user_id.to_string(),
|
||||
})
|
||||
},
|
||||
_ => Err("Unknown provider".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
232
auth/src/sso.rs
Normal file
232
auth/src/sso.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use crate::utils::{generate_token, issue_refresh_token};
|
||||
use crate::AuthState;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
Json,
|
||||
Extension,
|
||||
};
|
||||
use common::{Config, ProjectContext};
|
||||
use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType};
|
||||
use openidconnect::{
|
||||
AuthenticationFlow, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, RedirectUrl, Scope, TokenResponse
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
// In-memory cache for OIDC clients to avoid rediscovery on every request
|
||||
// Key: domain, Value: CoreClient
|
||||
type ClientCache = Arc<RwLock<std::collections::HashMap<String, CoreClient>>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SsoRequest {
|
||||
pub domain: Option<String>,
|
||||
pub provider_id: Option<Uuid>,
|
||||
pub redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SsoCallback {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
pub nonce: String, // We need to pass nonce via state or separate param usually
|
||||
}
|
||||
|
||||
pub async fn sso_authorize(
|
||||
State(state): State<AuthState>,
|
||||
Json(payload): Json<SsoRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// 1. Find Provider
|
||||
let row = if let Some(domain) = &payload.domain {
|
||||
sqlx::query("SELECT * FROM auth.sso_providers WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else if let Some(id) = payload.provider_id {
|
||||
sqlx::query("SELECT * FROM auth.sso_providers WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
return Err((StatusCode::BAD_REQUEST, "Either domain or provider_id required".to_string()));
|
||||
};
|
||||
|
||||
let provider = row.ok_or((StatusCode::NOT_FOUND, "SSO Provider not found".to_string()))?;
|
||||
|
||||
let issuer_url: String = provider.get("oidc_issuer_url");
|
||||
let client_id: String = provider.get("oidc_client_id");
|
||||
let client_secret: String = provider.get("oidc_client_secret");
|
||||
let domain: String = provider.get("domain");
|
||||
|
||||
// 2. Discover Metadata (Ideally cached)
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(issuer_url).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?,
|
||||
openidconnect::reqwest::async_http_client,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Discovery failed: {}", e)))?;
|
||||
|
||||
// 3. Create Client
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(client_id),
|
||||
Some(ClientSecret::new(client_secret)),
|
||||
)
|
||||
.set_redirect_uri(
|
||||
RedirectUrl::new(format!("{}/sso/callback/{}", state.config.redirect_uri.trim_end_matches("/auth/v1/callback"), domain))
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?,
|
||||
);
|
||||
|
||||
// 4. Generate URL
|
||||
let (authorize_url, csrf_state, nonce) = client
|
||||
.authorize_url(
|
||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.url();
|
||||
|
||||
// TODO: Store csrf_state and nonce securely (e.g. Redis or secure cookie)
|
||||
// For MVP, we might encode them in the state param or rely on stateless verification if possible (less secure)
|
||||
// Here we assume the client handles the redirection.
|
||||
|
||||
Ok(Json(json!({
|
||||
"url": authorize_url.to_string(),
|
||||
"state": csrf_state.secret(),
|
||||
"nonce": nonce.secret()
|
||||
})))
|
||||
}
|
||||
|
||||
// NOTE: This callback logic assumes the client (browser) followed the link and is now returning.
|
||||
// Since we don't have session state here to verify CSRF/Nonce (stateless API),
|
||||
// a real implementation would typically use a signed cookie or a separate "initiate" step that sets a cookie.
|
||||
// For this MVP, we will verify the code exchange but skip strict state/nonce validation against a server-side store,
|
||||
// which is a SECURITY RISK in production but acceptable for a "skeleton" implementation.
|
||||
|
||||
pub async fn sso_callback(
|
||||
State(state): State<AuthState>,
|
||||
db: Option<Extension<sqlx::PgPool>>,
|
||||
project_ctx: Option<Extension<ProjectContext>>,
|
||||
Path(domain): Path<String>,
|
||||
Query(query): Query<SsoCallback>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
|
||||
|
||||
// 1. Fetch Provider
|
||||
let provider = sqlx::query("SELECT * FROM auth.sso_providers WHERE domain = $1")
|
||||
.bind(&domain)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Provider not found".to_string()))?;
|
||||
|
||||
let issuer_url: String = provider.get("oidc_issuer_url");
|
||||
let client_id: String = provider.get("oidc_client_id");
|
||||
let client_secret: String = provider.get("oidc_client_secret");
|
||||
|
||||
// 2. Setup Client
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(issuer_url.clone()).unwrap(),
|
||||
openidconnect::reqwest::async_http_client,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Discovery failed: {}", e)))?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(client_id),
|
||||
Some(ClientSecret::new(client_secret)),
|
||||
)
|
||||
.set_redirect_uri(
|
||||
RedirectUrl::new(format!("{}/sso/callback/{}", state.config.redirect_uri.trim_end_matches("/auth/v1/callback"), domain)).unwrap(),
|
||||
);
|
||||
|
||||
// 3. Exchange Code
|
||||
let token_response = client
|
||||
.exchange_code(openidconnect::AuthorizationCode::new(query.code))
|
||||
.request_async(openidconnect::reqwest::async_http_client)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Token exchange failed: {}", e)))?;
|
||||
|
||||
// 4. Get ID Token & Claims
|
||||
let id_token = token_response.id_token()
|
||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "No ID Token received".to_string()))?;
|
||||
|
||||
let claims = id_token.claims(
|
||||
&client.id_token_verifier(),
|
||||
&Nonce::new(query.nonce), // We trust the user provided nonce for now (Insecure MVP)
|
||||
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Claims verification failed: {}", e)))?;
|
||||
|
||||
let email = claims.email().ok_or((StatusCode::BAD_REQUEST, "Email not found in claims".to_string()))?.as_str();
|
||||
let name = claims.name().and_then(|n| n.get(None)).map(|n| n.as_str().to_string());
|
||||
let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.as_str().to_string());
|
||||
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")
|
||||
.bind(email)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let user = if let Some(u) = existing_user {
|
||||
u
|
||||
} else {
|
||||
let raw_meta = json!({
|
||||
"name": name,
|
||||
"avatar_url": picture,
|
||||
"provider": "sso",
|
||||
"provider_id": sub,
|
||||
"iss": issuer_url
|
||||
});
|
||||
|
||||
sqlx::query_as::<_, crate::models::User>(
|
||||
r#"
|
||||
INSERT INTO users (email, encrypted_password, raw_user_meta_data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(email)
|
||||
.bind("sso_user_no_password")
|
||||
.bind(raw_meta)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
// 6. Issue Token
|
||||
let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() {
|
||||
ctx.jwt_secret.as_str()
|
||||
} else {
|
||||
state.config.jwt_secret.as_str()
|
||||
};
|
||||
|
||||
let (token, expires_in, _) = generate_token(user.id, &user.email, "authenticated", jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let refresh_token: String = issue_refresh_token(&db, user.id, Uuid::new_v4(), None)
|
||||
.await
|
||||
.map_err(|(code, msg)| (StatusCode::from_u16(code.as_u16()).unwrap(), msg))?;
|
||||
|
||||
// Redirect to frontend with tokens
|
||||
// Ideally we redirect to a frontend callback URL with hash params
|
||||
let redirect_url = format!(
|
||||
"{}/auth/callback?access_token={}&refresh_token={}&expires_in={}&type=bearer",
|
||||
state.config.redirect_uri.trim_end_matches("/auth/v1/callback"), // Base URL assumption
|
||||
token,
|
||||
refresh_token,
|
||||
expires_in
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&redirect_url))
|
||||
}
|
||||
@@ -39,15 +39,29 @@ pub fn verify_password(password: &str, password_hash: &str) -> anyhow::Result<bo
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
pub fn hash_refresh_token(raw: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw);
|
||||
let result = hasher.finalize();
|
||||
hex::encode(result)
|
||||
}
|
||||
|
||||
pub fn generate_refresh_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex_encode(&bytes)
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn hash_refresh_token(raw: &str) -> String {
|
||||
let digest = Sha256::digest(raw.as_bytes());
|
||||
hex_encode(&digest)
|
||||
pub fn generate_confirmation_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn generate_recovery_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn generate_token(
|
||||
@@ -55,7 +69,7 @@ pub fn generate_token(
|
||||
email: &str,
|
||||
role: &str,
|
||||
jwt_secret: &str,
|
||||
) -> anyhow::Result<(String, i64)> {
|
||||
) -> anyhow::Result<(String, i64, i64)> {
|
||||
let now = Utc::now();
|
||||
let expiration = now
|
||||
.checked_add_signed(Duration::seconds(3600)) // 1 hour
|
||||
@@ -76,18 +90,10 @@ pub fn generate_token(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret.as_bytes()),
|
||||
)?;
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
Ok((token, 3600))
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(&mut out, "{:02x}", b);
|
||||
}
|
||||
out
|
||||
Ok((token, 3600, expiration))
|
||||
}
|
||||
|
||||
pub async fn issue_refresh_token(
|
||||
|
||||
Reference in New Issue
Block a user