feat!: start implementing repo listing & auth middleware
This commit is contained in:
1265
api/Cargo.lock
generated
1265
api/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,10 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.12.1"
|
||||
reqwest = "0.13.1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||
reqwest = { version = "0.13.1", features = ["json"] }
|
||||
serde = "1.0.228"
|
||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.49.0"
|
||||
|
||||
21
api/src/account.rs
Normal file
21
api/src/account.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::error::Result;
|
||||
|
||||
use sqlx::{PgPool, query_scalar};
|
||||
|
||||
pub struct AccountRepository<'a> {
|
||||
pub pool: &'a PgPool,
|
||||
}
|
||||
|
||||
impl<'a> AccountRepository<'a> {
|
||||
pub fn new(pool: &'a PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn get_access_token(&self, user_id: &str) -> Result<String> {
|
||||
query_scalar("SELECT access_token FROM account WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool)
|
||||
.await
|
||||
.map_err(|_| crate::error::Error::AccessToken)
|
||||
}
|
||||
}
|
||||
74
api/src/auth.rs
Normal file
74
api/src/auth.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::{env, sync::Arc};
|
||||
|
||||
use crate::error::Result;
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, Validation, decode, decode_header, errors::ErrorKind, jwk::JwkSet,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct UserId(pub String);
|
||||
|
||||
pub struct JWT {
|
||||
reqwest_client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub trait AuthImpl {
|
||||
async fn for_protected(&self, token: &str) -> Result<UserId>;
|
||||
}
|
||||
|
||||
pub enum Auth {
|
||||
JWT(JWT),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
}
|
||||
|
||||
impl JWT {
|
||||
pub fn new(reqwest_client: reqwest::Client) -> Self {
|
||||
Self { reqwest_client }
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthImpl for JWT {
|
||||
async fn for_protected(&self, token: &str) -> Result<UserId> {
|
||||
let frontend_url =
|
||||
env::var("FRONTEND_BASE_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
|
||||
|
||||
let jwks = get_jwks(
|
||||
&self.reqwest_client,
|
||||
&format!("{frontend_url}/api/auth/jwks"),
|
||||
)
|
||||
.await?;
|
||||
let header = decode_header(token)?;
|
||||
let kid = header.kid.ok_or(crate::error::Error::Unauthorized)?;
|
||||
|
||||
let jwk = jwks.find(&kid).ok_or(crate::error::Error::Unauthorized)?;
|
||||
let decoding_key = DecodingKey::from_jwk(jwk)?;
|
||||
|
||||
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||
validation.set_issuer(&[&frontend_url]);
|
||||
validation.set_audience(&[&frontend_url]);
|
||||
|
||||
let token_data =
|
||||
decode::<Claims>(token, &decoding_key, &validation).map_err(|e| match e.kind() {
|
||||
ErrorKind::ExpiredSignature => crate::error::Error::TokenExpired,
|
||||
_ => crate::error::Error::Unauthorized,
|
||||
})?;
|
||||
|
||||
Ok(UserId(token_data.claims.sub))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthImpl for Auth {
|
||||
async fn for_protected(&self, token: &str) -> Result<UserId> {
|
||||
match self {
|
||||
Auth::JWT(jwt) => jwt.for_protected(token).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_jwks(client: &reqwest::Client, jwks_url: &str) -> Result<JwkSet> {
|
||||
Ok(client.get(jwks_url).send().await?.json::<JwkSet>().await?)
|
||||
}
|
||||
35
api/src/endpoints/get_repos.rs
Normal file
35
api/src/endpoints/get_repos.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AppState, account::AccountRepository, error::Result};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Repository {
|
||||
name: String,
|
||||
full_name: String,
|
||||
description: String,
|
||||
language: String,
|
||||
stars: usize,
|
||||
updated_at: DateTime<Utc>,
|
||||
private: bool,
|
||||
}
|
||||
|
||||
pub async fn get_repos(
|
||||
app_state: web::Data<AppState>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse> {
|
||||
let user_id = path.into_inner();
|
||||
let query = AccountRepository::new(&app_state.pool);
|
||||
let token = query.get_access_token(&user_id).await?;
|
||||
|
||||
let response = app_state
|
||||
.reqwest_client
|
||||
.get("https://api.github.com/user/repos")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
response.error_for_status_ref()?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(response.json::<Vec<Repository>>().await?))
|
||||
}
|
||||
1
api/src/endpoints/mod.rs
Normal file
1
api/src/endpoints/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod get_repos;
|
||||
39
api/src/error.rs
Normal file
39
api/src/error.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use actix_web::{HttpResponse, ResponseError, http::StatusCode};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("db error: {0}")]
|
||||
DB(#[from] sqlx::Error),
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("error getting access token")]
|
||||
AccessToken,
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("jwx error")]
|
||||
Jwx(#[from] jsonwebtoken::errors::Error),
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl ResponseError for Error {
|
||||
fn error_response(&self) -> actix_web::HttpResponse<actix_web::body::BoxBody> {
|
||||
match self {
|
||||
Error::AccessToken => HttpResponse::Unauthorized().finish(),
|
||||
Error::Unauthorized => HttpResponse::Unauthorized().finish(),
|
||||
Error::TokenExpired => HttpResponse::Unauthorized().json(ErrorResponse {
|
||||
error: "token expired".to_string(),
|
||||
}),
|
||||
_ => HttpResponse::InternalServerError().finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,53 @@
|
||||
use actix_web::{App, HttpServer};
|
||||
mod account;
|
||||
mod auth;
|
||||
mod endpoints;
|
||||
mod error;
|
||||
mod middleware;
|
||||
|
||||
struct AppState {}
|
||||
use std::env;
|
||||
|
||||
use actix_web::{App, HttpServer, middleware::from_fn, web};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::auth::{Auth, JWT};
|
||||
|
||||
struct AppState {
|
||||
reqwest_client: reqwest::Client,
|
||||
pool: PgPool,
|
||||
auth: Auth,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
HttpServer::new(|| App::new())
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
let reqwest_client = reqwest::Client::new();
|
||||
let app_data = web::Data::new(AppState {
|
||||
reqwest_client: reqwest_client.clone(),
|
||||
pool: PgPool::connect(
|
||||
&env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"),
|
||||
)
|
||||
.await
|
||||
.expect("error connecting to db"),
|
||||
auth: Auth::JWT(JWT::new(reqwest_client.clone())),
|
||||
});
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(app_data.clone())
|
||||
.route(
|
||||
"/",
|
||||
web::get()
|
||||
.to(async || concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))),
|
||||
)
|
||||
.service(
|
||||
web::scope("/api")
|
||||
.route(
|
||||
"/{user_id}/repos",
|
||||
web::get().to(endpoints::get_repos::get_repos),
|
||||
)
|
||||
.wrap(from_fn(middleware::protected)),
|
||||
)
|
||||
})
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
3
api/src/middleware/mod.rs
Normal file
3
api/src/middleware/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod protected;
|
||||
|
||||
pub use protected::protected;
|
||||
44
api/src/middleware/protected.rs
Normal file
44
api/src/middleware/protected.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use actix_web::{
|
||||
Error, HttpMessage, HttpRequest, HttpResponse,
|
||||
body::MessageBody,
|
||||
dev::{ServiceRequest, ServiceResponse},
|
||||
http::header::AUTHORIZATION,
|
||||
middleware::Next,
|
||||
web,
|
||||
};
|
||||
|
||||
use crate::{AppState, auth::AuthImpl};
|
||||
|
||||
pub async fn protected(
|
||||
app_state: web::Data<AppState>,
|
||||
req: ServiceRequest,
|
||||
next: Next<impl MessageBody + 'static>,
|
||||
) -> Result<ServiceResponse<impl MessageBody>, Error> {
|
||||
let Some(token) = token_from_request(&req) else {
|
||||
return Ok(req
|
||||
.into_response(HttpResponse::Unauthorized().finish())
|
||||
.map_into_right_body());
|
||||
};
|
||||
|
||||
let user_id = app_state.auth.for_protected(&token).await?;
|
||||
req.extensions_mut().insert(user_id);
|
||||
|
||||
next.call(req)
|
||||
.await
|
||||
.map(ServiceResponse::map_into_left_body)
|
||||
}
|
||||
|
||||
fn token_from_request(req: &ServiceRequest) -> Option<String> {
|
||||
let token = req
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|s| s.to_str().ok())?;
|
||||
|
||||
let token = if token.to_lowercase().starts_with("bearer ") {
|
||||
token[7..].to_string()
|
||||
} else {
|
||||
token.to_string()
|
||||
};
|
||||
|
||||
Some(token)
|
||||
}
|
||||
Reference in New Issue
Block a user