Skip to main content

alibi_plugins/jwt/
signing.rs

1use super::JwtAudience;
2use super::JwtClaimsConfig;
3use super::JwtPlugin;
4use super::JwtSignOptions;
5use super::RemoteJwtPayload;
6use super::ResolvedJwtSigningKey;
7use super::claims::normalize_signing_claims;
8use super::claims::validate_critical_header;
9use super::claims::validate_numeric_date;
10use super::crypto;
11use alibi_core::AuthContext;
12use alibi_core::AuthError;
13use alibi_core::AuthRequest;
14use alibi_core::AuthResult;
15use alibi_core::AuthSchema;
16use base64::Engine;
17use base64::engine::general_purpose::URL_SAFE_NO_PAD;
18use serde_json::Map;
19use serde_json::Value;
20use serde_json::json;
21impl JwtPlugin {
22    /// Sign an application-owned payload through the trusted server API.
23    pub async fn sign_jwt(
24        &self,
25        payload: Map<String, Value>,
26        options: &JwtSignOptions,
27        request: Option<&AuthRequest>,
28        ctx: &AuthContext<impl AuthSchema>,
29    ) -> AuthResult<String> {
30        if self.config.remote_signer.is_some() {
31            return self
32                .sign_remote_jwt(Value::Object(payload).into(), options, ctx)
33                .await;
34        }
35        self.sign_jwt_checked(payload, options, request, ctx, Ok(()))
36            .await
37    }
38
39    /// Sign decoded JavaScript JSON through the trusted server API.
40    ///
41    /// The decoded representation retains nonfinite numbers until claim
42    /// validation in managed local signing. Remote signers instead receive
43    /// the raw values and own-property metadata without JOSE claim validation.
44    /// Local ordinary claims follow JSON.stringify, including rounding and
45    /// null for nonfinite values.
46    pub async fn sign_jwt_json(
47        &self,
48        payload: &alibi_core::utils::json::JsValue,
49        options: &JwtSignOptions,
50        request: Option<&AuthRequest>,
51        ctx: &AuthContext<impl AuthSchema>,
52    ) -> AuthResult<String> {
53        let object = payload
54            .as_object()
55            .ok_or_else(|| AuthError::bad_request("JWT payload must be an object"))?;
56        if self.config.remote_signer.is_some() {
57            return self.sign_remote_jwt(payload.clone(), options, ctx).await;
58        }
59        let validation = (|| {
60            for field in ["exp", "iat", "nbf"] {
61                validate_numeric_date(
62                    field,
63                    object
64                        .get(field)
65                        .and_then(alibi_core::utils::json::JsValue::as_f64),
66                )?;
67            }
68            for field in ["iss", "sub", "jti"] {
69                if object
70                    .get(field)
71                    .and_then(alibi_core::utils::json::JsValue::as_f64)
72                    .is_some_and(|number| {
73                        !number.is_finite() && (field == "iss" || !number.is_nan())
74                    })
75                {
76                    return Err(AuthError::internal(format!(
77                        "\"{field}\" claim must be a string"
78                    )));
79                }
80            }
81            Ok(())
82        })();
83        let payload = payload
84            .to_json_value()?
85            .as_object()
86            .cloned()
87            .ok_or_else(|| AuthError::bad_request("JWT payload must be an object"))?;
88        self.sign_jwt_checked(payload, options, request, ctx, validation)
89            .await
90    }
91
92    pub(in crate::jwt) async fn sign_jwt_checked(
93        &self,
94        payload: Map<String, Value>,
95        options: &JwtSignOptions,
96        request: Option<&AuthRequest>,
97        ctx: &AuthContext<impl AuthSchema>,
98        validation: AuthResult<()>,
99    ) -> AuthResult<String> {
100        let payload = self.default_claims(payload, options.claims.as_ref(), ctx)?;
101        let resolved;
102        let key = if let Some(key) = options.resolved_key.as_deref() {
103            key
104        } else {
105            resolved = self
106                .resolve_signing_key(options, request, ctx)
107                .await?
108                .ok_or_else(|| AuthError::internal("No local JWT signing key"))?;
109            &resolved
110        };
111        // Upstream resolves/mints the local key before JOSE validates claims.
112        validation?;
113        Self::sign_resolved(payload, options, key)
114    }
115
116    pub(in crate::jwt) async fn sign_remote_jwt(
117        &self,
118        mut raw_claims: alibi_core::utils::json::JsValue,
119        options: &JwtSignOptions,
120        ctx: &AuthContext<impl AuthSchema>,
121    ) -> AuthResult<String> {
122        use alibi_core::utils::json::JsValue;
123        let JsValue::Object(payload) = &mut raw_claims else {
124            return Err(AuthError::bad_request("JWT payload must be an object"));
125        };
126        let config = options.claims.as_ref().unwrap_or(&self.config.claims);
127        let mut own_keys: Vec<String> = payload.keys().cloned().collect();
128        // JavaScript enumerates canonical array-index property names first.
129        own_keys.sort_by_key(|key| {
130            key.parse::<u32>()
131                .ok()
132                .filter(|index| *index != u32::MAX && index.to_string() == *key)
133                .map_or(u64::MAX, u64::from)
134        });
135        let mut undefined_claims = Vec::new();
136        // Source spreads the original object, then assigns these properties in
137        // this order. Undefined dates are still observable own properties.
138        for name in ["iat", "exp", "nbf", "iss", "aud"] {
139            if !payload.contains_key(name) {
140                own_keys.push(name.to_owned());
141                if name == "iat" || name == "nbf" {
142                    undefined_claims.push(name.to_owned());
143                }
144            }
145            if payload.get(name).is_none_or(JsValue::is_null) {
146                let value = match name {
147                    "exp" => config.expiration.timestamp_raw(payload.get("iat")),
148                    "iss" => JsValue::String(
149                        config
150                            .issuer
151                            .clone()
152                            .unwrap_or_else(|| ctx.config.base_url.clone()),
153                    ),
154                    "aud" => serde_json::to_value(
155                        config
156                            .audience
157                            .clone()
158                            .unwrap_or_else(|| JwtAudience::One(ctx.config.base_url.clone())),
159                    )?
160                    .into(),
161                    _ => continue,
162                };
163                _ = payload.insert(name.to_owned(), value);
164            }
165        }
166        let payload = RemoteJwtPayload {
167            raw_claims,
168            own_keys,
169            undefined_claims,
170        };
171        let remote = self
172            .config
173            .remote_signer
174            .as_ref()
175            .ok_or_else(|| AuthError::internal("No remote JWT signer"))?;
176        remote
177            .sign(&payload, options)
178            .await
179            .map_err(|error| match error {
180                AuthError::Internal(_) => AuthError::CallbackFailure(Box::new(error)),
181                error => error,
182            })
183    }
184
185    pub(in crate::jwt) fn default_claims(
186        &self,
187        mut payload: Map<String, Value>,
188        override_claims: Option<&JwtClaimsConfig>,
189        ctx: &AuthContext<impl AuthSchema>,
190    ) -> AuthResult<Map<String, Value>> {
191        let config = override_claims.unwrap_or(&self.config.claims);
192        if payload.get("exp").is_none_or(Value::is_null) {
193            let expiration = config.expiration.timestamp(payload.get("iat"));
194            _ = payload.insert("exp".to_owned(), expiration);
195        }
196        if payload.get("iss").is_none_or(Value::is_null) {
197            _ = payload.insert(
198                "iss".to_owned(),
199                json!(config.issuer.as_deref().unwrap_or(&ctx.config.base_url)),
200            );
201        }
202        if payload.get("aud").is_none_or(Value::is_null) {
203            _ = payload.insert(
204                "aud".to_owned(),
205                serde_json::to_value(
206                    config
207                        .audience
208                        .clone()
209                        .unwrap_or_else(|| JwtAudience::One(ctx.config.base_url.clone())),
210                )?,
211            );
212        }
213        Ok(payload)
214    }
215
216    pub(in crate::jwt) fn sign_resolved(
217        mut payload: Map<String, Value>,
218        options: &JwtSignOptions,
219        key: &ResolvedJwtSigningKey,
220    ) -> AuthResult<String> {
221        let mut header = options.header.clone().unwrap_or_default();
222        _ = header.insert("alg".to_owned(), json!(key.algorithm.as_str()));
223        _ = header.insert("kid".to_owned(), json!(key.key_id));
224        validate_critical_header(&header, true)?;
225        normalize_signing_claims(&mut payload)?;
226        let input = format!(
227            "{}.{}",
228            URL_SAFE_NO_PAD.encode(alibi_core::utils::json::to_vec(&header)?),
229            URL_SAFE_NO_PAD.encode(alibi_core::utils::json::to_vec(&payload)?)
230        );
231        let signature = crypto::sign(key.algorithm, &key.private_key, input.as_bytes())?;
232        Ok(format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)))
233    }
234}