alibi_plugins/oauth_provider/
resource_seed.rs1use super::OAuthProviderPlugin;
3use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthStore};
4use futures_util::{
5 FutureExt as _,
6 future::{BoxFuture, Shared},
7};
8use serde_json::{Value, json};
9
10const FIELDS: &[&str] = &[
11 "name",
12 "accessTokenTtl",
13 "refreshTokenTtl",
14 "signingAlgorithm",
15 "signingKeyId",
16 "allowedScopes",
17 "customClaims",
18 "dpopBoundAccessTokensRequired",
19 "disabled",
20 "metadata",
21];
22
23#[derive(Default)]
25pub(super) struct SeedState(tokio::sync::Mutex<SeedLifecycle>);
26
27type SeedFuture = Shared<BoxFuture<'static, Result<(), SeedFailure>>>;
28#[derive(Default)]
29struct SeedLifecycle {
30 completed: bool,
31 attempt: u64,
32 pending: Option<SeedFuture>,
33}
34
35#[derive(Clone)]
38enum SeedFailure {
39 Api {
40 status: u16,
41 code: Option<String>,
42 message: String,
43 },
44 RateLimited {
45 retry_after: Option<f64>,
46 },
47 Private {
48 message: String,
49 empty: bool,
50 },
51}
52impl SeedFailure {
53 fn capture(error: &AuthError) -> Self {
54 if let AuthError::RateLimited { retry_after } = error {
55 return Self::RateLimited {
56 retry_after: *retry_after,
57 };
58 }
59 if alibi_core::endpoint::is_endpoint_api_error(error) {
60 let (status, code, message) = error.error_payload();
61 return Self::Api {
62 status,
63 code,
64 message,
65 };
66 }
67 Self::Private {
68 empty: matches!(
69 &error,
70 AuthError::CallbackFailure(_) | AuthError::Encryption(_)
71 ),
72 message: error.to_string(),
73 }
74 }
75 fn into_error(self) -> AuthError {
76 match self {
77 Self::Api {
78 status,
79 code,
80 message,
81 } => AuthError::Api {
82 status,
83 code,
84 message,
85 },
86 Self::RateLimited { retry_after } => AuthError::RateLimited { retry_after },
87 Self::Private { message, empty } => {
88 let error = AuthError::internal(message);
89 if empty {
90 AuthError::CallbackFailure(Box::new(error))
91 } else {
92 error
93 }
94 }
95 }
96 }
97}
98
99fn missing_table(error: &AuthError) -> bool {
100 static PATTERN: std::sync::LazyLock<Option<regex::Regex>> = std::sync::LazyLock::new(|| {
101 regex::Regex::new(
102 r"(?i)no such table|relation.*does not exist|table.*does(?: not|n'?t) exist|model.*(?:does not exist|was not found in the schema)",
103 )
104 .ok()
105 });
106 PATTERN
107 .as_ref()
108 .is_some_and(|pattern| pattern.is_match(&error.to_string()))
109}
110
111pub(super) fn log_startup_seed_failure(error: &AuthError) {
114 if missing_table(error) {
115 tracing::debug!(
116 "oauth-provider: oauthResource table not yet created; deferring resource seed to first access."
117 );
118 } else {
119 tracing::error!(
120 %error,
121 "oauth-provider: could not seed `resources` at startup; retrying on first resource access."
122 );
123 }
124}
125
126fn seed_row(input: &serde_json::Map<String, Value>, identifier: &str) -> Value {
127 let mut row = serde_json::Map::new();
128 for &field in FIELDS {
129 let default = match field {
130 "name" => json!(identifier),
131 "disabled" | "dpopBoundAccessTokensRequired" => json!(false),
132 _ => Value::Null,
133 };
134 _ = row.insert(
135 field.into(),
136 input
137 .get(field)
138 .filter(|value| !value.is_null())
139 .cloned()
140 .unwrap_or(default),
141 );
142 }
143 _ = row.insert("policyVersion".into(), json!(1));
144 Value::Object(row)
145}
146
147impl OAuthProviderPlugin {
148 pub(super) async fn seed_resources<S: AuthSchema>(
149 &self,
150 store: &dyn AuthStore<S>,
151 ) -> AuthResult<()> {
152 for raw in &self.config.resources {
153 let mut input = if let Some(identifier) = raw.as_str() {
154 serde_json::Map::from_iter([("identifier".into(), json!(identifier))])
155 } else {
156 raw.as_object().cloned().ok_or_else(|| {
157 AuthError::config("Resource seed must be an identifier or policy object")
158 })?
159 };
160 let identifier = input
161 .get("identifier")
162 .and_then(Value::as_str)
163 .ok_or_else(|| AuthError::config("Resource seed requires an identifier"))?
164 .to_owned();
165 if self.identifier_error(&identifier).await?.is_some() {
166 tracing::warn!(%identifier, "Skipping invalid configured OAuth resource");
167 continue;
168 }
169 if input.get("signingAlgorithm").is_some_and(|value| {
170 !value.is_null()
171 && !value
172 .as_str()
173 .is_some_and(|algorithm| super::resources::ALGORITHMS.contains(&algorithm))
174 }) {
175 tracing::warn!(%identifier, "Dropping unsupported configured resource signingAlgorithm");
176 _ = input.remove("signingAlgorithm");
177 }
178 let mut existing = store.get_oauth_resource(&identifier).await?;
181 let now =
182 chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
183 .ok_or_else(|| AuthError::internal("Invalid resource seed timestamp"))?;
184 if existing.is_none() {
185 match store
186 .create_oauth_resource(alibi_core::store::CreateOAuthResource {
187 identifier: identifier.clone(),
188 metadata: seed_row(&input, &identifier),
189 created_at: now,
190 })
191 .await
192 {
193 Ok(_) => continue,
194 Err(error) => {
198 existing = store.get_oauth_resource(&identifier).await.ok().flatten();
199 if existing.is_none() {
200 return Err(error);
201 }
202 }
203 }
204 }
205 if let Some(existing) = existing {
206 if self.config.resource_seed_mode == OAuthResourceSeedMode::InsertOnly {
207 continue;
208 }
209 let row = if self.config.resource_seed_mode == OAuthResourceSeedMode::Overwrite {
210 seed_row(&input, &identifier)
211 } else {
212 let mut row =
213 existing.metadata.as_object().cloned().ok_or_else(|| {
214 AuthError::internal("Invalid persisted resource policy")
215 })?;
216 for &field in FIELDS {
217 if let Some(value) = input.get(field) {
218 _ = row.insert(field.into(), value.clone());
219 }
220 }
221 Value::Object(row)
222 };
223 _ = store.update_oauth_resource(&identifier, row, now).await?;
224 }
225 }
226 Ok(())
227 }
228
229 pub(super) async fn get_resource<S: AuthSchema>(
230 &self,
231 ctx: &AuthContext<S>,
232 identifier: &str,
233 ) -> AuthResult<Option<alibi_core::store::OAuthResource>> {
234 if let Some(state) = ctx.extensions.get::<SeedState>() {
237 let work = {
238 let mut lifecycle = state.0.lock().await;
239 if lifecycle.completed {
240 None
241 } else {
242 let future = lifecycle
243 .pending
244 .get_or_insert_with(|| {
245 let provider = self.clone();
246 let store = ctx.database.clone();
247 async move {
248 provider
249 .seed_resources(store.as_ref())
250 .await
251 .map_err(|error| SeedFailure::capture(&error))
252 }
253 .boxed()
254 .shared()
255 })
256 .clone();
257 Some((lifecycle.attempt, future))
258 }
259 };
260 if let Some((attempt, future)) = work {
261 let result = future.await;
262 let mut lifecycle = state.0.lock().await;
263 if lifecycle.attempt == attempt {
264 lifecycle.pending = None;
265 lifecycle.completed = result.is_ok();
266 lifecycle.attempt = lifecycle.attempt.wrapping_add(1);
267 }
268 result.map_err(SeedFailure::into_error)?;
269 }
270 }
271 super::resource_cache::get(
272 ctx,
273 identifier,
274 self.config.cached_resources.contains(identifier),
275 )
276 .await
277 }
278}
279
280#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
282#[serde(rename_all = "camelCase")]
283pub enum OAuthResourceSeedMode {
284 #[default]
285 InsertOnly,
286 Merge,
287 Overwrite,
288}
289
290#[async_trait::async_trait]
292pub trait OAuthResourceIdentifierValidator: Send + Sync + std::fmt::Debug {
293 async fn validate(&self, identifier: &str) -> AuthResult<bool>;
294}