redirect_repo.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. //! `redirect_uris` catalog + `client_redirect_uris` attachments.
  2. //!
  3. //! URIs are stored normalized-exact; authorize matches strictly against the
  4. //! client's effective attached+enabled set. Dynamic loopback URIs accepted
  5. //! by DCR are upserted here, then attached. Every query is a parameterized
  6. //! `sqlx` macro call, so the schema is checked at compile time and no SQL is
  7. //! ever interpolated.
  8. //!
  9. //! The URI itself is PII: it is sealed into `uri_enc` (AES-256-GCM under the
  10. //! PII key chain) and equality-matched through the keyed blind index
  11. //! `uri_index` (`"{kid}:{hex}"`), with a legacy-plaintext fallback for rows
  12. //! whose `uri_index` is still NULL. Plaintext `uri` is written only while
  13. //! `dual_write` holds. Reads never compare decrypted URIs in SQL; result
  14. //! sets are ordered by the decrypted URI in Rust.
  15. use std::fmt;
  16. use sqlx::PgPool;
  17. use time::OffsetDateTime;
  18. use uuid::Uuid;
  19. use crate::crypto::field::{blind_index_active, blind_index_candidates, open_text, seal_text};
  20. use crate::error::AppError;
  21. use crate::keys::KeyRing;
  22. #[cfg(test)]
  23. mod tests;
  24. /// The role a catalog URI plays. Both kinds share exact-match validation.
  25. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  26. pub enum RedirectKind {
  27. /// The authorization-response callback (carries `code`/`state`).
  28. Authorize,
  29. /// A post-logout return target (a bare navigation, no code/state).
  30. PostLogout,
  31. }
  32. impl RedirectKind {
  33. /// The stored `kind` discriminator value.
  34. #[must_use]
  35. pub const fn as_str(self) -> &'static str {
  36. match self {
  37. Self::Authorize => "authorize",
  38. Self::PostLogout => "post_logout",
  39. }
  40. }
  41. /// Parse a stored `kind` value; unknown values fall closed to `Authorize`.
  42. #[must_use]
  43. pub fn parse(value: &str) -> Self {
  44. match value {
  45. "post_logout" => Self::PostLogout,
  46. _ => Self::Authorize,
  47. }
  48. }
  49. }
  50. /// A catalog redirect URI.
  51. ///
  52. /// `Debug` redacts `uri`: the rendered form carries `[REDACTED]` so the
  53. /// sealed PII never reaches a log.
  54. #[derive(Clone, PartialEq)]
  55. pub struct RedirectUri {
  56. /// Surrogate primary key.
  57. pub id: Uuid,
  58. /// Normalized-exact URI, unique per kind in the catalog (decrypted).
  59. pub uri: String,
  60. /// Stored `kind` discriminator; see [`RedirectUri::kind`].
  61. pub kind: String,
  62. /// Whether the URI may be used (disabled URIs are invisible to matching).
  63. pub enabled: bool,
  64. /// Human-readable description.
  65. pub description: String,
  66. /// Admin who created the entry, if known.
  67. pub created_by: Option<Uuid>,
  68. /// Row creation timestamp.
  69. pub created_at: OffsetDateTime,
  70. /// Row last-update timestamp.
  71. pub updated_at: OffsetDateTime,
  72. }
  73. impl fmt::Debug for RedirectUri {
  74. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  75. f.debug_struct("RedirectUri")
  76. .field("id", &self.id)
  77. .field("uri", &"[REDACTED]")
  78. .field("kind", &self.kind)
  79. .field("enabled", &self.enabled)
  80. .field("description", &self.description)
  81. .field("created_by", &self.created_by)
  82. .field("created_at", &self.created_at)
  83. .field("updated_at", &self.updated_at)
  84. .finish()
  85. }
  86. }
  87. impl RedirectUri {
  88. /// The typed role this URI plays.
  89. #[must_use]
  90. pub fn kind(&self) -> RedirectKind {
  91. RedirectKind::parse(&self.kind)
  92. }
  93. }
  94. /// Input for [`upsert`]: the normalized-exact URI plus its catalog identity.
  95. pub struct NewRedirectUri<'a> {
  96. /// Surrogate id used only when the row is new.
  97. pub id: Uuid,
  98. /// Normalized-exact URI to seal and index.
  99. pub uri: &'a str,
  100. /// The role the URI plays.
  101. pub kind: RedirectKind,
  102. /// Human-readable description, stored only for new rows.
  103. pub description: &'a str,
  104. /// Admin creating the entry, if known.
  105. pub created_by: Option<Uuid>,
  106. }
  107. /// Whether [`upsert`] inserted a row or matched an existing one.
  108. ///
  109. /// Mirrors the CLI `redirect::upsert` created-vs-updated distinction (`xmax =
  110. /// 0`): `Created` for a fresh insert, `Updated` for an adopted legacy row, a
  111. /// partial-index conflict winner, or a re-upsert of an existing row.
  112. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  113. pub enum UpsertOutcome {
  114. /// A new catalog row was inserted.
  115. Created,
  116. /// An existing row was matched (and re-sealed) instead.
  117. Updated,
  118. }
  119. /// Resolve the stored URI: open the ciphertext when present, else use the
  120. /// legacy plaintext. A non-NULL ciphertext that fails to open never falls
  121. /// back to plaintext; a row with neither representation fails closed.
  122. ///
  123. /// # Errors
  124. ///
  125. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open
  126. /// or when the row carries no usable URI at all.
  127. fn decrypt_stored_uri(
  128. keys: &KeyRing,
  129. enc: Option<Vec<u8>>,
  130. plain: Option<String>,
  131. ) -> Result<String, AppError> {
  132. if let Some(blob) = enc {
  133. return open_text(keys, &blob).map_err(|_| AppError::PiiDecrypt);
  134. }
  135. plain.ok_or(AppError::PiiDecrypt)
  136. }
  137. /// Sealed form of a URI: ciphertext plus its blind indexes.
  138. struct SealedUri {
  139. /// AES-256-GCM ciphertext of the exact URI.
  140. enc: Vec<u8>,
  141. /// Blind index under the active PII key (`"{kid}:{hex}"`).
  142. index: String,
  143. /// Blind indexes under every retained PII key, newest first.
  144. candidates: Vec<String>,
  145. }
  146. /// Seal a URI and derive its blind indexes under `keys`.
  147. ///
  148. /// # Errors
  149. ///
  150. /// Returns a masked `temporarily_unavailable` error when sealing fails.
  151. fn seal_uri(keys: &KeyRing, uri: &str) -> Result<SealedUri, AppError> {
  152. Ok(SealedUri {
  153. enc: seal_text(keys, uri).map_err(AppError::from)?,
  154. index: blind_index_active(keys, uri),
  155. candidates: blind_index_candidates(keys, uri),
  156. })
  157. }
  158. /// Re-seal an existing catalog row matched by the dual predicate (blind-index
  159. /// candidates or legacy plaintext) without touching `description`/`enabled`.
  160. ///
  161. /// Returns the decrypted row, or `None` when no row matches.
  162. async fn update_existing(
  163. pool: &PgPool,
  164. keys: &KeyRing,
  165. dual_write: bool,
  166. new: &NewRedirectUri<'_>,
  167. ) -> Result<Option<RedirectUri>, AppError> {
  168. let kind = new.kind.as_str();
  169. let sealed = seal_uri(keys, new.uri)?;
  170. let plain: Option<&str> = if dual_write { Some(new.uri) } else { None };
  171. let row = sqlx::query!(
  172. "UPDATE redirect_uris SET uri = $1, uri_enc = $2, uri_index = $3, updated_at = now() \
  173. WHERE kind = $4 AND (uri_index = ANY($5) OR uri = $6) \
  174. RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
  175. plain,
  176. sealed.enc,
  177. sealed.index,
  178. kind,
  179. &sealed.candidates,
  180. new.uri,
  181. )
  182. .fetch_optional(pool)
  183. .await?;
  184. row
  185. .map(|row| {
  186. Ok(RedirectUri {
  187. id: row.id,
  188. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  189. kind: row.kind,
  190. enabled: row.enabled,
  191. description: row.description,
  192. created_by: row.created_by,
  193. created_at: row.created_at,
  194. updated_at: row.updated_at,
  195. })
  196. })
  197. .transpose()
  198. }
  199. /// Upsert a catalog URI by its normalized-exact value and kind.
  200. ///
  201. /// The URI is sealed into `uri_enc` and blind-indexed into `uri_index`;
  202. /// plaintext `uri` is written only while `dual_write` holds. If a row for
  203. /// `(uri, kind)` already exists — as an indexed row or as a legacy
  204. /// NULL-index row — it is re-sealed in place and returned with
  205. /// [`UpsertOutcome::Updated`] — the passed `id` is used only when the row is
  206. /// new. `description` and `enabled` are never overwritten, so DCR can
  207. /// register a dynamic loopback URI without resurrecting an admin-disabled
  208. /// entry or clobbering its description.
  209. ///
  210. /// The write runs in two phases because a legacy row carries a NULL
  211. /// `uri_index` the partial unique index cannot see: first an `UPDATE` over
  212. /// the dual predicate, then an `INSERT` whose conflict target carries the
  213. /// partial-index predicate (`WHERE uri_index IS NOT NULL`, required for
  214. /// index inference — omitting it raises `42P10`). A `23505` from either
  215. /// unique constraint (a lost insert race against the legacy `(uri, kind)`
  216. /// constraint or the indexed one) retries the `UPDATE` against the winner.
  217. ///
  218. /// # Errors
  219. ///
  220. /// Returns [`AppError::PiiDecrypt`] when a matched row's ciphertext fails to
  221. /// open, a masked error on any other database failure, or a seal failure as
  222. /// `temporarily_unavailable` when PII sealing itself fails.
  223. pub async fn upsert(
  224. pool: &PgPool,
  225. keys: &KeyRing,
  226. dual_write: bool,
  227. new: &NewRedirectUri<'_>,
  228. ) -> Result<(RedirectUri, UpsertOutcome), AppError> {
  229. let kind = new.kind.as_str();
  230. let sealed = seal_uri(keys, new.uri)?;
  231. let plain: Option<&str> = if dual_write { Some(new.uri) } else { None };
  232. if let Some(row) = update_existing(pool, keys, dual_write, new).await? {
  233. return Ok((row, UpsertOutcome::Updated));
  234. }
  235. let inserted = sqlx::query!(
  236. "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at, \
  237. uri_enc, uri_index) \
  238. VALUES ($1, $2, $3, $4, $5, now(), now(), $6, $7) \
  239. ON CONFLICT (uri_index, kind) WHERE uri_index IS NOT NULL \
  240. DO UPDATE SET updated_at = now() \
  241. RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc, \
  242. (xmax = 0) AS \"inserted!\"",
  243. new.id,
  244. plain,
  245. kind,
  246. new.description,
  247. new.created_by,
  248. sealed.enc,
  249. sealed.index,
  250. )
  251. .fetch_one(pool)
  252. .await;
  253. match inserted {
  254. Ok(row) => {
  255. let outcome = if row.inserted {
  256. UpsertOutcome::Created
  257. } else {
  258. UpsertOutcome::Updated
  259. };
  260. Ok((
  261. RedirectUri {
  262. id: row.id,
  263. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  264. kind: row.kind,
  265. enabled: row.enabled,
  266. description: row.description,
  267. created_by: row.created_by,
  268. created_at: row.created_at,
  269. updated_at: row.updated_at,
  270. },
  271. outcome,
  272. ))
  273. }
  274. Err(sqlx::Error::Database(db)) if db.code().as_deref() == Some("23505") => {
  275. match update_existing(pool, keys, dual_write, new).await? {
  276. Some(row) => Ok((row, UpsertOutcome::Updated)),
  277. None => Err(AppError::server_error()),
  278. }
  279. }
  280. Err(other) => Err(AppError::from(other)),
  281. }
  282. }
  283. /// List catalog URIs ordered by the decrypted `uri`, optionally including
  284. /// disabled entries.
  285. ///
  286. /// # Errors
  287. ///
  288. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
  289. /// or a masked error on any database failure.
  290. pub async fn list(
  291. pool: &PgPool,
  292. keys: &KeyRing,
  293. include_disabled: bool,
  294. ) -> Result<Vec<RedirectUri>, AppError> {
  295. let rows = sqlx::query!(
  296. "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc \
  297. FROM redirect_uris WHERE ($1 OR enabled = true)",
  298. include_disabled,
  299. )
  300. .fetch_all(pool)
  301. .await?;
  302. let mut uris = Vec::with_capacity(rows.len());
  303. for row in rows {
  304. uris.push(RedirectUri {
  305. id: row.id,
  306. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  307. kind: row.kind,
  308. enabled: row.enabled,
  309. description: row.description,
  310. created_by: row.created_by,
  311. created_at: row.created_at,
  312. updated_at: row.updated_at,
  313. });
  314. }
  315. // SQL `ORDER BY uri` is meaningless once plaintext is blanked; sort by the
  316. // decrypted value instead.
  317. uris.sort_by(|left, right| left.uri.cmp(&right.uri));
  318. Ok(uris)
  319. }
  320. /// Enable or disable a catalog URI. Returns the updated row, or `None` if the
  321. /// id is unknown.
  322. ///
  323. /// # Errors
  324. ///
  325. /// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
  326. /// or a masked error on any database failure.
  327. pub async fn set_enabled(
  328. pool: &PgPool,
  329. keys: &KeyRing,
  330. id: Uuid,
  331. enabled: bool,
  332. ) -> Result<Option<RedirectUri>, AppError> {
  333. let row = sqlx::query!(
  334. "UPDATE redirect_uris SET enabled = $2, updated_at = now() WHERE id = $1 \
  335. RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
  336. id,
  337. enabled,
  338. )
  339. .fetch_optional(pool)
  340. .await?;
  341. row
  342. .map(|row| {
  343. Ok(RedirectUri {
  344. id: row.id,
  345. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  346. kind: row.kind,
  347. enabled: row.enabled,
  348. description: row.description,
  349. created_by: row.created_by,
  350. created_at: row.created_at,
  351. updated_at: row.updated_at,
  352. })
  353. })
  354. .transpose()
  355. }
  356. /// Enable a catalog URI. Returns the updated row, or `None` if unknown.
  357. ///
  358. /// # Errors
  359. ///
  360. /// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
  361. /// or a masked error on any database failure.
  362. pub async fn enable(
  363. pool: &PgPool,
  364. keys: &KeyRing,
  365. id: Uuid,
  366. ) -> Result<Option<RedirectUri>, AppError> {
  367. set_enabled(pool, keys, id, true).await
  368. }
  369. /// Disable a catalog URI. Returns the updated row, or `None` if unknown.
  370. ///
  371. /// # Errors
  372. ///
  373. /// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
  374. /// or a masked error on any database failure.
  375. pub async fn disable(
  376. pool: &PgPool,
  377. keys: &KeyRing,
  378. id: Uuid,
  379. ) -> Result<Option<RedirectUri>, AppError> {
  380. set_enabled(pool, keys, id, false).await
  381. }
  382. /// Delete a catalog URI only while no client is attached to it.
  383. ///
  384. /// The check and the delete are one atomic statement, so a concurrent
  385. /// attachment cannot race the guard. Returns `true` if a row was deleted and
  386. /// `false` if it was attached (or absent).
  387. ///
  388. /// # Errors
  389. ///
  390. /// Returns a database error if the delete fails.
  391. pub async fn delete_if_unattached(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
  392. let result = sqlx::query!(
  393. "DELETE FROM redirect_uris WHERE id = $1 \
  394. AND NOT EXISTS (SELECT 1 FROM client_redirect_uris WHERE redirect_uri_id = $1)",
  395. id,
  396. )
  397. .execute(pool)
  398. .await?;
  399. Ok(result.rows_affected() > 0)
  400. }
  401. /// Attach a catalog URI to a client. Returns `true` if a new attachment was
  402. /// created, `false` if it was already attached.
  403. ///
  404. /// # Errors
  405. ///
  406. /// Returns a database error if the write fails (e.g. an unknown client or
  407. /// URI).
  408. pub async fn attach(
  409. pool: &PgPool,
  410. client_id: &str,
  411. redirect_uri_id: Uuid,
  412. attached_by: Option<Uuid>,
  413. ) -> Result<bool, sqlx::Error> {
  414. let result = sqlx::query!(
  415. "INSERT INTO client_redirect_uris (client_id, redirect_uri_id, attached_at, attached_by) \
  416. VALUES ($1, $2, now(), $3) ON CONFLICT (client_id, redirect_uri_id) DO NOTHING",
  417. client_id,
  418. redirect_uri_id,
  419. attached_by,
  420. )
  421. .execute(pool)
  422. .await?;
  423. Ok(result.rows_affected() > 0)
  424. }
  425. /// Detach a catalog URI from a client. Returns `true` if an attachment was
  426. /// removed.
  427. ///
  428. /// # Errors
  429. ///
  430. /// Returns a database error if the delete fails.
  431. pub async fn detach(
  432. pool: &PgPool,
  433. client_id: &str,
  434. redirect_uri_id: Uuid,
  435. ) -> Result<bool, sqlx::Error> {
  436. let result = sqlx::query!(
  437. "DELETE FROM client_redirect_uris WHERE client_id = $1 AND redirect_uri_id = $2",
  438. client_id,
  439. redirect_uri_id,
  440. )
  441. .execute(pool)
  442. .await?;
  443. Ok(result.rows_affected() > 0)
  444. }
  445. /// Detach every URI from a client, returning the number removed.
  446. ///
  447. /// Used by RFC 7592 PUT to replace a client's attachment set.
  448. ///
  449. /// # Errors
  450. ///
  451. /// Returns a database error if the delete fails.
  452. pub async fn detach_all(pool: &PgPool, client_id: &str) -> Result<u64, sqlx::Error> {
  453. let result = sqlx::query!(
  454. "DELETE FROM client_redirect_uris WHERE client_id = $1",
  455. client_id,
  456. )
  457. .execute(pool)
  458. .await?;
  459. Ok(result.rows_affected())
  460. }
  461. /// List every URI attached to a client (enabled or not), ordered by the
  462. /// decrypted `uri`.
  463. ///
  464. /// This is the management view; use [`effective_for_client`] for matching.
  465. ///
  466. /// # Errors
  467. ///
  468. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
  469. /// or a masked error on any database failure.
  470. pub async fn list_for_client(
  471. pool: &PgPool,
  472. keys: &KeyRing,
  473. client_id: &str,
  474. ) -> Result<Vec<RedirectUri>, AppError> {
  475. let rows = sqlx::query!(
  476. "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, \
  477. ru.created_at, ru.updated_at, ru.uri_enc \
  478. FROM client_redirect_uris AS cru \
  479. JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
  480. WHERE cru.client_id = $1",
  481. client_id,
  482. )
  483. .fetch_all(pool)
  484. .await?;
  485. let mut uris = Vec::with_capacity(rows.len());
  486. for row in rows {
  487. uris.push(RedirectUri {
  488. id: row.id,
  489. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  490. kind: row.kind,
  491. enabled: row.enabled,
  492. description: row.description,
  493. created_by: row.created_by,
  494. created_at: row.created_at,
  495. updated_at: row.updated_at,
  496. });
  497. }
  498. uris.sort_by(|left, right| left.uri.cmp(&right.uri));
  499. Ok(uris)
  500. }
  501. /// The effective (attached + enabled) authorization-response URIs for a client,
  502. /// ordered by the decrypted `uri`.
  503. ///
  504. /// This is the exact set authorize matching (and RFC 7592 GET serialization)
  505. /// must use. Post-logout URIs are excluded: they are a separate landing kind and
  506. /// must never be accepted as an authorization callback.
  507. ///
  508. /// # Errors
  509. ///
  510. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
  511. /// or a masked error on any database failure.
  512. pub async fn effective_for_client(
  513. pool: &PgPool,
  514. keys: &KeyRing,
  515. client_id: &str,
  516. ) -> Result<Vec<RedirectUri>, AppError> {
  517. let rows = sqlx::query!(
  518. "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, \
  519. ru.created_at, ru.updated_at, ru.uri_enc \
  520. FROM client_redirect_uris AS cru \
  521. JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
  522. WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize'",
  523. client_id,
  524. )
  525. .fetch_all(pool)
  526. .await?;
  527. let mut uris = Vec::with_capacity(rows.len());
  528. for row in rows {
  529. uris.push(RedirectUri {
  530. id: row.id,
  531. uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
  532. kind: row.kind,
  533. enabled: row.enabled,
  534. description: row.description,
  535. created_by: row.created_by,
  536. created_at: row.created_at,
  537. updated_at: row.updated_at,
  538. });
  539. }
  540. uris.sort_by(|left, right| left.uri.cmp(&right.uri));
  541. Ok(uris)
  542. }
  543. /// The client's effective (attached + enabled) post-logout URIs, ordered by
  544. /// the decrypted value.
  545. ///
  546. /// Used at logout to re-validate that the session's frozen target is still
  547. /// registered for the client before redirecting to it.
  548. ///
  549. /// # Errors
  550. ///
  551. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
  552. /// or a masked error on any database failure.
  553. pub async fn effective_post_logout_for_client(
  554. pool: &PgPool,
  555. keys: &KeyRing,
  556. client_id: &str,
  557. ) -> Result<Vec<String>, AppError> {
  558. let rows = sqlx::query!(
  559. "SELECT ru.uri, ru.uri_enc \
  560. FROM client_redirect_uris AS cru \
  561. JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
  562. WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout'",
  563. client_id,
  564. )
  565. .fetch_all(pool)
  566. .await?;
  567. let mut uris = Vec::with_capacity(rows.len());
  568. for row in rows {
  569. uris.push(decrypt_stored_uri(keys, row.uri_enc, row.uri)?);
  570. }
  571. uris.sort();
  572. Ok(uris)
  573. }
  574. /// The client's single effective post-logout URI, if exactly one is registered.
  575. ///
  576. /// A post-logout redirect must be unambiguous. Zero registered post-logout URIs
  577. /// yields `None` (no redirect); more than one is also `None`, because choosing
  578. /// arbitrarily would make the logout target nondeterministic. Registration of a
  579. /// second post-logout URI for one client is therefore a configuration error, not
  580. /// a silent last-writer-wins.
  581. ///
  582. /// # Errors
  583. ///
  584. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
  585. /// or a masked error on any database failure.
  586. pub async fn post_logout_for_client(
  587. pool: &PgPool,
  588. keys: &KeyRing,
  589. client_id: &str,
  590. ) -> Result<Option<String>, AppError> {
  591. let uris = effective_post_logout_for_client(pool, keys, client_id).await?;
  592. match uris.as_slice() {
  593. [only] => Ok(Some(only.clone())),
  594. _ => Ok(None),
  595. }
  596. }