Archived
feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//! 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
|
||||
}
|
||||
Reference in New Issue
Block a user