Skip to main content

arcly_http/auth/
oauth.rs

1//! OAuth 2.0 provider abstraction.
2//!
3//! The framework provides the trait and supporting types. Concrete providers
4//! (Google, GitHub, etc.) are implemented in the application layer and
5//! registered with `OAuth2Service` in the app's `ArclyPlugin::on_init`.
6//!
7//! ## Flow (Authorization Code + PKCE)
8//!
9//! 1. `GET /oauth/{provider}/authorize`
10//!    — controller calls `provider.authorize_url()` which returns
11//!    `(url, pkce_verifier, csrf_state)`.
12//!    — controller stores `oauth_state::{state}` → `{verifier}::{provider}`
13//!    in Redis with a 10-minute TTL.
14//!    — returns `{ "url": "..." }` to the client.
15//!
16//! 2. `GET /oauth/{provider}/callback?code=&state=`
17//!    — controller looks up state in Redis, extracts verifier + provider.
18//!    — calls `provider.exchange_code(code, pkce_verifier)` → access token.
19//!    — calls `provider.fetch_user_info(access_token)` → `OAuth2UserInfo`.
20//!    — upserts local user, mints JWT + session, returns both.
21
22use std::collections::HashMap;
23
24use futures::future::BoxFuture;
25use serde::{Deserialize, Serialize};
26
27// ─── User info ────────────────────────────────────────────────────────────────
28
29/// Normalised user information returned by any OAuth2 provider.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct OAuth2UserInfo {
32    /// Provider name, e.g. `"google"`, `"github"`.
33    pub provider: String,
34    /// The provider's unique identifier for this account.
35    pub provider_id: String,
36    pub email: Option<String>,
37    pub name: Option<String>,
38    pub avatar_url: Option<String>,
39    /// Raw provider response (for debugging / storing extra fields).
40    pub raw: serde_json::Value,
41}
42
43// ─── Provider trait ───────────────────────────────────────────────────────────
44
45/// Implement this to add an OAuth2 provider to the application.
46///
47/// Each implementation is responsible for:
48/// - Generating the authorization URL with a fresh PKCE challenge.
49/// - Exchanging the authorization code for an access token (via HTTP POST).
50/// - Fetching the user's profile from the provider's user-info endpoint.
51pub trait OAuth2Provider: Send + Sync + 'static {
52    /// Short lowercase name identifying this provider, e.g. `"google"`.
53    fn name(&self) -> &'static str;
54
55    /// Generate an authorization URL for the OAuth2 flow.
56    ///
57    /// Returns `(authorize_url, pkce_verifier, csrf_state)`.
58    /// The caller stores `oauth_state::{csrf_state}` → `{pkce_verifier}::{provider}`
59    /// in Redis (or another short-lived store) before redirecting the user.
60    fn authorize_url(&self) -> (String, String, String);
61
62    /// Exchange an authorization code for a provider access token.
63    ///
64    /// `pkce_verifier` is the plain verifier string stored in Redis after
65    /// `authorize_url()` was called.
66    fn exchange_code<'a>(
67        &'a self,
68        code: &'a str,
69        pkce_verifier: &'a str,
70    ) -> BoxFuture<'a, Result<String, String>>;
71
72    /// Fetch the user's profile using the access token from `exchange_code`.
73    fn fetch_user_info<'a>(
74        &'a self,
75        access_token: &'a str,
76    ) -> BoxFuture<'a, Result<OAuth2UserInfo, String>>;
77}
78
79// ─── Service ──────────────────────────────────────────────────────────────────
80
81/// Registry of OAuth2 providers keyed by `provider.name()`.
82///
83/// Not `#[Injectable]` — provide manually via `ctx.provide(OAuth2Service::new())`.
84pub struct OAuth2Service {
85    providers: HashMap<String, Box<dyn OAuth2Provider>>,
86}
87
88impl Default for OAuth2Service {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl OAuth2Service {
95    pub fn new() -> Self {
96        Self {
97            providers: HashMap::new(),
98        }
99    }
100
101    /// Register a provider. The key is `provider.name()`.
102    pub fn register(&mut self, p: impl OAuth2Provider) -> &mut Self {
103        self.providers.insert(p.name().to_owned(), Box::new(p));
104        self
105    }
106
107    /// Look up a provider by name.
108    pub fn get(&self, name: &str) -> Option<&dyn OAuth2Provider> {
109        self.providers.get(name).map(|b| b.as_ref())
110    }
111}