Archived
f4abfe6fdc
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
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
|
|
}
|