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