Files
upstream-affine/packages/backend/native/src/auth_session.rs
upstream-mirror 702cc698a7
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 04:01:20 +05:00

265 lines
7.5 KiB
Rust

use affine_core::auth::{
ACCESS_TOKEN_AUDIENCE, ACCESS_TOKEN_ISSUER, ACCESS_TOKEN_TYPE, CLOCK_TOLERANCE_SECONDS, REFRESH_TOKEN_PREFIX,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize)]
struct AccessTokenHeader<'a> {
alg: &'static str,
typ: &'static str,
kid: &'a str,
}
#[derive(Deserialize)]
struct ParsedAccessTokenHeader {
alg: String,
typ: String,
kid: String,
}
#[derive(Serialize, Deserialize)]
struct AccessTokenClaims<'a> {
sub: &'a str,
sid: &'a str,
typ: &'static str,
iss: &'static str,
aud: &'static str,
iat: i64,
exp: i64,
}
#[derive(Deserialize)]
struct ParsedAccessTokenClaims {
sub: String,
sid: String,
typ: String,
iss: String,
aud: String,
iat: i64,
exp: i64,
}
pub(crate) struct AuthSessionAccessTokenVerification {
pub(crate) status: &'static str,
pub(crate) user_id: Option<String>,
pub(crate) auth_session_id: Option<String>,
}
pub(crate) struct AuthSessionRefreshToken {
pub(crate) token: String,
pub(crate) id: String,
pub(crate) secret_hash: String,
}
pub(crate) struct ParsedAuthSessionRefreshToken {
pub(crate) id: String,
pub(crate) secret_hash: String,
}
fn invalid_access_token() -> AuthSessionAccessTokenVerification {
AuthSessionAccessTokenVerification {
status: "invalid",
user_id: None,
auth_session_id: None,
}
}
fn decode_segment<T: for<'de> Deserialize<'de>>(segment: &str) -> Option<T> {
let bytes = URL_SAFE_NO_PAD.decode(segment).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn hash_refresh_secret(secret: &[u8]) -> String {
hex::encode(Sha256::digest(secret))
}
pub(crate) fn auth_session_access_token_key_id(token: &str) -> Option<String> {
let mut segments = token.split('.');
let header: ParsedAccessTokenHeader = decode_segment(segments.next()?)?;
segments.next()?;
segments.next()?;
if segments.next().is_some() || header.alg != "HS256" || header.typ != "JWT" {
return None;
}
Some(header.kid)
}
pub(crate) fn sign_auth_session_access_token(
user_id: &str,
auth_session_id: &str,
key_id: &str,
secret: &[u8],
issued_at: i64,
expires_at: i64,
) -> Result<String, &'static str> {
if user_id.is_empty() || auth_session_id.is_empty() || key_id.is_empty() {
return Err("access_token_invalid_identity");
}
if secret.len() < 32 {
return Err("access_token_signing_key_too_short");
}
if expires_at <= issued_at {
return Err("access_token_invalid_expiry");
}
let header = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&AccessTokenHeader {
alg: "HS256",
typ: "JWT",
kid: key_id,
})
.map_err(|_| "access_token_encode_failed")?,
);
let claims = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&AccessTokenClaims {
sub: user_id,
sid: auth_session_id,
typ: ACCESS_TOKEN_TYPE,
iss: ACCESS_TOKEN_ISSUER,
aud: ACCESS_TOKEN_AUDIENCE,
iat: issued_at,
exp: expires_at,
})
.map_err(|_| "access_token_encode_failed")?,
);
let signing_input = format!("{header}.{claims}");
let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| "access_token_signing_key_invalid")?;
mac.update(signing_input.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Ok(format!("{signing_input}.{signature}"))
}
pub(crate) fn verify_auth_session_access_token(
token: &str,
expected_key_id: &str,
secret: &[u8],
now: i64,
) -> AuthSessionAccessTokenVerification {
let segments = token.split('.').collect::<Vec<_>>();
if segments.len() != 3 {
return invalid_access_token();
}
let Some(header) = decode_segment::<ParsedAccessTokenHeader>(segments[0]) else {
return invalid_access_token();
};
if header.alg != "HS256" || header.typ != "JWT" || header.kid != expected_key_id {
return invalid_access_token();
}
let Ok(signature) = URL_SAFE_NO_PAD.decode(segments[2]) else {
return invalid_access_token();
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret) else {
return invalid_access_token();
};
mac.update(format!("{}.{}", segments[0], segments[1]).as_bytes());
if mac.verify_slice(&signature).is_err() {
return invalid_access_token();
}
let Some(claims) = decode_segment::<ParsedAccessTokenClaims>(segments[1]) else {
return invalid_access_token();
};
if claims.typ != ACCESS_TOKEN_TYPE
|| claims.iss != ACCESS_TOKEN_ISSUER
|| claims.aud != ACCESS_TOKEN_AUDIENCE
|| claims.iat > now + CLOCK_TOLERANCE_SECONDS
{
return invalid_access_token();
}
if claims.exp + CLOCK_TOLERANCE_SECONDS <= now {
return AuthSessionAccessTokenVerification {
status: "expired",
user_id: None,
auth_session_id: None,
};
}
if claims.exp <= claims.iat {
return invalid_access_token();
}
AuthSessionAccessTokenVerification {
status: "valid",
user_id: Some(claims.sub),
auth_session_id: Some(claims.sid),
}
}
pub(crate) fn create_auth_session_refresh_token() -> AuthSessionRefreshToken {
let mut id = [0_u8; 18];
let mut secret = [0_u8; 32];
rand::rng().fill_bytes(&mut id);
rand::rng().fill_bytes(&mut secret);
let id = URL_SAFE_NO_PAD.encode(id);
let encoded_secret = URL_SAFE_NO_PAD.encode(secret);
AuthSessionRefreshToken {
token: format!("{REFRESH_TOKEN_PREFIX}.{id}.{encoded_secret}"),
id,
secret_hash: hash_refresh_secret(&secret),
}
}
pub(crate) fn parse_auth_session_refresh_token(token: &str) -> Option<ParsedAuthSessionRefreshToken> {
let segments = token.split('.').collect::<Vec<_>>();
if segments.len() != 3 || segments[0] != REFRESH_TOKEN_PREFIX {
return None;
}
let id = URL_SAFE_NO_PAD.decode(segments[1]).ok()?;
let secret = URL_SAFE_NO_PAD.decode(segments[2]).ok()?;
if id.len() != 18 || secret.len() != 32 {
return None;
}
Some(ParsedAuthSessionRefreshToken {
id: segments[1].into(),
secret_hash: hash_refresh_secret(&secret),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_token_vector_and_policy() {
let secret = || vec![7; 32];
let token =
sign_auth_session_access_token("user-1", "session-1", "key-1", &secret(), 1_700_000_000, 1_700_000_900).unwrap();
assert_eq!(auth_session_access_token_key_id(&token), Some("key-1".into()));
let valid = verify_auth_session_access_token(&token, "key-1", &secret(), 1_700_000_100);
assert_eq!(valid.status, "valid");
assert_eq!(valid.user_id.as_deref(), Some("user-1"));
assert_eq!(
verify_auth_session_access_token(&token, "key-1", &secret(), 1_700_000_929).status,
"valid"
);
assert_eq!(
verify_auth_session_access_token(&token, "key-1", &secret(), 1_700_000_930).status,
"expired"
);
assert_eq!(
verify_auth_session_access_token(&token, "key-1", &secret(), 1_700_001_000).status,
"expired"
);
}
#[test]
fn refresh_token_round_trip_and_rejection() {
let created = create_auth_session_refresh_token();
let parsed = parse_auth_session_refresh_token(&created.token).unwrap();
assert_eq!(parsed.id, created.id);
assert_eq!(parsed.secret_hash, created.secret_hash);
assert!(parse_auth_session_refresh_token("aff_rt_v1.bad.bad").is_none());
let golden = parse_auth_session_refresh_token(
"aff_rt_v1.AAECAwQFBgcICQoLDA0ODxAR.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
)
.unwrap();
assert_eq!(golden.id, "AAECAwQFBgcICQoLDA0ODxAR");
assert_eq!(
golden.secret_hash,
"630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd"
);
}
}