atproto Thingiverse but good
12

Configure Feed

Select the types of activity you want to include in your feed.

PM-27: OAuth sessions, SQLite ClientAuthStore, and viewer state

Orual (Jun 29, 2026, 7:06 PM EDT) 90a45973 cd8c69ef

+1470 -173
+12
.sqlx/query-10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9" 12 + }
+26
.sqlx/query-213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT payload FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "payload", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "oauth_sessions", 13 + "name": "payload" 14 + } 15 + } 16 + } 17 + ], 18 + "parameters": { 19 + "Right": 2 20 + }, 21 + "nullable": [ 22 + false 23 + ] 24 + }, 25 + "hash": "213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642" 26 + }
+12
.sqlx/query-33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT INTO oauth_auth_requests\n (state, authserver_url, account_did, request_uri, token_endpoint,\n revocation_endpoint, created_at, payload)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(state) DO UPDATE SET\n authserver_url = excluded.authserver_url,\n account_did = excluded.account_did,\n request_uri = excluded.request_uri,\n token_endpoint = excluded.token_endpoint,\n revocation_endpoint = excluded.revocation_endpoint,\n payload = excluded.payload", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 8 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6" 12 + }
+38
.sqlx/query-418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT account_did, session_id FROM oauth_sessions", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "account_did", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "oauth_sessions", 13 + "name": "account_did" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "session_id", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "oauth_sessions", 24 + "name": "session_id" 25 + } 26 + } 27 + } 28 + ], 29 + "parameters": { 30 + "Right": 0 31 + }, 32 + "nullable": [ 33 + false, 34 + false 35 + ] 36 + }, 37 + "hash": "418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a" 38 + }
+12
.sqlx/query-45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM oauth_auth_requests WHERE state = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 1 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a" 12 + }
+26
.sqlx/query-7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT payload FROM oauth_auth_requests WHERE state = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "payload", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "oauth_auth_requests", 13 + "name": "payload" 14 + } 15 + } 16 + } 17 + ], 18 + "parameters": { 19 + "Right": 1 20 + }, 21 + "nullable": [ 22 + false 23 + ] 24 + }, 25 + "hash": "7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450" 26 + }
+12
.sqlx/query-c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT INTO oauth_sessions\n (account_did, session_id, host_url, authserver_url, token_endpoint,\n revocation_endpoint, created_at, updated_at, payload)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(account_did, session_id) DO UPDATE SET\n host_url = excluded.host_url,\n authserver_url = excluded.authserver_url,\n token_endpoint = excluded.token_endpoint,\n revocation_endpoint = excluded.revocation_endpoint,\n updated_at = excluded.updated_at,\n payload = excluded.payload", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 9 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b" 12 + }
+13
Cargo.lock
··· 4716 4716 ] 4717 4717 4718 4718 [[package]] 4719 + name = "keyring" 4720 + version = "3.6.3" 4721 + source = "registry+https://github.com/rust-lang/crates.io-index" 4722 + checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" 4723 + dependencies = [ 4724 + "log", 4725 + "zeroize", 4726 + ] 4727 + 4728 + [[package]] 4719 4729 name = "konst" 4720 4730 version = "0.2.20" 4721 4731 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6184 6194 dependencies = [ 6185 6195 "anyhow", 6186 6196 "axum", 6197 + "axum-extra", 6198 + "base64", 6187 6199 "chrono", 6188 6200 "console_error_panic_hook", 6189 6201 "dioxus", ··· 6196 6208 "jacquard-common 0.12.0", 6197 6209 "jacquard-derive 0.12.0", 6198 6210 "js-sys", 6211 + "keyring", 6199 6212 "libsqlite3-sys", 6200 6213 "polymodel-api", 6201 6214 "reqwest",
+12
Cargo.toml
··· 44 44 "dep:chrono", 45 45 "dep:tracing-subscriber", 46 46 "dep:rustls", 47 + # OAuth (PM-27): confidential client, private cookie jar key, secret sources. 48 + "dep:axum-extra", 49 + 50 + "dep:keyring", 51 + "dep:base64", 47 52 ] 48 53 [dependencies] 49 54 dioxus = { version = "0.7", features = ["router", "fullstack"] } ··· 93 98 ] } 94 99 http = "1.4" 95 100 reqwest = { version = "0.12", default-features = false, features = ["json", "charset"] } 101 + 102 + # PM-27 OAuth: private cookie-jar signing key and swappable secret sources 103 + # (OS keyring preferred; env override). reqwest is shared with the client crate. 104 + axum-extra = { version = "0.10", optional = true, features = ["cookie"] } 105 + 106 + keyring = { version = "3", optional = true } 107 + base64 = { version = "0.22", optional = true } 96 108 97 109 98 110 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies]
+15 -8
justfile
··· 2 2 3 3 set dotenv-load := false 4 4 5 + # Canonical SQLite database URL for the projection store and the compile-time 6 + # `query!` macros. Scoping it here (rather than via .env) keeps `just` recipes 7 + # hermetic — see `set dotenv-load := false` above. 8 + db-url := 'sqlite:./data/polymodel.db' 9 + 5 10 # Format Rust sources. Run clippy/check separately; clippy --fix can corrupt RSX. 6 11 fix: 7 12 cargo fmt --all ··· 11 16 cargo clippy -p polymodel --all-targets --features server -- -D warnings 12 17 cargo clippy -p polymodel --target wasm32-unknown-unknown --features web -- -D warnings 13 18 # Compile checks across the whole workspace plus the app's server/wasm targets. 14 - # migrated SQLite database. Run `just migrate` first (or run `just sqlx-prepare`) 15 - # so the macros can resolve. 19 + # The server check resolves compile-time `query!` macros against the committed 20 + # `.sqlx` offline cache; regenerate it with `just sqlx-prepare` after changing 21 + # SQL or migrations. 16 22 check: 17 23 cargo check --workspace 18 24 cargo check -p polymodel --features server 19 25 cargo check -p polymodel --target wasm32-unknown-unknown --features web 20 26 21 27 # Create and migrate the SQLite projection database. 22 - # Required by the compile-time `query!` macros: run before the first server 23 - # check, and re-run after editing migrations. 28 + # Required when regenerating the `.sqlx` cache, and re-run after editing migrations. 24 29 migrate: 25 30 mkdir -p ./data 26 - -cargo sqlx database create --database-url 'sqlite:./data/polymodel.db' 27 - cargo sqlx migrate run --source migrations --database-url 'sqlite:./data/polymodel.db' 31 + -cargo sqlx database create --database-url '{{ db-url }}' 32 + cargo sqlx migrate run --source migrations --database-url '{{ db-url }}' 28 33 29 34 # Regenerate the committed `.sqlx` offline query cache after changing SQL. 30 - sqlx-prepare: 31 - cargo sqlx prepare -- -p polymodel --features server 35 + # Self-contained: migrates the local database first, then points `cargo sqlx 36 + # prepare` at it via DATABASE_URL so no manual env setup is required. 37 + sqlx-prepare: migrate 38 + DATABASE_URL='{{ db-url }}' cargo sqlx prepare -- -p polymodel --features server 32 39 33 40 # Run unit tests across the workspace. 34 41 test:
+32 -7
migrations/001_projection_schema.sql
··· 150 150 VALUES (new.rowid, new.name, new.summary, new.tags_text, new.instructions_text); 151 151 END; 152 152 153 - -- OAuth session persistence (DB-backed ClientAuthStore; used in Task 3) 153 + -- OAuth session persistence (DB-backed ClientAuthStore for Jacquard 0.12 OAuth). 154 + -- 155 + -- Each row mirrors the durable surfaces of `jacquard::oauth::session::ClientSessionData`: 156 + -- the structured columns (account_did, session_id, endpoints, timestamps) power 157 + -- key enumeration, by-DID/any selection, and deletes *without* deserializing the 158 + -- payload, while `payload` holds the canonical JSON serialization of the full 159 + -- `ClientSessionData` (token set, DPoP key + nonces, scopes). DPoP nonces are 160 + -- persisted here, not in an ad-hoc store; the in-process `SessionRegistry` 161 + -- mutex is concurrency-only and not durable. 154 162 CREATE TABLE oauth_sessions ( 155 - did TEXT NOT NULL, 163 + account_did TEXT NOT NULL, 156 164 session_id TEXT NOT NULL, 157 - session_data BLOB NOT NULL, 165 + host_url TEXT NOT NULL, 166 + authserver_url TEXT NOT NULL, 167 + token_endpoint TEXT NOT NULL, 168 + revocation_endpoint TEXT, 158 169 created_at INTEGER NOT NULL, 159 170 updated_at INTEGER NOT NULL, 160 - PRIMARY KEY (did, session_id) 171 + payload TEXT NOT NULL, 172 + PRIMARY KEY (account_did, session_id) 161 173 ); 174 + CREATE INDEX idx_oauth_sessions_did ON oauth_sessions(account_did); 162 175 176 + -- Pending OAuth authorization requests (PAR `state` round-trip). 177 + -- `payload` holds the canonical JSON serialization of 178 + -- `jacquard::oauth::session::AuthRequestData` (PKCE verifier, DPoP key + nonce, 179 + -- request_uri). `account_did` is nullable because the initiating identifier is 180 + -- not always known when the request is created. 163 181 CREATE TABLE oauth_auth_requests ( 164 - state TEXT PRIMARY KEY, 165 - request_data BLOB NOT NULL, 166 - created_at INTEGER NOT NULL 182 + state TEXT NOT NULL, 183 + authserver_url TEXT NOT NULL, 184 + account_did TEXT, 185 + request_uri TEXT NOT NULL, 186 + token_endpoint TEXT NOT NULL, 187 + revocation_endpoint TEXT, 188 + created_at INTEGER NOT NULL, 189 + payload TEXT NOT NULL, 190 + PRIMARY KEY (state) 167 191 ); 192 + CREATE INDEX idx_oauth_auth_req_did ON oauth_auth_requests(account_did);
+63 -11
src/appview/actor.rs
··· 2 2 //! of polymodel profileView / app.bsky profileViewDetailed) and `getSession` 3 3 //! (PM-9 identity primitive; PM-26 returns the unauthenticated marker). 4 4 5 - use std::sync::Arc; 6 - 7 5 use axum::extract::State; 6 + use jacquard::identity::PublicResolver; 8 7 use jacquard::identity::resolver::IdentityResolver; 8 + use jacquard_axum::oauth::OptionalBrowserOAuthSession; 9 9 use jacquard_axum::{ExtractXrpc, XrpcResponse}; 10 + use jacquard_common::deps::smol_str::SmolStr; 10 11 use jacquard_common::types::ident::AtIdentifier; 11 - use jacquard_common::types::string::Did; 12 + use jacquard_common::types::string::{Did, Handle}; 13 + use jacquard_common::types::value::Data; 12 14 use jacquard_common::xrpc::XrpcClient; 13 15 use polymodel_api::app_bsky::actor::ProfileViewDetailed; 14 16 use polymodel_api::app_bsky::actor::get_profile::GetProfile as BskyGetProfile; 15 17 use polymodel_api::space_polymodel::actor::{ 16 - ProfileViewUnion, 18 + ProfileView, ProfileViewUnion, 17 19 get_profile::{GetProfileOutput, GetProfileRequest}, 18 - get_session::{GetSessionOutput, GetSessionRequest, SessionUnauthenticated, SessionView}, 20 + get_session::{ 21 + GetSessionOutput, GetSessionRequest, SessionAuthenticated, SessionUnauthenticated, 22 + SessionView, 23 + }, 19 24 }; 20 25 21 26 use super::error::{AppResult, internal}; 22 27 use super::state::AppState; 23 28 use super::views; 29 + use crate::oauth::SqliteAuthStore; 24 30 25 31 /// Canonicalize an at-identifier to a DID by matching on its variant (not by 26 32 /// sniffing its serialized form). DIDs pass through; handles are resolved via ··· 38 44 } 39 45 40 46 pub(super) async fn get_profile( 41 - State(state): State<Arc<AppState>>, 47 + State(state): State<AppState>, 42 48 ExtractXrpc(req): ExtractXrpc<GetProfileRequest>, 43 49 ) -> AppResult<XrpcResponse<GetProfileRequest>> { 44 50 let did = resolve_actor(&state, &req.actor).await?; ··· 80 86 } 81 87 82 88 pub(super) async fn get_session( 83 - State(_state): State<Arc<AppState>>, 89 + State(state): State<AppState>, 90 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 91 + PublicResolver, 92 + SqliteAuthStore, 93 + >, 84 94 ) -> AppResult<XrpcResponse<GetSessionRequest>> { 85 - // PM-26 ships the unauthenticated arm only. PM-27 will return 86 - // `SessionAuthenticated` once OAuth session extraction and profile writes are wired. 95 + if let Some(session) = session.as_ref() { 96 + let did = session.session_info().await.0; 97 + if let Some(handle_str) = views::handle_for_did(&state, &did).await? { 98 + let handle = Handle::new_owned(handle_str.clone()) 99 + .map_err(|e| internal(format!("invalid session handle: {e}")))?; 100 + let profile = match views::polymodel_profile_view(&state, &did).await? { 101 + Some(profile) => profile, 102 + None => minimal_session_profile(did.clone(), handle_str)?, 103 + }; 104 + return Ok(XrpcResponse(GetSessionOutput { 105 + value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { 106 + authenticated: true, 107 + did, 108 + handle, 109 + profile, 110 + extra_data: None, 111 + })), 112 + extra_data: None, 113 + })); 114 + } 115 + } 87 116 88 - Ok(XrpcResponse(GetSessionOutput { 117 + Ok(unauthenticated_session()) 118 + } 119 + 120 + fn unauthenticated_session() -> XrpcResponse<GetSessionRequest> { 121 + XrpcResponse(GetSessionOutput { 89 122 value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { 90 123 authenticated: false, 91 124 extra_data: None, 92 125 })), 93 126 extra_data: None, 94 - })) 127 + }) 128 + } 129 + 130 + fn minimal_session_profile(did: Did, handle: String) -> AppResult<ProfileView> { 131 + Ok(ProfileView { 132 + avatar: None, 133 + default_license: None, 134 + description: None, 135 + did, 136 + display_name: None, 137 + handle: SmolStr::from(handle), 138 + indexed_at: None, 139 + links: None, 140 + printers: None, 141 + pronouns: None, 142 + record: Data::Null, 143 + thing_count: None, 144 + total_likes: None, 145 + extra_data: None, 146 + }) 95 147 }
+12 -3
src/appview/graph.rs
··· 4 4 //! subjects (listitems may reference any record) are filtered out with debug 5 5 //! logging rather than failing the whole list. 6 6 7 - use std::sync::Arc; 8 - 9 7 use axum::extract::State; 8 + use jacquard::identity::PublicResolver; 9 + use jacquard_axum::oauth::OptionalBrowserOAuthSession; 10 10 use jacquard_axum::{ExtractXrpc, XrpcResponse}; 11 11 use jacquard_common::deps::smol_str::SmolStr; 12 12 use polymodel_api::space_polymodel::graph::get_list::{GetListOutput, GetListRequest}; ··· 14 14 use super::error::{AppResult, clamp_limit}; 15 15 use super::state::AppState; 16 16 use super::views; 17 + use super::views::viewer_did; 18 + use crate::oauth::SqliteAuthStore; 17 19 18 20 pub(super) async fn get_list( 19 - State(state): State<Arc<AppState>>, 21 + State(state): State<AppState>, 22 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 23 + PublicResolver, 24 + SqliteAuthStore, 25 + >, 20 26 ExtractXrpc(req): ExtractXrpc<GetListRequest>, 21 27 ) -> AppResult<XrpcResponse<GetListRequest>> { 28 + let viewer_owned = viewer_did(session.as_ref()).await; 29 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 22 30 let (items, cursor) = views::list_things( 23 31 &state, 24 32 req.list.as_ref(), 25 33 clamp_limit(req.limit), 26 34 req.cursor.as_deref(), 35 + viewer, 27 36 ) 28 37 .await?; 29 38 Ok(XrpcResponse(GetListOutput {
+45 -11
src/appview/library.rs
··· 4 4 //! hydrate ordered children via the junction tables; feeds/search return a 5 5 //! `feedView` of `thingViewBasic`. 6 6 7 - use std::sync::Arc; 8 - 9 7 use axum::extract::State; 8 + use jacquard::identity::PublicResolver; 9 + use jacquard_axum::oauth::OptionalBrowserOAuthSession; 10 10 use jacquard_axum::{ExtractXrpc, XrpcResponse}; 11 11 use polymodel_api::space_polymodel::library::{ 12 12 get_author_things::{GetAuthorThingsOutput, GetAuthorThingsRequest}, ··· 19 19 use super::error::{AppResult, clamp_limit, invalid_request}; 20 20 use super::state::AppState; 21 21 use super::views; 22 + use super::views::viewer_did; 23 + use crate::oauth::SqliteAuthStore; 22 24 23 25 pub(super) async fn get_thing( 24 - State(state): State<Arc<AppState>>, 26 + State(state): State<AppState>, 27 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 28 + PublicResolver, 29 + SqliteAuthStore, 30 + >, 25 31 ExtractXrpc(req): ExtractXrpc<GetThingRequest>, 26 32 ) -> AppResult<XrpcResponse<GetThingRequest>> { 27 - let (thing, models) = views::get_thing(&state, req.uri.as_ref(), None).await?; 33 + let viewer_owned = viewer_did(session.as_ref()).await; 34 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 35 + let (thing, models) = views::get_thing(&state, req.uri.as_ref(), viewer).await?; 28 36 Ok(XrpcResponse(GetThingOutput { 29 37 thing, 30 38 models: Some(models), ··· 33 41 } 34 42 35 43 pub(super) async fn get_model( 36 - State(state): State<Arc<AppState>>, 44 + State(state): State<AppState>, 45 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 46 + PublicResolver, 47 + SqliteAuthStore, 48 + >, 37 49 ExtractXrpc(req): ExtractXrpc<GetModelRequest>, 38 50 ) -> AppResult<XrpcResponse<GetModelRequest>> { 39 - let detail = views::get_model(&state, req.uri.as_ref(), None).await?; 51 + let viewer_owned = viewer_did(session.as_ref()).await; 52 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 53 + let detail = views::get_model(&state, req.uri.as_ref(), viewer).await?; 40 54 Ok(XrpcResponse(GetModelOutput { 41 55 model: detail.model, 42 56 parts: Some(detail.parts), ··· 46 60 } 47 61 48 62 pub(super) async fn get_author_things( 49 - State(state): State<Arc<AppState>>, 63 + State(state): State<AppState>, 64 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 65 + PublicResolver, 66 + SqliteAuthStore, 67 + >, 50 68 ExtractXrpc(req): ExtractXrpc<GetAuthorThingsRequest>, 51 69 ) -> AppResult<XrpcResponse<GetAuthorThingsRequest>> { 70 + let viewer_owned = viewer_did(session.as_ref()).await; 71 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 52 72 let did = super::actor::resolve_actor(&state, &req.actor).await?; 53 73 let feed = views::author_things( 54 74 &state, 55 75 did.as_ref(), 56 76 clamp_limit(req.limit), 57 77 req.cursor.as_deref(), 78 + viewer, 58 79 ) 59 80 .await?; 60 81 Ok(XrpcResponse(GetAuthorThingsOutput { ··· 64 85 } 65 86 66 87 pub(super) async fn get_feed( 67 - State(state): State<Arc<AppState>>, 88 + State(state): State<AppState>, 89 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 90 + PublicResolver, 91 + SqliteAuthStore, 92 + >, 68 93 ExtractXrpc(req): ExtractXrpc<GetFeedRequest>, 69 94 ) -> AppResult<XrpcResponse<GetFeedRequest>> { 95 + let viewer_owned = viewer_did(session.as_ref()).await; 96 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 70 97 let algorithm = req.algorithm.as_deref().unwrap_or("recent"); 71 98 let limit = clamp_limit(req.limit); 72 99 let cursor = req.cursor.as_deref(); 73 100 let feed = match algorithm { 74 - "recent" => views::feed_recent(&state, limit, cursor).await?, 75 - "hot" => views::feed_hot(&state, limit, cursor).await?, 101 + "recent" => views::feed_recent(&state, limit, cursor, viewer).await?, 102 + "hot" => views::feed_hot(&state, limit, cursor, viewer).await?, 76 103 other => return Err(invalid_request(format!("unknown algorithm: {other}"))), 77 104 }; 78 105 Ok(XrpcResponse(GetFeedOutput { ··· 82 109 } 83 110 84 111 pub(super) async fn search_things( 85 - State(state): State<Arc<AppState>>, 112 + State(state): State<AppState>, 113 + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< 114 + PublicResolver, 115 + SqliteAuthStore, 116 + >, 86 117 ExtractXrpc(req): ExtractXrpc<SearchThingsRequest>, 87 118 ) -> AppResult<XrpcResponse<SearchThingsRequest>> { 119 + let viewer_owned = viewer_did(session.as_ref()).await; 120 + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); 88 121 let feed = views::search_things( 89 122 &state, 90 123 req.q.as_str(), 91 124 clamp_limit(req.limit), 92 125 req.cursor.as_deref(), 126 + viewer, 93 127 ) 94 128 .await?; 95 129 Ok(XrpcResponse(SearchThingsOutput {
+2 -5
src/appview/mod.rs
··· 21 21 #[cfg(test)] 22 22 mod tests; 23 23 24 - use std::sync::Arc; 25 - 26 24 use axum::Router; 27 25 use jacquard_axum::IntoRouter; 28 26 use jacquard_common::xrpc::XrpcEndpoint; ··· 39 37 40 38 /// Build the appview XRPC router with the given state applied. 41 39 /// 42 - /// The returned `Router<()>` is merged with the Dioxus SSR router in `main`. 43 - pub fn router(state: Arc<AppState>) -> Router { 40 + /// Returns a state-bound `Router<AppState>`; merged with the OAuth routes and the Dioxus SSR router in `main`, where `.with_state` is applied once. 41 + pub fn router() -> Router<AppState> { 44 42 Router::new() 45 43 .merge(GetThingRequest::into_router(library::get_thing)) 46 44 .merge(GetModelRequest::into_router(library::get_model)) ··· 58 56 GetSessionRequest::PATH, 59 57 axum::routing::get(actor::get_session), 60 58 ) 61 - .with_state(state) 62 59 }
+50 -6
src/appview/state.rs
··· 6 6 //! authorities to DIDs for `getProfile`; `bsky` fetches the 7 7 //! `app.bsky.actor.defs#profileViewDetailed` union variant from the public 8 8 //! AppView for actors with no polymodel profile. 9 + //! 10 + //! PM-27 adds the confidential OAuth client, single-origin web config, and 11 + //! private cookie-jar key. `AppState` is the Axum router state type (local, so 12 + //! the [`FromRef`] impls for [`OAuthWebConfig`] and the cookie [`Key`] satisfy 13 + //! the orphan rule); it is cheaply `Clone` (pool/resolver/oauth are 14 + //! `Arc`-backed). 9 15 16 + use std::sync::Arc; 17 + 18 + use axum::extract::FromRef; 19 + use axum_extra::extract::cookie::Key; 10 20 use jacquard::client::BasicClient; 11 21 use jacquard::identity::PublicResolver; 22 + use jacquard::oauth::client::OAuthClient; 23 + use jacquard_axum::oauth::{OAuthWebConfig, OAuthWebState}; 12 24 use sqlx::SqlitePool; 13 25 14 - /// State shared by all XRPC read handlers. 26 + use crate::oauth::{OAuthBootstrap, SqliteAuthStore}; 27 + 28 + /// State shared by all XRPC read handlers and the OAuth routes. 29 + #[derive(Clone)] 15 30 pub struct AppState { 16 31 /// SQLite projection (PM-25): content, social counts, profiles, identities. 17 32 pub pool: SqlitePool, ··· 19 34 pub resolver: PublicResolver, 20 35 /// Unauthenticated client aimed at the public Bluesky AppView 21 36 /// (`https://public.api.bsky.app`) for the bluesky-only profile variant. 22 - pub bsky: BasicClient, 37 + /// `Arc`-wrapped because `BasicClient` (`Agent`) is not `Clone`. 38 + pub bsky: Arc<BasicClient>, 39 + /// Confidential OAuth client (PM-27) over the SQLite auth store. 40 + pub oauth: Arc<OAuthClient<PublicResolver, SqliteAuthStore>>, 41 + /// Single-origin OAuth web config (cookie name + route paths). 42 + pub oauth_config: OAuthWebConfig, 43 + /// Signing key for the private `jacquard_oauth_session` cookie jar. 44 + pub cookie_key: Key, 23 45 } 24 46 25 47 impl AppState { 26 - /// Construct read state from the shared SQLite pool. The resolver and bsky 27 - /// client are unauthenticated/public and need no configuration. 28 - pub fn new(pool: SqlitePool) -> Self { 48 + /// Construct read + OAuth state from the shared SQLite pool and the 49 + /// startup-built OAuth bootstrap (keyset, config, cookie key). 50 + pub fn new(pool: SqlitePool, bootstrap: OAuthBootstrap) -> Self { 29 51 Self { 30 52 pool, 31 53 resolver: jacquard::identity::slingshot_resolver_default(), 32 - bsky: jacquard::client::BasicClient::unauthenticated(), 54 + bsky: Arc::new(jacquard::client::BasicClient::unauthenticated()), 55 + oauth: Arc::new(bootstrap.client), 56 + oauth_config: bootstrap.config, 57 + cookie_key: bootstrap.cookie_key, 33 58 } 34 59 } 35 60 } 61 + 62 + /// Expose the OAuth client to `jacquard_axum::oauth` routes/extractors. 63 + impl OAuthWebState<PublicResolver, SqliteAuthStore> for AppState { 64 + fn oauth_client(&self) -> &OAuthClient<PublicResolver, SqliteAuthStore> { 65 + self.oauth.as_ref() 66 + } 67 + } 68 + 69 + impl FromRef<AppState> for OAuthWebConfig { 70 + fn from_ref(state: &AppState) -> Self { 71 + state.oauth_config.clone() 72 + } 73 + } 74 + 75 + impl FromRef<AppState> for Key { 76 + fn from_ref(state: &AppState) -> Self { 77 + state.cookie_key.clone() 78 + } 79 + }
+26 -19
src/appview/tests.rs
··· 8 8 9 9 #![cfg(test)] 10 10 11 - use std::sync::Arc; 12 - 13 11 use axum::body::Body; 14 12 use axum::http::{Request, StatusCode}; 15 13 use jacquard_common::types::string::Did; ··· 42 40 pool 43 41 } 44 42 45 - async fn state() -> Arc<AppState> { 46 - Arc::new(AppState::new(pool().await)) 43 + async fn state() -> AppState { 44 + let pool = pool().await; 45 + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) 46 + .expect("ephemeral OAuth bootstrap for tests"); 47 + AppState::new(pool, bootstrap) 47 48 } 48 49 49 - // Seeding uses runtime queries so the committed .sqlx cache stays focused on 50 - // production SQL. 50 + // Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL. 51 51 52 52 fn blob(cid: &str) -> serde_json::Value { 53 53 json!({ ··· 317 317 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; 318 318 seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await; 319 319 320 - let page1 = views::author_things(&state, DID_A, 2, None).await.unwrap(); 320 + let page1 = views::author_things(&state, DID_A, 2, None, None) 321 + .await 322 + .unwrap(); 321 323 assert_eq!(page1.items.len(), 2); 322 324 assert_eq!(page1.items[0].thing.name.as_str(), "t3"); 323 325 let cursor = page1.cursor.expect("cursor present when more remain"); 324 326 325 - let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref())) 327 + let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref()), None) 326 328 .await 327 329 .unwrap(); 328 330 assert_eq!(page2.items.len(), 1); ··· 339 341 seed_thing(&state.pool, DID_A, "3aaaaaaaaaaaa", "a", &[], 5).await; 340 342 seed_thing(&state.pool, DID_A, "3bbbbbbbbbbbb", "b", &[], 5).await; 341 343 342 - let feed = views::feed_hot(&state, 10, None).await.unwrap(); 344 + let feed = views::feed_hot(&state, 10, None, None).await.unwrap(); 343 345 assert_eq!(feed.items.len(), 2); 344 - let feed_again = views::feed_hot(&state, 10, None).await.unwrap(); 346 + let feed_again = views::feed_hot(&state, 10, None, None).await.unwrap(); 345 347 assert_eq!( 346 348 feed.items[0].thing.uri.as_ref(), 347 349 feed_again.items[0].thing.uri.as_ref() ··· 357 359 seed_thing(&state.pool, DID_A, "r1", "Calibration Cube", &[], 0).await; 358 360 seed_thing(&state.pool, DID_A, "r2", "Friendly Sphere", &[], 0).await; 359 361 360 - let feed = views::search_things(&state, "cube", 10, None) 362 + let feed = views::search_things(&state, "cube", 10, None, None) 361 363 .await 362 364 .unwrap(); 363 365 assert_eq!(feed.items.len(), 1); ··· 367 369 #[tokio::test] 368 370 async fn malformed_ranked_cursor_is_rejected() { 369 371 let state = state().await; 370 - let err = views::search_things(&state, "x", 10, Some("not-a-number")) 372 + let err = views::search_things(&state, "x", 10, Some("not-a-number"), None) 371 373 .await 372 374 .unwrap_err(); 373 375 assert!(matches!(err, AppError::InvalidRequest(_))); ··· 387 389 .bind(DID_A).bind("i2").bind(list).bind(non_thing).bind(2) 388 390 .execute(&state.pool).await.unwrap(); 389 391 390 - let (items, _cursor) = views::list_things(&state, list, 10, None).await.unwrap(); 392 + let (items, _cursor) = views::list_things(&state, list, 10, None, None) 393 + .await 394 + .unwrap(); 391 395 assert_eq!(items.len(), 1, "non-thing list item must be filtered out"); 392 396 assert_eq!(items[0].uri.as_ref(), thing); 393 397 } ··· 457 461 #[tokio::test] 458 462 async fn get_session_route_returns_unauthenticated_marker() { 459 463 let state = state().await; 460 - let app = super::router(state); 464 + let app = super::router().with_state(state); 461 465 let resp = app 462 466 .oneshot( 463 467 Request::builder() ··· 475 479 #[tokio::test] 476 480 async fn get_thing_route_decodes_query_and_404s_when_missing() { 477 481 let state = state().await; 478 - let app = super::router(state); 482 + let app = super::router().with_state(state); 479 483 let resp = app 480 484 .oneshot( 481 485 Request::builder() ··· 492 496 async fn negative_offset_cursor_is_rejected() { 493 497 let state = state().await; 494 498 let errs = [ 495 - views::search_things(&state, "x", 10, Some("-1")) 499 + views::search_things(&state, "x", 10, Some("-1"), None) 496 500 .await 497 501 .unwrap_err(), 498 - views::feed_hot(&state, 10, Some("-5")).await.unwrap_err(), 502 + views::feed_hot(&state, 10, Some("-5"), None) 503 + .await 504 + .unwrap_err(), 499 505 views::list_things( 500 506 &state, 501 507 "at://did:plc:l/space.polymodel.graph.list/self", 502 508 10, 503 509 Some("-1"), 510 + None, 504 511 ) 505 512 .await 506 513 .unwrap_err(), ··· 521 528 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; 522 529 seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await; 523 530 524 - let page1 = views::feed_recent(&state, 2, None).await.unwrap(); 531 + let page1 = views::feed_recent(&state, 2, None, None).await.unwrap(); 525 532 assert_eq!(page1.items.len(), 2); 526 533 assert_eq!(page1.items[0].thing.name.as_str(), "t3"); 527 534 let cursor = page1.cursor.expect("more remain"); 528 - let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref())) 535 + let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref()), None) 529 536 .await 530 537 .unwrap(); 531 538 assert_eq!(page2.items.len(), 1);
+54 -25
src/appview/views.rs
··· 18 18 19 19 use std::cmp::Ordering; 20 20 21 + use jacquard::identity::PublicResolver; 21 22 use jacquard::identity::resolver::IdentityResolver; 23 + use jacquard::oauth::client::OAuthSession; 22 24 use jacquard_common::deps::smol_str::SmolStr; 23 25 use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; 24 26 use jacquard_common::types::{ ··· 33 35 34 36 use super::error::{AppResult, db, internal, invalid_request, not_found}; 35 37 use super::state::AppState; 38 + use crate::oauth::SqliteAuthStore; 36 39 37 40 // --------------------------------------------------------------------------- 38 41 // value-construction helpers ··· 260 263 261 264 async fn viewer_state( 262 265 pool: &SqlitePool, 263 - viewer_did: Option<&str>, 266 + viewer_did: Option<Did<&str>>, 264 267 subject_uri: &str, 265 268 ) -> AppResult<ViewerState> { 266 269 let Some(did) = viewer_did else { 267 270 return Ok(ViewerState::default()); 268 271 }; 272 + let did = did.as_str(); 269 273 #[derive(sqlx::FromRow)] 270 274 struct IdRow { 271 275 rkey: String, ··· 302 306 }) 303 307 } 304 308 309 + pub(super) async fn viewer_did( 310 + session: Option<&OAuthSession<PublicResolver, SqliteAuthStore>>, 311 + ) -> Option<Did> { 312 + match session { 313 + Some(s) => Some(s.session_info().await.0), 314 + None => None, 315 + } 316 + } 317 + 305 318 // --------------------------------------------------------------------------- 306 319 // view builders 307 320 // --------------------------------------------------------------------------- ··· 309 322 async fn thing_view( 310 323 state: &AppState, 311 324 row: ThingRow, 312 - viewer_did: Option<&str>, 325 + viewer_did: Option<Did<&str>>, 313 326 ) -> AppResult<ThingView> { 314 327 let author = actor_for_did(state, &row.did).await?; 315 328 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; ··· 337 350 }) 338 351 } 339 352 340 - async fn thing_basic_view(state: &AppState, row: ThingRow) -> AppResult<ThingViewBasic> { 353 + async fn thing_basic_view( 354 + state: &AppState, 355 + row: ThingRow, 356 + viewer_did: Option<Did<&str>>, 357 + ) -> AppResult<ThingViewBasic> { 341 358 let record = thing_record(row.record_json.as_deref())?; 342 359 Ok(ThingViewBasic { 343 360 uri: at_uri(&row.uri)?, ··· 352 369 cover: image_views_from_images(&row.did, record.cover)?, 353 370 previews: image_views_from_images(&row.did, record.previews)?, 354 371 tags: smol_vec(row.tags_json.as_deref()), 355 - viewer: ViewerState::default(), 372 + viewer: viewer_state(&state.pool, viewer_did, &row.uri).await?, 356 373 extra_data: None, 357 374 }) 358 375 } ··· 360 377 async fn model_view( 361 378 state: &AppState, 362 379 row: ModelRow, 363 - viewer_did: Option<&str>, 380 + viewer_did: Option<Did<&str>>, 364 381 ) -> AppResult<ModelView> { 365 382 let author = actor_for_did(state, &row.did).await?; 366 383 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; ··· 384 401 async fn part_view( 385 402 state: &AppState, 386 403 row: PartRow, 387 - viewer_did: Option<&str>, 404 + viewer_did: Option<Did<&str>>, 388 405 ) -> AppResult<PartView> { 389 406 let author = actor_for_did(state, &row.did).await?; 390 407 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; ··· 431 448 pub(super) async fn get_thing( 432 449 state: &AppState, 433 450 uri: &str, 434 - viewer_did: Option<&str>, 451 + viewer_did: Option<Did<&str>>, 435 452 ) -> AppResult<(ThingView, Vec<ModelView>)> { 436 453 let row = fetch_thing_by_uri(state, uri) 437 454 .await? 438 455 .ok_or_else(not_found)?; 439 - let thing = thing_view(state, row, viewer_did).await?; 456 + let thing = thing_view(state, row, viewer_did.clone()).await?; 440 457 let models = models_for_thing(state, uri, viewer_did).await?; 441 458 Ok((thing, models)) 442 459 } ··· 444 461 async fn models_for_thing( 445 462 state: &AppState, 446 463 thing_uri: &str, 447 - viewer_did: Option<&str>, 464 + viewer_did: Option<Did<&str>>, 448 465 ) -> AppResult<Vec<ModelView>> { 449 466 let rows = db( 450 467 sqlx::query_as!( ··· 462 479 )?; 463 480 let mut views = Vec::with_capacity(rows.len()); 464 481 for row in rows { 465 - views.push(model_view(state, row, viewer_did).await?); 482 + views.push(model_view(state, row, viewer_did.clone()).await?); 466 483 } 467 484 Ok(views) 468 485 } ··· 476 493 pub(super) async fn get_model( 477 494 state: &AppState, 478 495 uri: &str, 479 - viewer_did: Option<&str>, 496 + viewer_did: Option<Did<&str>>, 480 497 ) -> AppResult<ModelDetail> { 481 498 let row = db( 482 499 sqlx::query_as!( ··· 492 509 .await, 493 510 )? 494 511 .ok_or_else(not_found)?; 495 - let model = model_view(state, row, viewer_did).await?; 512 + let model = model_view(state, row, viewer_did.clone()).await?; 496 513 497 514 let parts = db(sqlx::query_as!( 498 515 PartRow, ··· 507 524 .await)?; 508 525 let mut part_views = Vec::with_capacity(parts.len()); 509 526 for row in parts { 510 - part_views.push(part_view(state, row, viewer_did).await?); 527 + part_views.push(part_view(state, row, viewer_did.clone()).await?); 511 528 } 512 529 513 530 let thing = db( ··· 530 547 .await, 531 548 )?; 532 549 let thing = if let Some(row) = thing { 533 - Some(thing_basic_view(state, row).await?) 550 + Some(thing_basic_view(state, row, viewer_did).await?) 534 551 } else { 535 552 None 536 553 }; ··· 550 567 state: &AppState, 551 568 rows: Vec<ThingRow>, 552 569 cursor: Option<String>, 570 + viewer_did: Option<Did<&str>>, 553 571 ) -> AppResult<FeedView> { 554 572 let mut items = Vec::with_capacity(rows.len()); 555 573 for row in rows { 556 574 items.push(FeedItem { 557 - thing: thing_basic_view(state, row).await?, 575 + thing: thing_basic_view(state, row, viewer_did.clone()).await?, 558 576 extra_data: None, 559 577 }); 560 578 } ··· 570 588 did: &str, 571 589 limit: i64, 572 590 cursor: Option<&str>, 591 + viewer_did: Option<Did<&str>>, 573 592 ) -> AppResult<FeedView> { 574 593 let fetch = limit + 1; 575 594 let rows = db( ··· 593 612 .fetch_all(&state.pool) 594 613 .await, 595 614 )?; 596 - rkey_paged_feed(state, rows, limit).await 615 + rkey_paged_feed(state, rows, limit, viewer_did).await 597 616 } 598 617 599 618 pub(super) async fn feed_recent( 600 619 state: &AppState, 601 620 limit: i64, 602 621 cursor: Option<&str>, 622 + viewer_did: Option<Did<&str>>, 603 623 ) -> AppResult<FeedView> { 604 624 let fetch = limit + 1; 605 625 let rows = db( ··· 622 642 .fetch_all(&state.pool) 623 643 .await, 624 644 )?; 625 - rkey_paged_feed(state, rows, limit).await 645 + rkey_paged_feed(state, rows, limit, viewer_did).await 626 646 } 627 647 628 648 /// Reduce an over-fetched (`limit+1`) rkey-ordered page into a feed, returning ··· 633 653 state: &AppState, 634 654 mut rows: Vec<ThingRow>, 635 655 limit: i64, 656 + viewer_did: Option<Did<&str>>, 636 657 ) -> AppResult<FeedView> { 637 658 let cursor = if rows.len() as i64 > limit { 638 659 rows.get((limit - 1) as usize).map(|r| r.rkey.clone()) ··· 640 661 None 641 662 }; 642 663 rows.truncate(limit as usize); 643 - feed_view(state, rows, cursor).await 664 + feed_view(state, rows, cursor, viewer_did).await 644 665 } 645 666 646 667 pub(super) async fn feed_hot( 647 668 state: &AppState, 648 669 limit: i64, 649 670 cursor: Option<&str>, 671 + viewer_did: Option<Did<&str>>, 650 672 ) -> AppResult<FeedView> { 651 673 let offset = parse_offset(cursor)?; 652 674 // Candidate window: the most recent things. Hot score is computed in Rust so ··· 690 712 None 691 713 }; 692 714 let page = page.into_iter().take(limit as usize).collect(); 693 - feed_view(state, page, cursor).await 715 + feed_view(state, page, cursor, viewer_did).await 694 716 } 695 717 696 718 fn hot_score(row: &ThingRow, now_millis: i64) -> f64 { ··· 704 726 q: &str, 705 727 limit: i64, 706 728 cursor: Option<&str>, 729 + viewer_did: Option<Did<&str>>, 707 730 ) -> AppResult<FeedView> { 708 731 let offset = parse_offset(cursor)?; 709 732 let fetch = limit + 1; ··· 737 760 None 738 761 }; 739 762 let page: Vec<ThingRow> = rows.into_iter().take(limit as usize).collect(); 740 - feed_view(state, page, cursor).await 763 + feed_view(state, page, cursor, viewer_did).await 741 764 } 742 765 743 766 fn parse_offset(cursor: Option<&str>) -> AppResult<i64> { ··· 766 789 list_uri: &str, 767 790 limit: i64, 768 791 cursor: Option<&str>, 792 + viewer_did: Option<Did<&str>>, 769 793 ) -> AppResult<(Vec<ThingViewBasic>, Option<String>)> { 770 794 let offset = parse_offset(cursor)?; 771 795 // Over-fetch subjects, then hydrate only those resolving to a thing; non-thing ··· 795 819 }; 796 820 let mut items = Vec::with_capacity(rows.len().min(limit as usize)); 797 821 for li in rows.into_iter().take(limit as usize) { 798 - if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri).await? { 822 + if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? { 799 823 items.push(thing); 800 824 } else { 801 825 tracing::debug!(subject_uri = %li.subject_uri, "list item is not a thing; skipped"); ··· 804 828 Ok((items, cursor)) 805 829 } 806 830 807 - async fn thing_basic_by_uri(state: &AppState, uri: &str) -> AppResult<Option<ThingViewBasic>> { 831 + async fn thing_basic_by_uri( 832 + state: &AppState, 833 + uri: &str, 834 + viewer_did: Option<Did<&str>>, 835 + ) -> AppResult<Option<ThingViewBasic>> { 808 836 match fetch_thing_by_uri(state, uri).await? { 809 - Some(row) => Ok(Some(thing_basic_view(state, row).await?)), 837 + Some(row) => Ok(Some(thing_basic_view(state, row, viewer_did).await?)), 810 838 None => Ok(None), 811 839 } 812 840 } ··· 859 887 }) 860 888 } 861 889 862 - pub(super) async fn handle_for_did(state: &AppState, did: &str) -> AppResult<Option<String>> { 890 + pub(super) async fn handle_for_did(state: &AppState, did: &Did) -> AppResult<Option<String>> { 891 + let did = did.as_str(); 863 892 #[derive(sqlx::FromRow)] 864 893 struct HandleRow { 865 894 handle: Option<String>, ··· 879 908 /// always has a resolvable handle — never return an empty string, which would 880 909 /// fail handle validation. 881 910 async fn resolve_handle(state: &AppState, did: &Did) -> AppResult<String> { 882 - if let Some(h) = handle_for_did(state, did.as_ref()).await? { 911 + if let Some(h) = handle_for_did(state, did).await? { 883 912 return Ok(h); 884 913 } 885 914 let doc = state
-57
src/auth.rs
··· 44 44 toasts.push("Signed out in another tab."); 45 45 } 46 46 47 - pub fn login_url(identifier: &str) -> String { 48 - let identifier = identifier.trim(); 49 - if identifier.is_empty() { 50 - "/oauth/login".to_string() 51 - } else { 52 - format!( 53 - "/oauth/start?identifier={}", 54 - encode_query_component(identifier) 55 - ) 56 - } 57 - } 58 - 59 47 pub fn logout_url() -> &'static str { 60 48 "/oauth/logout" 61 49 } 62 50 63 - fn encode_query_component(value: &str) -> String { 64 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 65 - { 66 - js_sys::encode_uri_component(value) 67 - .as_string() 68 - .unwrap_or_default() 69 - } 70 - 71 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 72 - { 73 - value 74 - .bytes() 75 - .flat_map(|byte| match byte { 76 - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { 77 - vec![byte as char] 78 - } 79 - _ => format!("%{byte:02X}").chars().collect(), 80 - }) 81 - .collect() 82 - } 83 - } 84 - 85 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 86 - pub fn navigate_to(url: &str) { 87 - if let Some(window) = web_sys::window() { 88 - let _ = window.location().set_href(url); 89 - } 90 - } 91 - 92 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 93 - pub fn navigate_to(_url: &str) {} 94 - 95 51 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 96 52 pub fn broadcast_sign_out() { 97 53 if let Ok(channel) = web_sys::BroadcastChannel::new(AUTH_BROADCAST_CHANNEL) { ··· 107 63 use super::*; 108 64 use jacquard::types::string::Handle; 109 65 use jacquard_common::types::did::Did; 110 - 111 - #[test] 112 - fn login_url_uses_oauth_login_when_identifier_empty() { 113 - assert_eq!(login_url(" "), "/oauth/login"); 114 - } 115 - 116 - #[test] 117 - fn login_url_uses_oauth_start_with_encoded_identifier() { 118 - assert_eq!( 119 - login_url("alice model.example"), 120 - "/oauth/start?identifier=alice%20model.example" 121 - ); 122 - } 123 66 124 67 #[test] 125 68 fn remote_sign_out_clears_session_and_adds_toast() {
+19 -4
src/indexing/config.rs
··· 1 1 //! Server runtime configuration. 2 2 //! 3 - //! Configuration that varies per deployment (e.g. `DATABASE_URL`) and is only 4 - //! needed by the server binary. These values are read from the process 5 - //! environment at startup, not baked into the WASM bundle. 3 + //! Configuration that varies per deployment (e.g. `DATABASE_URL`, 4 + //! `POLYMODEL_BASE_URL`) and is only needed by the server binary. These values 5 + //! are read from the process environment at startup, not baked into the WASM 6 + //! bundle. 6 7 //! 7 8 //! The server binary calls `dotenvy::dotenv()` in `main` before 8 9 //! [`ServerConfig::load`]. Hydrant reads its own `HYDRANT_*` variables via its ··· 16 17 pub struct ServerConfig { 17 18 /// sqlx connect URL, e.g. `sqlite:./data/polymodel.db`. 18 19 pub database_url: String, 20 + /// Public origin of this server, e.g. `https://polymodel.example.com`. 21 + /// 22 + /// The OAuth `client_id` is `{base_url}/oauth-client-metadata.json` and the 23 + /// redirect URI is `{base_url}/oauth/callback` (PM-27). When unset, bootstrap 24 + /// defaults the OAuth client origin to `https://polymodel.space`, a functional 25 + /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins. 26 + pub base_url: Option<String>, 19 27 } 20 28 21 29 impl ServerConfig { ··· 26 34 pub fn load() -> Self { 27 35 let database_url = 28 36 env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./data/polymodel.db".to_string()); 29 - Self { database_url } 37 + let base_url = env::var("POLYMODEL_BASE_URL") 38 + .ok() 39 + .map(|s| s.trim().to_owned()) 40 + .filter(|s| !s.is_empty()); 41 + Self { 42 + database_url, 43 + base_url, 44 + } 30 45 } 31 46 }
+33 -17
src/main.rs
··· 19 19 #[cfg(feature = "server")] 20 20 mod appview; 21 21 22 - use auth::{ToastState, broadcast_sign_out, login_url, logout_url, navigate_to}; 22 + use auth::{ToastState, broadcast_sign_out, logout_url}; 23 23 use client::PolymodelClient; 24 24 use session::SessionIdentity; 25 25 use thing_card::ThingCard; 26 + 27 + #[cfg(feature = "server")] 28 + mod oauth; 29 + 26 30 use viewer::{ViewerPage, demo_model_for_route, demo_models}; 27 31 28 32 const FAVICON: Asset = asset!("/assets/favicon.jpg"); ··· 51 55 indexing::start_indexing(db.clone()) 52 56 .await 53 57 .expect("failed to start indexing pipeline"); 54 - 58 + let oauth = oauth::bootstrap_oauth(db.clone(), cfg.base_url.as_deref()) 59 + .expect("failed to bootstrap OAuth client"); 55 60 // The same SQLite pool backs both indexing and the appview read handlers. 56 - let app_state = std::sync::Arc::new(appview::state::AppState::new(db)); 57 - Ok(dioxus::server::router(App).merge(appview::router(app_state))) 61 + let app_state = appview::state::AppState::new(db, oauth); 62 + Ok(dioxus::server::router(App).merge( 63 + appview::router() 64 + .merge(jacquard_axum::oauth::routes::< 65 + jacquard::identity::PublicResolver, 66 + oauth::SqliteAuthStore, 67 + appview::state::AppState, 68 + >(&app_state.oauth_config.clone())) 69 + .with_state(app_state), 70 + )) 58 71 }); 59 72 } 60 73 ··· 294 307 #[component] 295 308 fn SessionControl(session: Signal<SessionIdentity>, toasts: Signal<ToastState>) -> Element { 296 309 let identity = session.read().clone(); 297 - let mut identifier = use_signal(String::new); 298 310 299 311 match identity { 300 312 SessionIdentity::Anonymous => rsx! { 301 313 form { 302 314 class: "session-control", 303 - onsubmit: move |_| navigate_to(&login_url(&identifier.read())), 315 + action: "/oauth/start", 316 + method: "get", 304 317 input { 305 318 r#type: "text", 319 + name: "identifier", 306 320 placeholder: "handle.test", 307 321 aria_label: "ATProto handle", 308 - value: "{identifier}", 309 - oninput: move |event| identifier.set(event.value()) 310 322 } 311 323 button { class: "button button-primary", r#type: "submit", "Sign in" } 312 324 } ··· 316 328 rsx! { 317 329 div { class: "session-control session-control-authenticated", 318 330 span { class: "status-pill", "{label}" } 319 - button { 320 - class: "button button-secondary", 321 - onclick: move |_| { 322 - broadcast_sign_out(); 323 - session.set(SessionIdentity::Anonymous); 324 - toasts.write().push("Signing out…"); 325 - navigate_to(logout_url()); 326 - }, 327 - "Sign out" 331 + form { 332 + method: "post", 333 + action: "{logout_url()}", 334 + button { 335 + class: "button button-secondary", 336 + r#type: "submit", 337 + onclick: move |_| { 338 + broadcast_sign_out(); 339 + session.set(SessionIdentity::Anonymous); 340 + toasts.write().push("Signing out…"); 341 + }, 342 + "Sign out" 343 + } 328 344 } 329 345 } 330 346 }
+956
src/oauth/mod.rs
··· 1 + //! SQLite-backed OAuth persistence + confidential-client bootstrap for Jacquard 0.12. 2 + //! 3 + //! [`SqliteAuthStore`] implements [`ClientAuthStore`] and 4 + //! [`SessionSelector`] over the `oauth_sessions` and `oauth_auth_requests` 5 + //! tables (see `migrations/001_projection_schema.sql`). Each row stores a 6 + //! structured, queryable projection of the Jacquard session / auth-request types 7 + //! alongside a canonical JSON `payload`. Structured columns power key 8 + //! enumeration, by-DID/any selection, and deletes without the payload; only 9 + //! `get_session` / `get_auth_req_info` deserialize the payload. 10 + //! DPoP key material and nonces live inside the payload — the Jacquard 11 + //! `SessionRegistry` mutex is in-process concurrency only and is not persisted, 12 + //! so the two tables are the *complete* durable OAuth surface. 13 + //! 14 + //! [`bootstrap_oauth`] constructs the confidential [`OAuthClient`], the 15 + //! single-origin [`OAuthWebConfig`], and the private cookie-jar [`Key`] from a 16 + //! swappable [`SecretSource`] (keyring → env → ephemeral-with-warning). 17 + //! 18 + //! [`SessionSelector`]: jacquard::common::session::SessionSelector 19 + 20 + use jacquard::common::bos::BosStr; 21 + use jacquard::common::session::{SessionHint, SessionKey, SessionSelector, SessionStoreError}; 22 + use jacquard::common::types::did::Did; 23 + use jacquard::oauth::authstore::{ClientAuthStore, OAuthSessionMatch}; 24 + use jacquard::oauth::session::{AuthRequestData, ClientSessionData}; 25 + use sqlx::SqlitePool; 26 + 27 + /// SQLite-backed implementation of [`ClientAuthStore`]. 28 + #[derive(Debug, Clone)] 29 + pub struct SqliteAuthStore { 30 + pool: SqlitePool, 31 + } 32 + 33 + impl SqliteAuthStore { 34 + /// Wrap the shared projection pool as an OAuth auth store. 35 + pub fn new(pool: SqlitePool) -> Self { 36 + Self { pool } 37 + } 38 + 39 + /// Exact-key lookup → session match (or `None`). 40 + async fn match_for_key( 41 + &self, 42 + key: SessionKey, 43 + ) -> Result<Option<OAuthSessionMatch>, SessionStoreError> { 44 + Ok(self 45 + .get_session(&key.did, key.session_id.as_str()) 46 + .await? 47 + .map(|session| OAuthSessionMatch { key, session })) 48 + } 49 + 50 + /// First session belonging to `did`, enumerated via [`ClientAuthStore::list_session_keys`]. 51 + async fn match_for_did<D: BosStr + Send + Sync>( 52 + &self, 53 + did: &Did<D>, 54 + ) -> Result<Option<OAuthSessionMatch>, SessionStoreError> { 55 + for key in self.list_session_keys().await? { 56 + if key.did.as_str() == did.as_ref() 57 + && let Some(matched) = self.match_for_key(key).await? 58 + { 59 + return Ok(Some(matched)); 60 + } 61 + } 62 + Ok(None) 63 + } 64 + } 65 + 66 + impl ClientAuthStore for SqliteAuthStore { 67 + async fn get_session<D: BosStr + Send + Sync>( 68 + &self, 69 + did: &Did<D>, 70 + session_id: &str, 71 + ) -> Result<Option<ClientSessionData>, SessionStoreError> { 72 + let row = sqlx::query!( 73 + "SELECT payload FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 74 + did.as_str(), 75 + session_id 76 + ) 77 + .fetch_optional(&self.pool) 78 + .await 79 + .map_err(store_err)?; 80 + match row { 81 + Some(r) => Ok(Some(serde_json::from_str(&r.payload)?)), 82 + None => Ok(None), 83 + } 84 + } 85 + 86 + async fn upsert_session(&self, session: ClientSessionData) -> Result<(), SessionStoreError> { 87 + let now = chrono::Utc::now().timestamp_millis(); 88 + let payload = serde_json::to_string(&session)?; 89 + sqlx::query!( 90 + "INSERT INTO oauth_sessions 91 + (account_did, session_id, host_url, authserver_url, token_endpoint, 92 + revocation_endpoint, created_at, updated_at, payload) 93 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 94 + ON CONFLICT(account_did, session_id) DO UPDATE SET 95 + host_url = excluded.host_url, 96 + authserver_url = excluded.authserver_url, 97 + token_endpoint = excluded.token_endpoint, 98 + revocation_endpoint = excluded.revocation_endpoint, 99 + updated_at = excluded.updated_at, 100 + payload = excluded.payload", 101 + session.account_did.as_str(), 102 + session.session_id.as_str(), 103 + session.host_url.as_str(), 104 + session.authserver_url.as_str(), 105 + session.authserver_token_endpoint.as_str(), 106 + session 107 + .authserver_revocation_endpoint 108 + .as_ref() 109 + .map(|s| s.as_str()), 110 + now, 111 + now, 112 + payload, 113 + ) 114 + .execute(&self.pool) 115 + .await 116 + .map_err(store_err)?; 117 + Ok(()) 118 + } 119 + 120 + async fn delete_session<D: BosStr + Send + Sync>( 121 + &self, 122 + did: &Did<D>, 123 + session_id: &str, 124 + ) -> Result<(), SessionStoreError> { 125 + sqlx::query!( 126 + "DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 127 + did.as_str(), 128 + session_id 129 + ) 130 + .execute(&self.pool) 131 + .await 132 + .map_err(store_err)?; 133 + Ok(()) 134 + } 135 + 136 + async fn get_auth_req_info( 137 + &self, 138 + state: &str, 139 + ) -> Result<Option<AuthRequestData>, SessionStoreError> { 140 + let row = sqlx::query!( 141 + "SELECT payload FROM oauth_auth_requests WHERE state = ?", 142 + state 143 + ) 144 + .fetch_optional(&self.pool) 145 + .await 146 + .map_err(store_err)?; 147 + match row { 148 + Some(r) => Ok(Some(serde_json::from_str(&r.payload)?)), 149 + None => Ok(None), 150 + } 151 + } 152 + 153 + async fn save_auth_req_info( 154 + &self, 155 + auth_req_info: &AuthRequestData, 156 + ) -> Result<(), SessionStoreError> { 157 + let now = chrono::Utc::now().timestamp_millis(); 158 + let payload = serde_json::to_string(auth_req_info)?; 159 + sqlx::query!( 160 + "INSERT INTO oauth_auth_requests 161 + (state, authserver_url, account_did, request_uri, token_endpoint, 162 + revocation_endpoint, created_at, payload) 163 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 164 + ON CONFLICT(state) DO UPDATE SET 165 + authserver_url = excluded.authserver_url, 166 + account_did = excluded.account_did, 167 + request_uri = excluded.request_uri, 168 + token_endpoint = excluded.token_endpoint, 169 + revocation_endpoint = excluded.revocation_endpoint, 170 + payload = excluded.payload", 171 + auth_req_info.state.as_str(), 172 + auth_req_info.authserver_url.as_str(), 173 + auth_req_info.account_did.as_ref().map(|d| d.as_str()), 174 + auth_req_info.request_uri.as_str(), 175 + auth_req_info.authserver_token_endpoint.as_str(), 176 + auth_req_info 177 + .authserver_revocation_endpoint 178 + .as_ref() 179 + .map(|s| s.as_str()), 180 + now, 181 + payload, 182 + ) 183 + .execute(&self.pool) 184 + .await 185 + .map_err(store_err)?; 186 + Ok(()) 187 + } 188 + 189 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 190 + sqlx::query!("DELETE FROM oauth_auth_requests WHERE state = ?", state) 191 + .execute(&self.pool) 192 + .await 193 + .map_err(store_err)?; 194 + Ok(()) 195 + } 196 + 197 + async fn list_session_keys(&self) -> Result<Vec<SessionKey>, SessionStoreError> { 198 + let rows = sqlx::query!("SELECT account_did, session_id FROM oauth_sessions") 199 + .fetch_all(&self.pool) 200 + .await 201 + .map_err(store_err)?; 202 + let mut keys: Vec<SessionKey> = Vec::with_capacity(rows.len()); 203 + for row in rows { 204 + let did = Did::new_owned(&row.account_did).map_err(store_err)?; 205 + keys.push(SessionKey::new(did, row.session_id)); 206 + } 207 + keys.sort(); 208 + Ok(keys) 209 + } 210 + } 211 + 212 + impl SessionSelector<OAuthSessionMatch> for SqliteAuthStore { 213 + type Error = SessionStoreError; 214 + 215 + async fn select_session<Str: BosStr + Send + Sync>( 216 + &self, 217 + hint: &SessionHint<Str>, 218 + ) -> Result<Option<OAuthSessionMatch>, Self::Error> { 219 + match hint { 220 + SessionHint::Any => { 221 + let Some(key) = self.list_session_keys().await?.into_iter().next() else { 222 + return Ok(None); 223 + }; 224 + self.match_for_key(key).await 225 + } 226 + SessionHint::Did(did) => self.match_for_did(did).await, 227 + SessionHint::Handle(_) | SessionHint::Identifier(_) => Ok(None), 228 + SessionHint::Key(key) => self.match_for_key(key.clone()).await, 229 + } 230 + } 231 + } 232 + 233 + /// Box any backend error into [`SessionStoreError::Other`]. 234 + fn store_err<E>(e: E) -> SessionStoreError 235 + where 236 + E: std::error::Error + Send + Sync + 'static, 237 + { 238 + SessionStoreError::Other(Box::new(e)) 239 + } 240 + 241 + // =========================================================================== 242 + // Confidential OAuth client bootstrap (PM-27) 243 + // =========================================================================== 244 + 245 + use anyhow::Context as _; 246 + use axum_extra::extract::cookie::Key; 247 + use base64::Engine as _; 248 + use jacquard::common::deps::fluent_uri::Uri; 249 + use jacquard::identity::PublicResolver; 250 + use jacquard::oauth::{ 251 + atproto::AtprotoClientMetadata, client::OAuthClient, keyset::Keyset, scopes::Scopes, 252 + session::ClientData, 253 + }; 254 + use jacquard_axum::oauth::OAuthWebConfig; 255 + 256 + /// The OAuth client, web config, and cookie-jar key built once at startup. 257 + /// 258 + /// Constructed by [`bootstrap_oauth`] and stored in `AppState`. 259 + pub struct OAuthBootstrap { 260 + /// Confidential OAuth client over the SQLite auth store + public resolver. 261 + pub client: OAuthClient<PublicResolver, SqliteAuthStore>, 262 + /// Single-origin cookie/route config (`OAuthWebConfig::default()`). 263 + pub config: OAuthWebConfig, 264 + /// Signing key for the private `jacquard_oauth_session` cookie jar. 265 + pub cookie_key: Key, 266 + } 267 + 268 + /// Build the OAuth client, web config, and cookie key. 269 + /// 270 + /// `base_url` is the server's public origin (`POLYMODEL_BASE_URL`); the OAuth 271 + /// `client_id` is `{base_url}/oauth-client-metadata.json` and the redirect URI 272 + /// is `{base_url}/oauth/callback`, mirroring the jacquard-axum example. Only the 273 + /// secret store (→ [`SqliteAuthStore`]) and key provisioning (→ 274 + /// [`SecretSource`]) differ from the example. 275 + pub fn bootstrap_oauth(pool: SqlitePool, base_url: Option<&str>) -> anyhow::Result<OAuthBootstrap> { 276 + let (keyset, cookie_key) = provision_oauth_secrets()?; 277 + let metadata = hosted_client_metadata(base_url)?; 278 + let client_data = ClientData::new(Some(keyset), metadata); 279 + let client: OAuthClient<PublicResolver, SqliteAuthStore> = OAuthClient::new( 280 + SqliteAuthStore::new(pool), 281 + client_data, 282 + reqwest::Client::new(), 283 + ); 284 + Ok(OAuthBootstrap { 285 + client, 286 + config: OAuthWebConfig::default(), 287 + cookie_key, 288 + }) 289 + } 290 + 291 + /// Build hosted (non-loopback) client metadata from the public origin. 292 + /// 293 + /// Defaults to the deployment origin `https://polymodel.space` when 294 + /// `POLYMODEL_BASE_URL` is unset; set that for local or alternate origins. This is 295 + /// never the loopback client spec (`new_localhost`); it uses the hosted 296 + /// `AtprotoClientMetadata::new` constructor for production origins. 297 + fn hosted_client_metadata( 298 + base_url: Option<&str>, 299 + ) -> anyhow::Result<AtprotoClientMetadata<jacquard::common::deps::smol_str::SmolStr>> { 300 + let origin = base_url 301 + .map(str::trim) 302 + .filter(|s| !s.is_empty()) 303 + .map(|s| s.trim_end_matches('/').to_owned()); 304 + let origin = match origin { 305 + Some(origin) => origin, 306 + None => { 307 + tracing::warn!( 308 + "POLYMODEL_BASE_URL is not set; defaulting the OAuth client origin to the \ 309 + deployment origin https://polymodel.space (used for client_id and redirect_uri). \ 310 + Set POLYMODEL_BASE_URL for local or alternate origins." 311 + ); 312 + "https://polymodel.space".to_owned() 313 + } 314 + }; 315 + let client_id = 316 + Uri::parse(format!("{origin}/oauth-client-metadata.json")).map_err(|(_e, _)| { 317 + anyhow::anyhow!("invalid OAuth client_id URL from POLYMODEL_BASE_URL") 318 + })?; 319 + let redirect_uri = Uri::parse(format!("{origin}/oauth/callback")) 320 + .map_err(|(_e, _)| anyhow::anyhow!("invalid OAuth redirect URI from POLYMODEL_BASE_URL"))?; 321 + let scopes = Scopes::builder() 322 + .atproto() 323 + .transition_generic() 324 + .build() 325 + .context("build OAuth scopes")?; 326 + Ok(AtprotoClientMetadata::new( 327 + vec![redirect_uri], 328 + client_id, 329 + Some(scopes), 330 + )) 331 + } 332 + 333 + /// Provision the OAuth signing keyset and cookie-jar key. 334 + /// 335 + /// Priority: (1) OS keyring — preferred, persists across restarts; on first run 336 + /// a fresh keyset/key is generated and stored so subsequent restarts reuse it; 337 + /// (2) environment override (`POLYMODEL_OAUTH_KEYSET_B64` / 338 + /// `POLYMODEL_COOKIE_KEY_B64`) — read-only; (3) ephemeral in-memory generation 339 + /// with a loud warning that identity + sessions are non-persistent and lost on 340 + /// restart (the demo default). 341 + fn provision_oauth_secrets() -> anyhow::Result<(Keyset, Key)> { 342 + let keyring = KeyringSource; 343 + let (keyset, generated_keyset) = provision_keyset(&keyring)?; 344 + let (cookie, generated_cookie) = provision_cookie_key(&keyring)?; 345 + if generated_keyset || generated_cookie { 346 + tracing::warn!( 347 + "Generated ephemeral OAuth secret material because no persisted keyring/env value was \ 348 + available for at least one secret. OAuth identity and browser sessions are \ 349 + NON-PERSISTENT for generated secrets and may be lost on restart. Provision both the \ 350 + OAuth keyset and cookie key in keyring or environment for durable sessions." 351 + ); 352 + } 353 + Ok((keyset, cookie)) 354 + } 355 + 356 + fn provision_keyset(keyring: &KeyringSource) -> anyhow::Result<(Keyset, bool)> { 357 + if !cfg!(test) 358 + && let Some(keyset) = keyring.load_keyset()? 359 + { 360 + return Ok((keyset, false)); 361 + } 362 + if !cfg!(test) 363 + && let Some(keyset) = EnvSource.load_keyset()? 364 + { 365 + return Ok((keyset, false)); 366 + } 367 + 368 + let keyset = Keyset::generate_es256("polymodel").context("generate OAuth keyset")?; 369 + // Best-effort: persist to keyring so a later restart (on a host with a usable 370 + // secret store) reuses this instead of regenerating. 371 + if !cfg!(test) { 372 + let _ = keyring.store_keyset(&keyset); 373 + } 374 + Ok((keyset, true)) 375 + } 376 + 377 + fn provision_cookie_key(keyring: &KeyringSource) -> anyhow::Result<(Key, bool)> { 378 + if !cfg!(test) 379 + && let Some(cookie) = keyring.load_cookie_key()? 380 + { 381 + return Ok((cookie, false)); 382 + } 383 + if !cfg!(test) 384 + && let Some(cookie) = EnvSource.load_cookie_key()? 385 + { 386 + return Ok((cookie, false)); 387 + } 388 + 389 + let cookie = Key::generate(); 390 + // Best-effort: persist to keyring so a later restart (on a host with a usable 391 + // secret store) reuses this instead of regenerating. The cookie key must stay 392 + // stable for OAuth session restore. 393 + if !cfg!(test) { 394 + let _ = keyring.store_cookie_key(&cookie); 395 + } 396 + Ok((cookie, true)) 397 + } 398 + 399 + /// Swappable source of the OAuth signing keyset and cookie-jar key. 400 + /// 401 + /// Sources are tried in priority order by [`provision_oauth_secrets`]; the 402 + /// ephemeral fallback is intentionally not a `SecretSource` because it is 403 + /// non-persistent by design. 404 + trait SecretSource { 405 + fn load_keyset(&self) -> anyhow::Result<Option<Keyset>>; 406 + fn store_keyset(&self, keyset: &Keyset) -> anyhow::Result<()>; 407 + fn load_cookie_key(&self) -> anyhow::Result<Option<Key>>; 408 + fn store_cookie_key(&self, key: &Key) -> anyhow::Result<()>; 409 + } 410 + 411 + /// OS keyring-backed source. On a host without an accessible secret store, every 412 + /// operation degrades to `None` / no-op so provisioning falls through to env. 413 + struct KeyringSource; 414 + 415 + impl KeyringSource { 416 + const SERVICE: &'static str = "polymodel"; 417 + const KEYSET_USER: &'static str = "oauth-client-keyset"; 418 + const COOKIE_USER: &'static str = "oauth-cookie-key"; 419 + 420 + fn entry(user: &'static str) -> Option<keyring::Entry> { 421 + keyring::Entry::new(Self::SERVICE, user).ok() 422 + } 423 + } 424 + 425 + impl SecretSource for KeyringSource { 426 + fn load_keyset(&self) -> anyhow::Result<Option<Keyset>> { 427 + let Some(entry) = Self::entry(Self::KEYSET_USER) else { 428 + return Ok(None); 429 + }; 430 + match entry.get_password() { 431 + Ok(b64) => { 432 + let bytes = decode_b64(&b64).context("keyring OAuth keyset")?; 433 + Ok(Some(serde_json::from_slice(&bytes).context( 434 + "keyring OAuth keyset is not a valid serialized Keyset", 435 + )?)) 436 + } 437 + // Not stored yet, or no usable secret-store backend → fall through. 438 + Err(_) => Ok(None), 439 + } 440 + } 441 + 442 + fn store_keyset(&self, keyset: &Keyset) -> anyhow::Result<()> { 443 + if let Some(entry) = Self::entry(Self::KEYSET_USER) { 444 + let bytes = serde_json::to_vec(keyset)?; 445 + let _ = entry.set_password(&b64::STANDARD.encode(&bytes)); 446 + } 447 + Ok(()) 448 + } 449 + 450 + fn load_cookie_key(&self) -> anyhow::Result<Option<Key>> { 451 + let Some(entry) = Self::entry(Self::COOKIE_USER) else { 452 + return Ok(None); 453 + }; 454 + match entry.get_password() { 455 + Ok(b64) => decode_cookie_key(&b64).map(Some), 456 + Err(_) => Ok(None), 457 + } 458 + } 459 + 460 + fn store_cookie_key(&self, key: &Key) -> anyhow::Result<()> { 461 + if let Some(entry) = Self::entry(Self::COOKIE_USER) { 462 + let _ = entry.set_password(&b64::STANDARD.encode(key.master())); 463 + } 464 + Ok(()) 465 + } 466 + } 467 + 468 + /// Environment-variable source (`POLYMODEL_OAUTH_KEYSET_B64`, 469 + /// `POLYMODEL_COOKIE_KEY_B64`). Base64 of a JSON-serialized `Keyset` and of 64 470 + /// raw cookie-key bytes respectively. Read-only. 471 + struct EnvSource; 472 + 473 + impl SecretSource for EnvSource { 474 + fn load_keyset(&self) -> anyhow::Result<Option<Keyset>> { 475 + let Some(b64) = env_get("POLYMODEL_OAUTH_KEYSET_B64")? else { 476 + return Ok(None); 477 + }; 478 + let bytes = decode_b64(&b64).context("POLYMODEL_OAUTH_KEYSET_B64")?; 479 + Ok(Some(serde_json::from_slice(&bytes).context( 480 + "POLYMODEL_OAUTH_KEYSET_B64 is not a valid serialized Keyset", 481 + )?)) 482 + } 483 + 484 + fn store_keyset(&self, _keyset: &Keyset) -> anyhow::Result<()> { 485 + Ok(()) 486 + } 487 + 488 + fn load_cookie_key(&self) -> anyhow::Result<Option<Key>> { 489 + let Some(b64) = env_get("POLYMODEL_COOKIE_KEY_B64")? else { 490 + return Ok(None); 491 + }; 492 + decode_cookie_key(&b64) 493 + .context("POLYMODEL_COOKIE_KEY_B64") 494 + .map(Some) 495 + } 496 + 497 + fn store_cookie_key(&self, _key: &Key) -> anyhow::Result<()> { 498 + Ok(()) 499 + } 500 + } 501 + 502 + fn env_get(name: &str) -> anyhow::Result<Option<String>> { 503 + match std::env::var(name) { 504 + Ok(v) => Ok(Some(v)), 505 + Err(std::env::VarError::NotPresent) => Ok(None), 506 + Err(std::env::VarError::NotUnicode(_)) => { 507 + anyhow::bail!("{name} is not valid UTF-8") 508 + } 509 + } 510 + } 511 + 512 + fn decode_b64(b64: &str) -> anyhow::Result<Vec<u8>> { 513 + Ok(b64::STANDARD.decode(b64)?) 514 + } 515 + 516 + fn decode_cookie_key(b64: &str) -> anyhow::Result<Key> { 517 + let bytes = decode_b64(b64)?; 518 + let arr: [u8; 64] = bytes 519 + .as_slice() 520 + .try_into() 521 + .map_err(|_| anyhow::anyhow!("cookie key must decode to exactly 64 bytes"))?; 522 + Ok(Key::from(&arr)) 523 + } 524 + 525 + /// Short alias for standard base64 to keep the keyring/env code compact. 526 + mod b64 { 527 + pub(crate) use base64::engine::general_purpose::STANDARD; 528 + } 529 + 530 + #[cfg(test)] 531 + mod tests { 532 + //! Store behavior, not serde: keyed upsert/get, overwrite-on-conflict, 533 + //! delete exactness, key enumeration + by-DID querying (structured column, 534 + //! no payload load), `select_session` for `Key`/`Did`/`Any`/no-match, 535 + //! auth-request lifecycle, and cross-session isolation. 536 + 537 + use super::*; 538 + use jacquard::common::deps::fluent_uri::Uri; 539 + use jacquard::common::deps::smol_str::SmolStr; 540 + use jacquard::oauth::keyset::Keyset; 541 + use jacquard::oauth::scopes::Scopes; 542 + use jacquard::oauth::session::{DpopClientData, DpopReqData}; 543 + use jacquard::oauth::types::{OAuthTokenType, TokenSet}; 544 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 545 + use std::str::FromStr; 546 + 547 + const ALICE: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; 548 + const BOB: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; 549 + 550 + fn did(s: &str) -> Did { 551 + Did::new_owned(s).unwrap() 552 + } 553 + 554 + /// Build a minimal valid session. The DPoP key is minted via the public 555 + /// keyset API and moved straight into the holder without naming `jose_jwk`; 556 + /// its contents are never asserted (the store never validates it). 557 + fn client_session( 558 + account_did: &str, 559 + session_id: &str, 560 + access_token: &str, 561 + ) -> ClientSessionData { 562 + let dpop_key = Keyset::generate_es256("test") 563 + .unwrap() 564 + .public_jwks() 565 + .keys 566 + .into_iter() 567 + .next() 568 + .unwrap() 569 + .key; 570 + ClientSessionData { 571 + account_did: did(account_did), 572 + session_id: SmolStr::new(session_id), 573 + host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), 574 + authserver_url: SmolStr::new("https://issuer.example.com"), 575 + authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), 576 + authserver_revocation_endpoint: None, 577 + scopes: Scopes::empty(), 578 + dpop_data: DpopClientData { 579 + dpop_key, 580 + dpop_authserver_nonce: SmolStr::default(), 581 + dpop_host_nonce: SmolStr::default(), 582 + }, 583 + token_set: TokenSet { 584 + iss: SmolStr::new("https://issuer.example.com"), 585 + sub: did(account_did), 586 + aud: SmolStr::new("https://pds.example.com"), 587 + scope: None, 588 + refresh_token: None, 589 + access_token: SmolStr::new(access_token), 590 + token_type: OAuthTokenType::DPoP, 591 + expires_at: None, 592 + }, 593 + resolved_scopes: None, 594 + } 595 + } 596 + 597 + fn auth_request(state: &str, account_did: Option<&str>) -> AuthRequestData { 598 + let dpop_key = Keyset::generate_es256("test") 599 + .unwrap() 600 + .public_jwks() 601 + .keys 602 + .into_iter() 603 + .next() 604 + .unwrap() 605 + .key; 606 + AuthRequestData { 607 + state: SmolStr::new(state), 608 + authserver_url: SmolStr::new("https://issuer.example.com"), 609 + account_did: account_did.map(did), 610 + scopes: Scopes::empty(), 611 + request_uri: SmolStr::new("urn:ietf:params:oauth:request_uri:abc"), 612 + authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), 613 + authserver_revocation_endpoint: None, 614 + pkce_verifier: SmolStr::new("verifier"), 615 + dpop_data: DpopReqData { 616 + dpop_key, 617 + dpop_authserver_nonce: None, 618 + }, 619 + } 620 + } 621 + 622 + async fn store() -> SqliteAuthStore { 623 + let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); 624 + let pool = SqlitePoolOptions::new() 625 + .max_connections(1) 626 + .connect_with(options) 627 + .await 628 + .unwrap(); 629 + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); 630 + SqliteAuthStore::new(pool) 631 + } 632 + 633 + #[tokio::test] 634 + async fn upsert_get_roundtrip_returns_the_stored_session() { 635 + let store = store().await; 636 + assert!( 637 + store 638 + .get_session(&did(ALICE), "s1") 639 + .await 640 + .unwrap() 641 + .is_none() 642 + ); 643 + 644 + store 645 + .upsert_session(client_session(ALICE, "s1", "tok")) 646 + .await 647 + .unwrap(); 648 + 649 + let got = store.get_session(&did(ALICE), "s1").await.unwrap().unwrap(); 650 + assert_eq!(got.account_did, did(ALICE)); 651 + assert_eq!(got.session_id.as_str(), "s1"); 652 + assert_eq!(got.token_set.access_token.as_str(), "tok"); 653 + } 654 + 655 + #[tokio::test] 656 + async fn upsert_overwrites_same_key_latest_wins() { 657 + let store = store().await; 658 + store 659 + .upsert_session(client_session(ALICE, "s1", "first")) 660 + .await 661 + .unwrap(); 662 + let first_created_at: i64 = sqlx::query_scalar( 663 + "SELECT created_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 664 + ) 665 + .bind(ALICE) 666 + .bind("s1") 667 + .fetch_one(&store.pool) 668 + .await 669 + .unwrap(); 670 + let first_updated_at: i64 = sqlx::query_scalar( 671 + "SELECT updated_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 672 + ) 673 + .bind(ALICE) 674 + .bind("s1") 675 + .fetch_one(&store.pool) 676 + .await 677 + .unwrap(); 678 + 679 + tokio::time::sleep(std::time::Duration::from_millis(10)).await; 680 + store 681 + .upsert_session(client_session(ALICE, "s1", "second")) 682 + .await 683 + .unwrap(); 684 + let second_created_at: i64 = sqlx::query_scalar( 685 + "SELECT created_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 686 + ) 687 + .bind(ALICE) 688 + .bind("s1") 689 + .fetch_one(&store.pool) 690 + .await 691 + .unwrap(); 692 + let second_updated_at: i64 = sqlx::query_scalar( 693 + "SELECT updated_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", 694 + ) 695 + .bind(ALICE) 696 + .bind("s1") 697 + .fetch_one(&store.pool) 698 + .await 699 + .unwrap(); 700 + 701 + assert_eq!( 702 + second_created_at, first_created_at, 703 + "upsert must preserve original created_at" 704 + ); 705 + assert!( 706 + second_updated_at > first_updated_at, 707 + "upsert must advance updated_at" 708 + ); 709 + 710 + // Exactly one row for the key (no duplicate), and the payload reflects 711 + // the latest write — proving upsert-overwrite + payload round-trip. 712 + assert_eq!(store.list_session_keys().await.unwrap().len(), 1); 713 + let got = store.get_session(&did(ALICE), "s1").await.unwrap().unwrap(); 714 + assert_eq!(got.token_set.access_token.as_str(), "second"); 715 + } 716 + 717 + #[tokio::test] 718 + async fn delete_removes_only_the_target_session() { 719 + let store = store().await; 720 + store 721 + .upsert_session(client_session(ALICE, "s1", "t")) 722 + .await 723 + .unwrap(); 724 + store 725 + .upsert_session(client_session(ALICE, "s2", "t")) 726 + .await 727 + .unwrap(); 728 + store 729 + .upsert_session(client_session(BOB, "s3", "t")) 730 + .await 731 + .unwrap(); 732 + 733 + store.delete_session(&did(ALICE), "s1").await.unwrap(); 734 + 735 + let keys = store.list_session_keys().await.unwrap(); 736 + assert_eq!(keys.len(), 2); 737 + assert!( 738 + store 739 + .get_session(&did(ALICE), "s1") 740 + .await 741 + .unwrap() 742 + .is_none() 743 + ); 744 + assert!( 745 + store 746 + .get_session(&did(ALICE), "s2") 747 + .await 748 + .unwrap() 749 + .is_some() 750 + ); 751 + assert!(store.get_session(&did(BOB), "s3").await.unwrap().is_some()); 752 + } 753 + 754 + #[tokio::test] 755 + async fn list_session_keys_enumerates_and_is_filterable_by_did() { 756 + let store = store().await; 757 + store 758 + .upsert_session(client_session(ALICE, "s1", "t")) 759 + .await 760 + .unwrap(); 761 + store 762 + .upsert_session(client_session(ALICE, "s2", "t")) 763 + .await 764 + .unwrap(); 765 + store 766 + .upsert_session(client_session(BOB, "s3", "t")) 767 + .await 768 + .unwrap(); 769 + 770 + // The structured `account_did` column is queryable *without* loading the 771 + // payload: a direct COUNT groups Alice's sessions by DID alone. 772 + let alice_count: i64 = 773 + sqlx::query_scalar("SELECT COUNT(*) FROM oauth_sessions WHERE account_did = ?") 774 + .bind(ALICE) 775 + .fetch_one(&store.pool) 776 + .await 777 + .unwrap(); 778 + assert_eq!(alice_count, 2); 779 + 780 + let keys = store.list_session_keys().await.unwrap(); 781 + assert_eq!(keys.len(), 3); 782 + assert!( 783 + keys.iter() 784 + .any(|k| k.did == did(ALICE) && k.session_id == "s1") 785 + ); 786 + assert!( 787 + keys.iter() 788 + .any(|k| k.did == did(ALICE) && k.session_id == "s2") 789 + ); 790 + assert!( 791 + keys.iter() 792 + .any(|k| k.did == did(BOB) && k.session_id == "s3") 793 + ); 794 + } 795 + 796 + #[tokio::test] 797 + async fn select_session_resolves_key_did_and_any() { 798 + let store = store().await; 799 + store 800 + .upsert_session(client_session(ALICE, "s1", "t")) 801 + .await 802 + .unwrap(); 803 + store 804 + .upsert_session(client_session(ALICE, "s2", "t")) 805 + .await 806 + .unwrap(); 807 + store 808 + .upsert_session(client_session(BOB, "s3", "t")) 809 + .await 810 + .unwrap(); 811 + 812 + // Exact key. 813 + let m = store 814 + .select_session(&SessionHint::key(SessionKey::new(did(ALICE), "s2"))) 815 + .await 816 + .unwrap() 817 + .unwrap(); 818 + assert_eq!(m.key.session_id.as_str(), "s2"); 819 + 820 + // By DID — must be one of Alice's, never Bob's. 821 + let m = store 822 + .select_session(&SessionHint::did(did(ALICE))) 823 + .await 824 + .unwrap() 825 + .unwrap(); 826 + assert_eq!(m.key.did, did(ALICE)); 827 + 828 + // Any — some session. 829 + let m = store 830 + .select_session(&SessionHint::any()) 831 + .await 832 + .unwrap() 833 + .unwrap(); 834 + assert!( 835 + store 836 + .get_session(&m.key.did, m.key.session_id.as_str()) 837 + .await 838 + .unwrap() 839 + .is_some() 840 + ); 841 + 842 + // Handle/identifier hints never resolve against a resolver-free store. 843 + assert!( 844 + store 845 + .select_session(&SessionHint::identifier("alice.example.com".into())) 846 + .await 847 + .unwrap() 848 + .is_none() 849 + ); 850 + } 851 + 852 + #[tokio::test] 853 + async fn select_session_returns_none_when_no_match() { 854 + let store = store().await; 855 + assert!( 856 + store 857 + .select_session(&SessionHint::any()) 858 + .await 859 + .unwrap() 860 + .is_none() 861 + ); 862 + assert!( 863 + store 864 + .select_session(&SessionHint::key(SessionKey::new(did(ALICE), "x"))) 865 + .await 866 + .unwrap() 867 + .is_none() 868 + ); 869 + assert!( 870 + store 871 + .select_session(&SessionHint::did(did(ALICE))) 872 + .await 873 + .unwrap() 874 + .is_none() 875 + ); 876 + 877 + store 878 + .upsert_session(client_session(BOB, "s3", "t")) 879 + .await 880 + .unwrap(); 881 + // A DID with no session still resolves to None even when others exist. 882 + assert!( 883 + store 884 + .select_session(&SessionHint::did(did(ALICE))) 885 + .await 886 + .unwrap() 887 + .is_none() 888 + ); 889 + } 890 + 891 + #[tokio::test] 892 + async fn auth_request_lifecycle_save_get_delete() { 893 + let store = store().await; 894 + assert!(store.get_auth_req_info("state-1").await.unwrap().is_none()); 895 + 896 + store 897 + .save_auth_req_info(&auth_request("state-1", Some(ALICE))) 898 + .await 899 + .unwrap(); 900 + let got = store.get_auth_req_info("state-1").await.unwrap().unwrap(); 901 + assert_eq!(got.state.as_str(), "state-1"); 902 + assert_eq!(got.account_did.as_ref().unwrap(), &did(ALICE)); 903 + 904 + store.delete_auth_req_info("state-1").await.unwrap(); 905 + assert!(store.get_auth_req_info("state-1").await.unwrap().is_none()); 906 + 907 + // A second request is unaffected by deleting the first. 908 + store 909 + .save_auth_req_info(&auth_request("state-2", None)) 910 + .await 911 + .unwrap(); 912 + store.delete_auth_req_info("state-1").await.unwrap(); 913 + assert!(store.get_auth_req_info("state-2").await.unwrap().is_some()); 914 + } 915 + 916 + #[tokio::test] 917 + async fn cross_session_isolation() { 918 + let store = store().await; 919 + store 920 + .upsert_session(client_session(ALICE, "s1", "a1")) 921 + .await 922 + .unwrap(); 923 + store 924 + .upsert_session(client_session(ALICE, "s2", "a2")) 925 + .await 926 + .unwrap(); 927 + store 928 + .upsert_session(client_session(BOB, "s1", "b1")) 929 + .await 930 + .unwrap(); 931 + 932 + // Two sessions share session_id "s1" under different DIDs. 933 + assert_eq!( 934 + store 935 + .get_session(&did(ALICE), "s1") 936 + .await 937 + .unwrap() 938 + .unwrap() 939 + .token_set 940 + .access_token 941 + .as_str(), 942 + "a1" 943 + ); 944 + assert_eq!( 945 + store 946 + .get_session(&did(BOB), "s1") 947 + .await 948 + .unwrap() 949 + .unwrap() 950 + .token_set 951 + .access_token 952 + .as_str(), 953 + "b1" 954 + ); 955 + } 956 + }