Files
el/ui/vessels/el-identity/src/provider.rs
T

373 lines
13 KiB
Rust

//! 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<TokenSet>;
/// Refresh an access token using a refresh token.
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet>;
/// 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<serde_json::Value> {
// 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<TokenSet> {
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<String>, client_secret: impl Into<String>) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
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, &params)?;
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<String>,
client_secret_jwt: impl Into<String>,
team_id: impl Into<String>,
) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
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, &params)?;
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<String>, client_secret: impl Into<String>) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
// 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
}