use crate::account::account::{Account, AuthenticationKey};
use crate::crypto::{Ed25519PrivateKey, Ed25519PublicKey, KEYLESS_SCHEME};
use crate::error::{AptosError, AptosResult};
use crate::types::AccountAddress;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha3::{Digest, Sha3_256};
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;
pub use jsonwebtoken::jwk::JwkSet;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeylessSignature {
pub ephemeral_public_key: Vec<u8>,
pub ephemeral_signature: Vec<u8>,
pub proof: Vec<u8>,
}
impl KeylessSignature {
pub fn to_bcs(&self) -> AptosResult<Vec<u8>> {
aptos_bcs::to_bytes(self).map_err(AptosError::bcs)
}
}
#[derive(Clone)]
pub struct EphemeralKeyPair {
private_key: Ed25519PrivateKey,
public_key: Ed25519PublicKey,
expiry: SystemTime,
nonce: String,
}
impl EphemeralKeyPair {
pub fn generate(expiry_secs: u64) -> Self {
let private_key = Ed25519PrivateKey::generate();
let public_key = private_key.public_key();
let nonce = {
let mut bytes = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut bytes);
const_hex::encode(bytes)
};
Self {
private_key,
public_key,
expiry: SystemTime::now() + Duration::from_secs(expiry_secs),
nonce,
}
}
pub fn is_expired(&self) -> bool {
SystemTime::now() >= self.expiry
}
pub fn nonce(&self) -> &str {
&self.nonce
}
pub fn public_key(&self) -> &Ed25519PublicKey {
&self.public_key
}
}
impl fmt::Debug for EphemeralKeyPair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EphemeralKeyPair")
.field("public_key", &self.public_key)
.field("expiry", &self.expiry)
.field("nonce", &self.nonce)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OidcProvider {
Google,
Apple,
Microsoft,
Custom {
issuer: String,
jwks_url: String,
},
}
impl OidcProvider {
pub fn issuer(&self) -> &str {
match self {
OidcProvider::Google => "https://accounts.google.com",
OidcProvider::Apple => "https://appleid.apple.com",
OidcProvider::Microsoft => "https://login.microsoftonline.com/common/v2.0",
OidcProvider::Custom { issuer, .. } => issuer,
}
}
pub fn jwks_url(&self) -> &str {
match self {
OidcProvider::Google => "https://www.googleapis.com/oauth2/v3/certs",
OidcProvider::Apple => "https://appleid.apple.com/auth/keys",
OidcProvider::Microsoft => {
"https://login.microsoftonline.com/common/discovery/v2.0/keys"
}
OidcProvider::Custom { jwks_url, .. } => jwks_url,
}
}
pub fn from_issuer(issuer: &str) -> Self {
match issuer {
"https://accounts.google.com" => OidcProvider::Google,
"https://appleid.apple.com" => OidcProvider::Apple,
"https://login.microsoftonline.com/common/v2.0" => OidcProvider::Microsoft,
_ => {
let jwks_url = if issuer.starts_with("https://") {
format!("{issuer}/.well-known/jwks.json")
} else {
String::new()
};
OidcProvider::Custom {
issuer: issuer.to_string(),
jwks_url,
}
}
}
}
}
#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
pub struct Pepper(Vec<u8>);
impl std::fmt::Debug for Pepper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Pepper(REDACTED)")
}
}
impl Pepper {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn from_hex(hex_str: &str) -> AptosResult<Self> {
Ok(Self(const_hex::decode(hex_str)?))
}
pub fn to_hex(&self) -> String {
const_hex::encode_prefixed(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ZkProof(Vec<u8>);
impl ZkProof {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn from_hex(hex_str: &str) -> AptosResult<Self> {
Ok(Self(const_hex::decode(hex_str)?))
}
pub fn to_hex(&self) -> String {
const_hex::encode_prefixed(&self.0)
}
}
pub trait PepperService: Send + Sync {
fn get_pepper(
&self,
jwt: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<Pepper>> + Send + '_>>;
}
pub trait ProverService: Send + Sync {
fn generate_proof<'a>(
&'a self,
jwt: &'a str,
ephemeral_key: &'a EphemeralKeyPair,
pepper: &'a Pepper,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<ZkProof>> + Send + 'a>>;
}
#[derive(Clone, Debug)]
pub struct HttpPepperService {
url: Url,
client: reqwest::Client,
}
impl HttpPepperService {
pub fn new(url: Url) -> Self {
Self {
url,
client: reqwest::Client::new(),
}
}
}
#[derive(Serialize)]
struct PepperRequest<'a> {
jwt: &'a str,
}
#[derive(Deserialize)]
struct PepperResponse {
pepper: String,
}
impl PepperService for HttpPepperService {
fn get_pepper(
&self,
jwt: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<Pepper>> + Send + '_>> {
let jwt = jwt.to_owned();
Box::pin(async move {
let response = self
.client
.post(self.url.clone())
.json(&PepperRequest { jwt: &jwt })
.send()
.await?
.error_for_status()?;
let bytes =
crate::config::read_response_bounded(response, MAX_JWKS_RESPONSE_SIZE).await?;
let payload: PepperResponse = serde_json::from_slice(&bytes).map_err(|e| {
AptosError::InvalidJwt(format!("failed to parse pepper response: {e}"))
})?;
Pepper::from_hex(&payload.pepper)
})
}
}
#[derive(Clone, Debug)]
pub struct HttpProverService {
url: Url,
client: reqwest::Client,
}
impl HttpProverService {
pub fn new(url: Url) -> Self {
Self {
url,
client: reqwest::Client::new(),
}
}
}
#[derive(Serialize)]
struct ProverRequest<'a> {
jwt: &'a str,
ephemeral_public_key: String,
nonce: &'a str,
pepper: String,
}
#[derive(Deserialize)]
struct ProverResponse {
proof: String,
}
impl ProverService for HttpProverService {
fn generate_proof<'a>(
&'a self,
jwt: &'a str,
ephemeral_key: &'a EphemeralKeyPair,
pepper: &'a Pepper,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<ZkProof>> + Send + 'a>>
{
Box::pin(async move {
let request = ProverRequest {
jwt,
ephemeral_public_key: const_hex::encode_prefixed(
ephemeral_key.public_key.to_bytes(),
),
nonce: ephemeral_key.nonce(),
pepper: pepper.to_hex(),
};
let response = self
.client
.post(self.url.clone())
.json(&request)
.send()
.await?
.error_for_status()?;
let bytes =
crate::config::read_response_bounded(response, MAX_JWKS_RESPONSE_SIZE).await?;
let payload: ProverResponse = serde_json::from_slice(&bytes).map_err(|e| {
AptosError::InvalidJwt(format!("failed to parse prover response: {e}"))
})?;
ZkProof::from_hex(&payload.proof)
})
}
}
pub struct KeylessAccount {
ephemeral_key: EphemeralKeyPair,
provider: OidcProvider,
issuer: String,
audience: String,
user_id: String,
pepper: Pepper,
proof: ZkProof,
address: AccountAddress,
auth_key: AuthenticationKey,
jwt_expiration: Option<SystemTime>,
}
impl KeylessAccount {
pub async fn from_jwt(
jwt: &str,
ephemeral_key: EphemeralKeyPair,
pepper_service: &dyn PepperService,
prover_service: &dyn ProverService,
) -> AptosResult<Self> {
let unverified_claims = decode_claims_unverified(jwt)?;
let issuer = unverified_claims
.iss
.as_ref()
.ok_or_else(|| AptosError::InvalidJwt("missing iss claim".into()))?;
let provider = OidcProvider::from_issuer(issuer);
let client = reqwest::Client::builder()
.timeout(JWKS_FETCH_TIMEOUT)
.build()
.map_err(|e| AptosError::InvalidJwt(format!("failed to create HTTP client: {e}")))?;
let jwks = fetch_jwks(&client, provider.jwks_url()).await?;
let claims = decode_and_verify_jwt(jwt, &jwks)?;
let (issuer, audience, user_id, exp, nonce) = extract_claims(&claims)?;
if nonce != ephemeral_key.nonce() {
return Err(AptosError::InvalidJwt("JWT nonce mismatch".into()));
}
let pepper = pepper_service.get_pepper(jwt).await?;
let proof = prover_service
.generate_proof(jwt, &ephemeral_key, &pepper)
.await?;
let address = derive_keyless_address(&issuer, &audience, &user_id, &pepper);
let auth_key = AuthenticationKey::new(address.to_bytes());
Ok(Self {
provider: OidcProvider::from_issuer(&issuer),
issuer,
audience,
user_id,
pepper,
proof,
address,
auth_key,
jwt_expiration: exp,
ephemeral_key,
})
}
pub async fn from_jwt_with_jwks(
jwt: &str,
jwks: &JwkSet,
ephemeral_key: EphemeralKeyPair,
pepper_service: &dyn PepperService,
prover_service: &dyn ProverService,
) -> AptosResult<Self> {
let claims = decode_and_verify_jwt(jwt, jwks)?;
let (issuer, audience, user_id, exp, nonce) = extract_claims(&claims)?;
if nonce != ephemeral_key.nonce() {
return Err(AptosError::InvalidJwt("JWT nonce mismatch".into()));
}
let pepper = pepper_service.get_pepper(jwt).await?;
let proof = prover_service
.generate_proof(jwt, &ephemeral_key, &pepper)
.await?;
let address = derive_keyless_address(&issuer, &audience, &user_id, &pepper);
let auth_key = AuthenticationKey::new(address.to_bytes());
Ok(Self {
provider: OidcProvider::from_issuer(&issuer),
issuer,
audience,
user_id,
pepper,
proof,
address,
auth_key,
jwt_expiration: exp,
ephemeral_key,
})
}
pub fn provider(&self) -> &OidcProvider {
&self.provider
}
pub fn issuer(&self) -> &str {
&self.issuer
}
pub fn audience(&self) -> &str {
&self.audience
}
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn proof(&self) -> &ZkProof {
&self.proof
}
pub fn is_valid(&self) -> bool {
if self.ephemeral_key.is_expired() {
return false;
}
match self.jwt_expiration {
Some(exp) => SystemTime::now() < exp,
None => true,
}
}
pub async fn refresh_proof(
&mut self,
jwt: &str,
prover_service: &dyn ProverService,
) -> AptosResult<()> {
let client = reqwest::Client::builder()
.timeout(JWKS_FETCH_TIMEOUT)
.build()
.map_err(|e| AptosError::InvalidJwt(format!("failed to create HTTP client: {e}")))?;
let jwks = fetch_jwks(&client, self.provider.jwks_url()).await?;
self.refresh_proof_with_jwks(jwt, &jwks, prover_service)
.await
}
pub async fn refresh_proof_with_jwks(
&mut self,
jwt: &str,
jwks: &JwkSet,
prover_service: &dyn ProverService,
) -> AptosResult<()> {
let claims = decode_and_verify_jwt(jwt, jwks)?;
let (issuer, audience, user_id, exp, nonce) = extract_claims(&claims)?;
if nonce != self.ephemeral_key.nonce() {
return Err(AptosError::InvalidJwt("JWT nonce mismatch".into()));
}
if issuer != self.issuer || audience != self.audience || user_id != self.user_id {
return Err(AptosError::InvalidJwt(
"JWT identity does not match account".into(),
));
}
let proof = prover_service
.generate_proof(jwt, &self.ephemeral_key, &self.pepper)
.await?;
self.proof = proof;
self.jwt_expiration = exp;
Ok(())
}
pub fn sign_keyless(&self, message: &[u8]) -> KeylessSignature {
let signature = self.ephemeral_key.private_key.sign(message).to_bytes();
KeylessSignature {
ephemeral_public_key: self.ephemeral_key.public_key.to_bytes().to_vec(),
ephemeral_signature: signature.to_vec(),
proof: self.proof.as_bytes().to_vec(),
}
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub async fn from_verified_claims(
issuer: String,
audience: String,
user_id: String,
nonce: String,
exp: Option<SystemTime>,
ephemeral_key: EphemeralKeyPair,
pepper_service: &dyn PepperService,
prover_service: &dyn ProverService,
jwt_for_services: &str,
) -> AptosResult<Self> {
if nonce != ephemeral_key.nonce() {
return Err(AptosError::InvalidJwt("nonce mismatch".into()));
}
let pepper = pepper_service.get_pepper(jwt_for_services).await?;
let proof = prover_service
.generate_proof(jwt_for_services, &ephemeral_key, &pepper)
.await?;
let address = derive_keyless_address(&issuer, &audience, &user_id, &pepper);
let auth_key = AuthenticationKey::new(address.to_bytes());
Ok(Self {
provider: OidcProvider::from_issuer(&issuer),
issuer,
audience,
user_id,
pepper,
proof,
address,
auth_key,
jwt_expiration: exp,
ephemeral_key,
})
}
}
impl Account for KeylessAccount {
fn address(&self) -> AccountAddress {
self.address
}
fn authentication_key(&self) -> AuthenticationKey {
self.auth_key
}
fn sign(&self, message: &[u8]) -> crate::error::AptosResult<Vec<u8>> {
let signature = self.sign_keyless(message);
signature
.to_bcs()
.map_err(|e| crate::error::AptosError::Bcs(e.to_string()))
}
fn public_key_bytes(&self) -> Vec<u8> {
self.ephemeral_key.public_key.to_bytes().to_vec()
}
fn signature_scheme(&self) -> u8 {
KEYLESS_SCHEME
}
}
impl fmt::Debug for KeylessAccount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KeylessAccount")
.field("address", &self.address)
.field("provider", &self.provider)
.field("issuer", &self.issuer)
.field("audience", &self.audience)
.field("user_id", &self.user_id)
.finish_non_exhaustive()
}
}
#[derive(Debug, Deserialize)]
struct JwtClaims {
iss: Option<String>,
aud: Option<AudClaim>,
sub: Option<String>,
exp: Option<u64>,
nonce: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AudClaim {
Single(String),
Multiple(Vec<String>),
}
impl AudClaim {
fn first(&self) -> Option<&str> {
match self {
AudClaim::Single(value) => Some(value.as_str()),
AudClaim::Multiple(values) => values.first().map(std::string::String::as_str),
}
}
}
const JWKS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const MAX_JWKS_RESPONSE_SIZE: usize = 1024 * 1024;
async fn fetch_jwks(client: &reqwest::Client, jwks_url: &str) -> AptosResult<JwkSet> {
let parsed_url = Url::parse(jwks_url)
.map_err(|e| AptosError::InvalidJwt(format!("invalid JWKS URL: {e}")))?;
if parsed_url.scheme() != "https" {
return Err(AptosError::InvalidJwt(format!(
"JWKS URL must use HTTPS scheme, got: {}",
parsed_url.scheme()
)));
}
let response = client.get(jwks_url).send().await?;
if !response.status().is_success() {
return Err(AptosError::InvalidJwt(format!(
"JWKS endpoint returned status: {}",
response.status()
)));
}
let bytes = crate::config::read_response_bounded(response, MAX_JWKS_RESPONSE_SIZE).await?;
let jwks: JwkSet = serde_json::from_slice(&bytes)
.map_err(|e| AptosError::InvalidJwt(format!("failed to parse JWKS: {e}")))?;
Ok(jwks)
}
fn decode_and_verify_jwt(jwt: &str, jwks: &JwkSet) -> AptosResult<JwtClaims> {
let header = decode_header(jwt)
.map_err(|e| AptosError::InvalidJwt(format!("failed to decode JWT header: {e}")))?;
let kid = header
.kid
.as_ref()
.ok_or_else(|| AptosError::InvalidJwt("JWT header missing 'kid' field".into()))?;
let signing_key = jwks.find(kid).ok_or_else(|| {
AptosError::InvalidJwt("no matching key found for provided key identifier".into())
})?;
let decoding_key = DecodingKey::from_jwk(signing_key)
.map_err(|e| AptosError::InvalidJwt(format!("failed to create decoding key: {e}")))?;
let jwk_alg = signing_key
.common
.key_algorithm
.ok_or_else(|| AptosError::InvalidJwt("JWK missing 'alg' (key_algorithm) field".into()))?;
let algorithm = match jwk_alg {
jsonwebtoken::jwk::KeyAlgorithm::RS256 => Algorithm::RS256,
jsonwebtoken::jwk::KeyAlgorithm::RS384 => Algorithm::RS384,
jsonwebtoken::jwk::KeyAlgorithm::RS512 => Algorithm::RS512,
jsonwebtoken::jwk::KeyAlgorithm::PS256 => Algorithm::PS256,
jsonwebtoken::jwk::KeyAlgorithm::PS384 => Algorithm::PS384,
jsonwebtoken::jwk::KeyAlgorithm::PS512 => Algorithm::PS512,
jsonwebtoken::jwk::KeyAlgorithm::ES256 => Algorithm::ES256,
jsonwebtoken::jwk::KeyAlgorithm::ES384 => Algorithm::ES384,
jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Algorithm::EdDSA,
_ => {
return Err(AptosError::InvalidJwt(format!(
"unsupported JWK algorithm: {jwk_alg:?}"
)));
}
};
if header.alg != algorithm {
return Err(AptosError::InvalidJwt(format!(
"JWT header algorithm ({:?}) does not match JWK algorithm ({:?})",
header.alg, algorithm
)));
}
let mut validation = Validation::new(algorithm);
validation.validate_exp = false;
validation.validate_aud = false; validation.set_required_spec_claims::<String>(&[]);
let data = decode::<JwtClaims>(jwt, &decoding_key, &validation)
.map_err(|e| AptosError::InvalidJwt(format!("JWT verification failed: {e}")))?;
Ok(data.claims)
}
fn decode_claims_unverified(jwt: &str) -> AptosResult<JwtClaims> {
let data = jsonwebtoken::dangerous::insecure_decode::<JwtClaims>(jwt)
.map_err(|e| AptosError::InvalidJwt(format!("failed to decode JWT claims: {e}")))?;
Ok(data.claims)
}
fn extract_claims(
claims: &JwtClaims,
) -> AptosResult<(String, String, String, Option<SystemTime>, String)> {
let issuer = claims
.iss
.clone()
.ok_or_else(|| AptosError::InvalidJwt("missing iss claim".into()))?;
let audience = claims
.aud
.as_ref()
.and_then(|aud| aud.first())
.map(std::string::ToString::to_string)
.ok_or_else(|| AptosError::InvalidJwt("missing aud claim".into()))?;
let user_id = claims
.sub
.clone()
.ok_or_else(|| AptosError::InvalidJwt("missing sub claim".into()))?;
let nonce = claims
.nonce
.clone()
.ok_or_else(|| AptosError::InvalidJwt("missing nonce claim".into()))?;
let exp_time = claims.exp.map(|exp| UNIX_EPOCH + Duration::from_secs(exp));
if let Some(exp) = exp_time
&& SystemTime::now() >= exp
{
let exp_secs = claims.exp.unwrap_or(0);
return Err(AptosError::InvalidJwt(format!(
"JWT is expired (exp: {exp_secs} seconds since UNIX_EPOCH)"
)));
}
Ok((issuer, audience, user_id, exp_time, nonce))
}
fn derive_keyless_address(
issuer: &str,
audience: &str,
user_id: &str,
pepper: &Pepper,
) -> AccountAddress {
let issuer_hash = sha3_256_bytes(issuer.as_bytes());
let audience_hash = sha3_256_bytes(audience.as_bytes());
let user_hash = sha3_256_bytes(user_id.as_bytes());
let mut hasher = Sha3_256::new();
hasher.update(issuer_hash);
hasher.update(audience_hash);
hasher.update(user_hash);
hasher.update(pepper.as_bytes());
hasher.update([KEYLESS_SCHEME]);
let result = hasher.finalize();
let mut address = [0u8; 32];
address.copy_from_slice(&result);
AccountAddress::new(address)
}
fn sha3_256_bytes(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha3_256::new();
hasher.update(data);
let result = hasher.finalize();
let mut output = [0u8; 32];
output.copy_from_slice(&result);
output
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
struct StaticPepperService {
pepper: Pepper,
}
impl PepperService for StaticPepperService {
fn get_pepper(
&self,
_jwt: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<Pepper>> + Send + '_>>
{
Box::pin(async move { Ok(self.pepper.clone()) })
}
}
struct StaticProverService {
proof: ZkProof,
}
impl ProverService for StaticProverService {
fn generate_proof<'a>(
&'a self,
_jwt: &'a str,
_ephemeral_key: &'a EphemeralKeyPair,
_pepper: &'a Pepper,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = AptosResult<ZkProof>> + Send + 'a>>
{
Box::pin(async move { Ok(self.proof.clone()) })
}
}
#[derive(Serialize, Deserialize)]
struct TestClaims {
iss: String,
aud: String,
sub: String,
exp: u64,
nonce: String,
}
#[tokio::test]
async fn test_keyless_account_creation() {
let ephemeral = EphemeralKeyPair::generate(3600);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let claims = TestClaims {
iss: "https://accounts.google.com".to_string(),
aud: "client-id".to_string(),
sub: "user-123".to_string(),
exp: now + 3600,
nonce: ephemeral.nonce().to_string(),
};
let jwt = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(b"secret"),
)
.unwrap();
let pepper_service = StaticPepperService {
pepper: Pepper::new(vec![1, 2, 3, 4]),
};
let prover_service = StaticProverService {
proof: ZkProof::new(vec![9, 9, 9]),
};
let exp_time = UNIX_EPOCH + std::time::Duration::from_secs(now + 3600);
let account = KeylessAccount::from_verified_claims(
"https://accounts.google.com".to_string(),
"client-id".to_string(),
"user-123".to_string(),
ephemeral.nonce().to_string(),
Some(exp_time),
ephemeral,
&pepper_service,
&prover_service,
&jwt,
)
.await
.unwrap();
assert_eq!(account.issuer(), "https://accounts.google.com");
assert_eq!(account.audience(), "client-id");
assert_eq!(account.user_id(), "user-123");
assert!(account.is_valid());
assert!(!account.address().is_zero());
}
#[tokio::test]
async fn test_keyless_account_nonce_mismatch() {
let ephemeral = EphemeralKeyPair::generate(3600);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let claims = TestClaims {
iss: "https://accounts.google.com".to_string(),
aud: "client-id".to_string(),
sub: "user-123".to_string(),
exp: now + 3600,
nonce: ephemeral.nonce().to_string(),
};
let jwt = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(b"secret"),
)
.unwrap();
let pepper_service = StaticPepperService {
pepper: Pepper::new(vec![1, 2, 3, 4]),
};
let prover_service = StaticProverService {
proof: ZkProof::new(vec![9, 9, 9]),
};
let result = KeylessAccount::from_verified_claims(
"https://accounts.google.com".to_string(),
"client-id".to_string(),
"user-123".to_string(),
"wrong-nonce".to_string(), None,
ephemeral,
&pepper_service,
&prover_service,
&jwt,
)
.await;
assert!(result.is_err());
assert!(matches!(result, Err(AptosError::InvalidJwt(_))));
}
#[test]
fn test_decode_claims_unverified() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let claims = TestClaims {
iss: "https://accounts.google.com".to_string(),
aud: "test-aud".to_string(),
sub: "test-sub".to_string(),
exp: now + 3600,
nonce: "test-nonce".to_string(),
};
let jwt = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(b"secret"),
)
.unwrap();
let decoded = decode_claims_unverified(&jwt).unwrap();
assert_eq!(decoded.iss.unwrap(), "https://accounts.google.com");
assert_eq!(decoded.sub.unwrap(), "test-sub");
assert_eq!(decoded.nonce.unwrap(), "test-nonce");
}
#[test]
fn test_oidc_provider_detection() {
assert!(matches!(
OidcProvider::from_issuer("https://accounts.google.com"),
OidcProvider::Google
));
assert!(matches!(
OidcProvider::from_issuer("https://appleid.apple.com"),
OidcProvider::Apple
));
assert!(matches!(
OidcProvider::from_issuer("https://unknown.example.com"),
OidcProvider::Custom { .. }
));
}
#[test]
fn test_decode_and_verify_jwt_missing_kid() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let claims = TestClaims {
iss: "https://accounts.google.com".to_string(),
aud: "test-aud".to_string(),
sub: "test-sub".to_string(),
exp: now + 3600,
nonce: "test-nonce".to_string(),
};
let jwt = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(b"secret"),
)
.unwrap();
let jwks = JwkSet { keys: vec![] };
let result = decode_and_verify_jwt(&jwt, &jwks);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(&err, AptosError::InvalidJwt(msg) if msg.contains("kid")),
"Expected error about missing kid, got: {err:?}"
);
}
#[test]
fn test_decode_and_verify_jwt_no_matching_key() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let claims = TestClaims {
iss: "https://accounts.google.com".to_string(),
aud: "test-aud".to_string(),
sub: "test-sub".to_string(),
exp: now + 3600,
nonce: "test-nonce".to_string(),
};
let mut header = Header::new(Algorithm::HS256);
header.kid = Some("test-kid-123".to_string());
let jwt = encode(&header, &claims, &EncodingKey::from_secret(b"secret")).unwrap();
let jwks = JwkSet { keys: vec![] };
let result = decode_and_verify_jwt(&jwt, &jwks);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(&err, AptosError::InvalidJwt(msg) if msg.contains("no matching key")),
"Expected error about no matching key, got: {err:?}"
);
}
#[test]
fn test_decode_and_verify_jwt_invalid_jwt_format() {
let jwks = JwkSet { keys: vec![] };
let result = decode_and_verify_jwt("not-a-valid-jwt", &jwks);
assert!(result.is_err());
let result = decode_and_verify_jwt("aaa.bbb.ccc", &jwks);
assert!(result.is_err());
}
}