webgates 1.0.0

Application-facing composition crate for webgates authentication and authorization.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
//! OAuth2 gate configuration and runtime.
//!
//! This module implements the OAuth2 Authorization Code + PKCE flow builder
//! and the runtime that performs login preparation and callback evaluation.
//! It is intentionally framework-agnostic: adapters or integration crates map
//! the runtime outcomes into transport-specific responses (HTTP redirects,
//! cookies to set/clear, JSON bodies, etc.).
//!
//! # Example — in-crate OAuth2 wiring and adapter sketch
//!
//! The core crate exposes `OAuth2Gate` (builder) and `OAuth2Runtime` (evaluator).
//! Integration code can prepare login redirects and evaluate callbacks using
//! these types without coupling the core to any HTTP framework. The example
//! below remains inside the crate boundaries and demonstrates:
//! - building a gate and converting it into a runtime, and
//! - a minimal `TokenExchanger` implementation sketch that could be used by a
//!   runtime during callback evaluation.
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use std::future::Future;
//! use std::pin::Pin;
//! use webgates::accounts::Account;
//! use webgates::groups::Group;
//! use webgates::roles::Role;
//! use webgates_codecs::jwt::{JsonWebToken, JwtClaims};
//! use webgates::gate::oauth2::{OAuth2Gate, OAuth2Runtime, TokenRequest, TokenExchanger, CallbackInput};
//! use webgates::cookie_template::CookieTemplate;
//!
//! // A trivial TokenExchanger that would call the provider's token endpoint.
//! // Integration code should implement this abstraction with an HTTP client.
//! struct DummyExchanger;
//! impl TokenExchanger for DummyExchanger {
//!     fn exchange_code(
//!         &self,
//!         _request: TokenRequest,
//!     ) -> Pin<Box<dyn Future<Output = Result<oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>, webgates::gate::oauth2::errors::OAuth2Error>> + Send>> {
//!         // Placeholder: real implementation would perform an HTTP POST to the
//!         // provider's token endpoint and return the parsed token response.
//!         Box::pin(async move {
//!             Err(webgates::gate::oauth2::errors::OAuth2Error::provider_error(
//!                 "dummy exchanger".to_string(),
//!                 None,
//!             ))
//!         })
//!     }
//! }
//!
//! async fn example() {
//!     // Configure cookie templates used for state/pkce/auth cookies.
//!     let state_tpl = CookieTemplate::recommended();
//!     let pkce_tpl = CookieTemplate::recommended();
//!     let auth_tpl = CookieTemplate::recommended();
//!
//!     // Build a minimal gate; real usage must set auth/token URLs and client creds.
//!     let gate = OAuth2Gate::<Role, Group>::default()
//!         .with_cookie_template(auth_tpl)
//!         .with_pkce_cookie_template(pkce_tpl)
//!         .with_state_cookie_template(state_tpl)
//!         .with_post_login_redirect("/".to_string());
//!
//!     // Convert builder into a runtime (validates configured templates and required fields).
//!     let runtime = match gate.build() {
//!         Ok(rt) => rt,
//!         Err(e) => {
//!             // handle misconfiguration
//!             // Real middleware: log with tracing::error!(error = %e, "oauth2 gate misconfigured")
//!             let _ = e;
//!             return;
//!         }
//!     };
//!
//!     // Prepare a login: get redirect URL and cookies to set (state/pkce)
//!     let login = match runtime.prepare_login() {
//!         Ok(p) => p,
//!         Err(e) => {
//!             // Real middleware: log with tracing::error!(error = %e, "prepare_login failed")
//!             let _ = e;
//!             return;
//!         }
//!     };
//!
//!     // In a real adapter: set `login.state_cookie` and `login.pkce_cookie` and
//!     // redirect the user to `login.redirect_url`.
//!
//!     // Later, in the callback handler the adapter should construct a
//!     // `CallbackInput` (including cookies received) and call `evaluate_callback`
//!     // with an implementation of `TokenExchanger` to perform the provider token
//!     // exchange. The returned `CallbackOutcome` is then mapped into framework
//!     // responses (redirect + set/clear cookies, error pages, etc.).
//! }
//! ```
//!
//! ## JWT validation
//!
//! `OAuth2Runtime::evaluate_callback` performs the provider token
//! exchange and constructs a first-party JWT using the configured encoder.
//! The jwt encoder uses the configured codec in this crate (typically backed
//! by the `jsonwebtoken` crate). Adapters should not re-validate signatures or
//! re-implement cryptographic checks; they should rely on the runtime's
//! outcome and map results to transport-specific responses.

use std::fmt::Display;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::sync::Arc;

use super::GateExt;
use crate::accounts::Account;
use crate::authz::access_hierarchy::AccessHierarchy;
use crate::codecs::Codec;
use crate::codecs::jwt::{JwtClaims, RegisteredClaims};
use crate::cookie_template::CookieTemplate;
use chrono::Utc;
use cookie::Cookie;
use oauth2::{
    AuthUrl, ClientId, ClientSecret, EmptyExtraTokenFields, RedirectUrl, Scope,
    StandardTokenResponse, TokenUrl, basic::BasicTokenType,
};
use serde::{Deserialize, Serialize};
use webgates_repositories::account_repository::AccountRepository;

pub mod errors;
use errors::{OAuth2CookieKind, OAuth2Error, Result as OAuth2Result};

/// Adapter trait so integration crates can adapt a core `OAuth2Gate` into a
/// framework-specific wrapper or router. Follows the pattern used by cookie & bearer.
pub trait OAuth2GateAdapter<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    /// Framework-specific output type produced by the adapter (e.g., a wrapper that can produce routes).
    type Output;

    /// Convert a core `OAuth2Gate<R, G>` into a framework-specific artifact.
    fn adapt(&self, gate: OAuth2Gate<R, G>) -> Self::Output;
}

// Bridge to the generic GateAdapter so callers can call `adapt_with`.
impl<R, G, A> crate::gate::adapter::GateAdapter<OAuth2Gate<R, G>> for A
where
    A: OAuth2GateAdapter<R, G>,
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    type Output = A::Output;

    fn adapt(&self, gate: OAuth2Gate<R, G>) -> Self::Output {
        A::adapt(self, gate)
    }
}

/// Alias for the resulting token exchange future.
type OAuth2TokenExchangeFuture = Pin<
    Box<
        dyn Future<
                Output = OAuth2Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>>,
            > + Send,
    >,
>;

/// Type alias for an async account mapper function.
type AccountMapperFn<R, G> = Arc<
    dyn for<'a> Fn(
            &'a StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>,
        )
            -> Pin<Box<dyn Future<Output = OAuth2Result<Account<R, G>>> + Send + 'a>>
        + Send
        + Sync,
>;

/// Type alias for an async account persistence function invoked before JWT issuance.
///
/// This closure should persist or load the account (idempotently), and return the account
/// that should be encoded into the first‑party JWT (typically with a stable `account_id`).
type AccountPersistFn<R, G> = Arc<
    dyn Fn(Account<R, G>) -> Pin<Box<dyn Future<Output = OAuth2Result<Account<R, G>>> + Send>>
        + Send
        + Sync,
>;

/// Type alias for an account encoding function.
type AccountEncoderFn<R, G> = Arc<dyn Fn(Account<R, G>) -> OAuth2Result<String> + Send + Sync>;

/// Minimal token request data passed to the exchanger.
#[derive(Clone, Debug)]
pub struct TokenRequest {
    /// Authorization code returned by the provider.
    pub code: String,
    /// PKCE verifier bound to the original authorization request.
    pub pkce_verifier: String,
    /// OAuth2 token endpoint.
    pub token_url: String,
    /// OAuth2 client id.
    pub client_id: String,
    /// Optional OAuth2 client secret.
    pub client_secret: Option<String>,
    /// Redirect URL used in the original authorization request.
    pub redirect_url: String,
}

/// Trait abstracting token exchange to keep the core free of HTTP clients.
pub trait TokenExchanger: Send + Sync {
    /// Exchange an authorization code for a token response.
    fn exchange_code(&self, request: TokenRequest) -> OAuth2TokenExchangeFuture;
}

/// Prepared login data: redirect target and cookies to set.
#[derive(Debug, Clone)]
pub struct LoginPreparation {
    /// Provider authorization URL to redirect the user to.
    pub redirect_url: String,
    /// CSRF state cookie to set before redirect.
    pub state_cookie: Cookie<'static>,
    /// PKCE verifier cookie to set before redirect.
    pub pkce_cookie: Cookie<'static>,
}

/// Callback input received from the transport adapter.
#[derive(Debug, Clone)]
pub struct CallbackInput {
    /// Authorization code from provider (if any).
    pub code: Option<String>,
    /// State parameter from provider (if any).
    pub state: Option<String>,
    /// Error parameter from provider (if any).
    pub error: Option<String>,
    /// Error description from provider (if any).
    pub error_description: Option<String>,
    /// CSRF state cookie value from the original login request.
    pub state_cookie: Option<Cookie<'static>>,
    /// PKCE verifier cookie value from the original login request.
    pub pkce_cookie: Option<Cookie<'static>>,
}

/// Callback evaluation outcome for adapters to map into HTTP responses.
#[derive(Debug, Clone)]
pub enum CallbackOutcome {
    /// Success with optional JWT cookie and optional redirect.
    Success {
        /// Cookies to set (state/pkce removals plus optional auth cookie).
        cookies: Vec<Cookie<'static>>,
        /// Optional redirect target (e.g., post-login URL).
        redirect_to: Option<String>,
        /// Optional message body for non-redirect responses.
        message: Option<String>,
    },
    /// Failure with cookies to clear and an error for logging/telemetry.
    Failure {
        /// Cookies to set (typically removals).
        cookies: Vec<Cookie<'static>>,
        /// Error describing the failure.
        error: OAuth2Error,
    },
}

/// Public builder for configuring OAuth2 routes and session issuance (framework-agnostic).
#[derive(Clone)]
#[must_use]
pub struct OAuth2Gate<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    auth_url: Option<String>,
    token_url: Option<String>,
    client_id: Option<String>,
    client_secret: Option<String>,
    redirect_url: Option<String>,
    scopes: Vec<String>,

    state_cookie_template: CookieTemplate,
    pkce_cookie_template: CookieTemplate,
    auth_cookie_template: CookieTemplate,
    post_login_redirect: Option<String>,

    mapper: Option<AccountMapperFn<R, G>>,
    account_inserter: Option<AccountPersistFn<R, G>>,
    jwt_encoder: Option<AccountEncoderFn<R, G>>,

    _phantom: PhantomData<(R, G)>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;

    use crate::codecs::jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER as JWT_CRYPTO_PROVIDER;
    use webgates_codecs::jwt::{JsonWebToken, JwtClaims};
    use webgates_core::groups::Group;
    use webgates_core::roles::Role;

    fn install_jwt_crypto_provider() {
        let _ = JWT_CRYPTO_PROVIDER.install_default();
    }

    struct StubTokenExchanger;

    impl TokenExchanger for StubTokenExchanger {
        fn exchange_code(&self, _request: TokenRequest) -> OAuth2TokenExchangeFuture {
            Box::pin(async move {
                Err(OAuth2Error::token_exchange(
                    "stub exchanger should not be reached in this test",
                ))
            })
        }
    }

    #[tokio::test]
    async fn oauth2_with_jwt_codec_mints_jti_and_leaves_sid_absent() {
        install_jwt_crypto_provider();
        let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
        let runtime = OAuth2Gate::<Role, Group>::new()
            .auth_url("https://provider.example/authorize")
            .token_url("https://provider.example/token")
            .client_id("client-id")
            .redirect_url("https://app.example/callback")
            .with_account_mapper(|_token| Box::pin(async { Ok(Account::<Role, Group>::new("u")) }))
            .with_jwt_codec("issuer", Arc::clone(&codec), 900)
            .build();
        let runtime = match runtime {
            Ok(runtime) => runtime,
            Err(error) => panic!("oauth2 runtime should build: {}", error),
        };

        let input = CallbackInput {
            code: Some("auth-code".to_string()),
            state: Some("state-1".to_string()),
            error: None,
            error_description: None,
            state_cookie: Some(Cookie::new("oauth2-state", "state-1")),
            pkce_cookie: Some(Cookie::new("oauth2-pkce", "pkce-1")),
        };

        let outcome = runtime.evaluate_callback(input, &StubTokenExchanger).await;
        let _ = outcome;

        // Build a minimal runtime path that actually mints by providing a concrete
        // exchanger result via oauth2 crate type conversions would be excessive here,
        // so verify minting semantics directly through the configured encoder closure.
        let encoder = match runtime.jwt_encoder.as_ref() {
            Some(encoder) => encoder.clone(),
            None => panic!("jwt encoder should be configured"),
        };
        let token = match encoder(Account::<Role, Group>::new("user@example.com")) {
            Ok(token) => token,
            Err(error) => panic!("token should be minted: {}", error),
        };

        let decoded = match codec.decode(token.as_bytes()) {
            Ok(claims) => claims,
            Err(error) => panic!("minted token should decode: {}", error),
        };
        assert!(decoded.registered_claims.jwt_id.is_some());
        assert!(decoded.registered_claims.session_id.is_none());
    }
}

/// Public, cloneable OAuth2 configuration for adapters.
///
/// This type contains the validated fields needed to construct adapter-side
/// OAuth2 integration.
#[derive(Clone)]
pub struct OAuth2Config<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    /// Provider authorization endpoint (the URL used to initiate the auth redirect).
    /// Example: `<https://provider.example.com/oauth2/authorize>`
    pub auth_url: String,

    /// Provider token endpoint used to exchange the authorization code for tokens.
    /// Example: `<https://provider.example.com/oauth2/token>`
    pub token_url: String,

    /// OAuth2 client identifier registered with the provider.
    pub client_id: String,

    /// Optional OAuth2 client secret for confidential clients. Keep None for public
    /// (browser) clients or when client authentication is not required.
    pub client_secret: Option<String>,

    /// Redirect URL that the provider will call after user authorization.
    /// Must match the redirect URL registered with the provider.
    pub redirect_url: String,

    /// Scopes requested from the provider (e.g., ["openid", "profile", "email"]).
    /// The wrapper/adapters use these to build the authorization request.
    pub scopes: Vec<String>,

    /// Cookie template used for the CSRF state cookie created before redirect.
    /// Adapters should use this template to set and validate the `state` cookie.
    pub state_cookie_template: CookieTemplate,

    /// Cookie template used for the PKCE verifier cookie created before redirect.
    /// Adapters should use this template to set and validate the PKCE verifier.
    pub pkce_cookie_template: CookieTemplate,

    /// Cookie template used to create the first-party auth cookie (e.g., JWT)
    /// after successful callback and session issuance.
    pub auth_cookie_template: CookieTemplate,

    /// Optional post-login redirect (application URL to send users to after sign-in).
    /// If present, adapter wrappers may redirect users to this path after successful login.
    pub post_login_redirect: Option<String>,

    /// Optional async mapper that converts a provider `StandardTokenResponse` into
    /// a domain `Account<R, G>`. This runs before persistence/issuance and may
    /// perform userinfo requests or other enrichment.
    pub mapper: Option<AccountMapperFn<R, G>>,

    /// Optional persistence hook invoked before issuing a first-party JWT.
    /// The inserter should persist or load the account idempotently and return
    /// the `Account` to be encoded into the session token.
    pub account_inserter: Option<AccountPersistFn<R, G>>,

    /// Optional encoder closure used to produce a first-party JWT string from an `Account`.
    /// Adapters that prefer codec-based encoding can call the wrapper's
    /// `with_jwt_codec(issuer, codec, ttl)` convenience instead.
    pub jwt_encoder: Option<AccountEncoderFn<R, G>>,
}

/// Consume the builder, validate it, and produce an `OAuth2Config` that is safe
/// for adapters to consume. This mirrors the validation performed by `build`
/// but returns the full, cloneable config instead of a runtime.
impl<R, G> OAuth2Gate<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    /// Validate and convert this builder into a public `OAuth2Config`.
    pub fn into_config(self) -> Result<OAuth2Config<R, G>, OAuth2Error> {
        let auth_url = self
            .auth_url
            .ok_or_else(|| OAuth2Error::missing("auth_url"))?;
        let token_url = self
            .token_url
            .ok_or_else(|| OAuth2Error::missing("token_url"))?;
        let client_id = self
            .client_id
            .ok_or_else(|| OAuth2Error::missing("client_id"))?;
        let redirect_url = self
            .redirect_url
            .ok_or_else(|| OAuth2Error::missing("redirect_url"))?;

        // Validate cookie templates the same way `build` does.
        self.state_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::State, e.to_string()))?;
        self.pkce_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Pkce, e.to_string()))?;
        self.auth_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Auth, e.to_string()))?;

        Ok(OAuth2Config {
            auth_url,
            token_url,
            client_id,
            client_secret: self.client_secret,
            redirect_url,
            scopes: self.scopes,
            state_cookie_template: self.state_cookie_template,
            pkce_cookie_template: self.pkce_cookie_template,
            auth_cookie_template: self.auth_cookie_template,
            post_login_redirect: self.post_login_redirect,
            mapper: self.mapper,
            account_inserter: self.account_inserter,
            jwt_encoder: self.jwt_encoder,
        })
    }
}

impl<R, G> Default for OAuth2Gate<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    fn default() -> Self {
        Self {
            auth_url: None,
            token_url: None,
            client_id: None,
            client_secret: None,
            redirect_url: None,
            scopes: Vec::new(),
            state_cookie_template: CookieTemplate::recommended(),
            pkce_cookie_template: CookieTemplate::recommended(),
            auth_cookie_template: CookieTemplate::recommended(),
            post_login_redirect: None,
            mapper: None,
            account_inserter: None,
            jwt_encoder: None,
            _phantom: PhantomData,
        }
    }
}

impl<R, G> OAuth2Gate<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    /// Create a new, empty builder.
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the authorization endpoint URL.
    pub fn auth_url(mut self, url: impl Into<String>) -> Self {
        self.auth_url = Some(url.into());
        self
    }

    /// Set the token endpoint URL.
    pub fn token_url(mut self, url: impl Into<String>) -> Self {
        self.token_url = Some(url.into());
        self
    }

    /// Set the OAuth2 client ID.
    pub fn client_id(mut self, id: impl Into<String>) -> Self {
        self.client_id = Some(id.into());
        self
    }

    /// Set the OAuth2 client secret (optional for public clients).
    pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
        self.client_secret = Some(secret.into());
        self
    }

    /// Set the redirect URL that your provider will call after user authorization.
    pub fn redirect_url(mut self, url: impl Into<String>) -> Self {
        self.redirect_url = Some(url.into());
        self
    }

    /// Add a scope to request from the provider.
    pub fn add_scope(mut self, scope: impl Into<String>) -> Self {
        self.scopes.push(scope.into());
        self
    }

    /// Set custom cookie names for state/PKCE (primarily for multi-provider setups).
    ///
    /// This also updates the underlying cookie templates to use the provided names.
    pub fn with_cookie_names(
        mut self,
        state_cookie: impl Into<String>,
        pkce_cookie: impl Into<String>,
    ) -> Self {
        let state_name: String = state_cookie.into();
        let pkce_name: String = pkce_cookie.into();

        self.state_cookie_template = self.state_cookie_template.name(state_name);
        self.pkce_cookie_template = self.pkce_cookie_template.name(pkce_name);
        self
    }

    /// Configure the state cookie template directly.
    pub fn with_state_cookie_template(mut self, template: CookieTemplate) -> Self {
        self.state_cookie_template = template;
        self
    }

    /// Convenience to configure the state cookie template via the high-level builder.
    pub fn configure_state_cookie_template<F>(mut self, f: F) -> OAuth2Result<Self>
    where
        F: FnOnce(CookieTemplate) -> CookieTemplate,
    {
        let template = f(CookieTemplate::recommended());
        template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::State, e.to_string()))?;

        self.state_cookie_template = template;
        Ok(self)
    }

    /// Configure the PKCE cookie template directly.
    pub fn with_pkce_cookie_template(mut self, template: CookieTemplate) -> Self {
        self.pkce_cookie_template = template;
        self
    }

    /// Convenience to configure the PKCE cookie template via the high-level builder.
    pub fn configure_pkce_cookie_template<F>(mut self, f: F) -> OAuth2Result<Self>
    where
        F: FnOnce(CookieTemplate) -> CookieTemplate,
    {
        let template = f(CookieTemplate::recommended());
        template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Pkce, e.to_string()))?;

        self.pkce_cookie_template = template;
        Ok(self)
    }

    /// Configure the auth cookie template used to store the first-party JWT.
    pub fn with_cookie_template(mut self, template: CookieTemplate) -> Self {
        self.auth_cookie_template = template;
        self
    }

    /// Convenience to configure the auth cookie template via the high-level builder.
    pub fn configure_cookie_template<F>(mut self, f: F) -> OAuth2Result<Self>
    where
        F: FnOnce(CookieTemplate) -> CookieTemplate,
    {
        let template = f(CookieTemplate::recommended());
        template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Auth, e.to_string()))?;

        self.auth_cookie_template = template;
        Ok(self)
    }

    /// Configure a post-login redirect URL (e.g., "/").
    pub fn with_post_login_redirect(mut self, url: impl Into<String>) -> Self {
        self.post_login_redirect = Some(url.into());
        self
    }

    /// Provide an async account mapper that converts the token response to an Account<R, G>.
    pub fn with_account_mapper<F>(mut self, f: F) -> Self
    where
        F: Send + Sync + 'static,
        for<'a> F: Fn(
            &'a StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>,
        )
            -> Pin<Box<dyn Future<Output = OAuth2Result<Account<R, G>>> + Send + 'a>>,
    {
        let f = Arc::new(f);
        self.mapper = Some(Arc::new(move |token_resp| (f)(token_resp)));
        self
    }

    /// Provide an async account inserter that persists or loads an account before JWT issuance.
    pub fn with_account_inserter<F, Fut>(mut self, f: F) -> Self
    where
        F: Fn(Account<R, G>) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = OAuth2Result<Account<R, G>>> + Send + 'static,
    {
        self.account_inserter = Some(Arc::new(move |account: Account<R, G>| Box::pin(f(account))));
        self
    }

    /// Convenience: insert into an AccountRepository on first login (idempotent).
    pub fn with_account_repository<AccRepo>(mut self, account_repository: Arc<AccRepo>) -> Self
    where
        AccRepo: AccountRepository<R, G> + Send + Sync + 'static,
    {
        self.account_inserter = Some(Arc::new(move |account: Account<R, G>| {
            let repo = Arc::clone(&account_repository);
            Box::pin(async move {
                match repo.query_account_by_user_id(&account.user_id).await {
                    Ok(Some(existing)) => Ok(existing),
                    Ok(None) => match repo.store_account(account).await {
                        Ok(Some(stored)) => Ok(stored),
                        Ok(None) => Err(OAuth2Error::account_persistence(
                            "account repo returned None on store",
                        )),
                        Err(e) => Err(OAuth2Error::account_persistence(e.to_string())),
                    },
                    Err(e) => Err(OAuth2Error::account_persistence(e.to_string())),
                }
            })
        }));
        self
    }

    /// Provide a JWT codec and issuer; sets up a type-erased encoder closure.
    pub fn with_jwt_codec<C>(mut self, issuer: &str, codec: Arc<C>, ttl_secs: u64) -> Self
    where
        C: Codec<Payload = JwtClaims<Account<R, G>>> + Send + Sync + 'static,
    {
        let issuer = issuer.to_string();
        self.jwt_encoder = Some(Arc::new(move |account: Account<R, G>| {
            let exp = Utc::now().timestamp() as u64 + ttl_secs;
            let mut registered = RegisteredClaims::new(&issuer, exp);
            registered.session_id = None;
            let claims = JwtClaims::new(account, registered);
            let bytes = codec
                .encode(&claims)
                .map_err(|e| OAuth2Error::jwt_encoding(e.to_string()))?;
            let token = String::from_utf8(bytes).map_err(|_| OAuth2Error::JwtNotUtf8)?;
            Ok(token)
        }));
        self
    }

    /// Build a runtime evaluator from the configured builder.
    pub fn build(self) -> OAuth2Result<OAuth2Runtime<R, G>> {
        let auth_url = self
            .auth_url
            .ok_or_else(|| OAuth2Error::missing("auth_url"))?;
        let token_url = self
            .token_url
            .ok_or_else(|| OAuth2Error::missing("token_url"))?;
        let client_id = self
            .client_id
            .ok_or_else(|| OAuth2Error::missing("client_id"))?;
        let redirect_url = self
            .redirect_url
            .ok_or_else(|| OAuth2Error::missing("redirect_url"))?;

        self.state_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::State, e.to_string()))?;
        self.pkce_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Pkce, e.to_string()))?;
        self.auth_cookie_template
            .validate()
            .map_err(|e| OAuth2Error::cookie_invalid(OAuth2CookieKind::Auth, e.to_string()))?;

        Ok(OAuth2Runtime {
            auth_url,
            token_url,
            client_id,
            client_secret: self.client_secret,
            redirect_url,
            scopes: self.scopes,
            state_cookie_template: self.state_cookie_template,
            pkce_cookie_template: self.pkce_cookie_template,
            auth_cookie_template: self.auth_cookie_template,
            post_login_redirect: self.post_login_redirect,
            mapper: self.mapper,
            account_inserter: self.account_inserter,
            jwt_encoder: self.jwt_encoder,
            _phantom: PhantomData,
        })
    }
}

/// Runtime evaluator for OAuth2 login + callback (framework-agnostic).
#[derive(Clone)]
pub struct OAuth2Runtime<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    auth_url: String,
    token_url: String,
    client_id: String,
    client_secret: Option<String>,
    redirect_url: String,
    scopes: Vec<String>,

    state_cookie_template: CookieTemplate,
    pkce_cookie_template: CookieTemplate,
    auth_cookie_template: CookieTemplate,
    post_login_redirect: Option<String>,

    mapper: Option<AccountMapperFn<R, G>>,
    account_inserter: Option<AccountPersistFn<R, G>>,
    jwt_encoder: Option<AccountEncoderFn<R, G>>,

    _phantom: PhantomData<(R, G)>,
}

impl<R, G> OAuth2Runtime<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
    /// Prepare login: build state/pkce cookies and provider authorization URL.
    pub fn prepare_login(&self) -> OAuth2Result<LoginPreparation> {
        let auth_url = AuthUrl::new(self.auth_url.clone())
            .map_err(|e| OAuth2Error::invalid_url("auth_url", e.to_string()))?;
        let token_url = TokenUrl::new(self.token_url.clone())
            .map_err(|e| OAuth2Error::invalid_url("token_url", e.to_string()))?;
        let redirect_url = RedirectUrl::new(self.redirect_url.clone())
            .map_err(|e| OAuth2Error::invalid_url("redirect_url", e.to_string()))?;

        let mut client = oauth2::basic::BasicClient::new(ClientId::new(self.client_id.clone()))
            .set_auth_uri(auth_url)
            .set_token_uri(token_url)
            .set_redirect_uri(redirect_url);

        if let Some(secret) = &self.client_secret {
            client = client.set_client_secret(ClientSecret::new(secret.clone()));
        }

        // CSRF state + PKCE
        let csrf = oauth2::CsrfToken::new_random();
        let (pkce_challenge, pkce_verifier) = oauth2::PkceCodeChallenge::new_random_sha256();

        let mut req = client
            .authorize_url(|| csrf.clone())
            .set_pkce_challenge(pkce_challenge);
        for s in &self.scopes {
            req = req.add_scope(Scope::new(s.clone()));
        }
        let (auth_url, csrf_token) = req.url();

        let state_cookie = self
            .state_cookie_template
            .build_with_value(csrf_token.secret());
        let pkce_cookie = self
            .pkce_cookie_template
            .build_with_value(pkce_verifier.secret());

        let prep = LoginPreparation {
            redirect_url: auth_url.to_string(),
            state_cookie,
            pkce_cookie,
        };

        Ok(prep)
    }

    /// Evaluate callback with provided input and token exchanger.
    pub async fn evaluate_callback(
        &self,
        input: CallbackInput,
        exchanger: &dyn TokenExchanger,
    ) -> CallbackOutcome {
        // prepare removal cookies
        let mut cookies: Vec<Cookie<'static>> = Vec::new();
        cookies.push(self.state_cookie_template.build_removal());
        cookies.push(self.pkce_cookie_template.build_removal());

        // Provider returned error?
        if let Some(err) = input.error.as_ref() {
            let oe = OAuth2Error::provider_error(err.clone(), input.error_description.clone());
            return CallbackOutcome::Failure { cookies, error: oe };
        }

        // Validate state cookie presence
        let state_cookie = match input.state_cookie {
            Some(c) => c,
            None => {
                return CallbackOutcome::Failure {
                    cookies,
                    error: OAuth2Error::MissingStateCookie,
                };
            }
        };
        let pkce_cookie = match input.pkce_cookie {
            Some(c) => c,
            None => {
                return CallbackOutcome::Failure {
                    cookies,
                    error: OAuth2Error::MissingPkceCookie,
                };
            }
        };

        // State must match and be present.
        // Use constant-time comparison to prevent timing side-channels on CSRF
        // state values (subtle::ConstantTimeEq avoids early-exit on mismatch).
        let state_valid = input.state.as_deref().is_some_and(|state| {
            use subtle::ConstantTimeEq;
            bool::from(state_cookie.value().as_bytes().ct_eq(state.as_bytes()))
        });
        if !state_valid {
            return CallbackOutcome::Failure {
                cookies,
                error: OAuth2Error::StateMismatch,
            };
        }

        // Authorization code must be present
        let code_str = match input.code {
            Some(c) => c,
            None => {
                return CallbackOutcome::Failure {
                    cookies,
                    error: OAuth2Error::MissingAuthorizationCode,
                };
            }
        };

        // Build token request
        let token_req = TokenRequest {
            code: code_str,
            pkce_verifier: pkce_cookie.value().to_string(),
            token_url: self.token_url.clone(),
            client_id: self.client_id.clone(),
            client_secret: self.client_secret.clone(),
            redirect_url: self.redirect_url.clone(),
        };

        // Exchange code
        let token_resp = match exchanger.exchange_code(token_req).await {
            Ok(resp) => resp,
            Err(e) => {
                return CallbackOutcome::Failure {
                    cookies,
                    error: OAuth2Error::token_exchange(e.to_string()),
                };
            }
        };

        // Map account if configured
        let mapped_account = if let Some(mapper) = &self.mapper {
            match (mapper)(&token_resp).await {
                Ok(acc) => Some(acc),
                Err(e) => {
                    return CallbackOutcome::Failure { cookies, error: e };
                }
            }
        } else {
            None
        };

        // Persist if configured
        let persisted_account = if let Some(account) = mapped_account {
            if let Some(inserter) = &self.account_inserter {
                match (inserter)(account).await {
                    Ok(acc) => Some(acc),
                    Err(e) => {
                        return CallbackOutcome::Failure { cookies, error: e };
                    }
                }
            } else {
                Some(account)
            }
        } else {
            None
        };

        // Encode JWT if configured
        if let (Some(account), Some(encoder)) = (persisted_account, &self.jwt_encoder) {
            match encoder(account) {
                Ok(token) => {
                    let auth_cookie = self.auth_cookie_template.build_with_value(&token);
                    cookies.push(auth_cookie);
                }
                Err(e) => {
                    return CallbackOutcome::Failure { cookies, error: e };
                }
            }
        }

        CallbackOutcome::Success {
            cookies,
            redirect_to: self.post_login_redirect.clone(),
            message: Some("OAuth2 callback OK".into()),
        }
    }
}

/// Internal representation of provider tokens used by some adapters.
/// This mirrors `oauth2` crate types for serialization if needed.
#[derive(Debug, Serialize, Deserialize)]
pub struct ProviderTokenResponse {
    /// Access token string.
    pub access_token: String,
    /// Optional refresh token.
    pub refresh_token: Option<String>,
    /// Optional id token (for OpenID Connect providers).
    pub id_token: Option<String>,
    /// Scopes granted.
    pub scopes: Option<Vec<String>>,
}

impl<R, G> GateExt for OAuth2Gate<R, G>
where
    R: AccessHierarchy + Eq + Display + Send + Sync + 'static,
    G: Eq + Clone + Send + Sync + 'static,
{
}