Skip to main content

alibi_plugins/oauth_provider/
resources.rs

1//! Durable resource administration and provider policy.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::utils::json::JsValue;
6use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema};
7use serde_json::{Value, json};
8const ALGORITHMS: &[&str] = &["EdDSA", "ES256", "ES512", "PS256", "RS256"];
9pub(super) fn validate_create(input: Option<&JsValue>) -> AuthResult<JsValue> {
10    validate_body(input, true)
11}
12pub(super) fn validate_update(input: Option<&JsValue>) -> AuthResult<JsValue> {
13    validate_body(input, false)
14}
15fn validate_body(input: Option<&JsValue>, require_identifier: bool) -> AuthResult<JsValue> {
16    use crate::authentication_helpers::{JsonField, JsonFieldKind, field_issue, json_type};
17    let mut output = crate::endpoint::validate_fields(
18        input,
19        "body",
20        &[JsonField {
21            name: "identifier",
22            kind: JsonFieldKind::NonEmptyString,
23            required: require_identifier,
24        }],
25    )?;
26    let mut issues = Vec::new();
27    for name in [
28        "name",
29        "accessTokenTtl",
30        "refreshTokenTtl",
31        "signingAlgorithm",
32        "signingKeyId",
33        "allowedScopes",
34        "customClaims",
35        "dpopBoundAccessTokensRequired",
36        "disabled",
37        "metadata",
38    ] {
39        let Some(value) = input.and_then(|input| input.get(name)) else {
40            continue;
41        };
42        if matches!(value, JsValue::Null)
43            && !["name", "dpopBoundAccessTokensRequired", "disabled"].contains(&name)
44        {
45            if let JsValue::Object(output) = &mut output {
46                _ = output.insert(name.into(), value.clone());
47            }
48            continue;
49        }
50        let issue = match name {
51            "name" | "signingKeyId" => {
52                field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
53            }
54            "dpopBoundAccessTokensRequired" | "disabled" => {
55                field_issue(JsonFieldKind::Boolean, Some(value), json_type(Some(value)))
56            }
57            "customClaims" | "metadata" => {
58                field_issue(JsonFieldKind::Record, Some(value), json_type(Some(value)))
59            }
60            "signingAlgorithm" => field_issue(
61                JsonFieldKind::OneOf(ALGORITHMS),
62                Some(value),
63                json_type(Some(value)),
64            ),
65            "accessTokenTtl" | "refreshTokenTtl" => match value.as_f64() {
66                None => Some(format!(
67                    "Invalid input: expected number, received {}",
68                    json_type(Some(value))
69                )),
70                Some(number) if number.fract() != 0.0 => {
71                    Some("Invalid input: expected int, received number".into())
72                }
73                Some(number) if number > 9_007_199_254_740_991.0 => {
74                    Some("Too big: expected int to be <=9007199254740991".into())
75                }
76                Some(number) if number <= 0.0 => Some("Too small: expected number to be >0".into()),
77                Some(_) => None,
78            },
79            "allowedScopes" => {
80                if let Some(values) = value.as_array() {
81                    for (index, value) in values.iter().enumerate() {
82                        if let Some(issue) =
83                            field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
84                        {
85                            issues.push(format!("[body.{name}.{index}] {issue}"));
86                        }
87                    }
88                    None
89                } else {
90                    Some(format!(
91                        "Invalid input: expected array, received {}",
92                        json_type(Some(value))
93                    ))
94                }
95            }
96            _ => None,
97        };
98        if let Some(issue) = issue {
99            issues.push(format!("[body.{name}] {issue}"));
100        }
101        if let JsValue::Object(output) = &mut output {
102            _ = output.insert(name.into(), value.clone());
103        }
104    }
105    if !issues.is_empty() {
106        return Err(crate::endpoint::validation(issues.join("; ")));
107    }
108    Ok(output)
109}
110pub(super) fn resource_value(row: alibi_core::store::OAuthResource) -> AuthResult<Value> {
111    let mut value: serde_json::Map<String, Value> = serde_json::from_value(row.metadata)?;
112    _ = value.insert("id".into(), json!(row.id));
113    _ = value.insert("identifier".into(), json!(row.identifier));
114    _ = value.insert("createdAt".into(), json!(row.created_at));
115    _ = value.insert("updatedAt".into(), json!(row.updated_at));
116    Ok(Value::Object(value))
117}
118impl OAuthProviderPlugin {
119    /// Create durable provider resource policy through the server-only administration API.
120    pub fn admin_create_oauth_resource_endpoint(
121        input: &impl serde::Serialize,
122    ) -> AuthResult<ServerEndpoint<Value>> {
123        ServerEndpoint::new("oauth-provider", "adminCreateOAuthResource").with_body(input)
124    }
125    pub(super) async fn create_resource<S: AuthSchema>(
126        &self,
127        input: Value,
128        req: &impl SessionRequest,
129        ctx: &AuthContext<S>,
130    ) -> AuthResult<EndpointResponse> {
131        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
132            return Ok(response);
133        }
134        let identifier = input
135            .get("identifier")
136            .and_then(Value::as_str)
137            .unwrap_or("");
138        let Ok(uri) = url::Url::parse(identifier) else {
139            return super::clients::status_protocol_error(
140                400,
141                "invalid_target",
142                &format!("resource identifier {identifier} must be an absolute URI (RFC 8707 §2)"),
143            );
144        };
145        if uri.fragment().is_some_and(|fragment| !fragment.is_empty()) {
146            return super::clients::status_protocol_error(
147                400,
148                "invalid_target",
149                &format!(
150                    "resource identifier {identifier} must not contain a URI fragment (RFC 8707 §2)"
151                ),
152            );
153        }
154        let mut metadata = input
155            .as_object()
156            .cloned()
157            .ok_or_else(|| AuthError::internal("Invalid resource body"))?;
158        _ = metadata.remove("identifier");
159        _ = metadata.entry("name").or_insert_with(|| json!(identifier));
160        for name in [
161            "accessTokenTtl",
162            "refreshTokenTtl",
163            "signingAlgorithm",
164            "signingKeyId",
165            "allowedScopes",
166            "customClaims",
167            "metadata",
168        ] {
169            _ = metadata.entry(name).or_insert(Value::Null);
170        }
171        for name in ["dpopBoundAccessTokensRequired", "disabled"] {
172            _ = metadata.entry(name).or_insert_with(|| json!(false));
173        }
174        _ = metadata.insert("policyVersion".into(), json!(1));
175        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
176            .ok_or_else(|| AuthError::internal("Invalid resource timestamp"))?;
177        let result = ctx
178            .database
179            .create_oauth_resource(alibi_core::store::CreateOAuthResource {
180                identifier: identifier.into(),
181                metadata: Value::Object(metadata),
182                created_at: now,
183            })
184            .await;
185        let row = match result {
186            Ok(row) => row,
187            Err(error)
188                if {
189                    let message = error.to_string().to_ascii_lowercase();
190                    message.contains("unique") || message.contains("duplicate")
191                } =>
192            {
193                return super::clients::status_protocol_error(
194                    400,
195                    "invalid_request",
196                    &format!("resource {identifier} already exists"),
197                );
198            }
199            Err(error) => return Err(error),
200        };
201        EndpointResponse::json(&resource_value(row)?)
202    }
203    async fn resource_admission<S: AuthSchema>(
204        &self,
205        req: &impl SessionRequest,
206        ctx: &AuthContext<S>,
207    ) -> AuthResult<Option<EndpointResponse>> {
208        if Box::pin(ctx.require_cached_session(req)).await.is_err() {
209            return Ok(Some(
210                EndpointResponse::error(AuthError::Api {
211                    status: 401,
212                    code: None,
213                    message: String::new(),
214                })
215                .without_error_body()
216                .with_header("Content-Type", "application/json"),
217            ));
218        }
219        if req
220            .endpoint_call()
221            .is_some_and(|call| call.headers().is_none())
222        {
223            return Ok(Some(
224                EndpointResponse::error(AuthError::Api {
225                    status: 400,
226                    code: None,
227                    message: String::new(),
228                })
229                .without_error_body()
230                .with_header("Content-Type", "application/json"),
231            ));
232        }
233        Ok(None)
234    }
235}
236
237impl OAuthProviderPlugin {
238    /// List the provider's complete resource catalog through its server-only API.
239    #[must_use]
240    pub fn admin_list_oauth_resources_endpoint() -> ServerEndpoint<Value> {
241        ServerEndpoint::new("oauth-provider", "adminListOAuthResources")
242    }
243    pub(super) async fn list_resources<S: AuthSchema>(
244        &self,
245        req: &impl SessionRequest,
246        ctx: &AuthContext<S>,
247    ) -> AuthResult<EndpointResponse> {
248        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
249            return Ok(response);
250        }
251        let rows = ctx.database.list_oauth_resources().await?;
252        let values = rows
253            .into_iter()
254            .map(resource_value)
255            .collect::<AuthResult<Vec<_>>>()?;
256        EndpointResponse::json(&values)
257    }
258}
259
260pub(super) fn decode_parameter(params: Option<&JsValue>, name: &str) -> AuthResult<String> {
261    let raw = params.and_then(|params| params.get(name)).map_or_else(
262        || Ok("undefined".to_owned()),
263        |value| value.coerce_string().map_err(AuthError::internal),
264    )?;
265    for (index, byte) in raw.bytes().enumerate() {
266        if byte == b'%'
267            && (!raw
268                .as_bytes()
269                .get(index + 1)
270                .is_some_and(u8::is_ascii_hexdigit)
271                || !raw
272                    .as_bytes()
273                    .get(index + 2)
274                    .is_some_and(u8::is_ascii_hexdigit))
275        {
276            return Ok(raw);
277        }
278    }
279    Ok(urlencoding::decode(&raw).map_or_else(|_| raw.clone(), std::borrow::Cow::into_owned))
280}
281impl OAuthProviderPlugin {
282    /// Read durable resource policy by a raw or URI-encoded identifier through the server-only API.
283    pub fn admin_get_oauth_resource_endpoint(
284        identifier: &str,
285    ) -> AuthResult<ServerEndpoint<Value>> {
286        ServerEndpoint::new("oauth-provider", "adminGetOAuthResource")
287            .with_params(&json!({"identifier":identifier}))
288    }
289    pub(super) async fn read_resource<S: AuthSchema>(
290        &self,
291        params: Option<&JsValue>,
292        req: &impl SessionRequest,
293        ctx: &AuthContext<S>,
294    ) -> AuthResult<EndpointResponse> {
295        let identifier = decode_parameter(params, "identifier")?;
296        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
297            return Ok(response);
298        }
299        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
300            return super::clients::status_protocol_error(
301                404,
302                "not_found",
303                &format!("resource {identifier} not found"),
304            );
305        };
306        EndpointResponse::json(&resource_value(row)?)
307    }
308}
309
310impl OAuthProviderPlugin {
311    /// Patch the provider's durable resource policy without changing its identifier.
312    pub fn admin_update_oauth_resource_endpoint(
313        identifier: &str,
314        patch: &impl serde::Serialize,
315    ) -> AuthResult<ServerEndpoint<Value>> {
316        ServerEndpoint::new("oauth-provider", "adminUpdateOAuthResource")
317            .with_params(&json!({"identifier":identifier}))?
318            .with_body(patch)
319    }
320    pub(super) async fn update_resource<S: AuthSchema>(
321        &self,
322        params: Option<&JsValue>,
323        input: Value,
324        req: &impl SessionRequest,
325        ctx: &AuthContext<S>,
326    ) -> AuthResult<EndpointResponse> {
327        let identifier = decode_parameter(params, "identifier")?;
328        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
329            return Ok(response);
330        }
331        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
332            return super::clients::status_protocol_error(
333                404,
334                "not_found",
335                &format!("resource {identifier} not found"),
336            );
337        };
338        let mut metadata = row
339            .metadata
340            .as_object()
341            .cloned()
342            .ok_or_else(|| AuthError::internal("Invalid resource metadata"))?;
343        for (name, value) in input
344            .as_object()
345            .ok_or_else(|| AuthError::internal("Invalid resource update"))?
346        {
347            if name != "identifier" {
348                _ = metadata.insert(name.clone(), value.clone());
349            }
350        }
351        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
352            .ok_or_else(|| AuthError::internal("Invalid resource update timestamp"))?;
353        _ = ctx
354            .database
355            .update_oauth_resource(&identifier, Value::Object(metadata), now)
356            .await?;
357        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
358            return super::clients::status_protocol_error(
359                404,
360                "not_found",
361                &format!("resource {identifier} not found"),
362            );
363        };
364        EndpointResponse::json(&resource_value(row)?)
365    }
366}
367
368impl OAuthProviderPlugin {
369    /// Remove durable provider resource policy through the server-only administration API.
370    pub fn admin_delete_oauth_resource_endpoint(
371        identifier: &str,
372    ) -> AuthResult<ServerEndpoint<Value>> {
373        ServerEndpoint::new("oauth-provider", "adminDeleteOAuthResource")
374            .with_params(&json!({"identifier":identifier}))
375    }
376    pub(super) async fn delete_resource<S: AuthSchema>(
377        &self,
378        params: Option<&JsValue>,
379        req: &impl SessionRequest,
380        ctx: &AuthContext<S>,
381    ) -> AuthResult<EndpointResponse> {
382        let identifier = decode_parameter(params, "identifier")?;
383        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
384            return Ok(response);
385        }
386        if ctx
387            .database
388            .get_oauth_resource(&identifier)
389            .await?
390            .is_none()
391        {
392            return super::clients::status_protocol_error(
393                404,
394                "not_found",
395                &format!("resource {identifier} not found"),
396            );
397        }
398        ctx.database.delete_oauth_resource(&identifier).await?;
399        EndpointResponse::json(&json!({"deleted":true}))
400    }
401}
402
403impl OAuthProviderPlugin {
404    /// Grant a client durable membership in a provider resource through the server-only API.
405    pub fn admin_link_client_resource_endpoint(
406        identifier: &str,
407        client_id: &str,
408    ) -> AuthResult<ServerEndpoint<Value>> {
409        ServerEndpoint::new("oauth-provider", "adminLinkClientResource")
410            .with_params(&json!({"identifier":identifier,"client_id":client_id}))
411    }
412    pub(super) async fn link_resource<S: AuthSchema>(
413        &self,
414        params: Option<&JsValue>,
415        req: &impl SessionRequest,
416        ctx: &AuthContext<S>,
417    ) -> AuthResult<EndpointResponse> {
418        let identifier = decode_parameter(params, "identifier")?;
419        let client_id = decode_parameter(params, "client_id")?;
420        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
421            return Ok(response);
422        }
423        if ctx
424            .database
425            .get_oauth_resource(&identifier)
426            .await?
427            .is_none()
428        {
429            return super::clients::status_protocol_error(
430                404,
431                "not_found",
432                &format!("resource {identifier} not found"),
433            );
434        }
435        if ctx.database.get_oauth_client(&client_id).await?.is_none() {
436            return super::clients::status_protocol_error(
437                404,
438                "not_found",
439                &format!("client {client_id} not found"),
440            );
441        }
442        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
443            .ok_or_else(|| AuthError::internal("Invalid resource link timestamp"))?;
444        match ctx
445            .database
446            .create_oauth_client_resource(alibi_core::store::CreateOAuthClientResource {
447                client_id,
448                resource_id: identifier,
449                created_at: now,
450            })
451            .await
452        {
453            Ok(_) => EndpointResponse::json(&json!({"linked":true})),
454            Err(error)
455                if {
456                    let message = error.to_string().to_ascii_lowercase();
457                    message.contains("unique") || message.contains("duplicate")
458                } =>
459            {
460                EndpointResponse::json(&json!({"linked":true,"alreadyLinked":true}))
461            }
462            Err(error) => Err(error),
463        }
464    }
465}
466
467impl OAuthProviderPlugin {
468    /// Remove a client's durable resource membership, including an already absent pair.
469    pub fn admin_unlink_client_resource_endpoint(
470        identifier: &str,
471        client_id: &str,
472    ) -> AuthResult<ServerEndpoint<Value>> {
473        ServerEndpoint::new("oauth-provider", "adminUnlinkClientResource")
474            .with_params(&json!({"identifier":identifier,"client_id":client_id}))
475    }
476    pub(super) async fn unlink_resource<S: AuthSchema>(
477        &self,
478        params: Option<&JsValue>,
479        req: &impl SessionRequest,
480        ctx: &AuthContext<S>,
481    ) -> AuthResult<EndpointResponse> {
482        let identifier = decode_parameter(params, "identifier")?;
483        let client_id = decode_parameter(params, "client_id")?;
484        if let Some(response) = Box::pin(self.resource_admission(req, ctx)).await? {
485            return Ok(response);
486        }
487        ctx.database
488            .delete_oauth_client_resource(&client_id, &identifier)
489            .await?;
490        EndpointResponse::json(&json!({"unlinked":true}))
491    }
492}