Skip to main content

alibi_api/plugins/jwt/
signing.rs

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