solid_pod_rs_idp/
tokens.rs1use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13
14use crate::jwks::SigningKey;
15
16#[derive(Debug, Error)]
18pub enum TokenError {
19 #[error("JWT encode: {0}")]
21 Encode(String),
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AccessTokenPayload {
30 pub iss: String,
32 pub sub: String,
34 pub aud: String,
36 pub webid: String,
38 pub iat: u64,
40 pub exp: u64,
42 pub jti: String,
44 pub client_id: String,
46 pub scope: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
51 pub cnf: Option<CnfClaim>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CnfClaim {
57 pub jkt: String,
59}
60
61#[derive(Debug, Clone)]
65pub struct AccessToken {
66 pub jwt: String,
68 pub payload: AccessTokenPayload,
70}
71
72#[allow(clippy::too_many_arguments)]
88pub fn issue_access_token(
89 signing_key: &SigningKey,
90 issuer: &str,
91 webid: &str,
92 account_id: &str,
93 client_id: &str,
94 scope: &str,
95 dpop_jkt: Option<&str>,
96 now: u64,
97 ttl_secs: u64,
98) -> Result<AccessToken, TokenError> {
99 let payload = AccessTokenPayload {
100 iss: issuer.to_string(),
101 sub: account_id.to_string(),
102 aud: "solid".into(),
103 webid: webid.to_string(),
104 iat: now,
105 exp: now + ttl_secs,
106 jti: uuid::Uuid::new_v4().to_string(),
107 client_id: client_id.to_string(),
108 scope: scope.to_string(),
109 cnf: dpop_jkt.map(|jkt| CnfClaim {
110 jkt: jkt.to_string(),
111 }),
112 };
113
114 let mut header = Header::new(Algorithm::ES256);
115 header.kid = Some(signing_key.kid.clone());
116
117 let key = EncodingKey::from_ec_der(&signing_key.private_der);
118
119 let jwt = encode(&header, &payload, &key)
120 .map_err(|e| TokenError::Encode(e.to_string()))?;
121 Ok(AccessToken { jwt, payload })
122}
123
124pub fn ath_hash(token: &str) -> String {
128 use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
129 use base64::Engine;
130 let digest = Sha256::digest(token.as_bytes());
131 B64.encode(digest)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::jwks::Jwks;
138
139 #[test]
140 fn issue_access_token_produces_signed_jwt() {
141 let jwks = Jwks::generate_es256().unwrap();
142 let key = jwks.active_key();
143 let t = issue_access_token(
144 &key,
145 "https://pod.example/",
146 "https://alice.example/profile#me",
147 "acct-1",
148 "client-xyz",
149 "openid webid",
150 Some("DPOP-JKT"),
151 1_700_000_000,
152 3600,
153 )
154 .unwrap();
155 assert_eq!(t.jwt.matches('.').count(), 2);
157 assert_eq!(t.payload.iss, "https://pod.example/");
158 assert_eq!(t.payload.webid, "https://alice.example/profile#me");
159 assert_eq!(t.payload.cnf.as_ref().unwrap().jkt, "DPOP-JKT");
160 assert_eq!(t.payload.exp - t.payload.iat, 3600);
161 }
162
163 #[test]
164 fn issue_token_without_dpop_has_no_cnf() {
165 let jwks = Jwks::generate_es256().unwrap();
166 let key = jwks.active_key();
167 let t = issue_access_token(
168 &key,
169 "https://pod.example/",
170 "https://a/me",
171 "a",
172 "c",
173 "openid",
174 None,
175 0,
176 60,
177 )
178 .unwrap();
179 assert!(t.payload.cnf.is_none());
180 }
181
182 #[test]
183 fn ath_hash_matches_known_value() {
184 assert_eq!(ath_hash("foo"), "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564");
187 }
188}