feat!: start implementing repo listing & auth middleware

This commit is contained in:
2026-01-15 00:20:14 -08:00
parent 92d409f812
commit 6f2f64fd73
18 changed files with 1982 additions and 21 deletions

1265
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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?)
}

View 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
View File

@@ -0,0 +1 @@
pub mod get_repos;

39
api/src/error.rs Normal file
View 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(),
}
}
}

View File

@@ -1,10 +1,52 @@
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())
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

View File

@@ -0,0 +1,3 @@
mod protected;
pub use protected::protected;

View 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)
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE "jwks" (
"id" text PRIMARY KEY NOT NULL,
"public_key" text NOT NULL,
"private_key" text NOT NULL,
"created_at" timestamp NOT NULL,
"expires_at" timestamp
);

View File

@@ -0,0 +1,417 @@
{
"id": "2d1e972c-8275-4278-a835-e8bc5d477168",
"prevId": "2cbcf685-9d9c-420f-9c34-16cea513e9b8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jwks": {
"name": "jwks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1768364902417,
"tag": "0000_many_joseph",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1768455615596,
"tag": "0001_abnormal_swordsman",
"breakpoints": true
}
]
}

View File

@@ -10,9 +10,7 @@
const query = createQuery(() => ({
queryKey: ['github-repositories'],
queryFn: async () => {
const response = await fetch('https://api.github.com/user/repos?affiliation=owner', {
headers: {}
});
const response = await fetch(`/api/${$session.data?.user.id}/repos`);
return await response.json();
}
}));

View File

@@ -1,5 +1,7 @@
import { jwt } from 'better-auth/plugins';
import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient({
baseURL: 'http://localhost:5173'
baseURL: 'http://localhost:5173',
plugins: [jwt()]
});

View File

@@ -2,12 +2,14 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db/drizzle';
import * as authSchema from './db/auth-schema';
import { jwt } from 'better-auth/plugins';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: { ...authSchema }
}),
plugins: [jwt()],
socialProviders: {
github: {
clientId: process.env.GH_CLIENT_ID!,

View File

@@ -73,6 +73,14 @@ export const verification = pgTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const jwks = pgTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: timestamp("created_at").notNull(),
expiresAt: timestamp("expires_at"),
});
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),

View File

@@ -2,4 +2,14 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
// '/api': {
// target: 'http://localhost:8080',
// changeOrigin: true
// }
}
}
});