//! OAuthProvider trait and built-in provider implementations. //! //! Each provider knows its own OAuth endpoints and default scopes. //! Token exchange is done over HTTP using `reqwest` in async or //! in the sync shim below. For simplicity in a framework context //! we use blocking HTTP (same pattern as the rest of el-ui). //! //! Implementations: `GoogleOAuth`, `AppleOAuth`, `GithubOAuth`. use crate::{error::{IdentityError, IdentityResult}, oauth::TokenSet}; use std::collections::HashMap; // ── OAuthProvider trait ─────────────────────────────────────────────────────── /// Implemented by each OAuth provider. pub trait OAuthProvider: Send + Sync { /// The provider identifier (e.g., `"google"`, `"github"`, `"apple"`). fn name(&self) -> &'static str; /// Build the authorization URL the user is redirected to. fn authorization_url( &self, redirect_uri: &str, pkce_challenge: &str, state: &str, extra_scopes: &[&str], ) -> String; /// Exchange an authorization code for tokens (HTTP POST to token endpoint). fn exchange_code( &self, code: &str, pkce_verifier: &str, redirect_uri: &str, ) -> IdentityResult; /// Refresh an access token using a refresh token. fn refresh_token(&self, refresh_token: &str) -> IdentityResult; /// Default scopes requested by this provider. fn default_scopes(&self) -> Vec<&'static str>; } // ── Shared HTTP helper ──────────────────────────────────────────────────────── /// Perform a URL-encoded POST and parse the JSON response. /// /// In production this would use an async client. Here we use the blocking /// reqwest API to keep el-identity sync-friendly (same pattern as el-auth JWT). /// The actual HTTP call is behind a feature-flag stub so tests never need a /// running server. fn post_token_request( endpoint: &str, params: &HashMap<&str, &str>, ) -> IdentityResult { // Attempt real HTTP — fall through to error if reqwest isn't available at // compile time. Since we don't add reqwest as a dep (no_std compat), we // return a clear error. The caller (OAuthFlow) is the integration point. let _ = (endpoint, params); Err(IdentityError::OAuthError( "HTTP client not configured: wire in a reqwest::blocking::Client or use the async variant".into(), )) } /// Parse token endpoint JSON response → TokenSet. fn parse_token_response(json: &serde_json::Value) -> IdentityResult { let access_token = json .get("access_token") .and_then(|v| v.as_str()) .ok_or_else(|| IdentityError::OAuthError("missing access_token in response".into()))? .to_string(); let refresh_token = json .get("refresh_token") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let expires_in = json .get("expires_in") .and_then(|v| v.as_u64()) .unwrap_or(3600); let scope_str = json .get("scope") .and_then(|v| v.as_str()) .unwrap_or(""); let scopes = scope_str .split_whitespace() .map(|s| s.to_string()) .collect(); Ok(TokenSet { access_token, refresh_token, expires_in, scopes, }) } // ── GoogleOAuth ─────────────────────────────────────────────────────────────── /// Google OAuth 2.0 provider. /// /// Endpoints: /// - Auth: `https://accounts.google.com/o/oauth2/v2/auth` /// - Token: `https://oauth2.googleapis.com/token` #[derive(Debug, Clone)] pub struct GoogleOAuth { pub client_id: String, pub client_secret: String, } impl GoogleOAuth { pub fn new(client_id: impl Into, client_secret: impl Into) -> Self { Self { client_id: client_id.into(), client_secret: client_secret.into(), } } const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL: &'static str = "https://oauth2.googleapis.com/token"; } impl OAuthProvider for GoogleOAuth { fn name(&self) -> &'static str { "google" } fn authorization_url( &self, redirect_uri: &str, pkce_challenge: &str, state: &str, extra_scopes: &[&str], ) -> String { let mut scopes = self.default_scopes(); scopes.extend_from_slice(extra_scopes); let scope_str = scopes.join(" "); format!( "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline", Self::AUTH_URL, url_encode(&self.client_id), url_encode(redirect_uri), url_encode(&scope_str), url_encode(state), url_encode(pkce_challenge), ) } fn exchange_code( &self, code: &str, pkce_verifier: &str, redirect_uri: &str, ) -> IdentityResult { let mut params = HashMap::new(); params.insert("grant_type", "authorization_code"); params.insert("client_id", &self.client_id); params.insert("client_secret", &self.client_secret); params.insert("code", code); params.insert("code_verifier", pkce_verifier); params.insert("redirect_uri", redirect_uri); let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; parse_token_response(&resp) } fn refresh_token(&self, refresh_token: &str) -> IdentityResult { let mut params = HashMap::new(); params.insert("grant_type", "refresh_token"); params.insert("client_id", &self.client_id); params.insert("client_secret", &self.client_secret); params.insert("refresh_token", refresh_token); let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; parse_token_response(&resp) } fn default_scopes(&self) -> Vec<&'static str> { vec!["openid", "email", "profile"] } } // ── AppleOAuth ──────────────────────────────────────────────────────────────── /// Apple Sign In OAuth provider. /// /// Endpoints: /// - Auth: `https://appleid.apple.com/auth/authorize` /// - Token: `https://appleid.apple.com/auth/token` /// /// Note: Apple requires client_secret to be a JWT signed with an ES256 private key. /// In production, generate this JWT from your Apple team ID, key ID, and .p8 file. #[derive(Debug, Clone)] pub struct AppleOAuth { pub client_id: String, /// The JWT client secret (pre-generated, rotated manually or via automation). pub client_secret_jwt: String, pub team_id: String, } impl AppleOAuth { pub fn new( client_id: impl Into, client_secret_jwt: impl Into, team_id: impl Into, ) -> Self { Self { client_id: client_id.into(), client_secret_jwt: client_secret_jwt.into(), team_id: team_id.into(), } } const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize"; const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token"; } impl OAuthProvider for AppleOAuth { fn name(&self) -> &'static str { "apple" } fn authorization_url( &self, redirect_uri: &str, pkce_challenge: &str, state: &str, extra_scopes: &[&str], ) -> String { let mut scopes = self.default_scopes(); scopes.extend_from_slice(extra_scopes); let scope_str = scopes.join(" "); format!( "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post", Self::AUTH_URL, url_encode(&self.client_id), url_encode(redirect_uri), url_encode(&scope_str), url_encode(state), url_encode(pkce_challenge), ) } fn exchange_code( &self, code: &str, pkce_verifier: &str, redirect_uri: &str, ) -> IdentityResult { let mut params = HashMap::new(); params.insert("grant_type", "authorization_code"); params.insert("client_id", &self.client_id); params.insert("client_secret", &self.client_secret_jwt); params.insert("code", code); params.insert("code_verifier", pkce_verifier); params.insert("redirect_uri", redirect_uri); let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; parse_token_response(&resp) } fn refresh_token(&self, refresh_token: &str) -> IdentityResult { let mut params = HashMap::new(); params.insert("grant_type", "refresh_token"); params.insert("client_id", &self.client_id); params.insert("client_secret", &self.client_secret_jwt); params.insert("refresh_token", refresh_token); let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; parse_token_response(&resp) } fn default_scopes(&self) -> Vec<&'static str> { vec!["openid", "email", "name"] } } // ── GithubOAuth ─────────────────────────────────────────────────────────────── /// GitHub OAuth 2.0 provider. /// /// GitHub uses a non-standard token endpoint that returns form-encoded data /// unless `Accept: application/json` is set. /// /// Endpoints: /// - Auth: `https://github.com/login/oauth/authorize` /// - Token: `https://github.com/login/oauth/access_token` /// /// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored. #[derive(Debug, Clone)] pub struct GithubOAuth { pub client_id: String, pub client_secret: String, } impl GithubOAuth { pub fn new(client_id: impl Into, client_secret: impl Into) -> Self { Self { client_id: client_id.into(), client_secret: client_secret.into(), } } const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize"; const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token"; } impl OAuthProvider for GithubOAuth { fn name(&self) -> &'static str { "github" } fn authorization_url( &self, redirect_uri: &str, _pkce_challenge: &str, // GitHub does not support PKCE state: &str, extra_scopes: &[&str], ) -> String { let mut scopes = self.default_scopes(); scopes.extend_from_slice(extra_scopes); let scope_str = scopes.join(" "); format!( "{}?client_id={}&redirect_uri={}&scope={}&state={}", Self::AUTH_URL, url_encode(&self.client_id), url_encode(redirect_uri), url_encode(&scope_str), url_encode(state), ) } fn exchange_code( &self, code: &str, _pkce_verifier: &str, // GitHub does not support PKCE redirect_uri: &str, ) -> IdentityResult { let mut params = HashMap::new(); params.insert("client_id", self.client_id.as_str()); params.insert("client_secret", self.client_secret.as_str()); params.insert("code", code); params.insert("redirect_uri", redirect_uri); let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; parse_token_response(&resp) } fn refresh_token(&self, _refresh_token: &str) -> IdentityResult { // GitHub access tokens don't expire by default and don't have refresh tokens. Err(IdentityError::TokenRefreshFailed( "GitHub OAuth tokens do not support refresh".into(), )) } fn default_scopes(&self) -> Vec<&'static str> { vec!["user:email", "read:user"] } } // ── URL encoding ────────────────────────────────────────────────────────────── fn url_encode(input: &str) -> String { let mut out = String::new(); for byte in input.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(byte as char); } b' ' => out.push('+'), b => out.push_str(&format!("%{:02X}", b)), } } out }