use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::jwks::SigningKey;
#[derive(Debug, Error)]
pub enum TokenError {
#[error("JWT encode: {0}")]
Encode(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessTokenPayload {
pub iss: String,
pub sub: String,
pub aud: String,
pub webid: String,
pub iat: u64,
pub exp: u64,
pub jti: String,
pub client_id: String,
pub scope: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cnf: Option<CnfClaim>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CnfClaim {
pub jkt: String,
}
#[derive(Debug, Clone)]
pub struct AccessToken {
pub jwt: String,
pub payload: AccessTokenPayload,
}
#[allow(clippy::too_many_arguments)]
pub fn issue_access_token(
signing_key: &SigningKey,
issuer: &str,
webid: &str,
account_id: &str,
client_id: &str,
scope: &str,
dpop_jkt: Option<&str>,
now: u64,
ttl_secs: u64,
) -> Result<AccessToken, TokenError> {
let payload = AccessTokenPayload {
iss: issuer.to_string(),
sub: account_id.to_string(),
aud: "solid".into(),
webid: webid.to_string(),
iat: now,
exp: now + ttl_secs,
jti: uuid::Uuid::new_v4().to_string(),
client_id: client_id.to_string(),
scope: scope.to_string(),
cnf: dpop_jkt.map(|jkt| CnfClaim {
jkt: jkt.to_string(),
}),
};
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(signing_key.kid.clone());
let key = EncodingKey::from_ec_der(&signing_key.private_der);
let jwt = encode(&header, &payload, &key)
.map_err(|e| TokenError::Encode(e.to_string()))?;
Ok(AccessToken { jwt, payload })
}
pub fn ath_hash(token: &str) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
let digest = Sha256::digest(token.as_bytes());
B64.encode(digest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jwks::Jwks;
#[test]
fn issue_access_token_produces_signed_jwt() {
let jwks = Jwks::generate_es256().unwrap();
let key = jwks.active_key();
let t = issue_access_token(
&key,
"https://pod.example/",
"https://alice.example/profile#me",
"acct-1",
"client-xyz",
"openid webid",
Some("DPOP-JKT"),
1_700_000_000,
3600,
)
.unwrap();
assert_eq!(t.jwt.matches('.').count(), 2);
assert_eq!(t.payload.iss, "https://pod.example/");
assert_eq!(t.payload.webid, "https://alice.example/profile#me");
assert_eq!(t.payload.cnf.as_ref().unwrap().jkt, "DPOP-JKT");
assert_eq!(t.payload.exp - t.payload.iat, 3600);
}
#[test]
fn issue_token_without_dpop_has_no_cnf() {
let jwks = Jwks::generate_es256().unwrap();
let key = jwks.active_key();
let t = issue_access_token(
&key,
"https://pod.example/",
"https://a/me",
"a",
"c",
"openid",
None,
0,
60,
)
.unwrap();
assert!(t.payload.cnf.is_none());
}
#[test]
fn ath_hash_matches_known_value() {
assert_eq!(ath_hash("foo"), "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564");
}
}