alibi_plugins/oauth_provider/
metadata_document.rs1use 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
16pub struct OAuthClientMetadata {
17 #[serde(flatten)]
18 pub metadata: CreateOAuthClientInput,
19 #[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#[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 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(¤t), 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 _ = 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}