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        if let Some(reason) = self.identifier_error(identifier).await? {
151            return super::clients::status_protocol_error(400, "invalid_target", &reason);
152        }
153        let mut metadata = input
154            .as_object()
155            .cloned()
156            .ok_or_else(|| AuthError::internal("Invalid resource body"))?;
157        _ = metadata.remove("identifier");
158        _ = metadata.entry("name").or_insert_with(|| json!(identifier));
159        for name in [
160            "accessTokenTtl",
161            "refreshTokenTtl",
162            "signingAlgorithm",
163            "signingKeyId",
164            "allowedScopes",
165            "customClaims",
166            "metadata",
167        ] {
168            _ = metadata.entry(name).or_insert(Value::Null);
169        }
170        for name in ["dpopBoundAccessTokensRequired", "disabled"] {
171            _ = metadata.entry(name).or_insert_with(|| json!(false));
172        }
173        _ = metadata.insert("policyVersion".into(), json!(1));
174        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
175            .ok_or_else(|| AuthError::internal("Invalid resource timestamp"))?;
176        let result = ctx
177            .database
178            .create_oauth_resource(alibi_core::store::CreateOAuthResource {
179                identifier: identifier.into(),
180                metadata: Value::Object(metadata),
181                created_at: now,
182            })
183            .await;
184        let row = match result {
185            Ok(row) => row,
186            Err(error)
187                if {
188                    let message = error.to_string().to_ascii_lowercase();
189                    message.contains("unique") || message.contains("duplicate")
190                } =>
191            {
192                return super::clients::status_protocol_error(
193                    400,
194                    "invalid_request",
195                    &format!("resource {identifier} already exists"),
196                );
197            }
198            Err(error) => return Err(error),
199        };
200        super::resource_cache::invalidate(&row.identifier).await;
201        EndpointResponse::json(&resource_value(row)?)
202    }
203    /// Validate a resource identifier like the token endpoint's `resource`
204    /// parameter, unless an application identifier validator replaces it.
205    pub(super) async fn identifier_error(&self, identifier: &str) -> AuthResult<Option<String>> {
206        if let Some(validator) = &self.config.identifier_validator {
207            return Ok((!validator.validate(identifier).await?)
208                .then(|| format!("resource identifier {identifier} failed validation")));
209        }
210        Ok(resource_uri_issue(identifier)
211            .map(|issue| format!("resource identifier {identifier} is invalid: {issue}")))
212    }
213    pub(super) async fn resource_admission<S: AuthSchema>(
214        &self,
215        req: &impl SessionRequest,
216        ctx: &AuthContext<S>,
217        action: &'static str,
218        resource_id: Option<&str>,
219    ) -> AuthResult<Option<EndpointResponse>> {
220        let Ok((user, session)) = Box::pin(ctx.require_cached_session(req)).await else {
221            return Ok(Some(
222                EndpointResponse::error(AuthError::Api {
223                    status: 401,
224                    code: None,
225                    message: String::new(),
226                })
227                .without_error_body()
228                .with_header("Content-Type", "application/json"),
229            ));
230        };
231        if req
232            .endpoint_call()
233            .is_some_and(|call| call.headers().is_none())
234        {
235            return Ok(Some(
236                EndpointResponse::error(AuthError::Api {
237                    status: 400,
238                    code: None,
239                    message: String::new(),
240                })
241                .without_error_body()
242                .with_header("Content-Type", "application/json"),
243            ));
244        }
245        let Some(privileges) = &self.config.resource_privileges else {
246            return Ok(None);
247        };
248        let mut headers = alibi_core::Headers::new();
249        for (name, value) in req.session_headers() {
250            _ = headers.insert(name.clone(), value.clone());
251        }
252        match privileges
253            .allowed(&super::OAuthResourcePrivilegeContext {
254                action,
255                headers,
256                user: serde_json::to_value(user)?,
257                session: serde_json::to_value(session)?,
258                resource_id: resource_id.map(str::to_owned),
259            })
260            .await
261        {
262            Ok(true) => Ok(None),
263            Ok(false) => Ok(Some(
264                EndpointResponse::error(AuthError::Api {
265                    status: 401,
266                    code: None,
267                    message: String::new(),
268                })
269                .without_error_body()
270                .with_header("Content-Type", "application/json"),
271            )),
272            Err(error) if alibi_core::endpoint::is_endpoint_api_error(&error) => {
273                Ok(Some(EndpointResponse::error(error)))
274            }
275            Err(error) => Err(error),
276        }
277    }
278}
279
280impl OAuthProviderPlugin {
281    /// List the provider's complete resource catalog through its server-only API.
282    #[must_use]
283    pub fn admin_list_oauth_resources_endpoint() -> ServerEndpoint<Value> {
284        ServerEndpoint::new("oauth-provider", "adminListOAuthResources")
285    }
286    pub(super) async fn list_resources<S: AuthSchema>(
287        &self,
288        req: &impl SessionRequest,
289        ctx: &AuthContext<S>,
290    ) -> AuthResult<EndpointResponse> {
291        if let Some(response) = Box::pin(self.resource_admission(req, ctx, "list", None)).await? {
292            return Ok(response);
293        }
294        let rows = ctx.database.list_oauth_resources().await?;
295        let values = rows
296            .into_iter()
297            .map(resource_value)
298            .collect::<AuthResult<Vec<_>>>()?;
299        EndpointResponse::json(&values)
300    }
301}
302
303pub(super) fn decode_parameter(params: Option<&JsValue>, name: &str) -> AuthResult<String> {
304    let raw = params.and_then(|params| params.get(name)).map_or_else(
305        || Ok("undefined".to_owned()),
306        |value| value.coerce_string().map_err(AuthError::internal),
307    )?;
308    for (index, byte) in raw.bytes().enumerate() {
309        if byte == b'%'
310            && (!raw
311                .as_bytes()
312                .get(index + 1)
313                .is_some_and(u8::is_ascii_hexdigit)
314                || !raw
315                    .as_bytes()
316                    .get(index + 2)
317                    .is_some_and(u8::is_ascii_hexdigit))
318        {
319            return Ok(raw);
320        }
321    }
322    Ok(urlencoding::decode(&raw).map_or_else(|_| raw.clone(), std::borrow::Cow::into_owned))
323}
324impl OAuthProviderPlugin {
325    /// Read durable resource policy by a raw or URI-encoded identifier through the server-only API.
326    pub fn admin_get_oauth_resource_endpoint(
327        identifier: &str,
328    ) -> AuthResult<ServerEndpoint<Value>> {
329        ServerEndpoint::new("oauth-provider", "adminGetOAuthResource")
330            .with_params(&json!({"identifier":identifier}))
331    }
332    pub(super) async fn read_resource<S: AuthSchema>(
333        &self,
334        params: Option<&JsValue>,
335        req: &impl SessionRequest,
336        ctx: &AuthContext<S>,
337    ) -> AuthResult<EndpointResponse> {
338        let identifier = decode_parameter(params, "identifier")?;
339        if let Some(response) =
340            Box::pin(self.resource_admission(req, ctx, "read", Some(&identifier))).await?
341        {
342            return Ok(response);
343        }
344        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
345            return super::clients::status_protocol_error(
346                404,
347                "not_found",
348                &format!("resource {identifier} not found"),
349            );
350        };
351        EndpointResponse::json(&resource_value(row)?)
352    }
353}
354
355impl OAuthProviderPlugin {
356    /// Patch the provider's durable resource policy without changing its identifier.
357    pub fn admin_update_oauth_resource_endpoint(
358        identifier: &str,
359        patch: &impl serde::Serialize,
360    ) -> AuthResult<ServerEndpoint<Value>> {
361        ServerEndpoint::new("oauth-provider", "adminUpdateOAuthResource")
362            .with_params(&json!({"identifier":identifier}))?
363            .with_body(patch)
364    }
365    pub(super) async fn update_resource<S: AuthSchema>(
366        &self,
367        params: Option<&JsValue>,
368        input: Value,
369        req: &impl SessionRequest,
370        ctx: &AuthContext<S>,
371    ) -> AuthResult<EndpointResponse> {
372        let identifier = decode_parameter(params, "identifier")?;
373        if let Some(response) =
374            Box::pin(self.resource_admission(req, ctx, "update", Some(&identifier))).await?
375        {
376            return Ok(response);
377        }
378        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
379            return super::clients::status_protocol_error(
380                404,
381                "not_found",
382                &format!("resource {identifier} not found"),
383            );
384        };
385        let mut metadata = row
386            .metadata
387            .as_object()
388            .cloned()
389            .ok_or_else(|| AuthError::internal("Invalid resource metadata"))?;
390        for (name, value) in input
391            .as_object()
392            .ok_or_else(|| AuthError::internal("Invalid resource update"))?
393        {
394            if name != "identifier" {
395                _ = metadata.insert(name.clone(), value.clone());
396            }
397        }
398        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
399            .ok_or_else(|| AuthError::internal("Invalid resource update timestamp"))?;
400        _ = ctx
401            .database
402            .update_oauth_resource(&identifier, Value::Object(metadata), now)
403            .await?;
404        super::resource_cache::invalidate(&identifier).await;
405        let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
406            return super::clients::status_protocol_error(
407                404,
408                "not_found",
409                &format!("resource {identifier} not found"),
410            );
411        };
412        EndpointResponse::json(&resource_value(row)?)
413    }
414}
415
416impl OAuthProviderPlugin {
417    /// Remove durable provider resource policy through the server-only administration API.
418    pub fn admin_delete_oauth_resource_endpoint(
419        identifier: &str,
420    ) -> AuthResult<ServerEndpoint<Value>> {
421        ServerEndpoint::new("oauth-provider", "adminDeleteOAuthResource")
422            .with_params(&json!({"identifier":identifier}))
423    }
424    pub(super) async fn delete_resource<S: AuthSchema>(
425        &self,
426        params: Option<&JsValue>,
427        req: &impl SessionRequest,
428        ctx: &AuthContext<S>,
429    ) -> AuthResult<EndpointResponse> {
430        let identifier = decode_parameter(params, "identifier")?;
431        if let Some(response) =
432            Box::pin(self.resource_admission(req, ctx, "delete", Some(&identifier))).await?
433        {
434            return Ok(response);
435        }
436        if ctx
437            .database
438            .get_oauth_resource(&identifier)
439            .await?
440            .is_none()
441        {
442            return super::clients::status_protocol_error(
443                404,
444                "not_found",
445                &format!("resource {identifier} not found"),
446            );
447        }
448        ctx.database.delete_oauth_resource(&identifier).await?;
449        super::resource_cache::invalidate(&identifier).await;
450        EndpointResponse::json(&json!({"deleted":true}))
451    }
452}
453
454impl OAuthProviderPlugin {
455    /// Grant a client durable membership in a provider resource through the server-only API.
456    pub fn admin_link_client_resource_endpoint(
457        identifier: &str,
458        client_id: &str,
459    ) -> AuthResult<ServerEndpoint<Value>> {
460        ServerEndpoint::new("oauth-provider", "adminLinkClientResource")
461            .with_params(&json!({"identifier":identifier,"client_id":client_id}))
462    }
463    pub(super) async fn link_resource<S: AuthSchema>(
464        &self,
465        params: Option<&JsValue>,
466        req: &impl SessionRequest,
467        ctx: &AuthContext<S>,
468    ) -> AuthResult<EndpointResponse> {
469        let identifier = decode_parameter(params, "identifier")?;
470        let client_id = decode_parameter(params, "client_id")?;
471        if let Some(response) =
472            Box::pin(self.resource_admission(req, ctx, "link", Some(&identifier))).await?
473        {
474            return Ok(response);
475        }
476        if ctx
477            .database
478            .get_oauth_resource(&identifier)
479            .await?
480            .is_none()
481        {
482            return super::clients::status_protocol_error(
483                404,
484                "not_found",
485                &format!("resource {identifier} not found"),
486            );
487        }
488        if ctx.database.get_oauth_client(&client_id).await?.is_none() {
489            return super::clients::status_protocol_error(
490                404,
491                "not_found",
492                &format!("client {client_id} not found"),
493            );
494        }
495        let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
496            .ok_or_else(|| AuthError::internal("Invalid resource link timestamp"))?;
497        match ctx
498            .database
499            .create_oauth_client_resource(alibi_core::store::CreateOAuthClientResource {
500                client_id,
501                resource_id: identifier,
502                created_at: now,
503            })
504            .await
505        {
506            Ok(_) => EndpointResponse::json(&json!({"linked":true})),
507            Err(error)
508                if {
509                    let message = error.to_string().to_ascii_lowercase();
510                    message.contains("unique") || message.contains("duplicate")
511                } =>
512            {
513                EndpointResponse::json(&json!({"linked":true,"alreadyLinked":true}))
514            }
515            Err(error) => Err(error),
516        }
517    }
518}
519
520impl OAuthProviderPlugin {
521    /// Remove a client's durable resource membership, including an already absent pair.
522    pub fn admin_unlink_client_resource_endpoint(
523        identifier: &str,
524        client_id: &str,
525    ) -> AuthResult<ServerEndpoint<Value>> {
526        ServerEndpoint::new("oauth-provider", "adminUnlinkClientResource")
527            .with_params(&json!({"identifier":identifier,"client_id":client_id}))
528    }
529    pub(super) async fn unlink_resource<S: AuthSchema>(
530        &self,
531        params: Option<&JsValue>,
532        req: &impl SessionRequest,
533        ctx: &AuthContext<S>,
534    ) -> AuthResult<EndpointResponse> {
535        let identifier = decode_parameter(params, "identifier")?;
536        let client_id = decode_parameter(params, "client_id")?;
537        if let Some(response) =
538            Box::pin(self.resource_admission(req, ctx, "unlink", Some(&identifier))).await?
539        {
540            return Ok(response);
541        }
542        ctx.database
543            .delete_oauth_client_resource(&client_id, &identifier)
544            .await?;
545        EndpointResponse::json(&json!({"unlinked":true}))
546    }
547}
548/// The first RFC 8707 `resource` parameter issue: an absolute URI without a
549/// fragment and without a script scheme.
550pub(super) fn resource_uri_issue(uri: &str) -> Option<&'static str> {
551    let Ok(url) = url::Url::parse(uri) else {
552        return Some("resource must be an absolute URI");
553    };
554    if uri.contains('#') {
555        Some("resource must not contain a fragment")
556    } else if ["javascript", "data", "vbscript"].contains(&url.scheme()) {
557        Some("resource cannot use javascript:, data:, or vbscript: scheme")
558    } else {
559        None
560    }
561}