373 lines
13 KiB
Rust
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, ¶ms)?;
|
|
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, ¶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<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, ¶ms)?;
|
|
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, ¶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<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, ¶ms)?;
|
|
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
|
|
}
|