650 lines
19 KiB
Rust
650 lines
19 KiB
Rust
mod common;
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
|
use chrono::{Duration as ChronoDuration, Utc};
|
|
use common::{acquire_db_lock, body_to_vec, ApiErrorResponse, TestApp};
|
|
use diesel::prelude::*;
|
|
use papercrate::auth::passkeys::{
|
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
|
RegistrationChallengeResponse,
|
|
};
|
|
use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
|
use papercrate::openapi::schemas::PasskeySummary;
|
|
use papercrate::schema::{refresh_tokens, tenants, user_memberships, users};
|
|
use rand::rngs::OsRng;
|
|
use rand::RngCore;
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use uuid::Uuid;
|
|
use webauthn_rs_core::proto::{
|
|
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
|
RegisterPublicKeyCredential,
|
|
};
|
|
|
|
#[derive(Deserialize)]
|
|
struct AuthenticatedUser {
|
|
username: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LoginTenant {
|
|
id: Uuid,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LoginResponse {
|
|
access_token: String,
|
|
tenant: LoginTenant,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SignupStartResponse {
|
|
signup_token: String,
|
|
challenge: RegistrationChallengeResponse,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TenantSelectionResponse {
|
|
#[serde(rename = "access_token")]
|
|
_access_token: String,
|
|
#[serde(rename = "tenants")]
|
|
_tenants: Vec<TenantSummary>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TenantListResponse {
|
|
tenants: Vec<TenantSummary>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TenantSummary {
|
|
id: Uuid,
|
|
name: String,
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_and_me_roundtrip() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "s3cret";
|
|
app.insert_user("alice", password, "admin").await?;
|
|
|
|
let (login, _) = login_with_session(&app, "alice", password).await?;
|
|
|
|
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
|
|
|
assert_eq!(user.username, "alice");
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_rejects_unknown_user() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let payload = json!({ "username": "ghost", "password": "nope" });
|
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
|
|
assert_eq!(err.error, "password authentication is no longer supported");
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let payload = json!({ "username": "signup-user" });
|
|
|
|
let response = app
|
|
.post_json("/api/auth/signup/start", &payload, None)
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let start: SignupStartResponse = serde_json::from_slice(&body)?;
|
|
assert!(!start.signup_token.is_empty());
|
|
assert_ne!(start.challenge.challenge_id, Uuid::nil());
|
|
|
|
app.with_conn(|conn| {
|
|
let exists: bool = diesel::select(diesel::dsl::exists(
|
|
users::table.filter(users::username.eq("signup-user")),
|
|
))
|
|
.get_result(conn)?;
|
|
assert!(!exists);
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
|
|
let finish_payload = json!({
|
|
"signup_token": start.signup_token,
|
|
"credential": fake_register_credential(),
|
|
});
|
|
let finish_response = app
|
|
.post_json("/api/auth/signup/finish", &finish_payload, None)
|
|
.await?;
|
|
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passkey_register_start_creates_challenge() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
app.insert_user("passkey-user", password, "admin").await?;
|
|
|
|
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
|
|
|
|
let response = app
|
|
.post_json(
|
|
"/api/auth/passkeys/register/start",
|
|
&json!({}),
|
|
Some(&login.access_token),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
|
|
assert_ne!(challenge.challenge_id, Uuid::nil());
|
|
let challenge_id = challenge.challenge_id;
|
|
|
|
app.with_conn(move |conn| {
|
|
use diesel::dsl::{exists, select};
|
|
use papercrate::schema::webauthn_challenges::dsl;
|
|
|
|
let exists: bool = select(exists(
|
|
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
|
|
))
|
|
.get_result(conn)?;
|
|
assert!(exists, "challenge not persisted");
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
app.insert_user("passkey-register", password, "admin")
|
|
.await?;
|
|
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
|
|
|
|
let payload = PasskeyRegistrationFinishPayload {
|
|
challenge_id: Uuid::new_v4(),
|
|
credential: fake_register_credential(),
|
|
nickname: None,
|
|
};
|
|
|
|
let response = app
|
|
.post_json(
|
|
"/api/auth/passkeys/register/finish",
|
|
&payload,
|
|
Some(&login.access_token),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passkey_login_start_requires_passkey() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
app.insert_user("passkey-login", password, "admin").await?;
|
|
|
|
let payload = PasskeyLoginStartPayload {
|
|
username: "passkey-login".to_string(),
|
|
};
|
|
|
|
let response = app
|
|
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let payload = PasskeyLoginStartPayload {
|
|
username: "nobody".to_string(),
|
|
};
|
|
|
|
let response = app
|
|
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let payload = PasskeyLoginFinishPayload {
|
|
challenge_id: Uuid::new_v4(),
|
|
credential: fake_authentication_credential(),
|
|
};
|
|
|
|
let response = app
|
|
.post_json("/api/auth/passkeys/login/finish", &payload, None)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_passkeys_returns_entries() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
|
|
app.insert_passkey(user_id, Some("Laptop")).await?;
|
|
|
|
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
|
|
|
|
let response = app
|
|
.get("/api/profile/passkeys", Some(&session.access_token))
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
|
|
assert_eq!(summaries.len(), 1);
|
|
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
|
|
assert!(summaries[0].revoked_at.is_none());
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_passkey_soft_revokes() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
|
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
|
app.insert_passkey(user_id, Some("Backup")).await?;
|
|
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
|
|
|
let response = app
|
|
.delete(
|
|
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
|
|
Some(&session.access_token),
|
|
)
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
|
|
app.with_conn(move |conn| {
|
|
use papercrate::schema::user_passkeys::dsl as passkey_dsl;
|
|
|
|
let record = passkey_dsl::user_passkeys
|
|
.find(passkey_id)
|
|
.first::<UserPasskey>(conn)?;
|
|
assert!(record.revoked_at.is_some());
|
|
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_passkey_prevents_last() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "secret";
|
|
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
|
|
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
|
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
|
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
|
|
|
let response = app
|
|
.delete(
|
|
&format!("/api/profile/passkeys/{}", first_id),
|
|
Some(&session.access_token),
|
|
)
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
|
|
let block_response = app
|
|
.delete(
|
|
&format!("/api/profile/passkeys/{}", last_id),
|
|
Some(&session.access_token),
|
|
)
|
|
.await?;
|
|
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn fake_register_credential() -> RegisterPublicKeyCredential {
|
|
RegisterPublicKeyCredential {
|
|
id: "fake-passkey".to_string(),
|
|
raw_id: vec![1, 2, 3, 4].into(),
|
|
response: AuthenticatorAttestationResponseRaw {
|
|
attestation_object: vec![5, 6, 7, 8].into(),
|
|
client_data_json: vec![9, 10, 11, 12].into(),
|
|
transports: None,
|
|
},
|
|
type_: "public-key".to_string(),
|
|
extensions: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn fake_authentication_credential() -> PublicKeyCredential {
|
|
PublicKeyCredential {
|
|
id: "fake-auth".to_string(),
|
|
raw_id: vec![1, 2, 3].into(),
|
|
response: AuthenticatorAssertionResponseRaw {
|
|
authenticator_data: vec![4, 5, 6].into(),
|
|
client_data_json: vec![7, 8, 9].into(),
|
|
signature: vec![10, 11, 12].into(),
|
|
user_handle: None,
|
|
},
|
|
extensions: Default::default(),
|
|
type_: "public-key".to_string(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_rejects_invalid_password() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "valid";
|
|
app.insert_user("robin", password, "admin").await?;
|
|
|
|
let payload = json!({ "username": "robin", "password": "wrong" });
|
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
|
|
assert_eq!(err.error, "password authentication is no longer supported");
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_rotates_refresh_token() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "rotate";
|
|
app.insert_user("rita", password, "admin").await?;
|
|
|
|
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
|
|
|
let response = app
|
|
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let new_cookie = extract_refresh_cookie(response.headers())?;
|
|
let body = body_to_vec(response.into_body()).await?;
|
|
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
|
assert_eq!(refreshed.tenant.name, login.tenant.name);
|
|
|
|
let me_response = app
|
|
.get("/api/auth/me", Some(&refreshed.access_token))
|
|
.await?;
|
|
assert_eq!(me_response.status(), StatusCode::OK);
|
|
|
|
let retry = app
|
|
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
|
.await?;
|
|
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// new cookie should differ from old to avoid reuse
|
|
assert_ne!(new_cookie, refresh_cookie);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn logout_revokes_refresh_token() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "logout";
|
|
app.insert_user("logan", password, "admin").await?;
|
|
|
|
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
|
|
|
let response = app
|
|
.post_json_with_cookie(
|
|
"/api/auth/logout",
|
|
&json!({}),
|
|
Some(&login.access_token),
|
|
Some(&refresh_cookie),
|
|
)
|
|
.await?;
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
let cleared_cookie = extract_refresh_cookie(response.headers())?;
|
|
assert!(cleared_cookie.ends_with("="));
|
|
|
|
let after_logout = app
|
|
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
|
.await?;
|
|
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn me_requires_authentication() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let unauthenticated = app.get("/api/auth/me", None).await?;
|
|
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
|
|
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
|
|
let _lock = acquire_db_lock().await;
|
|
let app = TestApp::new().await?;
|
|
|
|
let password = "multipass";
|
|
let user_id = app.insert_user("multipass", password, "admin").await?;
|
|
|
|
let secondary_name = "secondary".to_string();
|
|
let name_for_insert = secondary_name.clone();
|
|
let secondary_id = Uuid::new_v4();
|
|
app.with_conn(move |conn| {
|
|
diesel::insert_into(tenants::table)
|
|
.values((
|
|
tenants::id.eq(secondary_id),
|
|
tenants::name.eq(&name_for_insert),
|
|
tenants::status.eq(TenantStatus::Active),
|
|
))
|
|
.execute(conn)?;
|
|
|
|
let membership = NewUserMembership {
|
|
id: Uuid::new_v4(),
|
|
user_id,
|
|
tenant_id: secondary_id,
|
|
};
|
|
|
|
diesel::insert_into(user_memberships::table)
|
|
.values(&membership)
|
|
.execute(conn)?;
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
|
|
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
|
|
|
let tenants_response = app
|
|
.get("/api/auth/tenants", Some(&login.access_token))
|
|
.await?;
|
|
assert_eq!(tenants_response.status(), StatusCode::OK);
|
|
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
|
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
|
|
assert!(tenant_list.tenants.len() >= 2);
|
|
|
|
let secondary = tenant_list
|
|
.tenants
|
|
.iter()
|
|
.find(|tenant| tenant.name == secondary_name)
|
|
.map(|t| t.id)
|
|
.context("secondary tenant missing from listing")?;
|
|
|
|
let select_response = app
|
|
.post_json_with_cookie(
|
|
"/api/auth/select-tenant",
|
|
&json!({ "tenant_id": secondary }),
|
|
Some(&login.access_token),
|
|
Some(&refresh_cookie),
|
|
)
|
|
.await?;
|
|
assert_eq!(select_response.status(), StatusCode::OK);
|
|
let select_body = body_to_vec(select_response.into_body()).await?;
|
|
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
|
|
assert_eq!(rotated.tenant.id, secondary);
|
|
assert_eq!(rotated.tenant.name, secondary_name);
|
|
|
|
app.cleanup().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn login_with_session(
|
|
app: &TestApp,
|
|
username: &str,
|
|
_password: &str,
|
|
) -> Result<(LoginResponse, String)> {
|
|
let username = username.to_string();
|
|
let state = app.state.clone();
|
|
app.with_conn(move |conn| {
|
|
use papercrate::schema::user_memberships::dsl as memberships_dsl;
|
|
use papercrate::schema::users::dsl as users_dsl;
|
|
|
|
let user: papercrate::models::User = users_dsl::users
|
|
.filter(users_dsl::username.eq(&username))
|
|
.first(conn)?;
|
|
|
|
let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships
|
|
.filter(memberships_dsl::user_id.eq(user.id))
|
|
.first(conn)?;
|
|
|
|
let tenant: papercrate::models::Tenant =
|
|
tenants::table.find(membership.tenant_id).first(conn)?;
|
|
|
|
let now = Utc::now();
|
|
let access_token = state
|
|
.jwt
|
|
.generate_token(user.id, tenant.id, &user.username)
|
|
.map_err(|err| anyhow!(err))?;
|
|
|
|
let refresh_value = generate_refresh_token();
|
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
|
|
|
let new_refresh = NewRefreshToken {
|
|
id: Uuid::new_v4(),
|
|
user_id: user.id,
|
|
token_hash: refresh_hash,
|
|
issued_at: now.naive_utc(),
|
|
expires_at: refresh_expires_at.naive_utc(),
|
|
tenant_id: tenant.id,
|
|
};
|
|
|
|
diesel::insert_into(refresh_tokens::table)
|
|
.values(&new_refresh)
|
|
.execute(conn)?;
|
|
|
|
let login = LoginResponse {
|
|
access_token,
|
|
tenant: LoginTenant {
|
|
id: tenant.id,
|
|
name: tenant.name.clone(),
|
|
},
|
|
};
|
|
|
|
let cookie = format!("refresh_token={refresh_value}");
|
|
Ok((login, cookie))
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
|
let header_value = headers
|
|
.get(SET_COOKIE)
|
|
.context("missing set-cookie header")?
|
|
.to_str()
|
|
.context("invalid set-cookie header")?;
|
|
let cookie = header_value
|
|
.split(';')
|
|
.next()
|
|
.context("set-cookie missing cookie value")?
|
|
.to_string();
|
|
Ok(cookie)
|
|
}
|
|
|
|
fn generate_refresh_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
fn hash_refresh_token(value: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(value.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|