chore(history): squash 100 commit(s) from 2026-09-24

- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params
- test(backend): assert password length cap boundary (256 ok, 257 rejected)
- docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens
- feat(backend): JWT access/refresh token issue and verify
- feat(backend): accounts repository (create/find_by_email/find_by_id)
- refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2)
- docs(backend): plan — fix sqlx::migrate! path in integration tests
- feat(backend): POST /auth/register and /auth/login
- refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2)
- docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429)
- feat(backend): OAuth device authorization grant for mod login
- fix(backend): first confirm wins for device codes
- docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling
- refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2)
- refactor(visuals): Trails 687→130 (8.5.2)
- refactor(render): ItemBatchRenderer 677->100 (8.5.2)
- refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2)
- refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2)
- refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2)
- refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2)
- refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2)
- refactor(render): UiStyle 644→170 + 3 helpers (8.5.2)
- refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2)
- refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2)
- refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2)
- chore(license): strip GPL file headers from all Java sources
- refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2)
- refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2)
- refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2)
- refactor(render): MeshBuilder +4 helpers (8.5.2)
- refactor(visuals): extract WorldParticlesRender helper (8.5.2)
- refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2)
- refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2)
- chore(license): strip GPL header from remaining 30 files and make strip script variant-aware
- refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2)
- refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2)
- refactor(gui): ThemeCardRenderer perf + readability polish
- refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2)
- refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2)
- refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2)
- refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2)
- refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2)
- refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2)
- refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2)
- refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2)
- refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2)
- refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength
- refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2)
- feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage
- refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2)
- docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service
- refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2)
- chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway
- refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2)
- refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2)
- refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2)
- refact(viewmodel): split swing animations into camera/swing package
- refact(kineticlyrics): split module into stage, playback and modes
- rename(holeesp): module HoleESP -> CrystalHoles
- refact(crystalholes): split module into crystal scanner, renderer and safety
- refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles
- refact(accountconfig): split config into store, session and value helpers
- refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper
- docs(TODO): mark AddonManager split done; close 9.2 refactor gate
- refactor(media): LinuxMediaSession 441->148, split reader + track/seek state
- refactor(nametags): split NameTags into facade + impl helpers
- refactor(clickgui): split MainSettingsComponent into facade + scroll + model
- refactor(hud): split CustomBar into facade, model and BarSettings
- docs(frontend): implementation plan with design system from the mod theme
- feat(frontend): design tokens from the mod theme, fonts and shared UI kit
- fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape
- docs(gateway): plan note on splitting auth handlers before refresh endpoints
- feat(frontend): API client with silent refresh, error descriptions and test helpers
- style(mod): group compact one-line bulk query methods in ModuleManager
- feat(frontend): session restore, login and registration with client-side validation
- refactor(hud): split CustomHealthBar into facade + painter + script renderer
- docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5
- refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers
- refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers
- refactor(backtrack): split BacktrackController into facade + model + impl helpers
- refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers
- refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers
- refactor(renderer3d): un-nest batch bindings + culling into sibling impl types
- refactor(renderwarp): extract static factories + geometry into impl helpers
- feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto
- refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows
- feat(accounts): accept only gateway traffic, read identity from gateway header
- refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl
- refactor(customskyboxrenderer): extract projection, shader passes and sun into impl
- refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl
- feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout
- refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer
- refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec
- refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/
- refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules
- refactor(lovisual): extract HUD/world render orchestration into HudRender helper
- refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors
- refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer
- refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages
- refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
loki5512344 2026-09-24 23:52:12 +02:00
parent 9d08fa910a
commit 72bc4c7148
1897 changed files with 36199 additions and 39289 deletions

View file

@ -0,0 +1,227 @@
use crate::accounts::repo;
use crate::auth::{password, tokens};
use crate::error::{AppError, AppJson};
use axum::{extract::State, http::StatusCode, Json};
use axum_extra::extract::cookie::CookieJar;
use common::jwt;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AuthState {
pub pool: sqlx::PgPool,
pub jwt_secret: String,
pub cookie_secure: bool,
pub hasher: password::PasswordHasher,
// A real Argon2id hash of a throwaway string. `login` verifies against
// it when the email is unknown so that "no such account" costs the same
// ~100ms as "wrong password" — otherwise response time leaks which
// emails are registered (user enumeration via timing).
dummy_hash: String,
}
impl AuthState {
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
// Hashing a fixed, short constant with fixed valid params cannot
// fail; this is not user input, so the expect is a startup invariant.
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
.expect("hashing a constant with pinned params cannot fail");
AuthState {
pool,
jwt_secret,
cookie_secure,
hasher: password::PasswordHasher::new(),
dummy_hash,
}
}
}
#[derive(Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub nick: String,
}
#[derive(Serialize)]
pub struct RegisterResponse {
pub id: uuid::Uuid,
pub email: String,
pub display_nick: String,
}
/// Validates and normalizes a register request. Returns the trimmed
/// `(email, nick)` on success.
fn validate_register(req: &RegisterRequest) -> Result<(String, String), AppError> {
let email = req.email.trim();
if email.is_empty() {
return Err(AppError::Validation("email must not be empty".into()));
}
if email.len() > 254 {
return Err(AppError::Validation("email must be at most 254 characters".into()));
}
let mut parts = email.split('@');
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
return Err(AppError::Validation("email must contain '@'".into()));
};
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
return Err(AppError::Validation("email must have exactly one '@' with non-empty parts".into()));
}
let nick = req.nick.trim();
let nick_len = nick.chars().count();
if nick_len == 0 || nick_len > 32 {
return Err(AppError::Validation("nick must be 1 to 32 characters".into()));
}
if nick.chars().any(|c| c.is_control()) {
return Err(AppError::Validation("nick must not contain control characters".into()));
}
if req.password.chars().count() < 8 {
return Err(AppError::Validation("password must be at least 8 characters".into()));
}
if req.password.len() > password::MAX_PASSWORD_BYTES {
return Err(AppError::Validation(format!(
"password must be at most {} bytes",
password::MAX_PASSWORD_BYTES
)));
}
Ok((email.to_string(), nick.to_string()))
}
pub async fn register(
State(state): State<AuthState>,
AppJson(req): AppJson<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
let (email, nick) = validate_register(&req)?;
let hash = state.hasher.hash(req.password.clone()).await.map_err(AppError::Internal)?;
let account = repo::create(&state.pool, &email, &hash, &nick).await?;
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
id: account.id,
email: account.email,
display_nick: account.display_nick,
}),
))
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub access_token: String,
}
pub async fn login(
State(state): State<AuthState>,
jar: CookieJar,
AppJson(req): AppJson<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
let Some(account) = repo::find_by_email(&state.pool, &req.email).await? else {
// Burn the same Argon2 cost as a real check, then fail identically.
state
.hasher
.verify(req.password.clone(), state.dummy_hash.clone())
.await
.map_err(AppError::Internal)?;
return Err(AppError::Unauthorized);
};
if !state
.hasher
.verify(req.password.clone(), account.password_hash.clone())
.await
.map_err(AppError::Internal)?
{
return Err(AppError::Unauthorized);
}
let refresh = tokens::store_refresh(&state.pool, account.id).await?;
Ok((
jar.add(tokens::refresh_cookie(refresh, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account.id, &state.jwt_secret) }),
))
}
pub async fn refresh(
State(state): State<AuthState>,
jar: CookieJar,
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
let token = jar.get(tokens::REFRESH_COOKIE).map(|c| c.value().to_owned()).ok_or(AppError::Unauthorized)?;
match tokens::rotate_refresh(&state.pool, &token).await? {
tokens::RotateOutcome::Rotated { account_id, new_token } => Ok((
jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account_id, &state.jwt_secret) }),
)),
tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized),
}
}
pub async fn logout(
State(state): State<AuthState>,
jar: CookieJar,
) -> Result<(CookieJar, StatusCode), AppError> {
if let Some(cookie) = jar.get(tokens::REFRESH_COOKIE) {
tokens::revoke_refresh(&state.pool, cookie.value()).await?;
}
Ok((
jar.remove(axum_extra::extract::cookie::Cookie::build(tokens::REFRESH_COOKIE).path("/auth")),
StatusCode::NO_CONTENT,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_request() -> RegisterRequest {
RegisterRequest {
email: "user@example.com".into(),
password: "password123".into(),
nick: "Rider".into(),
}
}
#[test]
fn valid_request_passes() {
assert!(validate_register(&valid_request()).is_ok());
}
#[test]
fn short_password_is_rejected() {
let mut req = valid_request();
req.password = "short12".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_email_is_rejected() {
let mut req = valid_request();
req.email = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn email_without_at_is_rejected() {
let mut req = valid_request();
req.email = "not-an-email".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_nick_is_rejected() {
let mut req = valid_request();
req.nick = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn thirty_three_char_nick_is_rejected() {
let mut req = valid_request();
req.nick = "a".repeat(33);
assert!(validate_register(&req).is_err());
}
}

View file

@ -1 +1,3 @@
pub mod password;
pub mod tokens;
pub mod handlers;

View file

@ -1,18 +1,91 @@
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use argon2::password_hash::phc::PasswordHash;
// `as _`: the trait must be in scope for `hash_password`, but the name
// belongs to the `PasswordHasher` struct below.
use argon2::{Algorithm, Argon2, Params, PasswordHasher as _, PasswordVerifier, Version};
use std::sync::Arc;
use tokio::sync::Semaphore;
// Explicit Argon2id parameters instead of `Argon2::default()`, so a
// dependency bump can never silently weaken the cost (the library default
// is the OWASP *minimum*: m=19 MiB, t=2). 64 MiB / t=3 / p=1 is
// deliberately above that floor.
const M_COST_KIB: u32 = 64 * 1024;
const T_COST: u32 = 3;
const P_COST: u32 = 1;
// Argon2 processes the whole input, so an unbounded password is a cheap
// CPU/memory DoS vector on both register and login. bcrypt-style 72-byte
// truncation is not a concern for Argon2; this is purely an abuse cap.
pub const MAX_PASSWORD_BYTES: usize = 256;
fn hasher() -> Result<Argon2<'static>, argon2::password_hash::Error> {
let params = Params::new(M_COST_KIB, T_COST, P_COST, None)
.map_err(|_| argon2::password_hash::Error::ParamsInvalid)?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
pub fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> {
let argon2 = Argon2::default();
Ok(argon2.hash_password(plain.as_bytes())?.to_string())
if plain.len() > MAX_PASSWORD_BYTES {
return Err(argon2::password_hash::Error::PasswordInvalid);
}
Ok(hasher()?.hash_password(plain.as_bytes())?.to_string())
}
pub fn verify_password(plain: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else { return false };
if plain.len() > MAX_PASSWORD_BYTES {
return false;
}
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
// Parameters are read from the stored PHC string, so hashes created
// under older/lower settings still verify after a cost increase.
Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok()
}
/// Argon2 (64 MiB, ~100ms) is CPU/memory heavy; without a cap, concurrent
/// register/login requests could starve the tokio worker pool. Sized to the
/// number of CPUs so hashing never oversubscribes them.
#[derive(Clone)]
pub struct PasswordHasher {
permits: Arc<Semaphore>,
}
impl Default for PasswordHasher {
fn default() -> Self {
Self::new()
}
}
impl PasswordHasher {
pub fn new() -> Self {
let permits = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
PasswordHasher { permits: Arc::new(Semaphore::new(permits)) }
}
/// Runs Argon2 hashing off the async workers, bounded by the permit count.
pub async fn hash(&self, plain: String) -> Result<String, anyhow::Error> {
let _permit = self.permits.acquire().await?;
let inner = tokio::task::spawn_blocking(move || hash_password(&plain))
.await
.map_err(|e| anyhow::anyhow!(e))??;
Ok(inner)
}
/// Runs Argon2 verification off the async workers, bounded by the permit
/// count. Used for both real and dummy (timing-equalizer) verification.
pub async fn verify(&self, plain: String, hash: String) -> Result<bool, anyhow::Error> {
let _permit = self.permits.acquire().await?;
tokio::task::spawn_blocking(move || verify_password(&plain, &hash))
.await
.map_err(|e| anyhow::anyhow!(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -34,4 +107,36 @@ mod tests {
let hash = hash_password("secret123").unwrap();
assert_ne!(hash, "secret123");
}
#[test]
fn hash_uses_pinned_argon2id_params() {
let hash = hash_password("secret123").unwrap();
assert!(
hash.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"),
"unexpected PHC prefix: {hash}"
);
}
#[test]
fn same_password_hashes_differently_each_time() {
let a = hash_password("secret123").unwrap();
let b = hash_password("secret123").unwrap();
assert_ne!(a, b, "salt must be random per hash");
}
#[test]
fn password_length_cap_boundary() {
let at_cap = "a".repeat(MAX_PASSWORD_BYTES);
let hash = hash_password(&at_cap).expect("exactly the cap must be accepted");
assert!(verify_password(&at_cap, &hash));
let over_cap = "a".repeat(MAX_PASSWORD_BYTES + 1);
assert!(hash_password(&over_cap).is_err(), "cap+1 must be rejected");
assert!(!verify_password(&over_cap, &hash));
}
#[test]
fn garbage_hash_string_fails_verify_without_panicking() {
assert!(!verify_password("whatever", "not-a-phc-string"));
}
}

View file

@ -0,0 +1,136 @@
use axum_extra::extract::cookie::{Cookie, SameSite};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngExt;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
pub const REFRESH_COOKIE: &str = "lv_refresh";
/// 256-bit random token: nothing to brute-force, so a slow hash would only
/// add latency to every refresh — plain SHA-256 for storage is enough.
pub fn new_opaque_token(prefix: &str) -> String {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes))
}
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub async fn store_refresh(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
let token = new_opaque_token("lvr_");
sqlx::query(
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '30 days')",
)
.bind(account_id)
.bind(hash_token(&token))
.execute(pool)
.await?;
Ok(token)
}
pub enum RotateOutcome {
Rotated { account_id: Uuid, new_token: String },
Invalid,
}
pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, bool, bool)> = sqlx::query_as(
"SELECT account_id, revoked_at IS NOT NULL, expires_at <= now()
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
)
.bind(hash_token(token))
.fetch_optional(&mut *tx)
.await?;
let outcome = match row {
None => RotateOutcome::Invalid,
Some((account_id, true, _)) => {
// Reuse of a rotated token: someone else holds a copy. Kill all sessions.
sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now()
WHERE account_id = $1 AND revoked_at IS NULL",
)
.bind(account_id)
.execute(&mut *tx)
.await?;
RotateOutcome::Invalid
}
Some((_, false, true)) => RotateOutcome::Invalid,
Some((account_id, false, false)) => {
sqlx::query("UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1")
.bind(hash_token(token))
.execute(&mut *tx)
.await?;
let new_token = new_opaque_token("lvr_");
sqlx::query(
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '30 days')",
)
.bind(account_id)
.bind(hash_token(&new_token))
.execute(&mut *tx)
.await?;
RotateOutcome::Rotated { account_id, new_token }
}
};
tx.commit().await?;
Ok(outcome)
}
pub async fn revoke_refresh(pool: &PgPool, token: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
)
.bind(hash_token(token))
.execute(pool)
.await?;
Ok(())
}
/// The refresh token never touches JS: httpOnly, Strict, scoped to /auth,
/// so an XSS on the site cannot exfiltrate it.
pub fn refresh_cookie(token: String, secure: bool) -> Cookie<'static> {
Cookie::build((REFRESH_COOKIE, token))
.http_only(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/auth")
.max_age(time::Duration::days(30))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opaque_tokens_are_prefixed_unique_and_long() {
let a = new_opaque_token("lvr_");
let b = new_opaque_token("lvr_");
assert!(a.starts_with("lvr_"));
assert_eq!(a.len(), 4 + 43);
assert_ne!(a, b);
}
#[test]
fn hash_is_stable_hex_sha256() {
assert_eq!(hash_token("x"), hash_token("x"));
assert_eq!(hash_token("x").len(), 64);
assert_ne!(hash_token("x"), hash_token("y"));
}
#[test]
fn refresh_cookie_has_the_hardened_attributes() {
let cookie = refresh_cookie("t".into(), true);
assert_eq!(cookie.name(), REFRESH_COOKIE);
assert_eq!(cookie.http_only(), Some(true));
assert_eq!(cookie.secure(), Some(true));
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
assert_eq!(cookie.path(), Some("/auth"));
}
}