Add example project
This commit is contained in:
parent
20b12f3c60
commit
db9e65e9d7
18 changed files with 4650 additions and 0 deletions
2758
example/backend/Cargo.lock
generated
Normal file
2758
example/backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
example/backend/Cargo.toml
Normal file
14
example/backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "tauth-example-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
dotenvy = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauth-sdk = { path = "../.." }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
url = "2"
|
||||
9
example/backend/package.json
Normal file
9
example/backend/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@tauth-example/backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cargo run",
|
||||
"build": "cargo build --release",
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
6
example/backend/src/bin/generate_keys.rs
Normal file
6
example/backend/src/bin/generate_keys.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
fn main() {
|
||||
let keys = tauth_sdk::generate_key_pair();
|
||||
|
||||
println!("TAUTH_PRIVATE_KEY={}", keys.private);
|
||||
println!("TAUTH_PUBLIC_KEY={}", keys.public);
|
||||
}
|
||||
255
example/backend/src/main.rs
Normal file
255
example/backend/src/main.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
use axum::extract::{Json, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, put};
|
||||
use axum::{Router, serve};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauth_sdk::{AuthCallback, TAuthClient, TAuthConfig, User};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
const SESSION_COOKIE: &str = "tauth_example_session";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
tauth: Arc<TAuthClient>,
|
||||
db: Arc<Mutex<Db>>,
|
||||
db_path: PathBuf,
|
||||
frontend_url: String,
|
||||
host_public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct Db {
|
||||
sessions: BTreeMap<String, StoredSession>,
|
||||
app_data: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct StoredSession {
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
user: User,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
logged_in: bool,
|
||||
session_id: Option<u64>,
|
||||
user: Option<User>,
|
||||
app_data: Option<Value>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let state = match AppState::from_env().await {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
eprintln!("failed to start example backend: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let addr: SocketAddr = env::var("BACKEND_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8787".to_string())
|
||||
.parse()
|
||||
.expect("BACKEND_ADDR must be host:port");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/login", get(login))
|
||||
.route("/api/me", get(me))
|
||||
.route("/api/app-data", put(save_app_data))
|
||||
.route("/tauth/callback", get(callback))
|
||||
.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any))
|
||||
.with_state(state);
|
||||
|
||||
let listener = TcpListener::bind(addr).await.expect("bind backend address");
|
||||
println!("example backend listening on http://{addr}");
|
||||
serve(listener, app).await.expect("serve backend");
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
async fn from_env() -> Result<Self, String> {
|
||||
let private_key = env::var("TAUTH_PRIVATE_KEY")
|
||||
.map_err(|_| "TAUTH_PRIVATE_KEY must be set".to_string())?;
|
||||
let identifier = env::var("TAUTH_IDENTIFIER").unwrap_or_else(|_| "example.local".to_string());
|
||||
let redirect_url = env::var("TAUTH_REDIRECT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8787/tauth/callback".to_string());
|
||||
let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
|
||||
let host_public_key = env::var("TAUTH_HOST_PUBLIC_KEY").ok().filter(|value| !value.is_empty());
|
||||
|
||||
let tauth = TAuthClient::from_config(TAuthConfig::new(identifier, redirect_url, private_key))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let db_path = PathBuf::from("data/db.json");
|
||||
let db = load_db(&db_path).await.map_err(|error| error.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
tauth: Arc::new(tauth),
|
||||
db: Arc::new(Mutex::new(db)),
|
||||
db_path,
|
||||
frontend_url,
|
||||
host_public_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(State(state): State<AppState>) -> Redirect {
|
||||
Redirect::temporary(&state.tauth.auth_url(None))
|
||||
}
|
||||
|
||||
async fn callback(State(state): State<AppState>, uri: axum::http::Uri) -> Result<Response, AppError> {
|
||||
let callback_url = format!("{}{}", state.tauth.redirect_url().origin().ascii_serialization(), uri);
|
||||
let callback = state.tauth.parse_callback(callback_url)?;
|
||||
let user = state.tauth.user(callback.user_id).await?;
|
||||
persist_session(&state, &callback, user).await?;
|
||||
|
||||
let mut response = Redirect::temporary(&state.frontend_url).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Lax",
|
||||
callback.session_id
|
||||
))
|
||||
.map_err(|error| AppError::Internal(error.to_string()))?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn me(State(state): State<AppState>, headers: HeaderMap) -> Result<Json<MeResponse>, AppError> {
|
||||
let Some(session_id) = read_session_cookie(&headers) else {
|
||||
return Ok(Json(MeResponse {
|
||||
logged_in: false,
|
||||
session_id: None,
|
||||
user: None,
|
||||
app_data: None,
|
||||
}));
|
||||
};
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let session_key = session_id.to_string();
|
||||
let Some(session) = db.sessions.get(&session_key) else {
|
||||
return Ok(Json(MeResponse {
|
||||
logged_in: false,
|
||||
session_id: None,
|
||||
user: None,
|
||||
app_data: None,
|
||||
}));
|
||||
};
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
logged_in: true,
|
||||
session_id: Some(session.session_id),
|
||||
user: Some(session.user.clone()),
|
||||
app_data: db.app_data.get(&session_key).cloned(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn save_app_data(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(data): Json<Value>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let session_id = read_session_cookie(&headers).ok_or(AppError::Unauthorized)?;
|
||||
let session = {
|
||||
let db = state.db.lock().await;
|
||||
db.sessions
|
||||
.get(&session_id.to_string())
|
||||
.cloned()
|
||||
.ok_or(AppError::Unauthorized)?
|
||||
};
|
||||
|
||||
if let Some(host_public_key) = &state.host_public_key {
|
||||
let tauth_session = state
|
||||
.tauth
|
||||
.connect_session(session.user_id, session.session_id, host_public_key)
|
||||
.await?;
|
||||
tauth_session.save_json(&data).await?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut db = state.db.lock().await;
|
||||
db.app_data.insert(session_id.to_string(), data.clone());
|
||||
save_db(&state.db_path, &db).await?;
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn persist_session(state: &AppState, callback: &AuthCallback, user: User) -> Result<(), AppError> {
|
||||
let mut db = state.db.lock().await;
|
||||
db.sessions.insert(
|
||||
callback.session_id.to_string(),
|
||||
StoredSession {
|
||||
user_id: callback.user_id,
|
||||
session_id: callback.session_id,
|
||||
user,
|
||||
},
|
||||
);
|
||||
save_db(&state.db_path, &db).await
|
||||
}
|
||||
|
||||
fn read_session_cookie(headers: &HeaderMap) -> Option<u64> {
|
||||
let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
cookie.split(';').find_map(|part| {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
(name == SESSION_COOKIE).then(|| value.parse().ok()).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_db(path: &PathBuf) -> std::io::Result<Db> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(raw) => Ok(serde_json::from_str(&raw).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Db::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_db(path: &PathBuf, db: &Db) -> Result<(), AppError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(db).map_err(|error| AppError::Internal(error.to_string()))?;
|
||||
tokio::fs::write(path, raw).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum AppError {
|
||||
Unauthorized,
|
||||
TAuth(tauth_sdk::TAuthError),
|
||||
Io(std::io::Error),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<tauth_sdk::TAuthError> for AppError {
|
||||
fn from(error: tauth_sdk::TAuthError) -> Self {
|
||||
Self::TAuth(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "not logged in".to_string()),
|
||||
AppError::TAuth(error) => (StatusCode::BAD_GATEWAY, error.to_string()),
|
||||
AppError::Io(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
|
||||
AppError::Internal(error) => (StatusCode::INTERNAL_SERVER_ERROR, error),
|
||||
};
|
||||
|
||||
(status, message).into_response()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue