···11+CREATE TABLE IF NOT EXISTS api_clients (
22+ id TEXT PRIMARY KEY,
33+ client_key TEXT NOT NULL UNIQUE,
44+ name TEXT NOT NULL,
55+ client_id_url TEXT NOT NULL UNIQUE,
66+ client_uri TEXT NOT NULL,
77+ redirect_uris TEXT NOT NULL,
88+ scopes TEXT NOT NULL DEFAULT 'atproto',
99+ rate_limit_capacity INTEGER,
1010+ rate_limit_refill_rate REAL,
1111+ is_active INTEGER NOT NULL DEFAULT 1,
1212+ created_by TEXT NOT NULL,
1313+ created_at TEXT NOT NULL DEFAULT '',
1414+ updated_at TEXT NOT NULL DEFAULT ''
1515+);
···11+CREATE TABLE IF NOT EXISTS api_clients (
22+ id TEXT PRIMARY KEY,
33+ client_key TEXT NOT NULL UNIQUE,
44+ name TEXT NOT NULL,
55+ client_id_url TEXT NOT NULL UNIQUE,
66+ client_uri TEXT NOT NULL,
77+ redirect_uris TEXT NOT NULL,
88+ scopes TEXT NOT NULL DEFAULT 'atproto',
99+ rate_limit_capacity INTEGER,
1010+ rate_limit_refill_rate REAL,
1111+ is_active INTEGER NOT NULL DEFAULT 1,
1212+ created_by TEXT NOT NULL,
1313+ created_at TEXT NOT NULL DEFAULT '',
1414+ updated_at TEXT NOT NULL DEFAULT ''
1515+);
···11+use dashmap::DashMap;
22+use std::sync::Arc;
33+44+use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig};
55+use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig};
66+use atrium_oauth::{
77+ AtprotoClientMetadata, AuthMethod, DefaultHttpClient, GrantType, OAuthClientConfig,
88+ OAuthResolverConfig,
99+};
1010+1111+use crate::HappyViewOAuthClient;
1212+use crate::auth::oauth_store::{DbSessionStore, DbStateStore};
1313+use crate::db::{DatabaseBackend, adapt_sql};
1414+use crate::dns::NativeDnsResolver;
1515+1616+/// Parameters needed to build an OAuth client for an API client registration.
1717+pub struct ApiClientOAuthParams {
1818+ pub plc_url: String,
1919+ pub state_store: DbStateStore,
2020+ pub session_store_pool: sqlx::AnyPool,
2121+ pub db_backend: DatabaseBackend,
2222+}
2323+2424+/// Registry of OAuth clients, keyed by `client_id_url`.
2525+///
2626+/// Each API client gets its own `OAuthClient` instance so the PDS auth screen
2727+/// shows the correct domain. The default client is HappyView's own identity,
2828+/// used for dashboard auth.
2929+pub struct OAuthClientRegistry {
3030+ default_client: Arc<HappyViewOAuthClient>,
3131+ clients: DashMap<String, Arc<HappyViewOAuthClient>>,
3232+}
3333+3434+impl OAuthClientRegistry {
3535+ pub fn new(default_client: Arc<HappyViewOAuthClient>) -> Self {
3636+ Self {
3737+ default_client,
3838+ clients: DashMap::new(),
3939+ }
4040+ }
4141+4242+ /// Register an API client's OAuth client, keyed by its `client_id_url`.
4343+ pub fn register(&self, client_id_url: String, client: Arc<HappyViewOAuthClient>) {
4444+ self.clients.insert(client_id_url, client);
4545+ }
4646+4747+ /// Remove an API client's OAuth client.
4848+ pub fn remove(&self, client_id_url: &str) {
4949+ self.clients.remove(client_id_url);
5050+ }
5151+5252+ /// Look up a client by `client_id_url`.
5353+ pub fn get(&self, client_id_url: &str) -> Option<Arc<HappyViewOAuthClient>> {
5454+ self.clients.get(client_id_url).map(|r| r.value().clone())
5555+ }
5656+5757+ /// Look up a client by `client_id_url`, falling back to the default.
5858+ pub fn get_or_default(&self, client_id_url: Option<&str>) -> Arc<HappyViewOAuthClient> {
5959+ if let Some(url) = client_id_url {
6060+ self.clients
6161+ .get(url)
6262+ .map(|r| r.value().clone())
6363+ .unwrap_or_else(|| self.default_client.clone())
6464+ } else {
6565+ self.default_client.clone()
6666+ }
6767+ }
6868+6969+ /// Get the default (HappyView dashboard) client.
7070+ pub fn default_client(&self) -> &Arc<HappyViewOAuthClient> {
7171+ &self.default_client
7272+ }
7373+7474+ /// Build and register a single OAuth client from API client metadata.
7575+ /// Used when creating or updating an API client via the admin UI.
7676+ pub fn register_api_client(
7777+ &self,
7878+ client_id_url: &str,
7979+ client_uri: &str,
8080+ redirect_uris: Vec<String>,
8181+ scopes_str: &str,
8282+ params: &ApiClientOAuthParams,
8383+ ) -> Result<(), String> {
8484+ let ApiClientOAuthParams {
8585+ plc_url,
8686+ state_store,
8787+ session_store_pool,
8888+ db_backend,
8989+ } = params;
9090+ let scopes = crate::auth::parse_scope_string(scopes_str);
9191+ let scopes = if scopes.is_empty() {
9292+ vec![atrium_oauth::Scope::Known(
9393+ atrium_oauth::KnownScope::Atproto,
9494+ )]
9595+ } else {
9696+ scopes
9797+ };
9898+9999+ let metadata = AtprotoClientMetadata {
100100+ client_id: client_id_url.to_string(),
101101+ client_uri: Some(client_uri.to_string()),
102102+ redirect_uris,
103103+ token_endpoint_auth_method: AuthMethod::None,
104104+ grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
105105+ scopes,
106106+ jwks_uri: None,
107107+ token_endpoint_auth_signing_alg: None,
108108+ };
109109+110110+ let http = Arc::new(DefaultHttpClient::default());
111111+ let resolver = OAuthResolverConfig {
112112+ did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
113113+ plc_directory_url: plc_url.to_string(),
114114+ http_client: Arc::clone(&http),
115115+ }),
116116+ handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
117117+ dns_txt_resolver: NativeDnsResolver::new(),
118118+ http_client: Arc::clone(&http),
119119+ }),
120120+ authorization_server_metadata: Default::default(),
121121+ protected_resource_metadata: Default::default(),
122122+ };
123123+124124+ match atrium_oauth::OAuthClient::new(OAuthClientConfig {
125125+ client_metadata: metadata,
126126+ keys: None,
127127+ state_store: state_store.clone(),
128128+ session_store: DbSessionStore::new(session_store_pool.clone(), *db_backend),
129129+ resolver,
130130+ }) {
131131+ Ok(client) => {
132132+ self.register(client_id_url.to_string(), Arc::new(client));
133133+ Ok(())
134134+ }
135135+ Err(e) => Err(format!("failed to create OAuth client: {e}")),
136136+ }
137137+ }
138138+139139+ /// Load all active API clients from the database and register OAuth clients for each.
140140+ pub async fn load_from_db(
141141+ &self,
142142+ db: &sqlx::AnyPool,
143143+ db_backend: DatabaseBackend,
144144+ plc_url: &str,
145145+ state_store: DbStateStore,
146146+ session_store_pool: sqlx::AnyPool,
147147+ ) {
148148+ let sql = adapt_sql(
149149+ "SELECT client_id_url, client_uri, redirect_uris, scopes FROM api_clients WHERE is_active = 1",
150150+ db_backend,
151151+ );
152152+153153+ let rows: Vec<(String, String, String, String)> =
154154+ match sqlx::query_as(&sql).fetch_all(db).await {
155155+ Ok(r) => r,
156156+ Err(e) => {
157157+ tracing::error!("Failed to load API clients from database: {e}");
158158+ return;
159159+ }
160160+ };
161161+162162+ for (client_id_url, client_uri, redirect_uris_json, scopes_str) in rows {
163163+ let redirect_uris: Vec<String> =
164164+ serde_json::from_str(&redirect_uris_json).unwrap_or_default();
165165+166166+ let scopes = crate::auth::parse_scope_string(&scopes_str);
167167+ let scopes = if scopes.is_empty() {
168168+ vec![atrium_oauth::Scope::Known(
169169+ atrium_oauth::KnownScope::Atproto,
170170+ )]
171171+ } else {
172172+ scopes
173173+ };
174174+175175+ let metadata = AtprotoClientMetadata {
176176+ client_id: client_id_url.clone(),
177177+ client_uri: Some(client_uri),
178178+ redirect_uris,
179179+ token_endpoint_auth_method: AuthMethod::None,
180180+ grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
181181+ scopes,
182182+ jwks_uri: None,
183183+ token_endpoint_auth_signing_alg: None,
184184+ };
185185+186186+ // Each OAuthClient needs its own resolver instances (they're not Clone)
187187+ let http = Arc::new(DefaultHttpClient::default());
188188+ let resolver = OAuthResolverConfig {
189189+ did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
190190+ plc_directory_url: plc_url.to_string(),
191191+ http_client: Arc::clone(&http),
192192+ }),
193193+ handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
194194+ dns_txt_resolver: NativeDnsResolver::new(),
195195+ http_client: Arc::clone(&http),
196196+ }),
197197+ authorization_server_metadata: Default::default(),
198198+ protected_resource_metadata: Default::default(),
199199+ };
200200+201201+ match atrium_oauth::OAuthClient::new(OAuthClientConfig {
202202+ client_metadata: metadata,
203203+ keys: None,
204204+ state_store: state_store.clone(),
205205+ session_store: DbSessionStore::new(session_store_pool.clone(), db_backend),
206206+ resolver,
207207+ }) {
208208+ Ok(client) => {
209209+ tracing::info!(client_id = %client_id_url, "Registered API client OAuth identity");
210210+ self.register(client_id_url, Arc::new(client));
211211+ }
212212+ Err(e) => {
213213+ tracing::error!(client_id = %client_id_url, error = %e, "Failed to create OAuth client for API client");
214214+ }
215215+ }
216216+ }
217217+ }
218218+}
219219+220220+#[cfg(test)]
221221+mod tests {
222222+ use super::*;
223223+224224+ // Note: we can't easily construct real OAuthClient instances in unit tests
225225+ // because they require resolvers, stores, etc. The registry logic is simple
226226+ // enough that we test it via integration tests that stand up the full stack.
227227+ // These tests verify the DashMap-based lookup logic using a mock approach.
228228+229229+ #[test]
230230+ fn test_registry_stores_and_retrieves() {
231231+ // We can at least verify the DashMap operations work correctly
232232+ let map: DashMap<String, String> = DashMap::new();
233233+ map.insert("key1".to_string(), "val1".to_string());
234234+235235+ assert!(map.get("key1").is_some());
236236+ assert!(map.get("key2").is_none());
237237+238238+ map.remove("key1");
239239+ assert!(map.get("key1").is_none());
240240+ }
241241+242242+ #[test]
243243+ fn test_registry_overwrite() {
244244+ let map: DashMap<String, String> = DashMap::new();
245245+ map.insert("key1".to_string(), "val1".to_string());
246246+ map.insert("key1".to_string(), "val2".to_string());
247247+248248+ assert_eq!(map.get("key1").unwrap().value(), "val2");
249249+ }
250250+}
+27-4
src/auth/middleware.rs
···1515#[derive(Debug, Clone)]
1616pub struct Claims {
1717 did: String,
1818+ /// The API client key (e.g. "hvc_...") if the user authenticated via an API client.
1919+ client_key: Option<String>,
1820}
19212222+/// Separator used to encode `did` and `client_key` in a single cookie value.
2323+/// Newlines cannot appear in DIDs or client keys, so this is safe.
2424+const COOKIE_SEP: char = '\n';
2525+2026impl Claims {
2127 /// The authenticated user's DID.
2228 pub fn did(&self) -> &str {
2329 &self.did
2430 }
25313232+ /// The API client key, if the user logged in via an API client.
3333+ pub fn client_key(&self) -> Option<&str> {
3434+ self.client_key.as_deref()
3535+ }
3636+2637 /// Test-only constructor.
2738 #[cfg(test)]
2839 pub fn new_for_test(did: String) -> Self {
2929- Self { did }
4040+ Self {
4141+ did,
4242+ client_key: None,
4343+ }
3044 }
3145}
3246···4357 .map_err(|_| AppError::Auth("failed to read cookies".into()))?;
44584559 if let Some(cookie) = jar.get(COOKIE_NAME) {
4646- let did = cookie.value().to_string();
4747- return Ok(Claims { did });
6060+ let value = cookie.value().to_string();
6161+ let (did, client_key) = if let Some((d, k)) = value.split_once(COOKIE_SEP) {
6262+ (d.to_string(), Some(k.to_string()))
6363+ } else {
6464+ (value, None)
6565+ };
6666+ return Ok(Claims { did, client_key });
4867 }
49685069 // Path 2: Authorization header
···6382 // API key auth is handled by UserAuth extractor which looks up the key.
6483 // We need to extract the DID from the api_keys table.
6584 let did = resolve_api_key_did(state, token).await?;
6666- return Ok(Claims { did });
8585+ return Ok(Claims {
8686+ did,
8787+ client_key: None,
8888+ });
6789 }
68906991 // Otherwise, try service auth JWT
7092 let service_auth = super::service_auth::ServiceAuth::from_bearer(token, state).await?;
7193 return Ok(Claims {
7294 did: service_auth.did,
9595+ client_key: None,
7396 });
7497 }
7598
+2
src/auth/mod.rs
···11+pub mod client_registry;
12pub mod middleware;
23pub mod oauth_store;
34pub mod routes;
45pub mod service_auth;
5677+pub use client_registry::OAuthClientRegistry;
68pub use middleware::Claims;
79pub use routes::parse_scope_string;
810pub use service_auth::ServiceAuth;
+59-19
src/auth/routes.rs
···2121 handle: String,
2222 redirect_uri: Option<String>,
2323 scope: Option<String>,
2424+ client_id: Option<String>,
2425}
25262627/// Parse a whitespace-separated OAuth scope string into typed `Scope` values.
···8586 }
8687 };
87888888- tracing::debug!(scopes = ?scopes, "resolved oauth scopes");
8989+ tracing::debug!(scopes = ?scopes, client_id = ?query.client_id, "resolved oauth scopes");
9090+9191+ // Select the appropriate OAuth client based on client_id
9292+ let oauth_client = state.oauth.get_or_default(query.client_id.as_deref());
89939094 // Hold the authorize lock so that authorize() + take_last_state_key() are atomic.
9195 // This prevents concurrent logins from swapping each other's state keys.
···96100 ..Default::default()
97101 };
981029999- let url = state
100100- .oauth
103103+ let url = oauth_client
101104 .authorize(&query.handle, options)
102105 .await
103106 .map_err(|e| AppError::Internal(format!("OAuth authorize failed: {e}")))?;
···113116114117 // Store the redirect URI in the database, keyed by the OAuth state parameter.
115118 // This avoids third-party cookie issues when Pentaract (cross-origin) calls this endpoint.
116116- if let Some(redirect_uri) = &query.redirect_uri {
117117- tracing::debug!(oauth_state = ?oauth_state, redirect_uri = %redirect_uri, "storing redirect for state");
119119+ // Store redirect URI and client_id for the callback to use
120120+ if query.redirect_uri.is_some() || query.client_id.is_some() {
121121+ let redirect_uri = query.redirect_uri.as_deref().unwrap_or("");
122122+ tracing::debug!(oauth_state = ?oauth_state, redirect_uri = %redirect_uri, client_id = ?query.client_id, "storing redirect for state");
118123119124 if let Some(oauth_state) = oauth_state {
120125 let now = now_rfc3339();
121126 let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339();
122127 let sql = adapt_sql(
123123- "INSERT INTO auth_login_redirects (state, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?)",
128128+ "INSERT INTO auth_login_redirects (state, redirect_uri, client_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
124129 state.db_backend,
125130 );
126131 let _ = sqlx::query(&sql)
127132 .bind(&oauth_state)
128133 .bind(redirect_uri)
134134+ .bind(query.client_id.as_deref())
129135 .bind(&now)
130136 .bind(&expires_at)
131137 .execute(&state.db)
···145151) -> Result<(SignedCookieJar<Key>, Redirect), AppError> {
146152 tracing::debug!(state = ?query.state, "callback received");
147153148148- // Look up the redirect URI from the database before the OAuth library consumes the state
149149- let redirect_url = if let Some(oauth_state) = &query.state {
154154+ // Look up the redirect URI and client_id from the database before the OAuth library consumes the state
155155+ let (redirect_url, client_id) = if let Some(oauth_state) = &query.state {
150156 let sql = adapt_sql(
151151- "SELECT redirect_uri FROM auth_login_redirects WHERE state = ? AND expires_at > ?",
157157+ "SELECT redirect_uri, client_id FROM auth_login_redirects WHERE state = ? AND expires_at > ?",
152158 state.db_backend,
153159 );
154160 let now = now_rfc3339();
155155- let row: Option<(String,)> = sqlx::query_as(&sql)
161161+ let row: Option<(String, Option<String>)> = sqlx::query_as(&sql)
156162 .bind(oauth_state)
157163 .bind(&now)
158164 .fetch_optional(&state.db)
···172178 }
173179174180 tracing::debug!(found_redirect = ?row, "redirect lookup result");
175175- row.map(|(uri,)| uri)
181181+ match row {
182182+ Some((uri, cid)) => {
183183+ let uri = if uri.is_empty() { None } else { Some(uri) };
184184+ (uri, cid)
185185+ }
186186+ None => (None, None),
187187+ }
176188 } else {
177189 tracing::debug!("no state in callback query");
178178- None
190190+ (None, None)
179191 };
192192+193193+ // Use the same OAuth client that was used for authorize
194194+ let oauth_client = state.oauth.get_or_default(client_id.as_deref());
180195181196 let params = atrium_oauth::CallbackParams {
182197 code: query.code,
···184199 iss: query.iss,
185200 };
186201187187- let (session, _app_state) = state
188188- .oauth
202202+ let (session, _app_state) = oauth_client
189203 .callback(params)
190204 .await
191205 .map_err(|e| AppError::Internal(format!("OAuth callback failed: {e}")))?;
···195209 .did()
196210 .await
197211 .ok_or_else(|| AppError::Internal("no DID in OAuth session".into()))?;
212212+213213+ // Look up the client_key for the API client so we can store it in the session cookie
214214+ // for per-client rate limiting.
215215+ let client_key = if let Some(ref cid) = client_id {
216216+ let sql = adapt_sql(
217217+ "SELECT client_key FROM api_clients WHERE client_id_url = ? AND is_active = 1",
218218+ state.db_backend,
219219+ );
220220+ let row: Option<(String,)> = sqlx::query_as(&sql)
221221+ .bind(cid)
222222+ .fetch_optional(&state.db)
223223+ .await
224224+ .unwrap_or(None);
225225+ row.map(|(k,)| k)
226226+ } else {
227227+ None
228228+ };
198229199230 // Use DB-stored redirect, or default to "/"
200200- let redirect_url = redirect_url.unwrap_or_else(|| "/".to_string());
231231+ let redirect_url = redirect_url.unwrap_or_else(|| "/".into());
201232 tracing::debug!(redirect_url = %redirect_url, "redirecting after callback");
202233203234 // Set the session cookie
204235 // Must use SameSite=None for cross-origin requests (e.g., Pentaract calling HappyView)
205205- let mut session_cookie = Cookie::new(COOKIE_NAME, did.to_string());
236236+ // Encode did and optional client_key separated by newline.
237237+ let did_str = did.as_ref();
238238+ let cookie_value = if let Some(ref ck) = client_key {
239239+ format!("{did_str}\n{ck}")
240240+ } else {
241241+ did_str.to_string()
242242+ };
243243+ let mut session_cookie = Cookie::new(COOKIE_NAME, cookie_value);
206244 session_cookie.set_path("/");
207245 session_cookie.set_http_only(true);
208246 session_cookie.set_same_site(axum_extra::extract::cookie::SameSite::None);
···227265 jar: SignedCookieJar<Key>,
228266) -> Result<SignedCookieJar<Key>, AppError> {
229267 if let Some(cookie) = jar.get(COOKIE_NAME) {
230230- let did_str = cookie.value().to_string();
268268+ let raw = cookie.value().to_string();
269269+ let did_str = raw.split('\n').next().unwrap_or(&raw).to_string();
231270 if let Ok(did) = atrium_api::types::string::Did::new(did_str) {
232232- let _ = state.oauth.revoke(&did).await;
271271+ let _ = state.oauth.default_client().revoke(&did).await;
233272 }
234273 }
235274···254293 let cookie = jar
255294 .get(COOKIE_NAME)
256295 .ok_or(AppError::Auth("not authenticated".into()))?;
257257- let did = cookie.value().to_string();
296296+ let raw = cookie.value().to_string();
297297+ let did = raw.split('\n').next().unwrap_or(&raw).to_string();
258298259299 let backend = state.db_backend;
260300 let user: Option<(i32,)> =