oauth_as/delegate.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! [`delegate_storage!`](crate::delegate_storage), the answer to "I want Postgres for clients and
5//! memory for codes".
6//!
7//! [`crate::store::Storage`] has no default method bodies, and its own docs argue at length for
8//! why: there is no method here whose obvious default is safe, and a defaulted one fails in
9//! production in the direction that loses credentials. The cost of that decision falls on the host
10//! who wants to specialise ONE method, because they then have to hand-write the other twenty-eight
11//! as forwarding calls.
12//!
13//! That forwarding is mechanical, so this macro writes it. It is the answer the trait's docs
14//! promise, and it lands after the 0.9.1 `Storage` break so that it forwards the FINAL method set
15//! rather than a set about to change.
16//!
17//! # Why the feature gating goes through `__oauth_as_if_*` and not through `#[cfg]`
18//!
19//! `macro_rules!` has hygiene for identifiers and NONE for `cfg`. An attribute a macro EMITS is
20//! evaluated where the macro EXPANDS, so a `#[cfg(feature = "par")]` written in this file and
21//! expanded into a host crate asks whether THE HOST has a feature called `par`. Almost no host
22//! does. rustc says this in as many words when it sees it: "using a cfg inside a macro will use the
23//! cfgs from the destination crate and not the ones from the defining crate".
24//!
25//! THIS SHIPPED WRONG AND IS WORTH RECORDING. Through 0.9.1 the nine feature-gated arms carried
26//! their `#[cfg]` inline, so in every real host crate they expanded to NOTHING, whatever features
27//! `oauth-as` itself had been built with. A host that turned on `consent` and `par` and listed all
28//! twenty-nine names got E0046 naming `put_pushed_authorization_request`,
29//! `take_pushed_authorization_request`, the six consent methods and `claim_replay_id`, and was
30//! pushed straight back to hand-writing the forwarders this macro exists to spare them.
31//!
32//! The fix moves the DECISION to a place `oauth-as` compiles: a pair of `#[macro_export]` macros per
33//! gate, selected by `#[cfg]` HERE, one passing its input through and one swallowing it. The gated
34//! arms then call `$crate::__oauth_as_if_par! { ... }`, which is an ordinary macro path and carries
35//! this crate's answer with it. Nothing is emitted into the host that the host has to be able to
36//! evaluate.
37//!
38//! Nothing else works. `cfg_attr` has the same problem for the same reason; `cfg!()` is an
39//! expression and cannot delete an item; a proc macro could read `CARGO_FEATURE_*` but that is a
40//! `syn` dependency in the ONE crate whose whole argument is that it has almost none.
41//!
42//! `crates/oauth-as-delegate-fixture` is the standing gate, and it has to be a separate crate: the
43//! doctest below CANNOT catch this, because rustdoc compiles doctests with the declaring crate's
44//! cfg flags, so the doctest and the bug agreed with each other for as long as both existed.
45
46// THE THREE GATES, resolved HERE and carried into the host as macro paths. See the module docs for
47// why this shape rather than an emitted `#[cfg]`. Each pair is exhaustive and mutually exclusive, so
48// exactly one definition of each name exists in any build, and `#[macro_export]` puts it at this
49// crate's root where `$crate::` can reach it.
50//
51// `#[doc(hidden)]`: these are an implementation detail of `delegate_storage!`, not API. They are
52// nonetheless PUBLIC macros with a stable-looking name, which is unavoidable (`macro_rules!` has no
53// `pub(crate)` for exported macros), hence the `__oauth_as_` prefix. Do not call them.
54
55/// Emits its input when `oauth-as` was built with `par`, and nothing otherwise.
56#[doc(hidden)]
57#[macro_export]
58#[cfg(feature = "par")]
59macro_rules! __oauth_as_if_par {
60 ($($item:tt)*) => { $($item)* };
61}
62
63/// Emits its input when `oauth-as` was built with `par`, and nothing otherwise.
64#[doc(hidden)]
65#[macro_export]
66#[cfg(not(feature = "par"))]
67macro_rules! __oauth_as_if_par {
68 ($($item:tt)*) => {};
69}
70
71/// Emits its input when `oauth-as` was built with `consent`, and nothing otherwise.
72#[doc(hidden)]
73#[macro_export]
74#[cfg(feature = "consent")]
75macro_rules! __oauth_as_if_consent {
76 ($($item:tt)*) => { $($item)* };
77}
78
79/// Emits its input when `oauth-as` was built with `consent`, and nothing otherwise.
80#[doc(hidden)]
81#[macro_export]
82#[cfg(not(feature = "consent"))]
83macro_rules! __oauth_as_if_consent {
84 ($($item:tt)*) => {};
85}
86
87/// Emits its input when `oauth-as` was built with `client-assertion` or `dpop`, and nothing
88/// otherwise. The two features share one gate because they share one method: RFC 7523 assertion
89/// replay and RFC 9449 DPoP proof replay are the same claim-if-absent operation.
90#[doc(hidden)]
91#[macro_export]
92#[cfg(any(feature = "client-assertion", feature = "dpop"))]
93macro_rules! __oauth_as_if_replay {
94 ($($item:tt)*) => { $($item)* };
95}
96
97/// Emits its input when `oauth-as` was built with `client-assertion` or `dpop`, and nothing
98/// otherwise.
99#[doc(hidden)]
100#[macro_export]
101#[cfg(not(any(feature = "client-assertion", feature = "dpop")))]
102macro_rules! __oauth_as_if_replay {
103 ($($item:tt)*) => {};
104}
105
106/// Forward the named [`crate::store::Storage`] methods to an inner store.
107///
108/// # Usage
109///
110/// Name the FIELD holding the inner store, then the methods to forward. Anything you do NOT list, you write
111/// yourself in the same `impl` block. Listing is EXPLICIT rather than "everything except", and
112/// that is deliberate: `macro_rules!` cannot subtract a set, and more to the point a host reading
113/// this call should be able to see which methods are their own without cross-referencing the
114/// trait. If you forget one, the compiler names it, which is exactly the signal the trait's
115/// no-defaults rule exists to produce.
116///
117/// ```
118/// use oauth_as::store::{MemoryStorage, Storage, StorageError, WriteOutcome};
119/// use oauth_as::{ClientId, IssuedToken};
120///
121/// /// Counts issuance, and is otherwise an ordinary in-memory store.
122/// struct CountingStore {
123/// inner: MemoryStorage,
124/// issued: std::sync::atomic::AtomicU64,
125/// }
126///
127/// impl Storage for CountingStore {
128/// // The one method this store is actually for.
129/// async fn put_token(&self, token: IssuedToken) -> Result<WriteOutcome, StorageError> {
130/// let outcome = self.inner.put_token(token).await?;
131/// if outcome.is_applied() {
132/// self.issued.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
133/// }
134/// Ok(outcome)
135/// }
136///
137/// // Everything else, including the feature-gated methods: each gated one is generated only
138/// // when `oauth-as` itself has the feature, so naming one you have not enabled produces
139/// // nothing rather than an error. Your OWN crate's features are not consulted and do not
140/// // need to exist.
141/// oauth_as::delegate_storage! {
142/// to inner;
143/// get_client, put_client, compare_and_swap_client, delete_client,
144/// put_device_grant, get_device_grant, find_device_grant_by_user_code,
145/// take_device_grant, compare_and_swap_device_grant,
146/// put_authorization_code, compare_and_swap_authorization_code,
147/// take_authorization_code,
148/// put_pushed_authorization_request, take_pushed_authorization_request,
149/// get_token, delete_token,
150/// put_refresh_token, get_refresh_token, take_refresh_token, revoke_token_family,
151/// put_consent, compare_and_swap_consent, get_consent, find_consent,
152/// consents_for_subject, revoke_consent,
153/// claim_replay_id,
154/// sweep_expired,
155/// }
156/// }
157/// ```
158///
159/// # What it does not do
160///
161/// It forwards. It does not make two stores atomic with respect to each other, and no macro
162/// could: if clients live in Postgres and codes live in memory, then
163/// [`crate::store::Storage::delete_client`]'s cascade spans both, and the trait requires that
164/// cascade to be ONE event. A host splitting a store across backends owns that problem, and
165/// [`crate::storage_conformance`] is how they find out whether they have solved it.
166///
167/// The feature-gated methods are forwarded only when the feature is on IN `oauth-as`, so a name
168/// listed under a feature you have not enabled is simply not generated. Listing one you do not have
169/// is not an error; it produces nothing.
170///
171/// "in `oauth-as`" is the load-bearing part and it has to be said, because the obvious reading is
172/// wrong and was ALSO what the macro did until this was fixed: the gate is NOT your crate's feature
173/// set. Your crate does not need a feature called `par` and almost certainly does not have one.
174/// Note also that this means cargo FEATURE UNIFICATION can turn a forwarder on: an unrelated
175/// dependency enabling `consent` enables it for your build of `oauth-as` too, and the macro will
176/// then generate `put_consent` and the rest. That is the correct outcome, since the trait grew the
177/// methods at the same moment, and it is the reason this macro is worth having.
178///
179/// # Why it takes a FIELD and not an expression
180///
181/// `to inner`, not `to self.inner`, and that is macro hygiene rather than taste. A `self` written
182/// at the call site is a different `self` from the one in the generated method, so an expression
183/// containing it does not resolve and the error (`expected value, found module self`) points at
184/// the macro rather than at anything a host can act on. Taking the field name lets the macro build
185/// `self.$f` itself, out of its own `self`. A tuple field works too: `to 0`.
186#[macro_export]
187macro_rules! delegate_storage {
188 (to $field:tt; $($method:ident),* $(,)?) => {
189 $($crate::delegate_storage!(@one $field, $method);)*
190 };
191
192 // ------------------------------------------------------------------------------- clients
193 (@one $f:tt, get_client) => {
194 async fn get_client(
195 &self,
196 client_id: &$crate::ClientId,
197 ) -> ::core::result::Result<
198 ::core::option::Option<::std::sync::Arc<$crate::Client>>,
199 $crate::store::StorageError,
200 > {
201 $crate::store::Storage::get_client(&self.$f, client_id).await
202 }
203 };
204 (@one $f:tt, put_client) => {
205 async fn put_client(
206 &self,
207 client: $crate::Client,
208 ) -> ::core::result::Result<(), $crate::store::StorageError> {
209 $crate::store::Storage::put_client(&self.$f, client).await
210 }
211 };
212 (@one $f:tt, compare_and_swap_client) => {
213 async fn compare_and_swap_client(
214 &self,
215 expected: &$crate::Client,
216 updated: $crate::Client,
217 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
218 $crate::store::Storage::compare_and_swap_client(&self.$f, expected, updated).await
219 }
220 };
221 (@one $f:tt, delete_client) => {
222 async fn delete_client(
223 &self,
224 client_id: &$crate::ClientId,
225 window: $crate::store::RevocationWindow,
226 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
227 $crate::store::Storage::delete_client(&self.$f, client_id, window).await
228 }
229 };
230
231 // -------------------------------------------------------------------------- device grants
232 (@one $f:tt, put_device_grant) => {
233 async fn put_device_grant(
234 &self,
235 grant: $crate::DeviceGrant,
236 ) -> ::core::result::Result<(), $crate::store::StorageError> {
237 $crate::store::Storage::put_device_grant(&self.$f, grant).await
238 }
239 };
240 (@one $f:tt, get_device_grant) => {
241 async fn get_device_grant(
242 &self,
243 device_code: &str,
244 ) -> ::core::result::Result<
245 ::core::option::Option<$crate::DeviceGrant>,
246 $crate::store::StorageError,
247 > {
248 $crate::store::Storage::get_device_grant(&self.$f, device_code).await
249 }
250 };
251 (@one $f:tt, find_device_grant_by_user_code) => {
252 async fn find_device_grant_by_user_code(
253 &self,
254 normalized_user_code: &str,
255 ) -> ::core::result::Result<
256 ::core::option::Option<$crate::DeviceGrant>,
257 $crate::store::StorageError,
258 > {
259 $crate::store::Storage::find_device_grant_by_user_code(&self.$f, normalized_user_code).await
260 }
261 };
262 (@one $f:tt, take_device_grant) => {
263 async fn take_device_grant(
264 &self,
265 device_code: &str,
266 ) -> ::core::result::Result<
267 ::core::option::Option<$crate::DeviceGrant>,
268 $crate::store::StorageError,
269 > {
270 $crate::store::Storage::take_device_grant(&self.$f, device_code).await
271 }
272 };
273 (@one $f:tt, compare_and_swap_device_grant) => {
274 async fn compare_and_swap_device_grant(
275 &self,
276 expected: &$crate::DeviceGrantState,
277 updated: $crate::DeviceGrant,
278 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
279 $crate::store::Storage::compare_and_swap_device_grant(&self.$f, expected, updated).await
280 }
281 };
282
283 // --------------------------------------------------------------------- authorization codes
284 (@one $f:tt, put_authorization_code) => {
285 async fn put_authorization_code(
286 &self,
287 record: $crate::AuthorizationCodeRecord,
288 ) -> ::core::result::Result<(), $crate::store::StorageError> {
289 $crate::store::Storage::put_authorization_code(&self.$f, record).await
290 }
291 };
292 (@one $f:tt, compare_and_swap_authorization_code) => {
293 async fn compare_and_swap_authorization_code(
294 &self,
295 expected: &$crate::AuthorizationCodeState,
296 updated: $crate::AuthorizationCodeRecord,
297 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
298 $crate::store::Storage::compare_and_swap_authorization_code(&self.$f, expected, updated)
299 .await
300 }
301 };
302 (@one $f:tt, take_authorization_code) => {
303 async fn take_authorization_code(
304 &self,
305 code: &str,
306 ) -> ::core::result::Result<
307 ::core::option::Option<$crate::AuthorizationCodeRecord>,
308 $crate::store::StorageError,
309 > {
310 $crate::store::Storage::take_authorization_code(&self.$f, code).await
311 }
312 };
313
314 // ----------------------------------------------------------------------------------- PAR
315 (@one $f:tt, put_pushed_authorization_request) => {
316 $crate::__oauth_as_if_par! {
317 async fn put_pushed_authorization_request(
318 &self,
319 record: $crate::par::PushedAuthorizationRequest,
320 ) -> ::core::result::Result<$crate::store::WriteOutcome, $crate::store::StorageError> {
321 $crate::store::Storage::put_pushed_authorization_request(&self.$f, record).await
322 }
323 }
324 };
325 (@one $f:tt, take_pushed_authorization_request) => {
326 $crate::__oauth_as_if_par! {
327 async fn take_pushed_authorization_request(
328 &self,
329 request_uri: &str,
330 ) -> ::core::result::Result<
331 ::core::option::Option<$crate::par::PushedAuthorizationRequest>,
332 $crate::store::StorageError,
333 > {
334 $crate::store::Storage::take_pushed_authorization_request(&self.$f, request_uri)
335 .await
336 }
337 }
338 };
339
340 // -------------------------------------------------------------------------------- tokens
341 (@one $f:tt, put_token) => {
342 async fn put_token(
343 &self,
344 token: $crate::IssuedToken,
345 ) -> ::core::result::Result<$crate::store::WriteOutcome, $crate::store::StorageError> {
346 $crate::store::Storage::put_token(&self.$f, token).await
347 }
348 };
349 (@one $f:tt, get_token) => {
350 async fn get_token(
351 &self,
352 access_token: &str,
353 ) -> ::core::result::Result<
354 ::core::option::Option<::std::sync::Arc<$crate::IssuedToken>>,
355 $crate::store::StorageError,
356 > {
357 $crate::store::Storage::get_token(&self.$f, access_token).await
358 }
359 };
360 (@one $f:tt, delete_token) => {
361 async fn delete_token(
362 &self,
363 access_token: &str,
364 ) -> ::core::result::Result<(), $crate::store::StorageError> {
365 $crate::store::Storage::delete_token(&self.$f, access_token).await
366 }
367 };
368 (@one $f:tt, put_refresh_token) => {
369 async fn put_refresh_token(
370 &self,
371 record: $crate::RefreshTokenRecord,
372 ) -> ::core::result::Result<$crate::store::WriteOutcome, $crate::store::StorageError> {
373 $crate::store::Storage::put_refresh_token(&self.$f, record).await
374 }
375 };
376 (@one $f:tt, get_refresh_token) => {
377 async fn get_refresh_token(
378 &self,
379 refresh_token: &str,
380 ) -> ::core::result::Result<
381 ::core::option::Option<::std::sync::Arc<$crate::RefreshTokenRecord>>,
382 $crate::store::StorageError,
383 > {
384 $crate::store::Storage::get_refresh_token(&self.$f, refresh_token).await
385 }
386 };
387 (@one $f:tt, take_refresh_token) => {
388 async fn take_refresh_token(
389 &self,
390 refresh_token: &str,
391 ) -> ::core::result::Result<
392 ::core::option::Option<$crate::RefreshTokenRecord>,
393 $crate::store::StorageError,
394 > {
395 $crate::store::Storage::take_refresh_token(&self.$f, refresh_token).await
396 }
397 };
398 (@one $f:tt, revoke_token_family) => {
399 async fn revoke_token_family(
400 &self,
401 family_id: &str,
402 window: $crate::store::RevocationWindow,
403 ) -> ::core::result::Result<u64, $crate::store::StorageError> {
404 $crate::store::Storage::revoke_token_family(&self.$f, family_id, window).await
405 }
406 };
407
408 // ------------------------------------------------------------------------------- consent
409 (@one $f:tt, put_consent) => {
410 $crate::__oauth_as_if_consent! {
411 async fn put_consent(
412 &self,
413 record: $crate::ConsentRecord,
414 ) -> ::core::result::Result<(), $crate::store::StorageError> {
415 $crate::store::Storage::put_consent(&self.$f, record).await
416 }
417 }
418 };
419 (@one $f:tt, compare_and_swap_consent) => {
420 $crate::__oauth_as_if_consent! {
421 async fn compare_and_swap_consent(
422 &self,
423 expected: ::core::option::Option<&$crate::ConsentRecord>,
424 updated: $crate::ConsentRecord,
425 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
426 $crate::store::Storage::compare_and_swap_consent(&self.$f, expected, updated).await
427 }
428 }
429 };
430 (@one $f:tt, get_consent) => {
431 $crate::__oauth_as_if_consent! {
432 async fn get_consent(
433 &self,
434 consent_id: &str,
435 ) -> ::core::result::Result<
436 ::core::option::Option<::std::sync::Arc<$crate::ConsentRecord>>,
437 $crate::store::StorageError,
438 > {
439 $crate::store::Storage::get_consent(&self.$f, consent_id).await
440 }
441 }
442 };
443 (@one $f:tt, find_consent) => {
444 $crate::__oauth_as_if_consent! {
445 async fn find_consent(
446 &self,
447 client_id: &$crate::ClientId,
448 subject: &str,
449 ) -> ::core::result::Result<
450 ::core::option::Option<::std::sync::Arc<$crate::ConsentRecord>>,
451 $crate::store::StorageError,
452 > {
453 $crate::store::Storage::find_consent(&self.$f, client_id, subject).await
454 }
455 }
456 };
457 (@one $f:tt, consents_for_subject) => {
458 $crate::__oauth_as_if_consent! {
459 async fn consents_for_subject(
460 &self,
461 subject: &str,
462 ) -> ::core::result::Result<
463 ::std::vec::Vec<::std::sync::Arc<$crate::ConsentRecord>>,
464 $crate::store::StorageError,
465 > {
466 $crate::store::Storage::consents_for_subject(&self.$f, subject).await
467 }
468 }
469 };
470 (@one $f:tt, revoke_consent) => {
471 $crate::__oauth_as_if_consent! {
472 async fn revoke_consent(
473 &self,
474 consent_id: &str,
475 window: $crate::store::RevocationWindow,
476 ) -> ::core::result::Result<u64, $crate::store::StorageError> {
477 $crate::store::Storage::revoke_consent(&self.$f, consent_id, window).await
478 }
479 }
480 };
481
482 // --------------------------------------------------------------------------------- replay
483 (@one $f:tt, claim_replay_id) => {
484 $crate::__oauth_as_if_replay! {
485 async fn claim_replay_id(
486 &self,
487 id: &str,
488 expires_at: ::std::time::SystemTime,
489 ) -> ::core::result::Result<bool, $crate::store::StorageError> {
490 $crate::store::Storage::claim_replay_id(&self.$f, id, expires_at).await
491 }
492 }
493 };
494
495 // ---------------------------------------------------------------------------------- sweep
496 (@one $f:tt, sweep_expired) => {
497 async fn sweep_expired(
498 &self,
499 now: ::std::time::SystemTime,
500 ) -> ::core::result::Result<u64, $crate::store::StorageError> {
501 $crate::store::Storage::sweep_expired(&self.$f, now).await
502 }
503 };
504}