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}