use crate::AppConfig;
use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use uuid::Uuid;
pub mod authorize;
pub mod token;
pub mod userinfo;
pub use authorize::{oauth2_authorize_get_handler, oauth2_authorize_post_handler};
pub use token::oauth2_token_handler;
pub use userinfo::oauth2_userinfo_handler;
const CODE_TTL_SECS: i64 = 600;
const ACCESS_TTL_SECS: i64 = 3600;
const REFRESH_TTL_SECS: i64 = 14 * 24 * 3600;
fn rand_jti() -> String {
Uuid::new_v4().simple().to_string()[..16].to_string()
}
static KEY: LazyLock<Vec<u8>> = LazyLock::new(|| {
let mut key = Vec::with_capacity(32);
key.extend_from_slice(Uuid::new_v4().as_bytes());
key.extend_from_slice(Uuid::new_v4().as_bytes());
key
});
static SPENT: LazyLock<parking_lot::Mutex<HashMap<String, Instant>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
pub(crate) fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
pub(crate) fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut block = [0u8; 64];
if key.len() > 64 {
let digest = Sha256::digest(key);
block[..32].copy_from_slice(&digest);
} else {
block[..key.len()].copy_from_slice(key);
}
let ipad: Vec<u8> = block.iter().map(|b| b ^ 0x36).collect();
let opad: Vec<u8> = block.iter().map(|b| b ^ 0x5c).collect();
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(msg);
let inner_digest = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_digest);
outer.finalize().to_vec()
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct CodeClaims {
pub cid: String,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub st: Option<String>,
pub email: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chm: Option<String>,
#[serde(default)]
pub jti: String,
pub iat: i64,
pub exp: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct BearerClaims {
pub cid: String,
pub email: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub typ: String,
#[serde(default)]
pub jti: String,
pub iat: i64,
pub exp: i64,
}
impl CodeClaims {
pub fn new(cid: String, uri: String, st: Option<String>, email: String) -> Self {
let iat = now_unix();
Self {
cid,
uri,
st,
email,
scope: None,
chal: None,
chm: None,
jti: rand_jti(),
iat,
exp: iat + CODE_TTL_SECS,
}
}
}
impl BearerClaims {
pub fn access(cid: String, email: String, scope: Option<String>) -> Self {
let iat = now_unix();
Self {
cid,
email,
scope,
typ: "at".into(),
jti: rand_jti(),
iat,
exp: iat + ACCESS_TTL_SECS,
}
}
pub fn refresh(cid: String, email: String, scope: Option<String>) -> Self {
let iat = now_unix();
Self {
cid,
email,
scope,
typ: "rt".into(),
jti: rand_jti(),
iat,
exp: iat + REFRESH_TTL_SECS,
}
}
}
pub(crate) fn sign<T: Serialize>(claims: &T) -> String {
let payload = serde_json::to_vec(claims).expect("claims serialize");
let mac = hmac_sha256(&KEY, &payload);
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload),
URL_SAFE_NO_PAD.encode(mac)
)
}
pub(crate) fn verify<T: DeserializeOwned>(token: &str) -> Option<T> {
let (payload_b64, mac_b64) = token.split_once('.')?;
let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let mac = URL_SAFE_NO_PAD.decode(mac_b64).ok()?;
if !ct_eq(&mac, &hmac_sha256(&KEY, &payload)) {
return None;
}
serde_json::from_slice(&payload).ok()
}
fn fingerprint(token: &str) -> Option<String> {
token.split_once('.').map(|(_, mac)| mac.to_string())
}
pub(crate) fn spend(token: &str, ttl: Duration) -> bool {
let Some(fp) = fingerprint(token) else {
return false;
};
let mut spent = SPENT.lock();
let now = Instant::now();
spent.retain(|_, until| *until > now);
if spent.contains_key(&fp) {
return false;
}
spent.insert(fp, now + ttl);
true
}
pub(crate) fn token_error(status: StatusCode, error: &str, description: &str) -> HttpResponse {
let mut builder = HttpResponse::build(status);
builder.insert_header(("Cache-Control", "no-store"));
builder.insert_header(("Pragma", "no-cache"));
if error == "invalid_client" {
builder.insert_header(("WWW-Authenticate", "Basic"));
}
builder.json(json!({
"error": error,
"error_description": description,
}))
}
pub(crate) fn token_success(
access: &str,
refresh: Option<&str>,
scope: Option<&str>,
) -> HttpResponse {
let mut body = json!({
"access_token": access,
"token_type": "Bearer",
"expires_in": ACCESS_TTL_SECS,
});
if let Some(refresh) = refresh {
body["refresh_token"] = json!(refresh);
}
if let Some(scope) = scope {
body["scope"] = json!(scope);
}
HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.insert_header(("Pragma", "no-cache"))
.json(body)
}
pub(crate) fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub(crate) fn is_http_url(uri: &str) -> bool {
url::Url::parse(uri)
.map(|u| u.scheme() == "http" || u.scheme() == "https")
.unwrap_or(false)
}
pub(crate) fn oauth_redirect(
target: &str,
params: &[(String, String)],
fragment: bool,
) -> HttpResponse {
let Ok(mut url) = url::Url::parse(target) else {
return HttpResponse::BadRequest().body("Invalid redirect_uri");
};
if fragment {
let qs = params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
url.set_fragment(Some(&qs));
} else {
for (k, v) in params {
url.query_pairs_mut().append_pair(k, v);
}
}
HttpResponse::Found()
.insert_header(("Location", url.as_str()))
.finish()
}
pub(crate) fn state_param(st: Option<&str>) -> Option<(String, String)> {
st.filter(|s| !s.is_empty())
.map(|s| ("state".to_string(), s.to_string()))
}
pub async fn oauth2_index_handler() -> HttpResponse {
HttpResponse::Ok().json(json!({
"service": "httpcan OAuth 2.0 mock",
"note": "Mock authorization server. Any credentials are accepted unless --oauth2-clients is configured; the consent email is the only identity. One-time codes and refresh rotation are enforced in-process: restarting the server invalidates everything issued.",
"spec": "RFC 6749 (OAuth 2.0); PKCE per RFC 7636; discovery per RFC 8414",
"deprecated_grants": "implicit and password are implemented for legacy-client compatibility only; both are removed in OAuth 2.1",
"endpoints": {
"authorize": "/oauth2/authorize",
"token": "/oauth2/token",
"userinfo": "/oauth2/userinfo",
"discovery": "/.well-known/oauth-authorization-server"
},
"response_types_supported": ["code", "token"],
"grant_types_supported": ["authorization_code", "implicit", "password", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256", "plain"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
}))
}
pub async fn oauth2_metadata_handler(req: HttpRequest) -> HttpResponse {
let conn = req.connection_info();
let base = format!("{}://{}", conn.scheme(), conn.host());
HttpResponse::Ok().json(json!({
"issuer": base,
"authorization_endpoint": format!("{base}/oauth2/authorize"),
"token_endpoint": format!("{base}/oauth2/token"),
"userinfo_endpoint": format!("{base}/oauth2/userinfo"),
"service_documentation": format!("{base}/"),
"response_types_supported": ["code", "token"],
"grant_types_supported": ["authorization_code", "implicit", "password", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256", "plain"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
}))
}
pub(crate) const CLIENT_CREDENTIALS_EMAIL: &str = "client@httpcan.local";
pub(crate) fn code_ttl() -> Duration {
Duration::from_secs(CODE_TTL_SECS as u64)
}
pub(crate) fn refresh_ttl() -> Duration {
Duration::from_secs(REFRESH_TTL_SECS as u64)
}
pub(crate) fn clients_table(config: &web::Data<AppConfig>) -> Option<&HashMap<String, String>> {
config.oauth2_clients.as_ref()
}