Skip to main content

solid_pod_rs_idp/
tokens.rs

1//! Access token issuance.
2//!
3//! Produces a DPoP-bound JWT access token that can be verified by
4//! downstream resource servers via
5//! `solid_pod_rs::oidc::verify_access_token` plus the JWKS published
6//! at `/.well-known/jwks.json`. Matches the JSS payload shape in
7//! `src/idp/credentials.js:112-137`.
8
9use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13
14use crate::jwks::SigningKey;
15
16/// Errors from access-token issuance.
17#[derive(Debug, Error)]
18pub enum TokenError {
19    /// JWT encoding failure.
20    #[error("JWT encode: {0}")]
21    Encode(String),
22}
23
24/// Solid-OIDC access-token payload. Named explicitly rather than
25/// reusing `solid_pod_rs::oidc::SolidOidcClaims` because we own
26/// *issuance* here (we control every field) whereas the core crate
27/// owns *verification* (it must accept whatever upstream IdPs emit).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AccessTokenPayload {
30    /// Issuer URL (always trailing-slashed).
31    pub iss: String,
32    /// Subject — JSS matches this to `account.id`.
33    pub sub: String,
34    /// Audience. Solid-OIDC uses `"solid"` (JSS line 116).
35    pub aud: String,
36    /// WebID — the whole point of Solid-OIDC.
37    pub webid: String,
38    /// Issued at (seconds since Unix epoch).
39    pub iat: u64,
40    /// Expiry (seconds since Unix epoch).
41    pub exp: u64,
42    /// JWT id (random).
43    pub jti: String,
44    /// Requesting client id.
45    pub client_id: String,
46    /// OAuth2 scope string.
47    pub scope: String,
48    /// DPoP binding. `None` on Bearer-token issuance (no DPoP proof
49    /// supplied at /token); `Some(jkt)` on DPoP-bound issuance.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub cnf: Option<CnfClaim>,
52}
53
54/// DPoP binding — SHA-256 thumbprint of the proof's embedded JWK.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CnfClaim {
57    /// RFC 9449 §6.1 `jkt` — JWK thumbprint.
58    pub jkt: String,
59}
60
61/// Wrapped access token — the signed JWT plus the payload used to
62/// mint it (callers who want to emit `token_type`, `expires_in`, etc.
63/// can derive them).
64#[derive(Debug, Clone)]
65pub struct AccessToken {
66    /// Signed JWT string (header.payload.signature, base64url).
67    pub jwt: String,
68    /// Payload that was signed.
69    pub payload: AccessTokenPayload,
70}
71
72/// Issue a DPoP-bound (or Bearer) access token.
73///
74/// - `signing_key` — the currently-active JWKS key. Provides the
75///   `kid` header and the private key material.
76/// - `issuer` — the IdP's `iss` claim. Must match the discovery
77///   document's `issuer`.
78/// - `webid` — subject's WebID URL. Populates both `webid` and the
79///   `sub` claim (JSS populates `sub` with the account id and
80///   `webid` separately; we mirror that).
81/// - `account_id` — stable internal subject id.
82/// - `client_id` — the OAuth2 client requesting the token.
83/// - `scope` — space-separated scope string (`openid webid` is the
84///   baseline).
85/// - `dpop_jkt` — DPoP thumbprint, `None` for a Bearer token.
86/// - `now` / `ttl_secs` — issuance + expiry.
87#[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
124/// Hash an access token into the base64url-encoded SHA-256 value
125/// that RFC 9449 §4.3 calls `ath`. Callers who want to emit tokens
126/// alongside a DPoP `ath` challenge can use this helper.
127pub 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        // Three segments, base64url.
156        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        // Cross-check against `echo -n 'foo' | sha256sum` (base64url, no pad)
185        // → LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564
186        assert_eq!(ath_hash("foo"), "LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564");
187    }
188}