Add back some whitespaces

This commit is contained in:
lewis
2025-12-15 19:02:10 +02:00
parent c6f9062979
commit 0b45807c32
168 changed files with 1841 additions and 10 deletions
+11
View File
@@ -9,10 +9,13 @@
import Settings from './routes/Settings.svelte'
import Notifications from './routes/Notifications.svelte'
import RepoExplorer from './routes/RepoExplorer.svelte'
const auth = getAuthState()
$effect(() => {
initAuth()
})
function getComponent(path: string) {
switch (path) {
case '/login':
@@ -35,9 +38,11 @@
return auth.session ? Dashboard : Login
}
}
let currentPath = $derived(getCurrentPath())
let CurrentComponent = $derived(getComponent(currentPath))
</script>
<main>
{#if auth.loading}
<div class="loading">
@@ -47,6 +52,7 @@
<CurrentComponent />
{/if}
</main>
<style>
:global(:root) {
--bg-primary: #fafafa;
@@ -70,6 +76,7 @@
--warning-bg: #ffd;
--warning-text: #660;
}
@media (prefers-color-scheme: dark) {
:global(:root) {
--bg-primary: #1a1a1a;
@@ -94,6 +101,7 @@
--warning-text: #c6c67b;
}
}
:global(body) {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -101,13 +109,16 @@
color: var(--text-primary);
background: var(--bg-primary);
}
:global(*) {
box-sizing: border-box;
}
main {
min-height: 100vh;
background: var(--bg-primary);
}
.loading {
display: flex;
align-items: center;
+37
View File
@@ -1,4 +1,5 @@
const API_BASE = '/xrpc'
export class ApiError extends Error {
public did?: string
constructor(public status: number, public error: string, message: string, did?: string) {
@@ -7,6 +8,7 @@ export class ApiError extends Error {
this.did = did
}
}
async function xrpc<T>(method: string, options?: {
method?: 'GET' | 'POST'
params?: Record<string, string>
@@ -37,6 +39,7 @@ async function xrpc<T>(method: string, options?: {
}
return res.json()
}
export interface Session {
did: string
handle: string
@@ -47,10 +50,12 @@ export interface Session {
accessJwt: string
refreshJwt: string
}
export interface AppPassword {
name: string
createdAt: string
}
export interface InviteCode {
code: string
available: number
@@ -60,7 +65,9 @@ export interface InviteCode {
createdAt: string
uses: { usedBy: string; usedAt: string }[]
}
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
export interface CreateAccountParams {
handle: string
email: string
@@ -71,12 +78,14 @@ export interface CreateAccountParams {
telegramUsername?: string
signalNumber?: string
}
export interface CreateAccountResult {
handle: string
did: string
verificationRequired: boolean
verificationChannel: string
}
export interface ConfirmSignupResult {
accessJwt: string
refreshJwt: string
@@ -87,6 +96,7 @@ export interface ConfirmSignupResult {
preferredChannel?: string
preferredChannelVerified?: boolean
}
export const api = {
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
return xrpc('com.atproto.server.createAccount', {
@@ -103,42 +113,50 @@ export const api = {
},
})
},
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
return xrpc('com.atproto.server.confirmSignup', {
method: 'POST',
body: { did, verificationCode },
})
},
async resendVerification(did: string): Promise<{ success: boolean }> {
return xrpc('com.atproto.server.resendVerification', {
method: 'POST',
body: { did },
})
},
async createSession(identifier: string, password: string): Promise<Session> {
return xrpc('com.atproto.server.createSession', {
method: 'POST',
body: { identifier, password },
})
},
async getSession(token: string): Promise<Session> {
return xrpc('com.atproto.server.getSession', { token })
},
async refreshSession(refreshJwt: string): Promise<Session> {
return xrpc('com.atproto.server.refreshSession', {
method: 'POST',
token: refreshJwt,
})
},
async deleteSession(token: string): Promise<void> {
await xrpc('com.atproto.server.deleteSession', {
method: 'POST',
token,
})
},
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
return xrpc('com.atproto.server.listAppPasswords', { token })
},
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
return xrpc('com.atproto.server.createAppPassword', {
method: 'POST',
@@ -146,6 +164,7 @@ export const api = {
body: { name },
})
},
async revokeAppPassword(token: string, name: string): Promise<void> {
await xrpc('com.atproto.server.revokeAppPassword', {
method: 'POST',
@@ -153,9 +172,11 @@ export const api = {
body: { name },
})
},
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
},
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
return xrpc('com.atproto.server.createInviteCode', {
method: 'POST',
@@ -163,24 +184,28 @@ export const api = {
body: { useCount },
})
},
async requestPasswordReset(email: string): Promise<void> {
await xrpc('com.atproto.server.requestPasswordReset', {
method: 'POST',
body: { email },
})
},
async resetPassword(token: string, password: string): Promise<void> {
await xrpc('com.atproto.server.resetPassword', {
method: 'POST',
body: { token, password },
})
},
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
return xrpc('com.atproto.server.requestEmailUpdate', {
method: 'POST',
token,
})
},
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
await xrpc('com.atproto.server.updateEmail', {
method: 'POST',
@@ -188,6 +213,7 @@ export const api = {
body: { email, token: emailToken },
})
},
async updateHandle(token: string, handle: string): Promise<void> {
await xrpc('com.atproto.identity.updateHandle', {
method: 'POST',
@@ -195,18 +221,21 @@ export const api = {
body: { handle },
})
},
async requestAccountDelete(token: string): Promise<void> {
await xrpc('com.atproto.server.requestAccountDelete', {
method: 'POST',
token,
})
},
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
await xrpc('com.atproto.server.deleteAccount', {
method: 'POST',
body: { did, password, token: deleteToken },
})
},
async describeServer(): Promise<{
availableUserDomains: string[]
inviteCodeRequired: boolean
@@ -214,6 +243,7 @@ export const api = {
}> {
return xrpc('com.atproto.server.describeServer')
},
async getNotificationPrefs(token: string): Promise<{
preferredChannel: string
email: string
@@ -226,6 +256,7 @@ export const api = {
}> {
return xrpc('com.bspds.account.getNotificationPrefs', { token })
},
async updateNotificationPrefs(token: string, prefs: {
preferredChannel?: string
discordId?: string
@@ -238,6 +269,7 @@ export const api = {
body: prefs,
})
},
async describeRepo(token: string, repo: string): Promise<{
handle: string
did: string
@@ -250,6 +282,7 @@ export const api = {
params: { repo },
})
},
async listRecords(token: string, repo: string, collection: string, options?: {
limit?: number
cursor?: string
@@ -264,6 +297,7 @@ export const api = {
if (options?.reverse) params.reverse = 'true'
return xrpc('com.atproto.repo.listRecords', { token, params })
},
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
uri: string
cid: string
@@ -274,6 +308,7 @@ export const api = {
params: { repo, collection, rkey },
})
},
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
uri: string
cid: string
@@ -284,6 +319,7 @@ export const api = {
body: { repo, collection, record, rkey },
})
},
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
uri: string
cid: string
@@ -294,6 +330,7 @@ export const api = {
body: { repo, collection, rkey, record },
})
},
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
await xrpc('com.atproto.repo.deleteRecord', {
method: 'POST',
+16
View File
@@ -1,15 +1,19 @@
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
const STORAGE_KEY = 'bspds_session'
interface AuthState {
session: Session | null
loading: boolean
error: string | null
}
let state = $state<AuthState>({
session: null,
loading: true,
error: null,
})
function saveSession(session: Session | null) {
if (session) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
@@ -17,6 +21,7 @@ function saveSession(session: Session | null) {
localStorage.removeItem(STORAGE_KEY)
}
}
function loadSession(): Session | null {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
@@ -28,6 +33,7 @@ function loadSession(): Session | null {
}
return null
}
export async function initAuth() {
state.loading = true
state.error = null
@@ -54,6 +60,7 @@ export async function initAuth() {
}
state.loading = false
}
export async function login(identifier: string, password: string): Promise<void> {
state.loading = true
state.error = null
@@ -72,6 +79,7 @@ export async function login(identifier: string, password: string): Promise<void>
state.loading = false
}
}
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
try {
const result = await api.createAccount(params)
@@ -85,6 +93,7 @@ export async function register(params: CreateAccountParams): Promise<CreateAccou
throw e
}
}
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
state.loading = true
state.error = null
@@ -113,6 +122,7 @@ export async function confirmSignup(did: string, verificationCode: string): Prom
state.loading = false
}
}
export async function resendVerification(did: string): Promise<void> {
try {
await api.resendVerification(did)
@@ -123,6 +133,7 @@ export async function resendVerification(did: string): Promise<void> {
throw new Error('Failed to resend verification code')
}
}
export async function logout(): Promise<void> {
if (state.session) {
try {
@@ -134,20 +145,25 @@ export async function logout(): Promise<void> {
state.session = null
saveSession(null)
}
export function getAuthState() {
return state
}
export function getToken(): string | null {
return state.session?.accessJwt ?? null
}
export function isAuthenticated(): boolean {
return state.session !== null
}
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
state.session = newState.session
state.loading = newState.loading
state.error = newState.error
}
export function _testReset() {
state.session = null
state.loading = true
+3
View File
@@ -1,10 +1,13 @@
let currentPath = $state(window.location.hash.slice(1) || '/')
window.addEventListener('hashchange', () => {
currentPath = window.location.hash.slice(1) || '/'
})
export function navigate(path: string) {
window.location.hash = path
}
export function getCurrentPath() {
return currentPath
}
+2
View File
@@ -1,6 +1,8 @@
import App from './App.svelte'
import { mount } from 'svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+4
View File
@@ -1,7 +1,9 @@
import '@testing-library/jest-dom/vitest'
import { vi, beforeEach, afterEach } from 'vitest'
import { _testReset } from '../lib/auth.svelte'
let locationHash = ''
Object.defineProperty(window, 'location', {
value: {
get hash() { return locationHash },
@@ -19,6 +21,7 @@ Object.defineProperty(window, 'location', {
writable: true,
configurable: true,
})
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
@@ -26,6 +29,7 @@ beforeEach(() => {
locationHash = ''
_testReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
+7
View File
@@ -1,6 +1,7 @@
import { render, type RenderResult } from '@testing-library/svelte'
import { tick } from 'svelte'
import type { ComponentType } from 'svelte'
export async function renderAndWait<T extends ComponentType>(
component: T,
options?: Parameters<typeof render>[1]
@@ -10,6 +11,7 @@ export async function renderAndWait<T extends ComponentType>(
await new Promise(resolve => setTimeout(resolve, 0))
return result
}
export async function waitForElement(
queryFn: () => HTMLElement | null,
timeout = 1000
@@ -22,6 +24,7 @@ export async function waitForElement(
}
throw new Error('Element not found within timeout')
}
export async function waitForElementToDisappear(
queryFn: () => HTMLElement | null,
timeout = 1000
@@ -34,6 +37,7 @@ export async function waitForElementToDisappear(
}
throw new Error('Element still present after timeout')
}
export async function waitForText(
container: HTMLElement,
text: string | RegExp,
@@ -49,6 +53,7 @@ export async function waitForText(
}
throw new Error(`Text "${text}" not found within timeout`)
}
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
const store: Record<string, string> = { ...initialData }
Object.defineProperty(window, 'localStorage', {
@@ -63,6 +68,7 @@ export function mockLocalStorage(initialData: Record<string, string> = {}): void
writable: true,
})
}
export function setAuthState(session: {
did: string
handle: string
@@ -73,6 +79,7 @@ export function setAuthState(session: {
}): void {
localStorage.setItem('session', JSON.stringify(session))
}
export function clearAuthState(): void {
localStorage.removeItem('session')
}
+1
View File
@@ -1,4 +1,5 @@
mod preferences;
mod profile;
pub use preferences::{get_preferences, put_preferences};
pub use profile::{get_profile, get_profiles};
+3
View File
@@ -7,9 +7,11 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
const MAX_PREFERENCE_SIZE: usize = 10_000;
#[derive(Serialize)]
pub struct GetPreferencesOutput {
pub preferences: Vec<Value>,
@@ -84,6 +86,7 @@ pub async fn get_preferences(
.collect();
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
}
#[derive(Deserialize)]
pub struct PutPreferencesInput {
pub preferences: Vec<Value>,
+9
View File
@@ -11,14 +11,17 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::{error, info};
#[derive(Deserialize)]
pub struct GetProfileParams {
pub actor: String,
}
#[derive(Deserialize)]
pub struct GetProfilesParams {
pub actors: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProfileViewDetailed {
@@ -35,10 +38,12 @@ pub struct ProfileViewDetailed {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Serialize, Deserialize)]
pub struct GetProfilesOutput {
pub profiles: Vec<ProfileViewDetailed>,
}
async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value> {
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
@@ -55,6 +60,7 @@ async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value>
let block_bytes = state.block_store.get(&cid).await.ok()??;
serde_ipld_dagcbor::from_slice(&block_bytes).ok()
}
fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Value) {
if let Some(display_name) = local_record.get("displayName").and_then(|v| v.as_str()) {
profile.display_name = Some(display_name.to_string());
@@ -63,6 +69,7 @@ fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Va
profile.description = Some(description.to_string());
}
}
async fn proxy_to_appview(
method: &str,
params: &HashMap<String, String>,
@@ -104,6 +111,7 @@ async fn proxy_to_appview(
}
}
}
pub async fn get_profile(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -146,6 +154,7 @@ pub async fn get_profile(
}
(StatusCode::OK, Json(profile)).into_response()
}
pub async fn get_profiles(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+2
View File
@@ -8,10 +8,12 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct DeleteAccountInput {
pub did: String,
}
pub async fn delete_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+3
View File
@@ -8,6 +8,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendEmailInput {
@@ -17,10 +18,12 @@ pub struct SendEmailInput {
pub subject: Option<String>,
pub comment: Option<String>,
}
#[derive(Serialize)]
pub struct SendEmailOutput {
pub sent: bool,
}
pub async fn send_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+6
View File
@@ -8,10 +8,12 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct GetAccountInfoParams {
pub did: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
@@ -24,11 +26,13 @@ pub struct AccountInfo {
pub email_confirmed_at: Option<String>,
pub deactivated_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetAccountInfosOutput {
pub infos: Vec<AccountInfo>,
}
pub async fn get_account_info(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -92,10 +96,12 @@ pub async fn get_account_info(
}
}
}
#[derive(Deserialize)]
pub struct GetAccountInfosParams {
pub dids: String,
}
pub async fn get_account_infos(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+1
View File
@@ -3,6 +3,7 @@ mod email;
mod info;
mod profile;
mod update;
pub use delete::{delete_account, DeleteAccountInput};
pub use email::{send_email, SendEmailInput, SendEmailOutput};
pub use info::{
+6
View File
@@ -8,11 +8,13 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct UpdateAccountEmailInput {
pub account: String,
pub email: String,
}
pub async fn update_account_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -59,11 +61,13 @@ pub async fn update_account_email(
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountHandleInput {
pub did: String,
pub handle: String,
}
pub async fn update_account_handle(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -139,11 +143,13 @@ pub async fn update_account_handle(
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountPasswordInput {
pub did: String,
pub password: String,
}
pub async fn update_account_password(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+11
View File
@@ -8,12 +8,14 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisableInviteCodesInput {
pub codes: Option<Vec<String>>,
pub accounts: Option<Vec<String>>,
}
pub async fn disable_invite_codes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -51,12 +53,14 @@ pub async fn disable_invite_codes(
}
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Deserialize)]
pub struct GetInviteCodesParams {
pub sort: Option<String>,
pub limit: Option<i64>,
pub cursor: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeInfo {
@@ -68,17 +72,20 @@ pub struct InviteCodeInfo {
pub created_at: String,
pub uses: Vec<InviteCodeUseInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeUseInfo {
pub used_by: String,
pub used_at: String,
}
#[derive(Serialize)]
pub struct GetInviteCodesOutput {
pub cursor: Option<String>,
pub codes: Vec<InviteCodeInfo>,
}
pub async fn get_invite_codes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -192,10 +199,12 @@ pub async fn get_invite_codes(
)
.into_response()
}
#[derive(Deserialize)]
pub struct DisableAccountInvitesInput {
pub account: String,
}
pub async fn disable_account_invites(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -241,10 +250,12 @@ pub async fn disable_account_invites(
}
}
}
#[derive(Deserialize)]
pub struct EnableAccountInvitesInput {
pub account: String,
}
pub async fn enable_account_invites(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+1
View File
@@ -1,6 +1,7 @@
pub mod account;
pub mod invite;
pub mod status;
pub use account::{
create_profile, create_record_admin, delete_account, get_account_info, get_account_infos,
send_email, update_account_email, update_account_handle, update_account_password,
+7
View File
@@ -8,24 +8,28 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct GetSubjectStatusParams {
pub did: Option<String>,
pub uri: Option<String>,
pub blob: Option<String>,
}
#[derive(Serialize)]
pub struct SubjectStatus {
pub subject: serde_json::Value,
pub takedown: Option<StatusAttr>,
pub deactivated: Option<StatusAttr>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusAttr {
pub applied: bool,
pub r#ref: Option<String>,
}
pub async fn get_subject_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -184,6 +188,7 @@ pub async fn get_subject_status(
)
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSubjectStatusInput {
@@ -191,11 +196,13 @@ pub struct UpdateSubjectStatusInput {
pub takedown: Option<StatusAttrInput>,
pub deactivated: Option<StatusAttrInput>,
}
#[derive(Deserialize)]
pub struct StatusAttrInput {
pub apply: bool,
pub r#ref: Option<String>,
}
pub async fn update_subject_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+7
View File
@@ -4,12 +4,14 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct ErrorBody {
error: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Debug)]
pub enum ApiError {
InternalError,
@@ -46,6 +48,7 @@ pub enum ApiError {
UpstreamUnavailable(String),
UpstreamError { status: u16, error: Option<String>, message: Option<String> },
}
impl ApiError {
fn status_code(&self) -> StatusCode {
match self {
@@ -144,6 +147,7 @@ impl ApiError {
Self::UpstreamError { status, error: None, message: None }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = ErrorBody {
@@ -153,12 +157,14 @@ impl IntoResponse for ApiError {
(self.status_code(), Json(body)).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("Database error: {:?}", e);
Self::DatabaseError
}
}
impl From<crate::auth::TokenValidationError> for ApiError {
fn from(e: crate::auth::TokenValidationError) -> Self {
match e {
@@ -169,6 +175,7 @@ impl From<crate::auth::TokenValidationError> for ApiError {
}
}
}
impl From<crate::util::DbLookupError> for ApiError {
fn from(e: crate::util::DbLookupError) -> Self {
match e {
+3
View File
@@ -13,12 +13,14 @@ use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetActorLikesParams {
pub actor: String,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<LikeRecord>]) {
for like in likes {
let like_time = &like.indexed_at.to_rfc3339();
@@ -57,6 +59,7 @@ fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<
);
}
}
pub async fn get_actor_likes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+3
View File
@@ -13,6 +13,7 @@ use axum::{
use serde::Deserialize;
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetAuthorFeedParams {
pub actor: String,
@@ -22,6 +23,7 @@ pub struct GetAuthorFeedParams {
#[serde(rename = "includePins")]
pub include_pins: Option<bool>,
}
fn update_author_profile_in_feed(
feed: &mut [FeedViewPost],
author_did: &str,
@@ -35,6 +37,7 @@ fn update_author_profile_in_feed(
}
}
}
pub async fn get_author_feed(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+2
View File
@@ -11,12 +11,14 @@ use axum::{
use serde::Deserialize;
use std::collections::HashMap;
use tracing::{error, info};
#[derive(Deserialize)]
pub struct GetFeedParams {
pub feed: String,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
pub async fn get_feed(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+1
View File
@@ -3,6 +3,7 @@ mod author_feed;
mod custom_feed;
mod post_thread;
mod timeline;
pub use actor_likes::get_actor_likes;
pub use author_feed::get_author_feed;
pub use custom_feed::get_feed;
+10
View File
@@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetPostThreadParams {
pub uri: String,
@@ -20,6 +21,7 @@ pub struct GetPostThreadParams {
#[serde(rename = "parentHeight")]
pub parent_height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadViewPost {
@@ -33,6 +35,7 @@ pub struct ThreadViewPost {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ThreadNode {
@@ -40,6 +43,7 @@ pub enum ThreadNode {
NotFound(ThreadNotFound),
Blocked(ThreadBlocked),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadNotFound {
@@ -48,6 +52,7 @@ pub struct ThreadNotFound {
pub uri: String,
pub not_found: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadBlocked {
@@ -57,13 +62,16 @@ pub struct ThreadBlocked {
pub blocked: bool,
pub author: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostThreadOutput {
pub thread: ThreadNode,
#[serde(skip_serializing_if = "Option::is_none")]
pub threadgate: Option<Value>,
}
const MAX_THREAD_DEPTH: usize = 10;
fn add_replies_to_thread(
thread: &mut ThreadViewPost,
local_posts: &[RecordDescript<PostRecord>],
@@ -111,6 +119,7 @@ fn add_replies_to_thread(
}
}
}
pub async fn get_post_thread(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -190,6 +199,7 @@ pub async fn get_post_thread(
let lag = get_local_lag(&local_records);
format_munged_response(thread_output, lag)
}
async fn handle_not_found(
state: &AppState,
uri: &str,
+4
View File
@@ -15,12 +15,14 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::warn;
#[derive(Deserialize)]
pub struct GetTimelineParams {
pub algorithm: Option<String>,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
pub async fn get_timeline(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -56,6 +58,7 @@ pub async fn get_timeline(
}
get_timeline_local_only(&state, &auth_user.did).await
}
async fn get_timeline_with_appview(
state: &AppState,
headers: &axum::http::HeaderMap,
@@ -123,6 +126,7 @@ async fn get_timeline_with_appview(
let lag = get_local_lag(&local_records);
format_munged_response(feed_output, lag)
}
async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
let user_id: uuid::Uuid = match sqlx::query_scalar!(
"SELECT id FROM users WHERE did = $1",
+4
View File
@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
@@ -31,6 +32,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"unknown".to_string()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -45,6 +47,7 @@ pub struct CreateAccountInput {
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountOutput {
@@ -53,6 +56,7 @@ pub struct CreateAccountOutput {
pub verification_required: bool,
pub verification_channel: String,
}
pub async fn create_account(
State(state): State<AppState>,
headers: HeaderMap,
+14
View File
@@ -13,10 +13,12 @@ use reqwest;
use serde::Deserialize;
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct ResolveHandleParams {
pub handle: String,
}
pub async fn resolve_handle(
State(state): State<AppState>,
Query(params): Query<ResolveHandleParams>,
@@ -63,6 +65,7 @@ pub async fn resolve_handle(
}
}
}
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
let public_key = secret_key.public_key();
@@ -78,6 +81,7 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
"y": y_b64
}))
}
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
// Kinda for local dev, encode hostname if it contains port
@@ -96,6 +100,7 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
}]
}))
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
@@ -174,6 +179,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}]
})).into_response()
}
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
let expected_prefix = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
@@ -242,6 +248,7 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
}
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetRecommendedDidCredentialsOutput {
@@ -250,16 +257,19 @@ pub struct GetRecommendedDidCredentialsOutput {
pub verification_methods: VerificationMethods,
pub services: Services,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerificationMethods {
pub atproto: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Services {
pub atproto_pds: AtprotoPds,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AtprotoPds {
@@ -267,6 +277,7 @@ pub struct AtprotoPds {
pub service_type: String,
pub endpoint: String,
}
pub async fn get_recommended_did_credentials(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -329,10 +340,12 @@ pub async fn get_recommended_did_credentials(
)
.into_response()
}
#[derive(Deserialize)]
pub struct UpdateHandleInput {
pub handle: String,
}
pub async fn update_handle(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -410,6 +423,7 @@ pub async fn update_handle(
}
}
}
pub async fn well_known_atproto_did(
State(state): State<AppState>,
headers: HeaderMap,
+1
View File
@@ -1,6 +1,7 @@
pub mod account;
pub mod did;
pub mod plc;
pub use account::create_account;
pub use did::{
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did,
+1
View File
@@ -1,6 +1,7 @@
mod request;
mod sign;
mod submit;
pub use request::request_plc_operation_signature;
pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput};
pub use submit::{submit_plc_operation, SubmitPlcOperationInput};
+2
View File
@@ -9,9 +9,11 @@ use axum::{
use chrono::{Duration, Utc};
use serde_json::json;
use tracing::{error, info, warn};
fn generate_plc_token() -> String {
crate::util::generate_token_code()
}
pub async fn request_plc_operation_signature(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+4
View File
@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignPlcOperationInput {
@@ -25,16 +26,19 @@ pub struct SignPlcOperationInput {
pub verification_methods: Option<HashMap<String, String>>,
pub services: Option<HashMap<String, ServiceInput>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: String,
pub endpoint: String,
}
#[derive(Debug, Serialize)]
pub struct SignPlcOperationOutput {
pub operation: Value,
}
pub async fn sign_plc_operation(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+2
View File
@@ -12,10 +12,12 @@ use k256::ecdsa::SigningKey;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
pub struct SubmitPlcOperationInput {
pub operation: Value,
}
pub async fn submit_plc_operation(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+1
View File
@@ -13,5 +13,6 @@ pub mod repo;
pub mod server;
pub mod temp;
pub mod validation;
pub use error::ApiError;
pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts};
+3
View File
@@ -9,6 +9,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::error;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateReportInput {
@@ -16,6 +17,7 @@ pub struct CreateReportInput {
pub reason: Option<String>,
pub subject: Value,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateReportOutput {
@@ -26,6 +28,7 @@ pub struct CreateReportOutput {
pub reported_by: String,
pub created_at: String,
}
pub async fn create_report(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+1
View File
@@ -1,2 +1,3 @@
mod register_push;
pub use register_push::register_push;
+3
View File
@@ -10,6 +10,7 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterPushInput {
@@ -18,7 +19,9 @@ pub struct RegisterPushInput {
pub platform: String,
pub app_id: String,
}
const VALID_PLATFORMS: &[&str] = &["ios", "android", "web"];
pub async fn register_push(
State(state): State<AppState>,
headers: HeaderMap,
+4
View File
@@ -10,6 +10,7 @@ use sqlx::Row;
use tracing::info;
use crate::auth::validate_bearer_token;
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPrefsResponse {
@@ -22,6 +23,7 @@ pub struct NotificationPrefsResponse {
pub signal_number: Option<String>,
pub signal_verified: bool,
}
pub async fn get_notification_prefs(
State(state): State<AppState>,
headers: HeaderMap,
@@ -96,6 +98,7 @@ pub async fn get_notification_prefs(
})
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNotificationPrefsInput {
@@ -104,6 +107,7 @@ pub struct UpdateNotificationPrefsInput {
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
}
pub async fn update_notification_prefs(
State(state): State<AppState>,
headers: HeaderMap,
+1
View File
@@ -8,6 +8,7 @@ use axum::{
use crate::api::proxy_client::proxy_client;
use std::collections::HashMap;
use tracing::{error, info};
pub async fn proxy_handler(
State(state): State<AppState>,
Path(method): Path<String>,
+15
View File
@@ -3,11 +3,14 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::OnceLock;
use std::time::Duration;
use tracing::warn;
pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
static PROXY_CLIENT: OnceLock<Client> = OnceLock::new();
pub fn proxy_client() -> &'static Client {
PROXY_CLIENT.get_or_init(|| {
ClientBuilder::new()
@@ -20,6 +23,7 @@ pub fn proxy_client() -> &'static Client {
.expect("Failed to build HTTP client - this indicates a TLS or system configuration issue")
})
}
pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
let scheme = parsed.scheme();
@@ -61,6 +65,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
}
Ok(())
}
fn is_unicast_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
@@ -74,6 +79,7 @@ fn is_unicast_ip(ip: &IpAddr) -> bool {
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_multicast() && !v6.is_unspecified(),
}
}
fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
let octets = ip.octets();
octets[0] == 10
@@ -81,6 +87,7 @@ fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
|| (octets[0] == 192 && octets[1] == 168)
|| (octets[0] == 169 && octets[1] == 254)
}
#[derive(Debug, Clone)]
pub enum SsrfError {
InvalidUrl,
@@ -89,6 +96,7 @@ pub enum SsrfError {
NonUnicastIp(String),
DnsResolutionFailed(String),
}
impl std::fmt::Display for SsrfError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -100,7 +108,9 @@ impl std::fmt::Display for SsrfError {
}
}
}
impl std::error::Error for SsrfError {}
pub const HEADERS_TO_FORWARD: &[&str] = &[
"accept-language",
"atproto-accept-labelers",
@@ -112,6 +122,7 @@ pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
"retry-after",
"content-type",
];
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
if !uri.starts_with("at://") {
return Err("URI must start with at://");
@@ -137,12 +148,14 @@ pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
rkey: parts.get(2).map(|s| s.to_string()),
})
}
#[derive(Debug, Clone)]
pub struct AtUriParts {
pub did: String,
pub collection: Option<String>,
pub rkey: Option<String>,
}
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
match limit {
Some(l) if l == 0 => default,
@@ -151,6 +164,7 @@ pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
None => default,
}
}
pub fn validate_did(did: &str) -> Result<(), &'static str> {
if !did.starts_with("did:") {
return Err("Invalid DID format");
@@ -165,6 +179,7 @@ pub fn validate_did(did: &str) -> Result<(), &'static str> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+19
View File
@@ -17,8 +17,10 @@ use serde_json::Value;
use std::collections::HashMap;
use tracing::{error, info, warn};
use uuid::Uuid;
pub const REPO_REV_HEADER: &str = "atproto-repo-rev";
pub const UPSTREAM_LAG_HEADER: &str = "atproto-upstream-lag";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostRecord {
@@ -39,6 +41,7 @@ pub struct PostRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRecord {
@@ -55,6 +58,7 @@ pub struct ProfileRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone)]
pub struct RecordDescript<T> {
pub uri: String,
@@ -62,6 +66,7 @@ pub struct RecordDescript<T> {
pub indexed_at: DateTime<Utc>,
pub record: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeRecord {
@@ -72,12 +77,14 @@ pub struct LikeRecord {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeSubject {
pub uri: String,
pub cid: String,
}
#[derive(Debug, Default)]
pub struct LocalRecords {
pub count: usize,
@@ -85,6 +92,7 @@ pub struct LocalRecords {
pub posts: Vec<RecordDescript<PostRecord>>,
pub likes: Vec<RecordDescript<LikeRecord>>,
}
pub async fn get_records_since_rev(
state: &AppState,
did: &str,
@@ -187,6 +195,7 @@ pub async fn get_records_since_rev(
}
Ok(result)
}
pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
let mut oldest: Option<DateTime<Utc>> = local.profile.as_ref().map(|p| p.indexed_at);
for post in &local.posts {
@@ -205,18 +214,21 @@ pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
}
oldest.map(|o| (Utc::now() - o).num_milliseconds())
}
pub fn extract_repo_rev(headers: &HeaderMap) -> Option<String> {
headers
.get(REPO_REV_HEADER)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
}
#[derive(Debug)]
pub struct ProxyResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: bytes::Bytes,
}
pub async fn proxy_to_appview(
method: &str,
params: &HashMap<String, String>,
@@ -297,6 +309,7 @@ pub async fn proxy_to_appview(
}
}
}
pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Response {
let mut response = (StatusCode::OK, Json(data)).into_response();
if let Some(lag_ms) = lag {
@@ -308,6 +321,7 @@ pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Respon
}
response
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorView {
@@ -320,6 +334,7 @@ pub struct AuthorView {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostView {
@@ -341,6 +356,7 @@ pub struct PostView {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedViewPost {
@@ -354,12 +370,14 @@ pub struct FeedViewPost {
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedOutput {
pub feed: Vec<FeedViewPost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
pub fn format_local_post(
descript: &RecordDescript<PostRecord>,
author_did: &str,
@@ -387,6 +405,7 @@ pub fn format_local_post(
extra: HashMap::new(),
}
}
pub fn insert_posts_into_feed(feed: &mut Vec<FeedViewPost>, posts: Vec<PostView>) {
if posts.is_empty() {
return;
+7
View File
@@ -14,7 +14,9 @@ use serde_json::json;
use sha2::{Digest, Sha256};
use std::str::FromStr;
use tracing::error;
const MAX_BLOB_SIZE: usize = 1_000_000;
pub async fn upload_blob(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -154,22 +156,26 @@ pub async fn upload_blob(
}))
.into_response()
}
#[derive(Deserialize)]
pub struct ListMissingBlobsParams {
pub limit: Option<i64>,
pub cursor: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordBlob {
pub cid: String,
pub record_uri: String,
}
#[derive(Serialize)]
pub struct ListMissingBlobsOutput {
pub cursor: Option<String>,
pub blobs: Vec<RecordBlob>,
}
fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
if let Some(obj) = val.as_object() {
if let Some(type_val) = obj.get("$type") {
@@ -192,6 +198,7 @@ fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
}
}
}
pub async fn list_missing_blobs(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+3
View File
@@ -11,8 +11,10 @@ use axum::{
};
use serde_json::json;
use tracing::{debug, error, info, warn};
const DEFAULT_MAX_IMPORT_SIZE: usize = 100 * 1024 * 1024;
const DEFAULT_MAX_BLOCKS: usize = 50000;
pub async fn import_repo(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -355,6 +357,7 @@ pub async fn import_repo(
}
}
}
async fn sequence_import_event(
state: &AppState,
did: &str,
+2
View File
@@ -7,10 +7,12 @@ use axum::{
};
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
pub struct DescribeRepoInput {
pub repo: String,
}
pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
+1
View File
@@ -2,6 +2,7 @@ pub mod blob;
pub mod import;
pub mod meta;
pub mod record;
pub use blob::{list_missing_blobs, upload_blob};
pub use import::import_repo;
pub use meta::describe_repo;
+7
View File
@@ -17,7 +17,9 @@ use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
const MAX_BATCH_WRITES: usize = 200;
#[derive(Deserialize)]
#[serde(tag = "$type")]
pub enum WriteOp {
@@ -36,6 +38,7 @@ pub enum WriteOp {
#[serde(rename = "com.atproto.repo.applyWrites#delete")]
Delete { collection: String, rkey: String },
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyWritesInput {
@@ -44,6 +47,7 @@ pub struct ApplyWritesInput {
pub writes: Vec<WriteOp>,
pub swap_commit: Option<String>,
}
#[derive(Serialize)]
#[serde(tag = "$type")]
pub enum WriteResult {
@@ -54,16 +58,19 @@ pub enum WriteResult {
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
DeleteResult {},
}
#[derive(Serialize)]
pub struct ApplyWritesOutput {
pub commit: CommitInfo,
pub results: Vec<WriteResult>,
}
#[derive(Serialize)]
pub struct CommitInfo {
pub cid: String,
pub rev: String,
}
pub async fn apply_writes(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+2
View File
@@ -16,6 +16,7 @@ use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
#[derive(Deserialize)]
pub struct DeleteRecordInput {
pub repo: String,
@@ -26,6 +27,7 @@ pub struct DeleteRecordInput {
#[serde(rename = "swapCommit")]
pub swap_commit: Option<String>,
}
pub async fn delete_record(
State(state): State<AppState>,
headers: HeaderMap,
+1
View File
@@ -4,6 +4,7 @@ pub mod read;
pub mod utils;
pub mod validation;
pub mod write;
pub use batch::apply_writes;
pub use delete::{DeleteRecordInput, delete_record};
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
+2
View File
@@ -12,6 +12,7 @@ use serde_json::json;
use std::collections::HashMap;
use std::str::FromStr;
use tracing::error;
#[derive(Deserialize)]
pub struct GetRecordInput {
pub repo: String,
@@ -19,6 +20,7 @@ pub struct GetRecordInput {
pub rkey: String,
pub cid: Option<String>,
}
pub async fn get_record(
State(state): State<AppState>,
Query(input): Query<GetRecordInput>,
+4
View File
@@ -28,6 +28,7 @@ struct UnsignedCommit<'a> {
rev: &'a str,
version: i64,
}
fn create_signed_commit(
did: &str,
data: Cid,
@@ -68,15 +69,18 @@ fn create_signed_commit(
.map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?;
Ok((signed_bytes, sig_bytes))
}
pub enum RecordOp {
Create { collection: String, rkey: String, cid: Cid },
Update { collection: String, rkey: String, cid: Cid, prev: Option<Cid> },
Delete { collection: String, rkey: String, prev: Option<Cid> },
}
pub struct CommitResult {
pub commit_cid: Cid,
pub rev: String,
}
pub async fn commit_and_log(
state: &AppState,
did: &str,
+1
View File
@@ -5,6 +5,7 @@ use axum::{
Json,
};
use serde_json::json;
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Response> {
let validator = RecordValidator::new();
match validator.validate(record, collection) {
+2
View File
@@ -18,6 +18,7 @@ use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let row = sqlx::query(
r#"
@@ -44,6 +45,7 @@ pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result
None => Ok(false),
}
}
pub async fn prepare_repo_write(
state: &AppState,
headers: &HeaderMap,
+8
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use uuid::Uuid;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckAccountStatusOutput {
@@ -25,6 +26,7 @@ pub struct CheckAccountStatusOutput {
pub expected_blobs: i64,
pub imported_blobs: i64,
}
pub async fn check_account_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -94,6 +96,7 @@ pub async fn check_account_status(
)
.into_response()
}
pub async fn activate_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -133,11 +136,13 @@ pub async fn activate_account(
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeactivateAccountInput {
pub delete_after: Option<String>,
}
pub async fn deactivate_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -178,6 +183,7 @@ pub async fn deactivate_account(
}
}
}
pub async fn request_account_delete(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -232,12 +238,14 @@ pub async fn request_account_delete(
info!("Account deletion requested for user {}", did);
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Deserialize)]
pub struct DeleteAccountInput {
pub did: String,
pub password: String,
pub token: String,
}
pub async fn delete_account(
State(state): State<AppState>,
Json(input): Json<DeleteAccountInput>,
+8
View File
@@ -11,6 +11,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppPassword {
@@ -18,10 +19,12 @@ pub struct AppPassword {
pub created_at: String,
pub privileged: bool,
}
#[derive(Serialize)]
pub struct ListAppPasswordsOutput {
pub passwords: Vec<AppPassword>,
}
pub async fn list_app_passwords(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
@@ -54,11 +57,13 @@ pub async fn list_app_passwords(
}
}
}
#[derive(Deserialize)]
pub struct CreateAppPasswordInput {
pub name: String,
pub privileged: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAppPasswordOutput {
@@ -67,6 +72,7 @@ pub struct CreateAppPasswordOutput {
pub created_at: String,
pub privileged: bool,
}
pub async fn create_app_password(
State(state): State<AppState>,
headers: HeaderMap,
@@ -146,10 +152,12 @@ pub async fn create_app_password(
}
}
}
#[derive(Deserialize)]
pub struct RevokeAppPasswordInput {
pub name: String,
}
pub async fn revoke_app_password(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
+7
View File
@@ -10,14 +10,17 @@ use chrono::{Duration, Utc};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
fn generate_confirmation_code() -> String {
crate::util::generate_token_code()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
pub email: String,
}
pub async fn request_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -119,12 +122,14 @@ pub async fn request_email_update(
info!("Email update requested for user {}", user_id);
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmEmailInput {
pub email: String,
pub token: String,
}
pub async fn confirm_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -236,6 +241,7 @@ pub async fn confirm_email(
info!("Email updated for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateEmailInput {
@@ -244,6 +250,7 @@ pub struct UpdateEmailInput {
pub email_auth_factor: Option<bool>,
pub token: Option<String>,
}
pub async fn update_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+12
View File
@@ -10,16 +10,19 @@ use axum::{
use serde::{Deserialize, Serialize};
use tracing::error;
use uuid::Uuid;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateInviteCodeInput {
pub use_count: i32,
pub for_account: Option<String>,
}
#[derive(Serialize)]
pub struct CreateInviteCodeOutput {
pub code: String,
}
pub async fn create_invite_code(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
@@ -81,6 +84,7 @@ pub async fn create_invite_code(
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateInviteCodesInput {
@@ -88,15 +92,18 @@ pub struct CreateInviteCodesInput {
pub use_count: i32,
pub for_accounts: Option<Vec<String>>,
}
#[derive(Serialize)]
pub struct CreateInviteCodesOutput {
pub codes: Vec<AccountCodes>,
}
#[derive(Serialize)]
pub struct AccountCodes {
pub account: String,
pub codes: Vec<String>,
}
pub async fn create_invite_codes(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
@@ -172,12 +179,14 @@ pub async fn create_invite_codes(
}
Json(CreateInviteCodesOutput { codes: result_codes }).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetAccountInviteCodesParams {
pub include_used: Option<bool>,
pub create_available: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCode {
@@ -189,16 +198,19 @@ pub struct InviteCode {
pub created_at: String,
pub uses: Vec<InviteCodeUse>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeUse {
pub used_by: String,
pub used_at: String,
}
#[derive(Serialize)]
pub struct GetAccountInviteCodesOutput {
pub codes: Vec<InviteCode>,
}
pub async fn get_account_invite_codes(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
+1
View File
@@ -7,6 +7,7 @@ pub mod password;
pub mod service_auth;
pub mod session;
pub mod signing_key;
pub use account_status::{
activate_account, check_account_status, deactivate_account, delete_account,
request_account_delete,
+5
View File
@@ -10,6 +10,7 @@ use chrono::{Duration, Utc};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
fn generate_reset_code() -> String {
crate::util::generate_token_code()
}
@@ -28,10 +29,12 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"unknown".to_string()
}
#[derive(Deserialize)]
pub struct RequestPasswordResetInput {
pub email: String,
}
pub async fn request_password_reset(
State(state): State<AppState>,
headers: HeaderMap,
@@ -102,11 +105,13 @@ pub async fn request_password_reset(
info!("Password reset requested for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Deserialize)]
pub struct ResetPasswordInput {
pub token: String,
pub password: String,
}
pub async fn reset_password(
State(state): State<AppState>,
headers: HeaderMap,
+3
View File
@@ -9,16 +9,19 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: String,
pub lxm: Option<String>,
pub exp: Option<i64>,
}
#[derive(Serialize)]
pub struct GetServiceAuthOutput {
pub token: String,
}
pub async fn get_service_auth(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+12
View File
@@ -12,6 +12,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
@@ -27,11 +28,13 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"unknown".to_string()
}
#[derive(Deserialize)]
pub struct CreateSessionInput {
pub identifier: String,
pub password: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionOutput {
@@ -40,6 +43,7 @@ pub struct CreateSessionOutput {
pub handle: String,
pub did: String,
}
pub async fn create_session(
State(state): State<AppState>,
headers: HeaderMap,
@@ -155,6 +159,7 @@ pub async fn create_session(
did: row.did,
}).into_response()
}
pub async fn get_session(
State(state): State<AppState>,
BearerAuth(auth_user): BearerAuth,
@@ -194,6 +199,7 @@ pub async fn get_session(
}
}
}
pub async fn delete_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -227,6 +233,7 @@ pub async fn delete_session(
}
}
}
pub async fn refresh_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -395,12 +402,14 @@ pub async fn refresh_session(
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmSignupInput {
pub did: String,
pub verification_code: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmSignupOutput {
@@ -413,6 +422,7 @@ pub struct ConfirmSignupOutput {
pub preferred_channel: String,
pub preferred_channel_verified: bool,
}
pub async fn confirm_signup(
State(state): State<AppState>,
Json(input): Json<ConfirmSignupInput>,
@@ -535,11 +545,13 @@ pub async fn confirm_signup(
preferred_channel_verified: true,
}).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendVerificationInput {
pub did: String,
}
pub async fn resend_verification(
State(state): State<AppState>,
Json(input): Json<ResendVerificationInput>,
+5
View File
@@ -10,7 +10,9 @@ use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info};
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
let verifying_key = signing_key.verifying_key();
let compressed_pubkey = verifying_key.to_sec1_bytes();
@@ -20,15 +22,18 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> String {
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
format!("did:key:{}", encoded)
}
#[derive(Deserialize)]
pub struct ReserveSigningKeyInput {
pub did: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReserveSigningKeyOutput {
pub signing_key: String,
}
pub async fn reserve_signing_key(
State(state): State<AppState>,
Json(input): Json<ReserveSigningKeyInput>,
+2
View File
@@ -8,6 +8,7 @@ use serde::Serialize;
use serde_json::json;
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckSignupQueueOutput {
@@ -17,6 +18,7 @@ pub struct CheckSignupQueueOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_time_ms: Option<i64>,
}
pub async fn check_signup_queue(
State(state): State<AppState>,
headers: HeaderMap,
+2
View File
@@ -3,6 +3,7 @@ pub const MAX_LOCAL_PART_LENGTH: usize = 64;
pub const MAX_DOMAIN_LENGTH: usize = 253;
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
pub fn is_valid_email(email: &str) -> bool {
let email = email.trim();
if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
@@ -49,6 +50,7 @@ pub fn is_valid_email(email: &str) -> bool {
}
true
}
#[cfg(test)]
mod tests {
use super::*;
+27
View File
@@ -5,9 +5,12 @@ use axum::{
Json,
};
use serde_json::json;
use crate::state::AppState;
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated};
pub struct BearerAuth(pub AuthenticatedUser);
#[derive(Debug)]
pub enum AuthError {
MissingToken,
@@ -16,6 +19,7 @@ pub enum AuthError {
AccountDeactivated,
AccountTakedown,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error, message) = match self {
@@ -45,41 +49,54 @@ impl IntoResponse for AuthError {
"Account has been taken down",
),
};
(status, Json(json!({ "error": error, "message": message }))).into_response()
}
}
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
let auth_header = auth_header.trim();
if auth_header.len() < 8 {
return Err(AuthError::InvalidFormat);
}
let prefix = &auth_header[..7];
if !prefix.eq_ignore_ascii_case("bearer ") {
return Err(AuthError::InvalidFormat);
}
let token = auth_header[7..].trim();
if token.is_empty() {
return Err(AuthError::InvalidFormat);
}
Ok(token)
}
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
let header = auth_header?;
let header = header.trim();
if header.len() < 7 {
return None;
}
if !header[..7].eq_ignore_ascii_case("bearer ") {
return None;
}
let token = header[7..].trim();
if token.is_empty() {
return None;
}
Some(token.to_string())
}
impl FromRequestParts<AppState> for BearerAuth {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
@@ -90,7 +107,9 @@ impl FromRequestParts<AppState> for BearerAuth {
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
match validate_bearer_token_cached(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
@@ -99,9 +118,12 @@ impl FromRequestParts<AppState> for BearerAuth {
}
}
}
pub struct BearerAuthAllowDeactivated(pub AuthenticatedUser);
impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
@@ -112,7 +134,9 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
@@ -120,9 +144,11 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bearer_token() {
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
@@ -130,6 +156,7 @@ mod tests {
assert_eq!(extract_bearer_token("BEARER abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token(" Bearer abc123 ").unwrap(), "abc123");
assert!(extract_bearer_token("Basic abc123").is_err());
assert!(extract_bearer_token("Bearer").is_err());
assert!(extract_bearer_token("Bearer ").is_err());
+35 -1
View File
@@ -3,10 +3,13 @@ use sqlx::PgPool;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use crate::cache::Cache;
pub mod extractor;
pub mod token;
pub mod verify;
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header};
pub use token::{
create_access_token, create_refresh_token, create_service_token,
@@ -16,8 +19,10 @@ pub use token::{
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
};
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
const KEY_CACHE_TTL_SECS: u64 = 300;
const SESSION_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenValidationError {
AccountDeactivated,
@@ -25,6 +30,7 @@ pub enum TokenValidationError {
KeyDecryptionFailed,
AuthenticationFailed,
}
impl fmt::Display for TokenValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -35,23 +41,27 @@ impl fmt::Display for TokenValidationError {
}
}
}
pub struct AuthenticatedUser {
pub did: String,
pub key_bytes: Option<Vec<u8>>,
pub is_oauth: bool,
}
pub async fn validate_bearer_token(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, None, token, false).await
}
pub async fn validate_bearer_token_allow_deactivated(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, None, token, true).await
}
pub async fn validate_bearer_token_cached(
db: &PgPool,
cache: &Arc<dyn Cache>,
@@ -59,6 +69,7 @@ pub async fn validate_bearer_token_cached(
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, false).await
}
pub async fn validate_bearer_token_cached_allow_deactivated(
db: &PgPool,
cache: &Arc<dyn Cache>,
@@ -66,6 +77,7 @@ pub async fn validate_bearer_token_cached_allow_deactivated(
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, true).await
}
async fn validate_bearer_token_with_options_internal(
db: &PgPool,
cache: Option<&Arc<dyn Cache>>,
@@ -73,9 +85,11 @@ async fn validate_bearer_token_with_options_internal(
allow_deactivated: bool,
) -> Result<AuthenticatedUser, TokenValidationError> {
let did_from_token = get_did_from_token(token).ok();
if let Some(ref did) = did_from_token {
let key_cache_key = format!("auth:key:{}", did);
let mut cached_key: Option<Vec<u8>> = None;
if let Some(c) = cache {
cached_key = c.get_bytes(&key_cache_key).await;
if cached_key.is_some() {
@@ -84,6 +98,7 @@ async fn validate_bearer_token_with_options_internal(
crate::metrics::record_auth_cache_miss("key");
}
}
let (decrypted_key, deactivated_at, takedown_ref) = if let Some(key) = cached_key {
let user_status = sqlx::query!(
"SELECT deactivated_at, takedown_ref FROM users WHERE did = $1",
@@ -93,6 +108,7 @@ async fn validate_bearer_token_with_options_internal(
.await
.ok()
.flatten();
match user_status {
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref),
None => (None, None, None),
@@ -112,25 +128,31 @@ async fn validate_bearer_token_with_options_internal(
{
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
if let Some(c) = cache {
let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await;
}
(Some(key), user.deactivated_at, user.takedown_ref)
} else {
(None, None, None)
}
};
if let Some(decrypted_key) = decrypted_key {
if !allow_deactivated && deactivated_at.is_some() {
return Err(TokenValidationError::AccountDeactivated);
}
if takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
if let Ok(token_data) = verify_access_token(token, &decrypted_key) {
let jti = &token_data.claims.jti;
let session_cache_key = format!("auth:session:{}:{}", did, jti);
let mut session_valid = false;
if let Some(c) = cache {
if let Some(cached_value) = c.get(&session_cache_key).await {
session_valid = cached_value == "1";
@@ -139,6 +161,7 @@ async fn validate_bearer_token_with_options_internal(
crate::metrics::record_auth_cache_miss("session");
}
}
if !session_valid {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
@@ -149,13 +172,16 @@ async fn validate_bearer_token_with_options_internal(
.await
.ok()
.flatten();
session_valid = session_exists.is_some();
if session_valid {
if let Some(c) = cache {
let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await;
}
}
}
if session_valid {
return Ok(AuthenticatedUser {
did: did.clone(),
@@ -166,6 +192,7 @@ async fn validate_bearer_token_with_options_internal(
}
}
}
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
if let Some(oauth_token) = sqlx::query!(
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref
@@ -182,9 +209,11 @@ async fn validate_bearer_token_with_options_internal(
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
return Err(TokenValidationError::AccountDeactivated);
}
if oauth_token.takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
let now = chrono::Utc::now();
if oauth_token.expires_at > now {
return Ok(AuthenticatedUser {
@@ -195,12 +224,15 @@ async fn validate_bearer_token_with_options_internal(
}
}
}
Err(TokenValidationError::AuthenticationFailed)
}
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
let key_cache_key = format!("auth:key:{}", did);
let _ = cache.delete(&key_cache_key).await;
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub iss: String,
@@ -214,17 +246,19 @@ pub struct Claims {
pub lxm: Option<String>,
pub jti: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Header {
pub alg: String,
pub typ: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UnsafeClaims {
pub iss: String,
pub sub: Option<String>,
}
// fancy boy TokenData equivalent for compatibility/structure
pub struct TokenData<T> {
pub claims: T,
}
+42
View File
@@ -7,7 +7,9 @@ use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
use uuid;
type HmacSha256 = Hmac<Sha256>;
pub const TOKEN_TYPE_ACCESS: &str = "at+jwt";
pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt";
pub const TOKEN_TYPE_SERVICE: &str = "jwt";
@@ -15,29 +17,37 @@ pub const SCOPE_ACCESS: &str = "com.atproto.access";
pub const SCOPE_REFRESH: &str = "com.atproto.refresh";
pub const SCOPE_APP_PASS: &str = "com.atproto.appPass";
pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged";
pub struct TokenWithMetadata {
pub token: String,
pub jti: String,
pub expires_at: DateTime<Utc>,
}
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120))
}
pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90))
}
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
iss: did.to_owned(),
sub: did.to_owned(),
@@ -48,8 +58,10 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
};
sign_claims(claims, &signing_key)
}
fn create_signed_token_with_metadata(
did: &str,
scope: &str,
@@ -58,11 +70,14 @@ fn create_signed_token_with_metadata(
duration: Duration,
) -> Result<TokenWithMetadata> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expires_at = Utc::now()
.checked_add_signed(duration)
.expect("valid timestamp");
let expiration = expires_at.timestamp();
let jti = uuid::Uuid::new_v4().to_string();
let claims = Claims {
iss: did.to_owned(),
sub: did.to_owned(),
@@ -76,47 +91,61 @@ fn create_signed_token_with_metadata(
lxm: None,
jti: jti.clone(),
};
let token = sign_claims_with_type(claims, &signing_key, typ)?;
Ok(TokenWithMetadata {
token,
jti,
expires_at,
})
}
fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> {
sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE)
}
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> {
let header = Header {
alg: "ES256K".to_string(),
typ: typ.to_string(),
};
let header_json = serde_json::to_string(&header)?;
let claims_json = serde_json::to_string(&claims)?;
let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json);
let message = format!("{}.{}", header_b64, claims_b64);
let signature: Signature = key.sign(message.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(format!("{}.{}", message, signature_b64))
}
pub fn create_access_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
Ok(create_access_token_hs256_with_metadata(did, secret)?.token)
}
pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
}
pub fn create_access_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, secret, Duration::minutes(120))
}
pub fn create_refresh_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, secret, Duration::days(90))
}
pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]) -> Result<String> {
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
iss: did.to_owned(),
sub: did.to_owned(),
@@ -127,8 +156,10 @@ pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
};
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
}
fn create_hs256_token_with_metadata(
did: &str,
scope: &str,
@@ -139,8 +170,10 @@ fn create_hs256_token_with_metadata(
let expires_at = Utc::now()
.checked_add_signed(duration)
.expect("valid timestamp");
let expiration = expires_at.timestamp();
let jti = uuid::Uuid::new_v4().to_string();
let claims = Claims {
iss: did.to_owned(),
sub: did.to_owned(),
@@ -154,27 +187,36 @@ fn create_hs256_token_with_metadata(
lxm: None,
jti: jti.clone(),
};
let token = sign_claims_hs256(claims, typ, secret)?;
Ok(TokenWithMetadata {
token,
jti,
expires_at,
})
}
fn sign_claims_hs256(claims: Claims, typ: &str, secret: &[u8]) -> Result<String> {
let header = Header {
alg: "HS256".to_string(),
typ: typ.to_string(),
};
let header_json = serde_json::to_string(&header)?;
let claims_json = serde_json::to_string(&claims)?;
let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json);
let message = format!("{}.{}", header_b64, claims_b64);
let mut mac = HmacSha256::new_from_slice(secret)
.map_err(|e| anyhow::anyhow!("Invalid secret length: {}", e))?;
mac.update(message.as_bytes());
let signature = mac.finalize().into_bytes();
let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}.{}", message, signature_b64))
}
+48
View File
@@ -8,37 +8,48 @@ use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn get_did_from_token(token: &str) -> Result<String, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
let claims: UnsafeClaims =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
Ok(claims.sub.unwrap_or(claims.iss))
}
pub fn get_jti_from_token(token: &str) -> Result<String, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
let claims: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
claims.get("jti")
.and_then(|j| j.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No jti claim in token".to_string())
}
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(token, key_bytes, None, None)
}
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(
token,
@@ -47,6 +58,7 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
)
}
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(
token,
@@ -55,6 +67,7 @@ pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<C
Some(&[SCOPE_REFRESH]),
)
}
pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
verify_token_hs256_internal(
token,
@@ -63,6 +76,7 @@ pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
)
}
pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
verify_token_hs256_internal(
token,
@@ -71,6 +85,7 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
Some(&[SCOPE_REFRESH]),
)
}
fn verify_token_internal(
token: &str,
key_bytes: &[u8],
@@ -81,47 +96,61 @@ fn verify_token_internal(
if parts.len() != 3 {
return Err(anyhow!("Invalid token format"));
}
let header_b64 = parts[0];
let claims_b64 = parts[1];
let signature_b64 = parts[2];
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.context("Base64 decode of header failed")?;
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if let Some(expected) = expected_typ {
if header.typ != expected {
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.context("Base64 decode of signature failed")?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
let signing_key = SigningKey::from_slice(key_bytes)?;
let verifying_key = VerifyingKey::from(&signing_key);
let message = format!("{}.{}", header_b64, claims_b64);
verifying_key
.verify(message.as_bytes(), &signature)
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
let claims_bytes = URL_SAFE_NO_PAD
.decode(claims_b64)
.context("Base64 decode of claims failed")?;
let claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
let now = Utc::now().timestamp() as usize;
if claims.exp < now {
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
if !scopes.contains(&token_scope) {
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
}
Ok(TokenData { claims })
}
fn verify_token_hs256_internal(
token: &str,
secret: &[u8],
@@ -132,60 +161,79 @@ fn verify_token_hs256_internal(
if parts.len() != 3 {
return Err(anyhow!("Invalid token format"));
}
let header_b64 = parts[0];
let claims_b64 = parts[1];
let signature_b64 = parts[2];
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.context("Base64 decode of header failed")?;
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if header.alg != "HS256" {
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
}
if let Some(expected) = expected_typ {
if header.typ != expected {
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.context("Base64 decode of signature failed")?;
let message = format!("{}.{}", header_b64, claims_b64);
let mut mac = HmacSha256::new_from_slice(secret)
.map_err(|e| anyhow!("Invalid secret: {}", e))?;
mac.update(message.as_bytes());
let expected_signature = mac.finalize().into_bytes();
let is_valid: bool = signature_bytes.ct_eq(&expected_signature).into();
if !is_valid {
return Err(anyhow!("Signature verification failed"));
}
let claims_bytes = URL_SAFE_NO_PAD
.decode(claims_b64)
.context("Base64 decode of claims failed")?;
let claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
let now = Utc::now().timestamp() as usize;
if claims.exp < now {
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
if !scopes.contains(&token_scope) {
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
}
Ok(TokenData { claims })
}
pub fn get_algorithm_from_token(token: &str) -> Result<String, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
let header: Header =
serde_json::from_slice(&header_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
Ok(header.alg)
}
+24
View File
@@ -2,6 +2,7 @@ use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("Cache connection error: {0}")]
@@ -9,6 +10,7 @@ pub enum CacheError {
#[error("Serialization error: {0}")]
Serialization(String),
}
#[async_trait]
pub trait Cache: Send + Sync {
async fn get(&self, key: &str) -> Option<String>;
@@ -22,10 +24,12 @@ pub trait Cache: Send + Sync {
self.set(key, &encoded, ttl).await
}
}
#[derive(Clone)]
pub struct ValkeyCache {
conn: redis::aio::ConnectionManager,
}
impl ValkeyCache {
pub async fn new(url: &str) -> Result<Self, CacheError> {
let client = redis::Client::open(url)
@@ -36,10 +40,12 @@ impl ValkeyCache {
.map_err(|e| CacheError::Connection(e.to_string()))?;
Ok(Self { conn: manager })
}
pub fn connection(&self) -> redis::aio::ConnectionManager {
self.conn.clone()
}
}
#[async_trait]
impl Cache for ValkeyCache {
async fn get(&self, key: &str) -> Option<String> {
@@ -51,6 +57,7 @@ impl Cache for ValkeyCache {
.ok()
.flatten()
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
redis::cmd("SET")
@@ -62,6 +69,7 @@ impl Cache for ValkeyCache {
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
redis::cmd("DEL")
@@ -71,32 +79,40 @@ impl Cache for ValkeyCache {
.map_err(|e| CacheError::Connection(e.to_string()))
}
}
pub struct NoOpCache;
#[async_trait]
impl Cache for NoOpCache {
async fn get(&self, _key: &str) -> Option<String> {
None
}
async fn set(&self, _key: &str, _value: &str, _ttl: Duration) -> Result<(), CacheError> {
Ok(())
}
async fn delete(&self, _key: &str) -> Result<(), CacheError> {
Ok(())
}
}
#[async_trait]
pub trait DistributedRateLimiter: Send + Sync {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
}
#[derive(Clone)]
pub struct RedisRateLimiter {
conn: redis::aio::ConnectionManager,
}
impl RedisRateLimiter {
pub fn new(conn: redis::aio::ConnectionManager) -> Self {
Self { conn }
}
}
#[async_trait]
impl DistributedRateLimiter for RedisRateLimiter {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
@@ -124,17 +140,21 @@ impl DistributedRateLimiter for RedisRateLimiter {
count <= limit as i64
}
}
pub struct NoOpRateLimiter;
#[async_trait]
impl DistributedRateLimiter for NoOpRateLimiter {
async fn check_rate_limit(&self, _key: &str, _limit: u32, _window_ms: u64) -> bool {
true
}
}
pub enum CacheBackend {
Valkey(ValkeyCache),
NoOp,
}
impl CacheBackend {
pub fn rate_limiter(&self) -> Arc<dyn DistributedRateLimiter> {
match self {
@@ -145,6 +165,7 @@ impl CacheBackend {
}
}
}
#[async_trait]
impl Cache for CacheBackend {
async fn get(&self, key: &str) -> Option<String> {
@@ -153,12 +174,14 @@ impl Cache for CacheBackend {
CacheBackend::NoOp => None,
}
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
match self {
CacheBackend::Valkey(c) => c.set(key, value, ttl).await,
CacheBackend::NoOp => Ok(()),
}
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
match self {
CacheBackend::Valkey(c) => c.delete(key).await,
@@ -166,6 +189,7 @@ impl Cache for CacheBackend {
}
}
}
pub async fn create_cache() -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
match std::env::var("VALKEY_URL") {
Ok(url) => match ValkeyCache::new(&url).await {
+45
View File
@@ -2,12 +2,14 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
Closed,
Open,
HalfOpen,
}
pub struct CircuitBreaker {
name: String,
failure_threshold: u32,
@@ -18,6 +20,7 @@ pub struct CircuitBreaker {
success_count: AtomicU32,
last_failure_time: AtomicU64,
}
impl CircuitBreaker {
pub fn new(name: &str, failure_threshold: u32, success_threshold: u32, timeout_secs: u64) -> Self {
Self {
@@ -31,8 +34,10 @@ impl CircuitBreaker {
last_failure_time: AtomicU64::new(0),
}
}
pub async fn can_execute(&self) -> bool {
let state = self.state.read().await;
match *state {
CircuitState::Closed => true,
CircuitState::Open => {
@@ -41,6 +46,7 @@ impl CircuitBreaker {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if now - last_failure >= self.timeout.as_secs() {
drop(state);
let mut state = self.state.write().await;
@@ -56,8 +62,10 @@ impl CircuitBreaker {
CircuitState::HalfOpen => true,
}
}
pub async fn record_success(&self) {
let state = *self.state.read().await;
match state {
CircuitState::Closed => {
self.failure_count.store(0, Ordering::SeqCst);
@@ -75,8 +83,10 @@ impl CircuitBreaker {
CircuitState::Open => {}
}
}
pub async fn record_failure(&self) {
let state = *self.state.read().await;
match state {
CircuitState::Closed => {
let count = self.failure_count.fetch_add(1, Ordering::SeqCst) + 1;
@@ -110,23 +120,28 @@ impl CircuitBreaker {
CircuitState::Open => {}
}
}
pub async fn state(&self) -> CircuitState {
*self.state.read().await
}
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Clone)]
pub struct CircuitBreakers {
pub plc_directory: Arc<CircuitBreaker>,
pub relay_notification: Arc<CircuitBreaker>,
}
impl Default for CircuitBreakers {
fn default() -> Self {
Self::new()
}
}
impl CircuitBreakers {
pub fn new() -> Self {
Self {
@@ -135,16 +150,20 @@ impl CircuitBreakers {
}
}
}
#[derive(Debug)]
pub struct CircuitOpenError {
pub circuit_name: String,
}
impl std::fmt::Display for CircuitOpenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Circuit breaker '{}' is open", self.circuit_name)
}
}
impl std::error::Error for CircuitOpenError {}
pub async fn with_circuit_breaker<T, E, F, Fut>(
circuit: &CircuitBreaker,
operation: F,
@@ -158,6 +177,7 @@ where
circuit_name: circuit.name().to_string(),
}));
}
match operation().await {
Ok(result) => {
circuit.record_success().await;
@@ -169,11 +189,13 @@ where
}
}
}
#[derive(Debug)]
pub enum CircuitBreakerError<E> {
CircuitOpen(CircuitOpenError),
OperationFailed(E),
}
impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -182,6 +204,7 @@ impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
}
}
}
impl<E: std::error::Error + 'static> std::error::Error for CircuitBreakerError<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
@@ -190,71 +213,93 @@ impl<E: std::error::Error + 'static> std::error::Error for CircuitBreakerError<E
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_circuit_breaker_starts_closed() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
assert_eq!(cb.state().await, CircuitState::Closed);
assert!(cb.can_execute().await);
}
#[tokio::test]
async fn test_circuit_breaker_opens_after_failures() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Closed);
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Closed);
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Open);
assert!(!cb.can_execute().await);
}
#[tokio::test]
async fn test_circuit_breaker_success_resets_failures() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
cb.record_failure().await;
cb.record_failure().await;
cb.record_success().await;
cb.record_failure().await;
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Closed);
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Open);
}
#[tokio::test]
async fn test_circuit_breaker_half_open_closes_after_successes() {
let cb = CircuitBreaker::new("test", 3, 2, 0);
for _ in 0..3 {
cb.record_failure().await;
}
assert_eq!(cb.state().await, CircuitState::Open);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(cb.can_execute().await);
assert_eq!(cb.state().await, CircuitState::HalfOpen);
cb.record_success().await;
assert_eq!(cb.state().await, CircuitState::HalfOpen);
cb.record_success().await;
assert_eq!(cb.state().await, CircuitState::Closed);
}
#[tokio::test]
async fn test_circuit_breaker_half_open_reopens_on_failure() {
let cb = CircuitBreaker::new("test", 3, 2, 0);
for _ in 0..3 {
cb.record_failure().await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
cb.can_execute().await;
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Open);
}
#[tokio::test]
async fn test_with_circuit_breaker_helper() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
let result: Result<i32, CircuitBreakerError<std::io::Error>> =
with_circuit_breaker(&cb, || async { Ok(42) }).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
let result: Result<i32, CircuitBreakerError<&str>> =
with_circuit_breaker(&cb, || async { Err("error") }).await;
assert!(result.is_err());
+32
View File
@@ -8,8 +8,11 @@ use hkdf::Hkdf;
use p256::ecdsa::SigningKey;
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
static CONFIG: OnceLock<AuthConfig> = OnceLock::new();
pub const ENCRYPTION_VERSION: i32 = 1;
pub struct AuthConfig {
jwt_secret: String,
dpop_secret: String,
@@ -20,6 +23,7 @@ pub struct AuthConfig {
pub signing_key_y: String,
key_encryption_key: [u8; 32],
}
impl AuthConfig {
pub fn init() -> &'static Self {
CONFIG.get_or_init(|| {
@@ -33,6 +37,7 @@ impl AuthConfig {
);
}
});
let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-dpop-secret-not-for-production".to_string()
@@ -43,31 +48,39 @@ impl AuthConfig {
);
}
});
if jwt_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("JWT_SECRET must be at least 32 characters");
}
if dpop_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("DPOP_SECRET must be at least 32 characters");
}
let mut hasher = Sha256::new();
hasher.update(b"oauth-signing-key-derivation:");
hasher.update(jwt_secret.as_bytes());
let seed = hasher.finalize();
let signing_key = SigningKey::from_slice(&seed)
.unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e));
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let signing_key_x = URL_SAFE_NO_PAD.encode(
point.x().expect("EC point missing X coordinate - this should never happen")
);
let signing_key_y = URL_SAFE_NO_PAD.encode(
point.y().expect("EC point missing Y coordinate - this should never happen")
);
let mut kid_hasher = Sha256::new();
kid_hasher.update(signing_key_x.as_bytes());
kid_hasher.update(signing_key_y.as_bytes());
let kid_hash = kid_hasher.finalize();
let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]);
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
@@ -78,13 +91,16 @@ impl AuthConfig {
);
}
});
if master_key.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("MASTER_KEY must be at least 32 characters");
}
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key_encryption_key = [0u8; 32];
hk.expand(b"bspds-user-key-encryption", &mut key_encryption_key)
.expect("HKDF expansion failed");
AuthConfig {
jwt_secret,
dpop_secret,
@@ -96,48 +112,64 @@ impl AuthConfig {
}
})
}
pub fn get() -> &'static Self {
CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first")
}
pub fn jwt_secret(&self) -> &str {
&self.jwt_secret
}
pub fn dpop_secret(&self) -> &str {
&self.dpop_secret
}
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
use rand::RngCore;
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
#[allow(deprecated)]
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&ciphertext);
Ok(result)
}
pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
if encrypted.len() < 12 {
return Err("Encrypted data too short".to_string());
}
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
#[allow(deprecated)]
let nonce = Nonce::from_slice(&encrypted[..12]);
let ciphertext = &encrypted[12..];
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))
}
}
pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, String> {
AuthConfig::get().encrypt_user_key(plaintext)
}
pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, String> {
match version.unwrap_or(0) {
0 => Ok(encrypted.to_vec()),
+19
View File
@@ -6,7 +6,9 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tracing::{debug, error, info, warn};
const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60;
pub struct Crawlers {
hostname: String,
crawler_urls: Vec<String>,
@@ -14,6 +16,7 @@ pub struct Crawlers {
last_notified: AtomicU64,
circuit_breaker: Option<Arc<CircuitBreaker>>,
}
impl Crawlers {
pub fn new(hostname: String, crawler_urls: Vec<String>) -> Self {
Self {
@@ -27,56 +30,70 @@ impl Crawlers {
circuit_breaker: None,
}
}
pub fn with_circuit_breaker(mut self, circuit_breaker: Arc<CircuitBreaker>) -> Self {
self.circuit_breaker = Some(circuit_breaker);
self
}
pub fn from_env() -> Option<Self> {
let hostname = std::env::var("PDS_HOSTNAME").ok()?;
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
if crawler_urls.is_empty() {
return None;
}
Some(Self::new(hostname, crawler_urls))
}
fn should_notify(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let last = self.last_notified.load(Ordering::Relaxed);
now - last >= NOTIFY_THRESHOLD_SECS
}
fn mark_notified(&self) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.last_notified.store(now, Ordering::Relaxed);
}
pub async fn notify_of_update(&self) {
if !self.should_notify() {
debug!("Skipping crawler notification due to debounce");
return;
}
if let Some(cb) = &self.circuit_breaker {
if !cb.can_execute().await {
debug!("Skipping crawler notification due to circuit breaker open");
return;
}
}
self.mark_notified();
let circuit_breaker = self.circuit_breaker.clone();
for crawler_url in &self.crawler_urls {
let url = format!("{}/xrpc/com.atproto.sync.requestCrawl", crawler_url.trim_end_matches('/'));
let hostname = self.hostname.clone();
let client = self.http_client.clone();
let cb = circuit_breaker.clone();
tokio::spawn(async move {
match client
.post(&url)
@@ -116,6 +133,7 @@ impl Crawlers {
}
}
}
pub async fn start_crawlers_service(
crawlers: Arc<Crawlers>,
mut firehose_rx: broadcast::Receiver<SequencedEvent>,
@@ -127,6 +145,7 @@ pub async fn start_crawlers_service(
crawlers = ?crawlers.crawler_urls,
"Starting crawlers notification service"
);
loop {
tokio::select! {
result = firehose_rx.recv() => {
+28 -1
View File
@@ -1,7 +1,9 @@
use image::{DynamicImage, ImageFormat, ImageReader, imageops::FilterType};
use std::io::Cursor;
pub const THUMB_SIZE_FEED: u32 = 200;
pub const THUMB_SIZE_FULL: u32 = 1000;
#[derive(Debug, Clone)]
pub struct ProcessedImage {
pub data: Vec<u8>,
@@ -9,12 +11,14 @@ pub struct ProcessedImage {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone)]
pub struct ImageProcessingResult {
pub original: ProcessedImage,
pub thumbnail_feed: Option<ProcessedImage>,
pub thumbnail_full: Option<ProcessedImage>,
}
#[derive(Debug, thiserror::Error)]
pub enum ImageError {
#[error("Failed to decode image: {0}")]
@@ -32,13 +36,16 @@ pub enum ImageError {
#[error("File too large: {size} bytes exceeds maximum {max_size} bytes")]
FileTooLarge { size: usize, max_size: usize },
}
pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
pub struct ImageProcessor {
max_dimension: u32,
max_file_size: usize,
output_format: OutputFormat,
generate_thumbnails: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum OutputFormat {
WebP,
@@ -46,6 +53,7 @@ pub enum OutputFormat {
Png,
Original,
}
impl Default for ImageProcessor {
fn default() -> Self {
Self {
@@ -56,26 +64,32 @@ impl Default for ImageProcessor {
}
}
}
impl ImageProcessor {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_dimension(mut self, max: u32) -> Self {
self.max_dimension = max;
self
}
pub fn with_max_file_size(mut self, max: usize) -> Self {
self.max_file_size = max;
self
}
pub fn with_output_format(mut self, format: OutputFormat) -> Self {
self.output_format = format;
self
}
pub fn with_thumbnails(mut self, generate: bool) -> Self {
self.generate_thumbnails = generate;
self
}
pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> {
if data.len() > self.max_file_size {
return Err(ImageError::FileTooLarge {
@@ -109,6 +123,7 @@ impl ImageProcessor {
thumbnail_full,
})
}
fn detect_format(&self, mime_type: &str, data: &[u8]) -> Result<ImageFormat, ImageError> {
match mime_type.to_lowercase().as_str() {
"image/jpeg" | "image/jpg" => Ok(ImageFormat::Jpeg),
@@ -124,6 +139,7 @@ impl ImageProcessor {
}
}
}
fn decode_image(&self, data: &[u8], format: ImageFormat) -> Result<DynamicImage, ImageError> {
let cursor = Cursor::new(data);
let reader = ImageReader::with_format(cursor, format);
@@ -131,6 +147,7 @@ impl ImageProcessor {
.decode()
.map_err(|e| ImageError::DecodeError(e.to_string()))
}
fn encode_image(&self, img: &DynamicImage) -> Result<ProcessedImage, ImageError> {
let (data, mime_type) = match self.output_format {
OutputFormat::WebP => {
@@ -165,6 +182,7 @@ impl ImageProcessor {
height: img.height(),
})
}
fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> {
let (orig_width, orig_height) = (img.width(), img.height());
let (new_width, new_height) = if orig_width > orig_height {
@@ -177,12 +195,14 @@ impl ImageProcessor {
let thumb = img.resize(new_width, new_height, FilterType::Lanczos3);
self.encode_image(&thumb)
}
pub fn is_supported_mime_type(mime_type: &str) -> bool {
matches!(
mime_type.to_lowercase().as_str(),
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
)
}
pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> {
let format = image::guess_format(data)
.map_err(|e| ImageError::DecodeError(e.to_string()))?;
@@ -196,15 +216,18 @@ impl ImageProcessor {
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_image(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
buf
}
#[test]
fn test_process_small_image() {
let processor = ImageProcessor::new();
@@ -213,6 +236,7 @@ mod tests {
assert!(result.thumbnail_feed.is_none());
assert!(result.thumbnail_full.is_none());
}
#[test]
fn test_process_large_image_generates_thumbnails() {
let processor = ImageProcessor::new();
@@ -227,6 +251,7 @@ mod tests {
assert!(full_thumb.width <= THUMB_SIZE_FULL);
assert!(full_thumb.height <= THUMB_SIZE_FULL);
}
#[test]
fn test_webp_conversion() {
let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP);
@@ -234,6 +259,7 @@ mod tests {
let result = processor.process(&data, "image/png").unwrap();
assert_eq!(result.original.mime_type, "image/webp");
}
#[test]
fn test_reject_too_large() {
let processor = ImageProcessor::new().with_max_dimension(1000);
@@ -241,6 +267,7 @@ mod tests {
let result = processor.process(&data, "image/png");
assert!(matches!(result, Err(ImageError::TooLarge { .. })));
}
#[test]
fn test_is_supported_mime_type() {
assert!(ImageProcessor::is_supported_mime_type("image/jpeg"));
+4 -1
View File
@@ -16,6 +16,7 @@ pub mod storage;
pub mod sync;
pub mod util;
pub mod validation;
use axum::{
Router,
http::Method,
@@ -25,6 +26,7 @@ use axum::{
use state::AppState;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
pub fn app(state: AppState) -> Router {
let router = Router::new()
.route("/metrics", get(metrics::metrics_handler))
@@ -358,7 +360,6 @@ pub fn app(state: AppState) -> Router {
.route("/.well-known/did.json", get(api::identity::well_known_did))
.route("/.well-known/atproto-did", get(api::identity::well_known_atproto_did))
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
// OAuth 2.1 endpoints
.route(
"/.well-known/oauth-protected-resource",
get(oauth::endpoints::oauth_protected_resource),
@@ -402,8 +403,10 @@ pub fn app(state: AppState) -> Router {
.allow_headers(Any),
)
.with_state(state);
let frontend_dir = std::env::var("FRONTEND_DIR")
.unwrap_or_else(|_| "./frontend/dist".to_string());
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
let index_path = format!("{}/index.html", frontend_dir);
let serve_dir = ServeDir::new(&frontend_dir)
+30
View File
@@ -6,11 +6,13 @@ use std::process::ExitCode;
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info, warn};
#[tokio::main]
async fn main() -> ExitCode {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
bspds::metrics::init_metrics();
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
@@ -19,25 +21,31 @@ async fn main() -> ExitCode {
}
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable must be set")?;
let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
info!(
"Configuring database pool: max={}, min={}, acquire_timeout={}s",
max_connections, min_connections, acquire_timeout_secs
);
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
@@ -47,33 +55,43 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.connect(&database_url)
.await
.map_err(|e| format!("Failed to connect to Postgres: {}", e))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?;
let state = AppState::new(pool.clone()).await;
bspds::sync::listener::start_sequencer_listener(state.clone()).await;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let mut notification_service = NotificationService::new(pool);
if let Some(email_sender) = EmailSender::from_env() {
info!("Email notifications enabled");
notification_service = notification_service.register_sender(email_sender);
} else {
warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)");
}
if let Some(discord_sender) = DiscordSender::from_env() {
info!("Discord notifications enabled");
notification_service = notification_service.register_sender(discord_sender);
}
if let Some(telegram_sender) = TelegramSender::from_env() {
info!("Telegram notifications enabled");
notification_service = notification_service.register_sender(telegram_sender);
}
if let Some(signal_sender) = SignalSender::from_env() {
info!("Signal notifications enabled");
notification_service = notification_service.register_sender(signal_sender);
}
let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone()));
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
let crawlers = Arc::new(
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone())
@@ -85,24 +103,32 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
None
};
let app = bspds::app(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
let server_result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
.await;
notification_handle.await.ok();
if let Some(handle) = crawlers_handle {
handle.await.ok();
}
if let Err(e) = server_result {
return Err(format!("Server error: {}", e).into());
}
Ok(())
}
async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
let ctrl_c = async {
match tokio::signal::ctrl_c().await {
@@ -112,6 +138,7 @@ async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
}
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
@@ -124,12 +151,15 @@ async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("Shutdown signal received, stopping services...");
shutdown_tx.send(true).ok();
}
+28
View File
@@ -8,16 +8,21 @@ use metrics::{counter, gauge, histogram};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use std::sync::OnceLock;
use std::time::Instant;
static PROMETHEUS_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
pub fn init_metrics() -> PrometheusHandle {
let builder = PrometheusBuilder::new();
let handle = builder
.install_recorder()
.expect("failed to install Prometheus recorder");
PROMETHEUS_HANDLE.set(handle.clone()).ok();
describe_metrics();
handle
}
fn describe_metrics() {
metrics::describe_counter!(
"bspds_http_requests_total",
@@ -68,6 +73,7 @@ fn describe_metrics() {
"Database query duration in seconds"
);
}
pub async fn metrics_handler() -> impl IntoResponse {
match PROMETHEUS_HANDLE.get() {
Some(handle) => {
@@ -81,13 +87,17 @@ pub async fn metrics_handler() -> impl IntoResponse {
),
}
}
pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response {
let start = Instant::now();
let method = request.method().to_string();
let path = normalize_path(request.uri().path());
let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
counter!(
"bspds_http_requests_total",
"method" => method.clone(),
@@ -95,14 +105,17 @@ pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response
"status" => status.clone()
)
.increment(1);
histogram!(
"bspds_http_request_duration_seconds",
"method" => method,
"path" => path
)
.record(duration);
response
}
fn normalize_path(path: &str) -> String {
if path.starts_with("/xrpc/") {
if let Some(method) = path.strip_prefix("/xrpc/") {
@@ -112,32 +125,42 @@ fn normalize_path(path: &str) -> String {
return path.to_string();
}
}
if path.starts_with("/u/") && path.ends_with("/did.json") {
return "/u/{handle}/did.json".to_string();
}
if path.starts_with("/oauth/") {
return path.to_string();
}
path.to_string()
}
pub fn record_auth_cache_hit(cache_type: &str) {
counter!("bspds_auth_cache_hits_total", "cache_type" => cache_type.to_string()).increment(1);
}
pub fn record_auth_cache_miss(cache_type: &str) {
counter!("bspds_auth_cache_misses_total", "cache_type" => cache_type.to_string()).increment(1);
}
pub fn set_firehose_subscribers(count: usize) {
gauge!("bspds_firehose_subscribers").set(count as f64);
}
pub fn increment_firehose_subscribers() {
counter!("bspds_firehose_events_total").increment(1);
}
pub fn record_firehose_event() {
counter!("bspds_firehose_events_total").increment(1);
}
pub fn record_block_operation(op_type: &str) {
counter!("bspds_block_operations_total", "op_type" => op_type.to_string()).increment(1);
}
pub fn record_s3_operation(op_type: &str, status: &str) {
counter!(
"bspds_s3_operations_total",
@@ -146,12 +169,15 @@ pub fn record_s3_operation(op_type: &str, status: &str) {
)
.increment(1);
}
pub fn set_notification_queue_size(size: usize) {
gauge!("bspds_notification_queue_size").set(size as f64);
}
pub fn record_rate_limit_rejection(limiter: &str) {
counter!("bspds_rate_limit_rejections_total", "limiter" => limiter.to_string()).increment(1);
}
pub fn record_db_query(query_type: &str, duration_seconds: f64) {
counter!("bspds_db_queries_total", "query_type" => query_type.to_string()).increment(1);
histogram!(
@@ -160,9 +186,11 @@ pub fn record_db_query(query_type: &str, duration_seconds: f64) {
)
.record(duration_seconds);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path() {
assert_eq!(
+3
View File
@@ -1,15 +1,18 @@
mod sender;
mod service;
mod types;
pub use sender::{
DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, sanitize_header_value,
};
pub use service::{
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
};
pub use types::{
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
};
+30
View File
@@ -5,15 +5,19 @@ use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::types::{NotificationChannel, QueuedNotification};
const HTTP_TIMEOUT_SECS: u64 = 30;
const MAX_RETRIES: u32 = 3;
const INITIAL_RETRY_DELAY_MS: u64 = 500;
#[async_trait]
pub trait NotificationSender: Send + Sync {
fn channel(&self) -> NotificationChannel;
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>;
}
#[derive(Debug, thiserror::Error)]
pub enum SendError {
#[error("Failed to spawn sendmail process: {0}")]
@@ -31,6 +35,7 @@ pub enum SendError {
#[error("Max retries exceeded: {0}")]
MaxRetriesExceeded(String),
}
fn create_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
@@ -38,16 +43,20 @@ fn create_http_client() -> Client {
.build()
.unwrap_or_else(|_| Client::new())
}
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
async fn retry_delay(attempt: u32) {
let delay_ms = INITIAL_RETRY_DELAY_MS * 2u64.pow(attempt);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], " ").trim().to_string()
}
pub fn is_valid_phone_number(number: &str) -> bool {
if number.len() < 2 || number.len() > 20 {
return false;
@@ -59,11 +68,13 @@ pub fn is_valid_phone_number(number: &str) -> bool {
let remaining: String = chars.collect();
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
}
pub struct EmailSender {
from_address: String,
from_name: String,
sendmail_path: String,
}
impl EmailSender {
pub fn new(from_address: String, from_name: String) -> Self {
Self {
@@ -72,11 +83,13 @@ impl EmailSender {
sendmail_path: std::env::var("SENDMAIL_PATH").unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
}
}
pub fn from_env() -> Option<Self> {
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
let from_name = std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "BSPDS".to_string());
Some(Self::new(from_address, from_name))
}
pub fn format_email(&self, notification: &QueuedNotification) -> String {
let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
let recipient = sanitize_header_value(&notification.recipient);
@@ -94,11 +107,13 @@ impl EmailSender {
)
}
}
#[async_trait]
impl NotificationSender for EmailSender {
fn channel(&self) -> NotificationChannel {
NotificationChannel::Email
}
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
let email_content = self.format_email(notification);
let mut child = Command::new(&self.sendmail_path)
@@ -119,10 +134,12 @@ impl NotificationSender for EmailSender {
Ok(())
}
}
pub struct DiscordSender {
webhook_url: String,
http_client: Client,
}
impl DiscordSender {
pub fn new(webhook_url: String) -> Self {
Self {
@@ -130,16 +147,19 @@ impl DiscordSender {
http_client: create_http_client(),
}
}
pub fn from_env() -> Option<Self> {
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
Some(Self::new(webhook_url))
}
}
#[async_trait]
impl NotificationSender for DiscordSender {
fn channel(&self) -> NotificationChannel {
NotificationChannel::Discord
}
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
let subject = notification.subject.as_deref().unwrap_or("Notification");
let content = format!("**{}**\n\n{}", subject, notification.body);
@@ -193,10 +213,12 @@ impl NotificationSender for DiscordSender {
))
}
}
pub struct TelegramSender {
bot_token: String,
http_client: Client,
}
impl TelegramSender {
pub fn new(bot_token: String) -> Self {
Self {
@@ -204,16 +226,19 @@ impl TelegramSender {
http_client: create_http_client(),
}
}
pub fn from_env() -> Option<Self> {
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
Some(Self::new(bot_token))
}
}
#[async_trait]
impl NotificationSender for TelegramSender {
fn channel(&self) -> NotificationChannel {
NotificationChannel::Telegram
}
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
let chat_id = &notification.recipient;
let subject = notification.subject.as_deref().unwrap_or("Notification");
@@ -273,10 +298,12 @@ impl NotificationSender for TelegramSender {
))
}
}
pub struct SignalSender {
signal_cli_path: String,
sender_number: String,
}
impl SignalSender {
pub fn new(signal_cli_path: String, sender_number: String) -> Self {
Self {
@@ -284,6 +311,7 @@ impl SignalSender {
sender_number,
}
}
pub fn from_env() -> Option<Self> {
let signal_cli_path = std::env::var("SIGNAL_CLI_PATH")
.unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string());
@@ -291,11 +319,13 @@ impl SignalSender {
Some(Self::new(signal_cli_path, sender_number))
}
}
#[async_trait]
impl NotificationSender for SignalSender {
fn channel(&self) -> NotificationChannel {
NotificationChannel::Signal
}
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
let recipient = &notification.recipient;
if !is_valid_phone_number(recipient) {
+27
View File
@@ -1,20 +1,24 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use sqlx::PgPool;
use tokio::sync::watch;
use tokio::time::interval;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::sender::{NotificationSender, SendError};
use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification};
pub struct NotificationService {
db: PgPool,
senders: HashMap<NotificationChannel, Arc<dyn NotificationSender>>,
poll_interval: Duration,
batch_size: i64,
}
impl NotificationService {
pub fn new(db: PgPool) -> Self {
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
@@ -32,18 +36,22 @@ impl NotificationService {
batch_size,
}
}
pub fn with_poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
pub fn with_batch_size(mut self, size: i64) -> Self {
self.batch_size = size;
self
}
pub fn register_sender<S: NotificationSender + 'static>(mut self, sender: S) -> Self {
self.senders.insert(sender.channel(), Arc::new(sender));
self
}
pub async fn enqueue(&self, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
let id = sqlx::query_scalar!(
r#"
@@ -65,9 +73,11 @@ impl NotificationService {
debug!(notification_id = %id, "Notification enqueued");
Ok(id)
}
pub fn has_senders(&self) -> bool {
!self.senders.is_empty()
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
if self.senders.is_empty() {
warn!("Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured.");
@@ -95,6 +105,7 @@ impl NotificationService {
}
}
}
async fn process_batch(&self) -> Result<(), sqlx::Error> {
let notifications = self.fetch_pending_notifications().await?;
if notifications.is_empty() {
@@ -106,6 +117,7 @@ impl NotificationService {
}
Ok(())
}
async fn fetch_pending_notifications(&self) -> Result<Vec<QueuedNotification>, sqlx::Error> {
let now = Utc::now();
sqlx::query_as!(
@@ -137,6 +149,7 @@ impl NotificationService {
.fetch_all(&self.db)
.await
}
async fn process_notification(&self, notification: QueuedNotification) {
let notification_id = notification.id;
let channel = notification.channel;
@@ -179,6 +192,7 @@ impl NotificationService {
}
}
}
async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
@@ -192,6 +206,7 @@ impl NotificationService {
.await?;
Ok(())
}
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
@@ -215,6 +230,7 @@ impl NotificationService {
Ok(())
}
}
pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
sqlx::query_scalar!(
r#"
@@ -234,11 +250,13 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) ->
.fetch_one(db)
.await
}
pub struct UserNotificationPrefs {
pub channel: NotificationChannel,
pub email: Option<String>,
pub handle: String,
}
pub async fn get_user_notification_prefs(
db: &PgPool,
user_id: Uuid,
@@ -262,6 +280,7 @@ pub async fn get_user_notification_prefs(
handle: row.handle,
})
}
pub async fn enqueue_welcome(
db: &PgPool,
user_id: Uuid,
@@ -285,6 +304,7 @@ pub async fn enqueue_welcome(
)
.await
}
pub async fn enqueue_email_verification(
db: &PgPool,
user_id: Uuid,
@@ -309,6 +329,7 @@ pub async fn enqueue_email_verification(
)
.await
}
pub async fn enqueue_password_reset(
db: &PgPool,
user_id: Uuid,
@@ -333,6 +354,7 @@ pub async fn enqueue_password_reset(
)
.await
}
pub async fn enqueue_email_update(
db: &PgPool,
user_id: Uuid,
@@ -357,6 +379,7 @@ pub async fn enqueue_email_update(
)
.await
}
pub async fn enqueue_account_deletion(
db: &PgPool,
user_id: Uuid,
@@ -381,6 +404,7 @@ pub async fn enqueue_account_deletion(
)
.await
}
pub async fn enqueue_plc_operation(
db: &PgPool,
user_id: Uuid,
@@ -405,6 +429,7 @@ pub async fn enqueue_plc_operation(
)
.await
}
pub async fn enqueue_2fa_code(
db: &PgPool,
user_id: Uuid,
@@ -429,6 +454,7 @@ pub async fn enqueue_2fa_code(
)
.await
}
pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
match channel {
NotificationChannel::Email => "email",
@@ -437,6 +463,7 @@ pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
NotificationChannel::Signal => "Signal",
}
}
pub async fn enqueue_signup_verification(
db: &PgPool,
user_id: Uuid,
+7
View File
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "notification_channel", rename_all = "lowercase")]
pub enum NotificationChannel {
@@ -10,6 +11,7 @@ pub enum NotificationChannel {
Telegram,
Signal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "notification_status", rename_all = "lowercase")]
pub enum NotificationStatus {
@@ -18,6 +20,7 @@ pub enum NotificationStatus {
Sent,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "notification_type", rename_all = "snake_case")]
pub enum NotificationType {
@@ -30,6 +33,7 @@ pub enum NotificationType {
PlcOperation,
TwoFactorCode,
}
#[derive(Debug, Clone, FromRow)]
pub struct QueuedNotification {
pub id: Uuid,
@@ -49,6 +53,7 @@ pub struct QueuedNotification {
pub scheduled_for: DateTime<Utc>,
pub processed_at: Option<DateTime<Utc>>,
}
pub struct NewNotification {
pub user_id: Uuid,
pub channel: NotificationChannel,
@@ -58,6 +63,7 @@ pub struct NewNotification {
pub body: String,
pub metadata: Option<serde_json::Value>,
}
impl NewNotification {
pub fn new(
user_id: Uuid,
@@ -77,6 +83,7 @@ impl NewNotification {
metadata: None,
}
}
pub fn email(
user_id: Uuid,
notification_type: NotificationType,
+22
View File
@@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::OAuthError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
pub client_id: String,
@@ -31,6 +33,7 @@ pub struct ClientMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
}
impl Default for ClientMetadata {
fn default() -> Self {
Self {
@@ -50,6 +53,7 @@ impl Default for ClientMetadata {
}
}
}
#[derive(Clone)]
pub struct ClientMetadataCache {
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
@@ -57,14 +61,17 @@ pub struct ClientMetadataCache {
http_client: Client,
cache_ttl_secs: u64,
}
struct CachedMetadata {
metadata: ClientMetadata,
cached_at: std::time::Instant,
}
struct CachedJwks {
jwks: serde_json::Value,
cached_at: std::time::Instant,
}
impl ClientMetadataCache {
pub fn new(cache_ttl_secs: u64) -> Self {
Self {
@@ -78,6 +85,7 @@ impl ClientMetadataCache {
cache_ttl_secs,
}
}
pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
{
let cache = self.cache.read().await;
@@ -100,6 +108,7 @@ impl ClientMetadataCache {
}
Ok(metadata)
}
pub async fn get_jwks(&self, metadata: &ClientMetadata) -> Result<serde_json::Value, OAuthError> {
if let Some(jwks) = &metadata.jwks {
return Ok(jwks.clone());
@@ -130,6 +139,7 @@ impl ClientMetadataCache {
}
Ok(jwks)
}
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
if !jwks_uri.starts_with("https://") {
if !jwks_uri.starts_with("http://")
@@ -166,6 +176,7 @@ impl ClientMetadataCache {
}
Ok(jwks)
}
async fn fetch_metadata(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
return Err(OAuthError::InvalidClient(
@@ -207,6 +218,7 @@ impl ClientMetadataCache {
self.validate_metadata(&metadata)?;
Ok(metadata)
}
fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> {
if metadata.redirect_uris.is_empty() {
return Err(OAuthError::InvalidClient(
@@ -232,6 +244,7 @@ impl ClientMetadataCache {
}
Ok(())
}
pub fn validate_redirect_uri(
&self,
metadata: &ClientMetadata,
@@ -244,6 +257,7 @@ impl ClientMetadataCache {
}
Ok(())
}
fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
if uri.contains('#') {
return Err(OAuthError::InvalidClient(
@@ -278,16 +292,19 @@ impl ClientMetadataCache {
Ok(())
}
}
impl ClientMetadata {
pub fn requires_dpop(&self) -> bool {
self.dpop_bound_access_tokens.unwrap_or(false)
}
pub fn auth_method(&self) -> &str {
self.token_endpoint_auth_method
.as_deref()
.unwrap_or("none")
}
}
pub async fn verify_client_auth(
cache: &ClientMetadataCache,
metadata: &ClientMetadata,
@@ -321,6 +338,7 @@ pub async fn verify_client_auth(
))),
}
}
async fn verify_private_key_jwt_async(
cache: &ClientMetadataCache,
metadata: &ClientMetadata,
@@ -425,6 +443,7 @@ async fn verify_private_key_jwt_async(
"client_assertion signature verification failed".to_string(),
))
}
fn verify_es256(
key: &serde_json::Value,
signing_input: &str,
@@ -456,6 +475,7 @@ fn verify_es256(
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| OAuthError::InvalidClient("ES256 signature verification failed".to_string()))
}
fn verify_es384(
key: &serde_json::Value,
signing_input: &str,
@@ -487,6 +507,7 @@ fn verify_es384(
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| OAuthError::InvalidClient("ES384 signature verification failed".to_string()))
}
fn verify_rsa(
_alg: &str,
_key: &serde_json::Value,
@@ -497,6 +518,7 @@ fn verify_rsa(
"RSA signature verification not yet supported - use EC keys".to_string(),
))
}
fn verify_eddsa(
key: &serde_json::Value,
signing_input: &str,
+2
View File
@@ -1,6 +1,7 @@
use sqlx::PgPool;
use super::super::{AuthorizedClientData, OAuthError};
use super::helpers::{from_json, to_json};
pub async fn upsert_authorized_client(
pool: &PgPool,
did: &str,
@@ -22,6 +23,7 @@ pub async fn upsert_authorized_client(
.await?;
Ok(())
}
pub async fn get_authorized_client(
pool: &PgPool,
did: &str,
+8
View File
@@ -1,12 +1,14 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::super::{DeviceData, OAuthError};
pub struct DeviceAccountRow {
pub did: String,
pub handle: String,
pub email: Option<String>,
pub last_used_at: DateTime<Utc>,
}
pub async fn create_device(
pool: &PgPool,
device_id: &str,
@@ -27,6 +29,7 @@ pub async fn create_device(
.await?;
Ok(())
}
pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceData>, OAuthError> {
let row = sqlx::query!(
r#"
@@ -45,6 +48,7 @@ pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceD
last_seen_at: r.last_seen_at,
}))
}
pub async fn update_device_last_seen(
pool: &PgPool,
device_id: &str,
@@ -61,6 +65,7 @@ pub async fn update_device_last_seen(
.await?;
Ok(())
}
pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
@@ -72,6 +77,7 @@ pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthEr
.await?;
Ok(())
}
pub async fn upsert_account_device(
pool: &PgPool,
did: &str,
@@ -90,6 +96,7 @@ pub async fn upsert_account_device(
.await?;
Ok(())
}
pub async fn get_device_accounts(
pool: &PgPool,
device_id: &str,
@@ -118,6 +125,7 @@ pub async fn get_device_accounts(
})
.collect())
}
pub async fn verify_account_on_device(
pool: &PgPool,
device_id: &str,
+2
View File
@@ -1,5 +1,6 @@
use sqlx::PgPool;
use super::super::OAuthError;
pub async fn check_and_record_dpop_jti(
pool: &PgPool,
jti: &str,
@@ -16,6 +17,7 @@ pub async fn check_and_record_dpop_jti(
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn cleanup_expired_dpop_jtis(
pool: &PgPool,
max_age_secs: i64,
+2
View File
@@ -1,11 +1,13 @@
use serde::{de::DeserializeOwned, Serialize};
use super::super::OAuthError;
pub fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> {
serde_json::to_value(value).map_err(|e| {
tracing::error!("JSON serialization error: {}", e);
OAuthError::ServerError("Internal serialization error".to_string())
})
}
pub fn from_json<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, OAuthError> {
serde_json::from_value(value).map_err(|e| {
tracing::error!("JSON deserialization error: {}", e);
+1
View File
@@ -5,6 +5,7 @@ mod helpers;
mod request;
mod token;
mod two_factor;
pub use client::{get_authorized_client, upsert_authorized_client};
pub use device::{
create_device, delete_device, get_device, get_device_accounts, update_device_last_seen,
+6
View File
@@ -1,6 +1,7 @@
use sqlx::PgPool;
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
use super::helpers::{from_json, to_json};
pub async fn create_authorization_request(
pool: &PgPool,
request_id: &str,
@@ -30,6 +31,7 @@ pub async fn create_authorization_request(
.await?;
Ok(())
}
pub async fn get_authorization_request(
pool: &PgPool,
request_id: &str,
@@ -64,6 +66,7 @@ pub async fn get_authorization_request(
None => Ok(None),
}
}
pub async fn update_authorization_request(
pool: &PgPool,
request_id: &str,
@@ -86,6 +89,7 @@ pub async fn update_authorization_request(
.await?;
Ok(())
}
pub async fn consume_authorization_request_by_code(
pool: &PgPool,
code: &str,
@@ -120,6 +124,7 @@ pub async fn consume_authorization_request_by_code(
None => Ok(None),
}
}
pub async fn delete_authorization_request(
pool: &PgPool,
request_id: &str,
@@ -134,6 +139,7 @@ pub async fn delete_authorization_request(
.await?;
Ok(())
}
pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result<u64, OAuthError> {
let result = sqlx::query!(
r#"
+12
View File
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::super::{OAuthError, TokenData};
use super::helpers::{from_json, to_json};
pub async fn create_token(
pool: &PgPool,
data: &TokenData,
@@ -34,6 +35,7 @@ pub async fn create_token(
.await?;
Ok(row.id)
}
pub async fn get_token_by_id(
pool: &PgPool,
token_id: &str,
@@ -68,6 +70,7 @@ pub async fn get_token_by_id(
None => Ok(None),
}
}
pub async fn get_token_by_refresh_token(
pool: &PgPool,
refresh_token: &str,
@@ -105,6 +108,7 @@ pub async fn get_token_by_refresh_token(
None => Ok(None),
}
}
pub async fn rotate_token(
pool: &PgPool,
old_db_id: i32,
@@ -149,6 +153,7 @@ pub async fn rotate_token(
tx.commit().await?;
Ok(())
}
pub async fn check_refresh_token_used(
pool: &PgPool,
refresh_token: &str,
@@ -163,6 +168,7 @@ pub async fn check_refresh_token_used(
.await?;
Ok(row)
}
pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
@@ -174,6 +180,7 @@ pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthErro
.await?;
Ok(())
}
pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> {
sqlx::query!(
r#"
@@ -185,6 +192,7 @@ pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthE
.await?;
Ok(())
}
pub async fn list_tokens_for_user(
pool: &PgPool,
did: &str,
@@ -220,6 +228,7 @@ pub async fn list_tokens_for_user(
}
Ok(tokens)
}
pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> {
let count = sqlx::query_scalar!(
r#"
@@ -231,6 +240,7 @@ pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAut
.await?;
Ok(count)
}
pub async fn delete_oldest_tokens_for_user(
pool: &PgPool,
did: &str,
@@ -253,7 +263,9 @@ pub async fn delete_oldest_tokens_for_user(
.await?;
Ok(result.rows_affected())
}
const MAX_TOKENS_PER_USER: i64 = 100;
pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> {
let count = count_tokens_for_user(pool, did).await?;
if count > MAX_TOKENS_PER_USER {
+9
View File
@@ -3,6 +3,7 @@ use rand::Rng;
use sqlx::PgPool;
use uuid::Uuid;
use super::super::OAuthError;
pub struct TwoFactorChallenge {
pub id: Uuid,
pub did: String,
@@ -12,11 +13,13 @@ pub struct TwoFactorChallenge {
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
pub fn generate_2fa_code() -> String {
let mut rng = rand::thread_rng();
let code: u32 = rng.gen_range(0..1_000_000);
format!("{:06}", code)
}
pub async fn create_2fa_challenge(
pool: &PgPool,
did: &str,
@@ -47,6 +50,7 @@ pub async fn create_2fa_challenge(
expires_at: row.expires_at,
})
}
pub async fn get_2fa_challenge(
pool: &PgPool,
request_uri: &str,
@@ -71,6 +75,7 @@ pub async fn get_2fa_challenge(
expires_at: r.expires_at,
}))
}
pub async fn increment_2fa_attempts(pool: &PgPool, id: Uuid) -> Result<i32, OAuthError> {
let row = sqlx::query!(
r#"
@@ -85,6 +90,7 @@ pub async fn increment_2fa_attempts(pool: &PgPool, id: Uuid) -> Result<i32, OAut
.await?;
Ok(row.attempts)
}
pub async fn delete_2fa_challenge(pool: &PgPool, id: Uuid) -> Result<(), OAuthError> {
sqlx::query!(
r#"
@@ -96,6 +102,7 @@ pub async fn delete_2fa_challenge(pool: &PgPool, id: Uuid) -> Result<(), OAuthEr
.await?;
Ok(())
}
pub async fn delete_2fa_challenge_by_request_uri(
pool: &PgPool,
request_uri: &str,
@@ -110,6 +117,7 @@ pub async fn delete_2fa_challenge_by_request_uri(
.await?;
Ok(())
}
pub async fn cleanup_expired_2fa_challenges(pool: &PgPool) -> Result<u64, OAuthError> {
let result = sqlx::query!(
r#"
@@ -120,6 +128,7 @@ pub async fn cleanup_expired_2fa_challenges(pool: &PgPool) -> Result<u64, OAuthE
.await?;
Ok(result.rows_affected())
}
pub async fn check_user_2fa_enabled(pool: &PgPool, did: &str) -> Result<bool, OAuthError> {
let row = sqlx::query!(
r#"
+20
View File
@@ -3,20 +3,25 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::OAuthError;
const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
const DPOP_MAX_AGE_SECS: i64 = 300;
#[derive(Debug, Clone)]
pub struct DPoPVerifyResult {
pub jkt: String,
pub jti: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPProofHeader {
pub typ: String,
pub alg: String,
pub jwk: DPoPJwk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPJwk {
pub kty: String,
@@ -27,6 +32,7 @@ pub struct DPoPJwk {
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPProofPayload {
pub jti: String,
@@ -38,15 +44,18 @@ pub struct DPoPProofPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
pub struct DPoPVerifier {
secret: Vec<u8>,
}
impl DPoPVerifier {
pub fn new(secret: &[u8]) -> Self {
Self {
secret: secret.to_vec(),
}
}
pub fn generate_nonce(&self) -> String {
let timestamp = Utc::now().timestamp();
let timestamp_bytes = timestamp.to_be_bytes();
@@ -59,6 +68,7 @@ impl DPoPVerifier {
nonce_data.extend_from_slice(&hash[..16]);
URL_SAFE_NO_PAD.encode(&nonce_data)
}
pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> {
let nonce_bytes = URL_SAFE_NO_PAD
.decode(nonce)
@@ -83,6 +93,7 @@ impl DPoPVerifier {
}
Ok(())
}
pub fn verify_proof(
&self,
dpop_header: &str,
@@ -152,6 +163,7 @@ impl DPoPVerifier {
})
}
}
fn verify_dpop_signature(
alg: &str,
jwk: &DPoPJwk,
@@ -168,6 +180,7 @@ fn verify_dpop_signature(
))),
}
}
fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
@@ -208,6 +221,7 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
.verify(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use p384::ecdsa::signature::Verifier;
use p384::ecdsa::{Signature, VerifyingKey};
@@ -248,6 +262,7 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
.verify(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use ed25519_dalek::{Signature, VerifyingKey};
let crv = jwk.crv.as_ref().ok_or_else(|| {
@@ -277,6 +292,7 @@ fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
.verify_strict(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
let canonical = match jwk.kty.as_str() {
"EC" => {
@@ -319,15 +335,18 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
let hash = hasher.finalize();
Ok(URL_SAFE_NO_PAD.encode(&hash))
}
pub fn compute_access_token_hash(access_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(access_token.as_bytes());
let hash = hasher.finalize();
URL_SAFE_NO_PAD.encode(&hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_generation_and_validation() {
let secret = b"test-secret-key-32-bytes-long!!!";
@@ -335,6 +354,7 @@ mod tests {
let nonce = verifier.generate_nonce();
assert!(verifier.validate_nonce(&nonce).is_ok());
}
#[test]
fn test_jwk_thumbprint_ec() {
let jwk = DPoPJwk {
+23
View File
@@ -11,7 +11,9 @@ use urlencoding::encode as url_encode;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates};
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get("cookie")
@@ -26,6 +28,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
None
})
}
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
@@ -41,12 +44,14 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"0.0.0.0".to_string()
}
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn make_device_cookie(device_id: &str) -> String {
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
@@ -54,12 +59,14 @@ fn make_device_cookie(device_id: &str) -> String {
device_id
)
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeQuery {
pub request_uri: Option<String>,
pub client_id: Option<String>,
pub new_account: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct AuthorizeResponse {
pub client_id: String,
@@ -69,6 +76,7 @@ pub struct AuthorizeResponse {
pub state: Option<String>,
pub login_hint: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeSubmit {
pub request_uri: String,
@@ -77,11 +85,13 @@ pub struct AuthorizeSubmit {
#[serde(default)]
pub remember_device: bool,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeSelectSubmit {
pub request_uri: String,
pub did: String,
}
fn wants_json(headers: &HeaderMap) -> bool {
headers
.get("accept")
@@ -89,6 +99,7 @@ fn wants_json(headers: &HeaderMap) -> bool {
.map(|accept| accept.contains("application/json"))
.unwrap_or(false)
}
pub async fn authorize_get(
State(state): State<AppState>,
headers: HeaderMap,
@@ -216,6 +227,7 @@ pub async fn authorize_get(
request_data.parameters.login_hint.as_deref(),
)).into_response()
}
pub async fn authorize_get_json(
State(state): State<AppState>,
Query(query): Query<AuthorizeQuery>,
@@ -239,6 +251,7 @@ pub async fn authorize_get_json(
login_hint: request_data.parameters.login_hint.clone(),
}))
}
pub async fn authorize_post(
State(state): State<AppState>,
headers: HeaderMap,
@@ -441,6 +454,7 @@ pub async fn authorize_post(
redirect.into_response()
}
}
pub async fn authorize_select(
State(state): State<AppState>,
headers: HeaderMap,
@@ -574,6 +588,7 @@ pub async fn authorize_select(
);
Redirect::temporary(&redirect_url).into_response()
}
fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -> String {
let mut redirect_url = redirect_uri.to_string();
let separator = if redirect_url.contains('?') { '&' } else { '?' };
@@ -586,11 +601,13 @@ fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -
redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname))));
redirect_url
}
#[derive(Debug, Serialize)]
pub struct AuthorizeDenyResponse {
pub error: String,
pub error_description: String,
}
pub async fn authorize_deny(
State(state): State<AppState>,
Form(form): Form<AuthorizeDenyForm>,
@@ -610,21 +627,26 @@ pub async fn authorize_deny(
}
Ok(Redirect::temporary(&redirect_url).into_response())
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeDenyForm {
pub request_uri: String,
}
#[derive(Debug, Deserialize)]
pub struct Authorize2faQuery {
pub request_uri: String,
pub channel: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Authorize2faSubmit {
pub request_uri: String,
pub code: String,
}
const MAX_2FA_ATTEMPTS: i32 = 5;
pub async fn authorize_2fa_get(
State(state): State<AppState>,
Query(query): Query<Authorize2faQuery>,
@@ -673,6 +695,7 @@ pub async fn authorize_2fa_get(
None,
)).into_response()
}
pub async fn authorize_2fa_post(
State(state): State<AppState>,
headers: HeaderMap,
+5
View File
@@ -2,6 +2,7 @@ use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use crate::oauth::jwks::{JwkSet, create_jwk_set};
#[derive(Debug, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
@@ -11,6 +12,7 @@ pub struct ProtectedResourceMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_documentation: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizationServerMetadata {
pub issuer: String,
@@ -43,6 +45,7 @@ pub struct AuthorizationServerMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub introspection_endpoint: Option<String>,
}
pub async fn oauth_protected_resource(
State(_state): State<AppState>,
) -> Json<ProtectedResourceMetadata> {
@@ -56,6 +59,7 @@ pub async fn oauth_protected_resource(
resource_documentation: Some("https://atproto.com".to_string()),
})
}
pub async fn oauth_authorization_server(
State(_state): State<AppState>,
) -> Json<AuthorizationServerMetadata> {
@@ -96,6 +100,7 @@ pub async fn oauth_authorization_server(
introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)),
})
}
pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> {
use crate::config::AuthConfig;
use crate::oauth::jwks::Jwk;
+1
View File
@@ -2,6 +2,7 @@ pub mod metadata;
pub mod par;
pub mod authorize;
pub mod token;
pub use metadata::*;
pub use par::*;
pub use authorize::*;
+6
View File
@@ -11,8 +11,10 @@ use crate::oauth::{
client::ClientMetadataCache,
db,
};
const PAR_EXPIRY_SECONDS: i64 = 600;
const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"];
#[derive(Debug, Deserialize)]
pub struct ParRequest {
pub response_type: String,
@@ -37,11 +39,13 @@ pub struct ParRequest {
#[serde(default)]
pub client_assertion_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ParResponse {
pub request_uri: String,
pub expires_in: u64,
}
pub async fn pushed_authorization_request(
State(state): State<AppState>,
headers: HeaderMap,
@@ -115,6 +119,7 @@ pub async fn pushed_authorization_request(
expires_in: PAR_EXPIRY_SECONDS as u64,
}))
}
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
if let (Some(assertion), Some(assertion_type)) =
(&request.client_assertion, &request.client_assertion_type)
@@ -135,6 +140,7 @@ fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError>
}
Ok(ClientAuth::None)
}
fn validate_scope(
requested_scope: &Option<String>,
client_metadata: &crate::oauth::client::ClientMetadata,
+3
View File
@@ -11,8 +11,10 @@ use crate::oauth::{
};
use super::types::{TokenRequest, TokenResponse};
use super::helpers::{create_access_token, verify_pkce};
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60;
pub async fn handle_authorization_code_grant(
state: AppState,
_headers: HeaderMap,
@@ -125,6 +127,7 @@ pub async fn handle_authorization_code_grant(
}),
))
}
pub async fn handle_refresh_token_grant(
state: AppState,
_headers: HeaderMap,
+5
View File
@@ -6,12 +6,15 @@ use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::config::AuthConfig;
use crate::oauth::OAuthError;
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
pub struct TokenClaims {
pub jti: String,
pub exp: i64,
pub iat: i64,
}
pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> {
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
@@ -22,6 +25,7 @@ pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAut
}
Ok(())
}
pub fn create_access_token(
token_id: &str,
sub: &str,
@@ -60,6 +64,7 @@ pub fn create_access_token(
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
Ok(format!("{}.{}", signing_input, signature_b64))
}
pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
+5
View File
@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{OAuthError, db};
use super::helpers::extract_token_claims;
#[derive(Debug, Deserialize)]
pub struct RevokeRequest {
pub token: Option<String>,
#[serde(default)]
pub token_type_hint: Option<String>,
}
pub async fn revoke_token(
State(state): State<AppState>,
headers: HeaderMap,
@@ -31,12 +33,14 @@ pub async fn revoke_token(
}
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct IntrospectRequest {
pub token: String,
#[serde(default)]
pub token_type_hint: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct IntrospectResponse {
pub active: bool,
@@ -63,6 +67,7 @@ pub struct IntrospectResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
}
pub async fn introspect_token(
State(state): State<AppState>,
headers: HeaderMap,
+4
View File
@@ -2,6 +2,7 @@ mod grants;
mod helpers;
mod introspect;
mod types;
use axum::{
Form, Json,
extract::State,
@@ -9,12 +10,14 @@ use axum::{
};
use crate::state::{AppState, RateLimitKind};
use crate::oauth::OAuthError;
pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant};
pub use helpers::{create_access_token, extract_token_claims, verify_pkce, TokenClaims};
pub use introspect::{
introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest,
};
pub use types::{TokenRequest, TokenResponse};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
@@ -30,6 +33,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
"unknown".to_string()
}
pub async fn token_endpoint(
State(state): State<AppState>,
headers: HeaderMap,
+2
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct TokenRequest {
pub grant_type: String,
@@ -19,6 +20,7 @@ pub struct TokenRequest {
#[serde(default)]
pub client_assertion_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
+5
View File
@@ -4,6 +4,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug)]
pub enum OAuthError {
InvalidRequest(String),
@@ -20,11 +21,13 @@ pub enum OAuthError {
InvalidToken(String),
RateLimited,
}
#[derive(Serialize)]
struct OAuthErrorResponse {
error: String,
error_description: Option<String>,
}
impl IntoResponse for OAuthError {
fn into_response(self) -> Response {
let (status, error, description) = match self {
@@ -86,12 +89,14 @@ impl IntoResponse for OAuthError {
.into_response()
}
}
impl From<sqlx::Error> for OAuthError {
fn from(err: sqlx::Error) -> Self {
tracing::error!("Database error in OAuth flow: {}", err);
OAuthError::ServerError("An internal error occurred".to_string())
}
}
impl From<anyhow::Error> for OAuthError {
fn from(err: anyhow::Error) -> Self {
tracing::error!("Internal error in OAuth flow: {}", err);
+3
View File
@@ -1,8 +1,10 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwkSet {
pub keys: Vec<Jwk>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jwk {
pub kty: String,
@@ -19,6 +21,7 @@ pub struct Jwk {
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<String>,
}
pub fn create_jwk_set(keys: Vec<Jwk>) -> JwkSet {
JwkSet { keys }
}
+1
View File
@@ -7,6 +7,7 @@ pub mod endpoints;
pub mod error;
pub mod templates;
pub mod verify;
pub use types::*;
pub use error::OAuthError;
pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError};

Some files were not shown because too many files have changed in this diff Show More