This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "oauth"
version = "0.1.0"
edition = "2021"
[dependencies]
arctic-oauth = { version = "0.3.0", features = ["apple", "google"] }
base64 = "0.22"
hex = "0.4"
hmac = "0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
+24
View File
@@ -0,0 +1,24 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct GoogleConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppleConfig {
pub team_id: String,
pub key_id: String,
pub client_id: String,
pub client_secret: String,
pub redirect_url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub enable_notify: Option<bool>,
pub webhook_domain: Option<String>,
}
+26
View File
@@ -0,0 +1,26 @@
use std::fmt;
#[derive(Debug)]
pub enum OAuthError {
Config(String),
Telegram(String),
Arctic(arctic_oauth::Error),
}
impl fmt::Display for OAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OAuthError::Config(msg) => write!(f, "OAuth config error: {}", msg),
OAuthError::Telegram(msg) => write!(f, "Telegram OAuth error: {}", msg),
OAuthError::Arctic(e) => write!(f, "OAuth error: {}", e),
}
}
}
impl std::error::Error for OAuthError {}
impl From<arctic_oauth::Error> for OAuthError {
fn from(e: arctic_oauth::Error) -> Self {
OAuthError::Arctic(e)
}
}
+98
View File
@@ -0,0 +1,98 @@
//! OAuth 2.0 authentication library wrapping `arctic-oauth` with
//! config types matching the database schema, plus Telegram Login support.
//!
//! # Providers
//!
//! | Provider | Standard OAuth 2.0 | PKCE | Tokens |
//! |-----------|-------------------|----------|---------------|
//! | Google | ✅ (arctic-oauth) | Required | access + refresh + id_token |
//! | Apple | ✅ (arctic-oauth) | None | access + id_token |
//! | Telegram | ⚠️ Custom HMAC | N/A | N/A (stateless) |
pub mod config;
pub mod error;
pub mod telegram;
// Re-export arctic-oauth core types + providers
pub use arctic_oauth::{
create_code_challenge, decode_id_token, generate_code_verifier, generate_state,
Apple, AppleOptions, Google, GoogleOptions, OAuth2Tokens,
};
pub use arctic_oauth::{CodeChallengeMethod, Error as ArcticError};
pub use config::{AppleConfig, GoogleConfig, TelegramConfig};
pub use error::OAuthError;
pub use telegram::{parse_and_validate_auth_data, parse_base64_and_validate, validate_auth_data, AuthData};
/// Unified user info extracted from any OAuth provider.
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub open_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub picture: Option<String>,
}
impl OAuthUserInfo {
pub fn from_google(tokens: &OAuth2Tokens) -> Result<Self, OAuthError> {
let id_token = tokens.id_token()?;
let claims = decode_id_token(id_token)?;
let open_id = claims
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let email = claims.get("email").and_then(|v| v.as_str()).map(String::from);
let name = claims.get("name").and_then(|v| v.as_str()).map(String::from);
let picture = claims
.get("picture")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Self {
open_id,
email,
name,
picture,
})
}
pub fn from_apple(tokens: &OAuth2Tokens) -> Result<Self, OAuthError> {
let id_token = tokens.id_token()?;
let claims = decode_id_token(id_token)?;
let open_id = claims
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let email = claims.get("email").and_then(|v| v.as_str()).map(String::from);
let name = None;
let picture = None;
Ok(Self {
open_id,
email,
name,
picture,
})
}
pub fn from_telegram(data: &AuthData) -> Self {
let open_id = data.id.to_string();
let name = Some(
[data.first_name.as_deref(), data.last_name.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" "),
)
.filter(|s| !s.is_empty());
Self {
open_id,
email: None,
name,
picture: data.photo_url.clone(),
}
}
pub fn open_id(&self) -> &str {
&self.open_id
}
}
+103
View File
@@ -0,0 +1,103 @@
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::error::OAuthError;
const AUTH_DATE_TTL_SECS: i64 = 86400;
#[derive(Debug, Clone, Deserialize)]
pub struct AuthData {
pub id: i64,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub username: Option<String>,
pub photo_url: Option<String>,
pub auth_date: i64,
pub hash: String,
}
pub type TelegramUserInfo = AuthData;
fn check_string(data: &AuthData) -> String {
let mut pairs: Vec<(String, String)> = Vec::new();
pairs.push(("id".to_string(), data.id.to_string()));
if let Some(v) = &data.first_name {
pairs.push(("first_name".to_string(), v.clone()));
}
if let Some(v) = &data.last_name {
pairs.push(("last_name".to_string(), v.clone()));
}
if let Some(v) = &data.username {
pairs.push(("username".to_string(), v.clone()));
}
if let Some(v) = &data.photo_url {
pairs.push(("photo_url".to_string(), v.clone()));
}
pairs.push(("auth_date".to_string(), data.auth_date.to_string()));
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n")
}
pub fn validate_auth_data(data: &AuthData, bot_token: &[u8]) -> Result<(), OAuthError> {
if bot_token.is_empty() {
return Err(OAuthError::Telegram(
"telegram bot token is not provided".into(),
));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| OAuthError::Telegram(format!("system clock error: {}", e)))?
.as_secs() as i64;
if now - data.auth_date > AUTH_DATE_TTL_SECS {
return Err(OAuthError::Telegram("auth date is expired".into()));
}
let check_str = check_string(data);
let key = sha2::Sha256::digest(bot_token);
let mut mac = Hmac::<Sha256>::new_from_slice(&key)
.map_err(|e| OAuthError::Telegram(format!("HMAC key error: {}", e)))?;
mac.update(check_str.as_bytes());
let computed = hex::encode(mac.finalize().into_bytes());
if data.hash != computed {
return Err(OAuthError::Telegram("hash is not valid".into()));
}
Ok(())
}
pub fn parse_and_validate_auth_data(
json_bytes: &[u8],
bot_token: &[u8],
) -> Result<AuthData, OAuthError> {
let data: AuthData = serde_json::from_slice(json_bytes)
.map_err(|e| OAuthError::Telegram(format!("json parse error: {}", e)))?;
validate_auth_data(&data, bot_token)?;
Ok(data)
}
pub fn parse_base64_and_validate(
base64_str: &str,
bot_token: &[u8],
) -> Result<AuthData, OAuthError> {
let decoded = base64::engine::general_purpose::STANDARD
.decode(base64_str.as_bytes())
.or_else(|_| {
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(base64_str.as_bytes())
})
.map_err(|e| OAuthError::Telegram(format!("base64 decode error: {}", e)))?;
parse_and_validate_auth_data(&decoded, bot_token)
}