1use super::{
2 JwtAudience, JwtClaimsConfig, JwtExpiration, JwtKeyPairConfig, JwtPlugin, JwtSession,
3 JwtSignOptions,
4};
5use crate::authentication_helpers::{JsonField, JsonFieldKind};
6use crate::endpoint::{definition, validate_fields, validation};
7use alibi_core::endpoint::{
8 EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
9};
10use alibi_core::utils::json::JsValue;
11use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, HttpMethod};
12use chrono::Duration;
13use serde::Deserialize;
14use serde_json::{Map, Value};
15use std::str::FromStr;
16
17#[derive(Debug, Deserialize)]
18pub struct JwtTokenOutput {
19 pub token: String,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct JwtVerifyOutput {
24 pub payload: Option<Map<String, Value>>,
25}
26
27pub(super) fn definitions(jwks_path: &str) -> Vec<EndpointDefinition> {
28 vec![
29 definition(
30 "getToken",
31 "getJSONWebToken",
32 Some("/token"),
33 HttpMethod::Get,
34 ),
35 definition(
36 "getJwks",
37 "getJSONWebKeySet",
38 Some(jwks_path),
39 HttpMethod::Get,
40 ),
41 definition("signJWT", "signJWT", None, HttpMethod::Post),
42 definition("verifyJWT", "verifyJWT", None, HttpMethod::Post),
43 ]
44}
45
46pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
47 let body = match call.operation_id() {
48 "signJWT" => Some(validate_fields(
49 call.body(),
50 "body",
51 &[
52 JsonField {
53 name: "payload",
54 kind: JsonFieldKind::Record,
55 required: true,
56 },
57 JsonField {
58 name: "overrideOptions",
59 kind: JsonFieldKind::Record,
60 required: false,
61 },
62 ],
63 )?),
64 "verifyJWT" => Some(validate_fields(
65 call.body(),
66 "body",
67 &[
68 JsonField::string("token", true),
69 JsonField::string("issuer", false),
70 ],
71 )?),
72 "getJSONWebToken" if call.headers().is_none() => {
73 return Err(validation("Headers is required"));
74 }
75 _ => call.body().cloned(),
76 };
77 Ok(EndpointInput {
78 body,
79 query: call.query().cloned(),
80 })
81}
82
83impl JwtPlugin {
84 #[must_use]
85 pub const fn token_endpoint() -> ServerEndpoint<JwtTokenOutput> {
86 ServerEndpoint::new("jwt", "getToken")
87 }
88
89 #[must_use]
90 pub const fn jwks_endpoint() -> ServerEndpoint<Value> {
91 ServerEndpoint::new("jwt", "getJwks")
92 }
93
94 #[must_use]
96 pub fn sign_endpoint(payload: JsValue) -> ServerEndpoint<JwtTokenOutput> {
97 ServerEndpoint::new("jwt", "signJWT").with_body_value(JsValue::Object(
98 [("payload".into(), payload)].into_iter().collect(),
99 ))
100 }
101
102 #[must_use]
103 pub fn verify_endpoint(
104 token: impl Into<String>,
105 issuer: Option<String>,
106 ) -> ServerEndpoint<JwtVerifyOutput> {
107 let mut body = indexmap::IndexMap::new();
108 _ = body.insert("token".into(), JsValue::String(token.into()));
109 if let Some(issuer) = issuer {
110 _ = body.insert("issuer".into(), JsValue::String(issuer));
111 }
112 ServerEndpoint::new("jwt", "verifyJWT").with_body_value(JsValue::Object(body))
113 }
114
115 pub(super) async fn call_endpoint(
116 &self,
117 call: &EndpointCall,
118 ctx: &AuthContext<impl AuthSchema>,
119 ) -> AuthResult<EndpointResponse> {
120 match call.operation_id() {
121 "getJSONWebToken" => {
122 let read =
123 alibi_core::session::cookie_cache::runtime::authenticated(ctx, call, false)
124 .await
125 .map_err(|_| super::unauthorized())?
126 .ok_or_else(super::unauthorized)?;
127 let user = match &read.user {
128 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
129 alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
130 };
131 let session = JwtSession {
132 user,
133 session: call.virtual_session().unwrap_or(read.session),
134 needs_refresh: read.needs_refresh,
135 updated_at: None,
136 version: None,
137 };
138 call.record_authenticated_session(session.user.clone(), session.session.clone());
139 EndpointResponse::json(
140 &serde_json::json!({"token":self.sign_session_token(call.request(),ctx,&session).await?}),
141 )
142 }
143 "getJSONWebKeySet" => {
144 EndpointResponse::json(&self.jwks_value(call.request(), ctx).await?)
145 }
146 "signJWT" => {
147 let payload = call
148 .body()
149 .and_then(|body| body.get("payload"))
150 .ok_or_else(|| validation("[body.payload] Invalid input"))?;
151 let plugin = self.with_json_overrides(
152 call.body().and_then(|body| body.get("overrideOptions")),
153 )?;
154 EndpointResponse::json(
155 &serde_json::json!({"token":plugin.sign_jwt_json(payload,&JwtSignOptions::default(),call.request(),ctx).await?}),
156 )
157 }
158 "verifyJWT" => {
159 #[derive(Deserialize)]
160 struct Body {
161 token: String,
162 issuer: Option<String>,
163 }
164 let body: Body = call.body_as()?;
165 EndpointResponse::json(
166 &serde_json::json!({"payload":self.verify_jwt(&body.token,body.issuer.as_deref(),call.request(),ctx).await?}),
167 )
168 }
169 _ => Err(AuthError::not_found("Unregistered JWT operation")),
170 }
171 }
172
173 fn with_json_overrides(&self, overrides: Option<&JsValue>) -> AuthResult<Self> {
174 let mut plugin = self.clone();
175 let Some(overrides) = overrides else {
176 return Ok(plugin);
177 };
178 if let Some(jwt) = overrides.get("jwt") {
179 plugin.config.claims = JwtClaimsConfig::default();
180 plugin.config.remote_signer = None;
181 if let Some(issuer) = jwt.get("issuer").filter(|value| !value.is_null()) {
182 plugin.config.claims.issuer = Some(
183 issuer
184 .as_str()
185 .ok_or_else(|| AuthError::internal("JWT issuer must be a string"))?
186 .into(),
187 );
188 }
189 if let Some(audience) = jwt.get("audience").filter(|value| !value.is_null()) {
190 plugin.config.claims.audience = Some(alibi_core::utils::json::from_value::<
191 JwtAudience,
192 >(audience.clone())?);
193 }
194 if let Some(expiration) = jwt.get("expirationTime").filter(|value| !value.is_null()) {
195 plugin.config.claims.expiration = if let Some(value) = expiration.as_f64() {
196 JwtExpiration::Numeric(value)
197 } else if let Some(value) = expiration.as_str() {
198 JwtExpiration::AfterSeconds(time_seconds(value)?)
199 } else {
200 return Err(AuthError::internal("Invalid JWT expiration time"));
201 };
202 }
203 }
204 if let Some(jwks) = overrides.get("jwks") {
205 plugin.config.key_pair = JwtKeyPairConfig::default();
206 plugin.config.additional_key_pairs.clear();
207 plugin.config.remote_url = jwks
208 .get("remoteUrl")
209 .and_then(JsValue::as_str)
210 .map(str::to_owned);
211 plugin.config.disable_private_key_encryption = jwks
212 .get("disablePrivateKeyEncryption")
213 .and_then(JsValue::as_bool)
214 .unwrap_or(false);
215 plugin.config.rotation_interval = jwks
216 .get("rotationInterval")
217 .and_then(JsValue::as_f64)
218 .filter(|value| *value != 0.0)
219 .map(duration)
220 .transpose()?;
221 plugin.config.grace_period = duration(
222 jwks.get("gracePeriod")
223 .and_then(JsValue::as_f64)
224 .unwrap_or(2_592_000.0),
225 )?;
226 if let Some(config) = jwks.get("keyPairConfig") {
227 plugin.config.key_pair = key_config(config)?;
228 }
229 if let Some(configs) = jwks.get("keyPairConfigs").and_then(JsValue::as_array) {
230 plugin.config.additional_key_pairs =
231 configs.iter().map(key_config).collect::<AuthResult<_>>()?;
232 }
233 }
234 if overrides.get("adapter").is_some() {
235 plugin.config.keyring = None;
236 }
237 Ok(plugin)
238 }
239}
240
241fn key_config(value: &JsValue) -> AuthResult<JwtKeyPairConfig> {
242 Ok(JwtKeyPairConfig {
243 algorithm: super::JwtAlgorithm::from_str(
244 value
245 .get("alg")
246 .and_then(JsValue::as_str)
247 .unwrap_or("EdDSA"),
248 )?,
249 modulus_length: value
250 .get("modulusLength")
251 .and_then(JsValue::as_f64)
252 .map(|value| {
253 value
254 .to_string()
255 .parse::<usize>()
256 .map_err(|error| AuthError::internal(error.to_string()))
257 })
258 .transpose()?,
259 })
260}
261
262fn duration(value: f64) -> AuthResult<Duration> {
263 let millis = (value * 1000.0)
264 .trunc()
265 .to_string()
266 .parse::<i64>()
267 .map_err(|error| AuthError::internal(error.to_string()))?;
268 Ok(Duration::milliseconds(millis))
269}
270
271fn time_seconds(value: &str) -> AuthResult<f64> {
272 let invalid = || {
273 AuthError::internal(format!(
274 "Invalid time string format: \"{value}\". Use formats like \"7d\", \"30m\", \"1 hour\", etc."
275 ))
276 };
277 let mut text = value;
278 let sign = if let Some(rest) = text.strip_prefix('-') {
279 text = rest;
280 Some(-1.0)
281 } else if let Some(rest) = text.strip_prefix('+') {
282 text = rest;
283 Some(1.0)
284 } else {
285 None
286 };
287 text = text.strip_prefix(' ').unwrap_or(text);
288 let count = text
289 .bytes()
290 .take_while(|byte| byte.is_ascii_digit() || *byte == b'.')
291 .count();
292 let number = text.get(..count).ok_or_else(invalid)?;
293 if number.is_empty()
294 || number.starts_with('.')
295 || number.ends_with('.')
296 || number.bytes().filter(|byte| *byte == b'.').count() > 1
297 {
298 return Err(invalid());
299 }
300 let mut unit = text
301 .get(count..)
302 .ok_or_else(invalid)?
303 .strip_prefix(' ')
304 .unwrap_or_else(|| &text[count..]);
305 let ago = unit.ends_with(" ago");
306 let from_now = unit.ends_with(" from now");
307 if (ago || from_now) && sign.is_some() {
308 return Err(invalid());
309 }
310 if ago {
311 unit = unit.strip_suffix(" ago").ok_or_else(invalid)?;
312 } else if from_now {
313 unit = unit.strip_suffix(" from now").ok_or_else(invalid)?;
314 }
315 let factor = match unit.to_ascii_lowercase().as_str() {
316 "second" | "seconds" | "sec" | "secs" | "s" => 1.0,
317 "minute" | "minutes" | "min" | "mins" | "m" => 60.0,
318 "hour" | "hours" | "hr" | "hrs" | "h" => 3600.0,
319 "day" | "days" | "d" => 86400.0,
320 "week" | "weeks" | "w" => 604_800.0,
321 "month" | "months" | "mo" => 2_592_000.0,
322 "year" | "years" | "yr" | "yrs" | "y" => 31_557_600.0,
323 _ => return Err(invalid()),
324 };
325 let seconds = number.parse::<f64>().map_err(|_| invalid())?
326 * factor
327 * sign.unwrap_or(if ago { -1.0 } else { 1.0 });
328 Ok((seconds + 0.5).floor())
329}
330
331#[async_trait::async_trait]
332impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for JwtPlugin {
333 fn matches_before(&self, _: &EndpointCall, _: &AuthContext<S>) -> AuthResult<bool> {
334 Ok(false)
335 }
336
337 fn matches_after(
338 &self,
339 call: &EndpointCall,
340 _: &AuthContext<S>,
341 _: &EndpointResponse,
342 ) -> AuthResult<bool> {
343 Ok(call.operation_id() == "get_session"
344 && call.path() == Some("/get-session")
345 && !self.config.disable_setting_jwt_header)
346 }
347
348 async fn after(
349 &self,
350 call: &EndpointCall,
351 ctx: &AuthContext<S>,
352 response: EndpointResponse,
353 ) -> AuthResult<EndpointResponse> {
354 let Some((user, session)) = call.session_hook_snapshot() else {
355 return Ok(response);
356 };
357 let cache = alibi_core::session::cookie_cache::runtime::session_hook_cache_metadata(call);
358 let token = self
359 .sign_session_token(
360 call.request(),
361 ctx,
362 &JwtSession {
363 user,
364 session,
365 needs_refresh: None,
366 updated_at: cache.as_ref().map(|metadata| metadata.updated_at),
367 version: cache.and_then(|metadata| metadata.version),
368 },
369 )
370 .await
371 .map_err(super::public_jwt_error)?;
372 let mut expose = response
373 .headers()
374 .get("access-control-expose-headers")
375 .into_iter()
376 .flat_map(|value| value.split(','))
377 .map(str::trim)
378 .filter(|value| !value.is_empty())
379 .fold(Vec::new(), |mut headers, header| {
380 if !headers.iter().any(|existing| existing == header) {
381 headers.push(header.to_owned());
382 }
383 headers
384 });
385 if !expose.iter().any(|header| header == "set-auth-jwt") {
386 expose.push("set-auth-jwt".to_owned());
387 }
388 Ok(response
389 .with_header("set-auth-jwt", token)
390 .with_header("access-control-expose-headers", expose.join(", ")))
391 }
392}