Skip to main content

alibi_plugins/oauth_provider/
metadata_document.rs

1//! Trusted persistence for an already validated Client ID Metadata Document; no fetching.
2use super::{CreateOAuthClientInput, OAuthProviderPlugin};
3use alibi_core::AuthError;
4use alibi_core::endpoint::EndpointError;
5use alibi_core::store::oauth_clients::{
6    OAuthClientRegistrationReplacement, OAuthClientRegistrationWriteError,
7    PersistOAuthClientRegistration,
8};
9use alibi_core::store::{CreateOAuthClient, OAuthClient};
10use alibi_core::{AuthContext, AuthSchema};
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14/// OAuth metadata supplied by the first-party document validator, not a discovery callback.
15#[derive(Clone, Debug, Default, Serialize, Deserialize)]
16pub struct OAuthClientMetadata {
17    #[serde(flatten)]
18    pub metadata: CreateOAuthClientInput,
19    /// Opaque protocol metadata, distinct from the flattened standard client fields.
20    /// Reserved record/wire keys are stripped before persistence.
21    #[serde(rename = "metadata", skip_serializing_if = "Option::is_none")]
22    pub extensions: Option<serde_json::Map<String, Value>>,
23    pub client_id: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub client_secret: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub client_secret_expires_at: Option<u64>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub user_id: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub client_id_issued_at: Option<i64>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub disabled: Option<bool>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub skip_consent: Option<bool>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub enable_end_session: Option<bool>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub require_pkce: Option<bool>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub subject_type: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub reference_id: Option<String>,
44}
45
46#[derive(Clone, Debug)]
47pub struct RegisterClientMetadataDocumentInput {
48    pub metadata: OAuthClientMetadata,
49    pub client_id: String,
50    pub client_discovery_id: String,
51    pub existing_client: Option<OAuthClient>,
52}
53
54/// Durable registration result, rather than an HTTP registration response.
55#[derive(Clone, Debug)]
56pub struct OAuthClientRegistrationResult {
57    pub client: OAuthClient,
58    pub client_secret: Option<String>,
59    pub resources: Vec<String>,
60    pub created: bool,
61}
62
63impl OAuthProviderPlugin {
64    /// Persist a validated document under its discovery owner. This does not fetch or discover it.
65    pub async fn register_client_metadata_document<S: AuthSchema>(
66        &self,
67        input: RegisterClientMetadataDocumentInput,
68        ctx: &AuthContext<S>,
69    ) -> Result<OAuthClientRegistrationResult, EndpointError> {
70        if input.existing_client.as_ref().is_some_and(|client| {
71            client
72                .metadata
73                .get("client_discovery_id")
74                .and_then(Value::as_str)
75                != Some(&input.client_discovery_id)
76        }) {
77            return Err(document_error(
78                "invalid_client",
79                "client_id is already owned by a different registration source",
80            ));
81        }
82        if input.metadata.metadata.backchannel_logout_uri.is_some()
83            || input
84                .metadata
85                .metadata
86                .backchannel_logout_session_required
87                .is_some()
88        {
89            return Err(document_error(
90                "invalid_client_metadata",
91                "Client ID Metadata Documents cannot register a back-channel logout target",
92            ));
93        }
94        if !["none", "private_key_jwt"].contains(
95            &input
96                .metadata
97                .metadata
98                .token_endpoint_auth_method
99                .as_deref()
100                .unwrap_or("none"),
101        ) {
102            return Err(document_error(
103                "invalid_client_metadata",
104                "Client ID Metadata Document clients cannot use a shared secret",
105            ));
106        }
107        let (cause, resource_id) = match self
108            .persist_metadata_document(&input, input.existing_client.as_ref(), ctx)
109            .await
110        {
111            Ok(result) => return Ok(result),
112            Err(DocumentWriteError::Store(OAuthClientRegistrationWriteError::Collision {
113                cause,
114                resource_id,
115            })) => (cause, resource_id),
116            Err(error) => return Err(error.into_endpoint()),
117        };
118        let current = ctx.database.get_oauth_client(&input.client_id).await?;
119        let Some(current) = current.filter(|client| {
120            client
121                .metadata
122                .get("client_discovery_id")
123                .and_then(Value::as_str)
124                == Some(&input.client_discovery_id)
125        }) else {
126            return Err(cause.into());
127        };
128        if let Some(resource_id) = resource_id
129            && !ctx
130                .database
131                .has_oauth_client_resource(&input.client_id, &resource_id)
132                .await?
133        {
134            return Err(cause.into());
135        }
136        self.persist_metadata_document(&input, Some(&current), ctx)
137            .await
138            .map_err(DocumentWriteError::into_endpoint)
139    }
140
141    async fn persist_metadata_document<S: AuthSchema>(
142        &self,
143        input: &RegisterClientMetadataDocumentInput,
144        existing: Option<&OAuthClient>,
145        ctx: &AuthContext<S>,
146    ) -> Result<OAuthClientRegistrationResult, DocumentWriteError> {
147        let mut metadata = serde_json::to_value(&input.metadata)
148            .map_err(AuthError::from)?
149            .as_object()
150            .cloned()
151            .ok_or_else(|| AuthError::internal("Invalid OAuth metadata"))?;
152        _ = metadata.remove("client_secret");
153        _ = metadata.remove("client_secret_expires_at");
154        if let Some(Value::Object(extensions)) = metadata.get_mut("metadata") {
155            extensions.retain(|name, _| {
156                !super::clients::RESERVED_METADATA_FIELDS.contains(&name.as_str())
157            });
158        }
159        _ = metadata
160            .entry("token_endpoint_auth_method")
161            .or_insert_with(|| json!("none"));
162        let scopes = super::unique(
163            self.config
164                .client_registration_default_scopes
165                .as_ref()
166                .unwrap_or(&self.config.scopes)
167                .iter()
168                .chain(
169                    self.config
170                        .client_registration_allowed_scopes
171                        .iter()
172                        .flatten(),
173                )
174                .cloned()
175                .collect(),
176        );
177        let mut metadata = match self.admit_metadata_for_source(metadata, &scopes, ctx, true)? {
178            Ok(metadata) => metadata,
179            Err(response) => match response.into_output::<Value>() {
180                Err(error) => return Err(DocumentWriteError::Endpoint(error)),
181                Ok(_) => {
182                    return Err(AuthError::internal(
183                        "OAuth metadata admission returned an invalid error response",
184                    )
185                    .into());
186                }
187            },
188        };
189        for name in [
190            "client_id",
191            "user_id",
192            "reference_id",
193            "client_id_issued_at",
194            "disabled",
195        ] {
196            _ = metadata.remove(name);
197        }
198        _ = metadata.insert("scope".into(), json!(scopes.join(" ")));
199        _ = metadata.insert(
200            "client_discovery_id".into(),
201            json!(input.client_discovery_id),
202        );
203        // Canonical persistence explicitly clears applicationType; other absent
204        // remote fields stay undefined and the initialized adapter filters them out.
205        _ = metadata.entry("application_type").or_insert(Value::Null);
206        if existing.is_some() {
207            // Server-owned fields (disabled, consent and logout settings, the
208            // client_credentials ceiling) can change while a refresh fetches
209            // metadata, so the refresh never writes them.
210            for name in [
211                "client_credentials_scopes",
212                "disabled",
213                "skip_consent",
214                "enable_end_session",
215            ] {
216                _ = metadata.remove(name);
217            }
218        } else {
219            _ = metadata.insert("client_credentials_scopes".into(), json!([]));
220            _ = metadata.insert("disabled".into(), json!(false));
221            _ = metadata
222                .entry("dpop_bound_access_tokens")
223                .or_insert_with(|| json!(false));
224        }
225        let resources = super::unique(
226            self.config
227                .client_registration_default_resources
228                .clone()
229                .unwrap_or_default(),
230        );
231        for identifier in &resources {
232            let resource = self.get_resource(ctx, identifier).await?;
233            if resource.is_none() {
234                return Err(DocumentWriteError::Endpoint(document_error(
235                    "invalid_target",
236                    &format!("requested resource {identifier} does not exist"),
237                )));
238            }
239            if resource
240                .is_some_and(|resource| resource.metadata.get("disabled") == Some(&json!(true)))
241            {
242                return Err(DocumentWriteError::Endpoint(document_error(
243                    "invalid_target",
244                    &format!("requested resource {identifier} is disabled"),
245                )));
246            }
247        }
248        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
249            .ok_or_else(|| AuthError::internal("Invalid OAuth registration timestamp"))?;
250        let client = ctx
251            .database
252            .persist_oauth_client_registration(PersistOAuthClientRegistration {
253                client: CreateOAuthClient {
254                    client_id: input.client_id.clone(),
255                    user_id: None,
256                    client_secret: None,
257                    metadata: Value::Object(metadata),
258                    created_at: existing.and_then(|client| client.created_at).unwrap_or(now),
259                },
260                replacement: existing.map(|client| OAuthClientRegistrationReplacement {
261                    discovery_id: input.client_discovery_id.clone(),
262                    existing_created_at: client.created_at,
263                }),
264                resources: resources.clone(),
265                updated_at: now,
266            })
267            .await
268            .map_err(DocumentWriteError::Store)?
269            .ok_or_else(|| {
270                DocumentWriteError::Endpoint(document_error(
271                    "invalid_client",
272                    "client no longer exists",
273                ))
274            })?;
275        Ok(OAuthClientRegistrationResult {
276            client,
277            client_secret: None,
278            resources,
279            created: existing.is_none(),
280        })
281    }
282}
283
284enum DocumentWriteError {
285    Endpoint(EndpointError),
286    Store(OAuthClientRegistrationWriteError),
287}
288impl DocumentWriteError {
289    fn into_endpoint(self) -> EndpointError {
290        match self {
291            Self::Endpoint(error) => error,
292            Self::Store(
293                OAuthClientRegistrationWriteError::Failure(error)
294                | OAuthClientRegistrationWriteError::Collision { cause: error, .. },
295            ) => error.into(),
296        }
297    }
298}
299impl From<AuthError> for DocumentWriteError {
300    fn from(error: AuthError) -> Self {
301        Self::Endpoint(error.into())
302    }
303}
304fn document_error(error: &str, description: &str) -> EndpointError {
305    EndpointError {
306        error: AuthError::Api {
307            status: 400,
308            code: None,
309            message: String::new(),
310        },
311        headers: None,
312        body: Some(Box::new(
313            json!({"error":error,"error_description":description}).into(),
314        )),
315        body_absent: false,
316    }
317}