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        _ = metadata.insert(
207            "client_credentials_scopes".into(),
208            existing
209                .and_then(|client| client.metadata.get("client_credentials_scopes"))
210                .cloned()
211                .unwrap_or_else(|| json!([])),
212        );
213        if let Some(existing) = existing {
214            for name in ["disabled", "skip_consent", "enable_end_session"] {
215                _ = metadata.remove(name);
216                if let Some(value) = existing.metadata.get(name) {
217                    _ = metadata.insert(name.into(), value.clone());
218                }
219            }
220        } else {
221            _ = metadata.insert("disabled".into(), json!(false));
222            _ = metadata
223                .entry("dpop_bound_access_tokens")
224                .or_insert_with(|| json!(false));
225        }
226        let resources = super::unique(
227            self.config
228                .client_registration_default_resources
229                .clone()
230                .unwrap_or_default(),
231        );
232        for identifier in &resources {
233            let resource = self.get_resource(ctx, identifier).await?;
234            if resource.is_none() {
235                return Err(DocumentWriteError::Endpoint(document_error(
236                    "invalid_target",
237                    &format!("requested resource {identifier} does not exist"),
238                )));
239            }
240            if resource
241                .is_some_and(|resource| resource.metadata.get("disabled") == Some(&json!(true)))
242            {
243                return Err(DocumentWriteError::Endpoint(document_error(
244                    "invalid_target",
245                    &format!("requested resource {identifier} is disabled"),
246                )));
247            }
248        }
249        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
250            .ok_or_else(|| AuthError::internal("Invalid OAuth registration timestamp"))?;
251        let client = ctx
252            .database
253            .persist_oauth_client_registration(PersistOAuthClientRegistration {
254                client: CreateOAuthClient {
255                    client_id: input.client_id.clone(),
256                    user_id: None,
257                    client_secret: None,
258                    metadata: Value::Object(metadata),
259                    created_at: existing.and_then(|client| client.created_at).unwrap_or(now),
260                },
261                replacement: existing.map(|client| OAuthClientRegistrationReplacement {
262                    discovery_id: input.client_discovery_id.clone(),
263                    existing_created_at: client.created_at,
264                }),
265                resources: resources.clone(),
266                updated_at: now,
267            })
268            .await
269            .map_err(DocumentWriteError::Store)?
270            .ok_or_else(|| {
271                DocumentWriteError::Endpoint(document_error(
272                    "invalid_client",
273                    "client no longer exists",
274                ))
275            })?;
276        Ok(OAuthClientRegistrationResult {
277            client,
278            client_secret: None,
279            resources,
280            created: existing.is_none(),
281        })
282    }
283}
284
285enum DocumentWriteError {
286    Endpoint(EndpointError),
287    Store(OAuthClientRegistrationWriteError),
288}
289impl DocumentWriteError {
290    fn into_endpoint(self) -> EndpointError {
291        match self {
292            Self::Endpoint(error) => error,
293            Self::Store(
294                OAuthClientRegistrationWriteError::Failure(error)
295                | OAuthClientRegistrationWriteError::Collision { cause: error, .. },
296            ) => error.into(),
297        }
298    }
299}
300impl From<AuthError> for DocumentWriteError {
301    fn from(error: AuthError) -> Self {
302        Self::Endpoint(error.into())
303    }
304}
305fn document_error(error: &str, description: &str) -> EndpointError {
306    EndpointError {
307        error: AuthError::Api {
308            status: 400,
309            code: None,
310            message: String::new(),
311        },
312        headers: None,
313        body: Some(Box::new(
314            json!({"error":error,"error_description":description}).into(),
315        )),
316        body_absent: false,
317    }
318}