diff --git a/.sqlx/query-10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9.json b/.sqlx/query-10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9.json new file mode 100644 index 0000000..94eaa18 --- /dev/null +++ b/.sqlx/query-10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "10e5e0315276347043124548a8e2bbc83b4e6314bc1b1251aea2aef40cd3dad9" +} diff --git a/.sqlx/query-213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642.json b/.sqlx/query-213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642.json new file mode 100644 index 0000000..06996e0 --- /dev/null +++ b/.sqlx/query-213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT payload FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + "describe": { + "columns": [ + { + "name": "payload", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "oauth_sessions", + "name": "payload" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "213de62269af720d1aa355f9de0ae82fe2c185fc4efe3e83c6e33b2fc0419642" +} diff --git a/.sqlx/query-33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6.json b/.sqlx/query-33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6.json new file mode 100644 index 0000000..6f7f034 --- /dev/null +++ b/.sqlx/query-33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "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", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "33f6a8cb2e9af3c8f6692beb07397aa8397c3b63d3b9c41f82f6a9bf7aa72fa6" +} diff --git a/.sqlx/query-418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a.json b/.sqlx/query-418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a.json new file mode 100644 index 0000000..6adf367 --- /dev/null +++ b/.sqlx/query-418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT account_did, session_id FROM oauth_sessions", + "describe": { + "columns": [ + { + "name": "account_did", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "oauth_sessions", + "name": "account_did" + } + } + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "oauth_sessions", + "name": "session_id" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "418c04677aebf611068b93c1ad7db16437a28550487b37ca35a7452f75fe340a" +} diff --git a/.sqlx/query-45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a.json b/.sqlx/query-45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a.json new file mode 100644 index 0000000..290ab7d --- /dev/null +++ b/.sqlx/query-45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM oauth_auth_requests WHERE state = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "45e6b186ad62fe3223c12fbc50cc0c8b56a82dc5ab07f541d009587224165a5a" +} diff --git a/.sqlx/query-7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450.json b/.sqlx/query-7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450.json new file mode 100644 index 0000000..bc78bb5 --- /dev/null +++ b/.sqlx/query-7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT payload FROM oauth_auth_requests WHERE state = ?", + "describe": { + "columns": [ + { + "name": "payload", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "oauth_auth_requests", + "name": "payload" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "7644095a58c7092ab5ff06da480bef73e74e5c9a923c86529a79cfea0e029450" +} diff --git a/.sqlx/query-c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b.json b/.sqlx/query-c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b.json new file mode 100644 index 0000000..3528eca --- /dev/null +++ b/.sqlx/query-c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "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", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "c5121ea00bc569de5564a7f501ca8f8c4a1aa8802f59e3996ea38a11ad0ae62b" +} diff --git a/Cargo.lock b/Cargo.lock index e2bb4c4..8e5400f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4715,6 +4715,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "konst" version = "0.2.20" @@ -6184,6 +6194,8 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", + "base64", "chrono", "console_error_panic_hook", "dioxus", @@ -6196,6 +6208,7 @@ dependencies = [ "jacquard-common 0.12.0", "jacquard-derive 0.12.0", "js-sys", + "keyring", "libsqlite3-sys", "polymodel-api", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 8b56a09..ad0bf77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,11 @@ server = [ "dep:chrono", "dep:tracing-subscriber", "dep:rustls", + # OAuth (PM-27): confidential client, private cookie jar key, secret sources. + "dep:axum-extra", + + "dep:keyring", + "dep:base64", ] [dependencies] dioxus = { version = "0.7", features = ["router", "fullstack"] } @@ -94,6 +99,13 @@ tracing-subscriber = { version = "0.3", optional = true, default-features = fals http = "1.4" reqwest = { version = "0.12", default-features = false, features = ["json", "charset"] } +# PM-27 OAuth: private cookie-jar signing key and swappable secret sources +# (OS keyring preferred; env override). reqwest is shared with the client crate. +axum-extra = { version = "0.10", optional = true, features = ["cookie"] } + +keyring = { version = "3", optional = true } +base64 = { version = "0.22", optional = true } + [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] console_error_panic_hook = "0.1" diff --git a/justfile b/justfile index 3827bec..2e9a62e 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,11 @@ set dotenv-load := false +# Canonical SQLite database URL for the projection store and the compile-time +# `query!` macros. Scoping it here (rather than via .env) keeps `just` recipes +# hermetic — see `set dotenv-load := false` above. +db-url := 'sqlite:./data/polymodel.db' + # Format Rust sources. Run clippy/check separately; clippy --fix can corrupt RSX. fix: cargo fmt --all @@ -11,24 +16,26 @@ lint: cargo clippy -p polymodel --all-targets --features server -- -D warnings cargo clippy -p polymodel --target wasm32-unknown-unknown --features web -- -D warnings # Compile checks across the whole workspace plus the app's server/wasm targets. -# migrated SQLite database. Run `just migrate` first (or run `just sqlx-prepare`) -# so the macros can resolve. +# The server check resolves compile-time `query!` macros against the committed +# `.sqlx` offline cache; regenerate it with `just sqlx-prepare` after changing +# SQL or migrations. check: cargo check --workspace cargo check -p polymodel --features server cargo check -p polymodel --target wasm32-unknown-unknown --features web # Create and migrate the SQLite projection database. -# Required by the compile-time `query!` macros: run before the first server -# check, and re-run after editing migrations. +# Required when regenerating the `.sqlx` cache, and re-run after editing migrations. migrate: mkdir -p ./data - -cargo sqlx database create --database-url 'sqlite:./data/polymodel.db' - cargo sqlx migrate run --source migrations --database-url 'sqlite:./data/polymodel.db' + -cargo sqlx database create --database-url '{{ db-url }}' + cargo sqlx migrate run --source migrations --database-url '{{ db-url }}' # Regenerate the committed `.sqlx` offline query cache after changing SQL. -sqlx-prepare: - cargo sqlx prepare -- -p polymodel --features server +# Self-contained: migrates the local database first, then points `cargo sqlx +# prepare` at it via DATABASE_URL so no manual env setup is required. +sqlx-prepare: migrate + DATABASE_URL='{{ db-url }}' cargo sqlx prepare -- -p polymodel --features server # Run unit tests across the workspace. test: diff --git a/migrations/001_projection_schema.sql b/migrations/001_projection_schema.sql index 14c19a2..518be22 100644 --- a/migrations/001_projection_schema.sql +++ b/migrations/001_projection_schema.sql @@ -150,18 +150,43 @@ CREATE TRIGGER things_au AFTER UPDATE ON things BEGIN VALUES (new.rowid, new.name, new.summary, new.tags_text, new.instructions_text); END; --- OAuth session persistence (DB-backed ClientAuthStore; used in Task 3) +-- OAuth session persistence (DB-backed ClientAuthStore for Jacquard 0.12 OAuth). +-- +-- Each row mirrors the durable surfaces of `jacquard::oauth::session::ClientSessionData`: +-- the structured columns (account_did, session_id, endpoints, timestamps) power +-- key enumeration, by-DID/any selection, and deletes *without* deserializing the +-- payload, while `payload` holds the canonical JSON serialization of the full +-- `ClientSessionData` (token set, DPoP key + nonces, scopes). DPoP nonces are +-- persisted here, not in an ad-hoc store; the in-process `SessionRegistry` +-- mutex is concurrency-only and not durable. CREATE TABLE oauth_sessions ( - did TEXT NOT NULL, + account_did TEXT NOT NULL, session_id TEXT NOT NULL, - session_data BLOB NOT NULL, + host_url TEXT NOT NULL, + authserver_url TEXT NOT NULL, + token_endpoint TEXT NOT NULL, + revocation_endpoint TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - PRIMARY KEY (did, session_id) + payload TEXT NOT NULL, + PRIMARY KEY (account_did, session_id) ); +CREATE INDEX idx_oauth_sessions_did ON oauth_sessions(account_did); +-- Pending OAuth authorization requests (PAR `state` round-trip). +-- `payload` holds the canonical JSON serialization of +-- `jacquard::oauth::session::AuthRequestData` (PKCE verifier, DPoP key + nonce, +-- request_uri). `account_did` is nullable because the initiating identifier is +-- not always known when the request is created. CREATE TABLE oauth_auth_requests ( - state TEXT PRIMARY KEY, - request_data BLOB NOT NULL, - created_at INTEGER NOT NULL + state TEXT NOT NULL, + authserver_url TEXT NOT NULL, + account_did TEXT, + request_uri TEXT NOT NULL, + token_endpoint TEXT NOT NULL, + revocation_endpoint TEXT, + created_at INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (state) ); +CREATE INDEX idx_oauth_auth_req_did ON oauth_auth_requests(account_did); diff --git a/src/appview/actor.rs b/src/appview/actor.rs index 02e251d..c80f730 100644 --- a/src/appview/actor.rs +++ b/src/appview/actor.rs @@ -2,25 +2,31 @@ //! of polymodel profileView / app.bsky profileViewDetailed) and `getSession` //! (PM-9 identity primitive; PM-26 returns the unauthenticated marker). -use std::sync::Arc; - use axum::extract::State; +use jacquard::identity::PublicResolver; use jacquard::identity::resolver::IdentityResolver; +use jacquard_axum::oauth::OptionalBrowserOAuthSession; use jacquard_axum::{ExtractXrpc, XrpcResponse}; +use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::ident::AtIdentifier; -use jacquard_common::types::string::Did; +use jacquard_common::types::string::{Did, Handle}; +use jacquard_common::types::value::Data; use jacquard_common::xrpc::XrpcClient; use polymodel_api::app_bsky::actor::ProfileViewDetailed; use polymodel_api::app_bsky::actor::get_profile::GetProfile as BskyGetProfile; use polymodel_api::space_polymodel::actor::{ - ProfileViewUnion, + ProfileView, ProfileViewUnion, get_profile::{GetProfileOutput, GetProfileRequest}, - get_session::{GetSessionOutput, GetSessionRequest, SessionUnauthenticated, SessionView}, + get_session::{ + GetSessionOutput, GetSessionRequest, SessionAuthenticated, SessionUnauthenticated, + SessionView, + }, }; use super::error::{AppResult, internal}; use super::state::AppState; use super::views; +use crate::oauth::SqliteAuthStore; /// Canonicalize an at-identifier to a DID by matching on its variant (not by /// sniffing its serialized form). DIDs pass through; handles are resolved via @@ -38,7 +44,7 @@ pub(super) async fn resolve_actor(state: &AppState, ident: &AtIdentifier) -> App } pub(super) async fn get_profile( - State(state): State>, + State(state): State, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { let did = resolve_actor(&state, &req.actor).await?; @@ -80,16 +86,62 @@ async fn fetch_bsky_profile( } pub(super) async fn get_session( - State(_state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ) -> AppResult> { - // PM-26 ships the unauthenticated arm only. PM-27 will return - // `SessionAuthenticated` once OAuth session extraction and profile writes are wired. + if let Some(session) = session.as_ref() { + let did = session.session_info().await.0; + if let Some(handle_str) = views::handle_for_did(&state, &did).await? { + let handle = Handle::new_owned(handle_str.clone()) + .map_err(|e| internal(format!("invalid session handle: {e}")))?; + let profile = match views::polymodel_profile_view(&state, &did).await? { + Some(profile) => profile, + None => minimal_session_profile(did.clone(), handle_str)?, + }; + return Ok(XrpcResponse(GetSessionOutput { + value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { + authenticated: true, + did, + handle, + profile, + extra_data: None, + })), + extra_data: None, + })); + } + } + + Ok(unauthenticated_session()) +} - Ok(XrpcResponse(GetSessionOutput { +fn unauthenticated_session() -> XrpcResponse { + XrpcResponse(GetSessionOutput { value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { authenticated: false, extra_data: None, })), extra_data: None, - })) + }) +} + +fn minimal_session_profile(did: Did, handle: String) -> AppResult { + Ok(ProfileView { + avatar: None, + default_license: None, + description: None, + did, + display_name: None, + handle: SmolStr::from(handle), + indexed_at: None, + links: None, + printers: None, + pronouns: None, + record: Data::Null, + thing_count: None, + total_likes: None, + extra_data: None, + }) } diff --git a/src/appview/graph.rs b/src/appview/graph.rs index f30639c..66cedfd 100644 --- a/src/appview/graph.rs +++ b/src/appview/graph.rs @@ -4,9 +4,9 @@ //! subjects (listitems may reference any record) are filtered out with debug //! logging rather than failing the whole list. -use std::sync::Arc; - use axum::extract::State; +use jacquard::identity::PublicResolver; +use jacquard_axum::oauth::OptionalBrowserOAuthSession; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::space_polymodel::graph::get_list::{GetListOutput, GetListRequest}; @@ -14,16 +14,25 @@ use polymodel_api::space_polymodel::graph::get_list::{GetListOutput, GetListRequ use super::error::{AppResult, clamp_limit}; use super::state::AppState; use super::views; +use super::views::viewer_did; +use crate::oauth::SqliteAuthStore; pub(super) async fn get_list( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); let (items, cursor) = views::list_things( &state, req.list.as_ref(), clamp_limit(req.limit), req.cursor.as_deref(), + viewer, ) .await?; Ok(XrpcResponse(GetListOutput { diff --git a/src/appview/library.rs b/src/appview/library.rs index f6999da..0c15e47 100644 --- a/src/appview/library.rs +++ b/src/appview/library.rs @@ -4,9 +4,9 @@ //! hydrate ordered children via the junction tables; feeds/search return a //! `feedView` of `thingViewBasic`. -use std::sync::Arc; - use axum::extract::State; +use jacquard::identity::PublicResolver; +use jacquard_axum::oauth::OptionalBrowserOAuthSession; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use polymodel_api::space_polymodel::library::{ get_author_things::{GetAuthorThingsOutput, GetAuthorThingsRequest}, @@ -19,12 +19,20 @@ use polymodel_api::space_polymodel::library::{ use super::error::{AppResult, clamp_limit, invalid_request}; use super::state::AppState; use super::views; +use super::views::viewer_did; +use crate::oauth::SqliteAuthStore; pub(super) async fn get_thing( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { - let (thing, models) = views::get_thing(&state, req.uri.as_ref(), None).await?; + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); + let (thing, models) = views::get_thing(&state, req.uri.as_ref(), viewer).await?; Ok(XrpcResponse(GetThingOutput { thing, models: Some(models), @@ -33,10 +41,16 @@ pub(super) async fn get_thing( } pub(super) async fn get_model( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { - let detail = views::get_model(&state, req.uri.as_ref(), None).await?; + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); + let detail = views::get_model(&state, req.uri.as_ref(), viewer).await?; Ok(XrpcResponse(GetModelOutput { model: detail.model, parts: Some(detail.parts), @@ -46,15 +60,22 @@ pub(super) async fn get_model( } pub(super) async fn get_author_things( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); let did = super::actor::resolve_actor(&state, &req.actor).await?; let feed = views::author_things( &state, did.as_ref(), clamp_limit(req.limit), req.cursor.as_deref(), + viewer, ) .await?; Ok(XrpcResponse(GetAuthorThingsOutput { @@ -64,15 +85,21 @@ pub(super) async fn get_author_things( } pub(super) async fn get_feed( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); let algorithm = req.algorithm.as_deref().unwrap_or("recent"); let limit = clamp_limit(req.limit); let cursor = req.cursor.as_deref(); let feed = match algorithm { - "recent" => views::feed_recent(&state, limit, cursor).await?, - "hot" => views::feed_hot(&state, limit, cursor).await?, + "recent" => views::feed_recent(&state, limit, cursor, viewer).await?, + "hot" => views::feed_hot(&state, limit, cursor, viewer).await?, other => return Err(invalid_request(format!("unknown algorithm: {other}"))), }; Ok(XrpcResponse(GetFeedOutput { @@ -82,14 +109,21 @@ pub(super) async fn get_feed( } pub(super) async fn search_things( - State(state): State>, + State(state): State, + OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + PublicResolver, + SqliteAuthStore, + >, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { + let viewer_owned = viewer_did(session.as_ref()).await; + let viewer = viewer_owned.as_ref().map(|d| d.borrow()); let feed = views::search_things( &state, req.q.as_str(), clamp_limit(req.limit), req.cursor.as_deref(), + viewer, ) .await?; Ok(XrpcResponse(SearchThingsOutput { diff --git a/src/appview/mod.rs b/src/appview/mod.rs index f22ad57..fbed6ab 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -21,8 +21,6 @@ mod library; #[cfg(test)] mod tests; -use std::sync::Arc; - use axum::Router; use jacquard_axum::IntoRouter; use jacquard_common::xrpc::XrpcEndpoint; @@ -39,8 +37,8 @@ use self::state::AppState; /// Build the appview XRPC router with the given state applied. /// -/// The returned `Router<()>` is merged with the Dioxus SSR router in `main`. -pub fn router(state: Arc) -> Router { +/// Returns a state-bound `Router`; merged with the OAuth routes and the Dioxus SSR router in `main`, where `.with_state` is applied once. +pub fn router() -> Router { Router::new() .merge(GetThingRequest::into_router(library::get_thing)) .merge(GetModelRequest::into_router(library::get_model)) @@ -58,5 +56,4 @@ pub fn router(state: Arc) -> Router { GetSessionRequest::PATH, axum::routing::get(actor::get_session), ) - .with_state(state) } diff --git a/src/appview/state.rs b/src/appview/state.rs index 898508b..c17bbaf 100644 --- a/src/appview/state.rs +++ b/src/appview/state.rs @@ -6,12 +6,27 @@ //! authorities to DIDs for `getProfile`; `bsky` fetches the //! `app.bsky.actor.defs#profileViewDetailed` union variant from the public //! AppView for actors with no polymodel profile. +//! +//! PM-27 adds the confidential OAuth client, single-origin web config, and +//! private cookie-jar key. `AppState` is the Axum router state type (local, so +//! the [`FromRef`] impls for [`OAuthWebConfig`] and the cookie [`Key`] satisfy +//! the orphan rule); it is cheaply `Clone` (pool/resolver/oauth are +//! `Arc`-backed). + +use std::sync::Arc; +use axum::extract::FromRef; +use axum_extra::extract::cookie::Key; use jacquard::client::BasicClient; use jacquard::identity::PublicResolver; +use jacquard::oauth::client::OAuthClient; +use jacquard_axum::oauth::{OAuthWebConfig, OAuthWebState}; use sqlx::SqlitePool; -/// State shared by all XRPC read handlers. +use crate::oauth::{OAuthBootstrap, SqliteAuthStore}; + +/// State shared by all XRPC read handlers and the OAuth routes. +#[derive(Clone)] pub struct AppState { /// SQLite projection (PM-25): content, social counts, profiles, identities. pub pool: SqlitePool, @@ -19,17 +34,46 @@ pub struct AppState { pub resolver: PublicResolver, /// Unauthenticated client aimed at the public Bluesky AppView /// (`https://public.api.bsky.app`) for the bluesky-only profile variant. - pub bsky: BasicClient, + /// `Arc`-wrapped because `BasicClient` (`Agent`) is not `Clone`. + pub bsky: Arc, + /// Confidential OAuth client (PM-27) over the SQLite auth store. + pub oauth: Arc>, + /// Single-origin OAuth web config (cookie name + route paths). + pub oauth_config: OAuthWebConfig, + /// Signing key for the private `jacquard_oauth_session` cookie jar. + pub cookie_key: Key, } impl AppState { - /// Construct read state from the shared SQLite pool. The resolver and bsky - /// client are unauthenticated/public and need no configuration. - pub fn new(pool: SqlitePool) -> Self { + /// Construct read + OAuth state from the shared SQLite pool and the + /// startup-built OAuth bootstrap (keyset, config, cookie key). + pub fn new(pool: SqlitePool, bootstrap: OAuthBootstrap) -> Self { Self { pool, resolver: jacquard::identity::slingshot_resolver_default(), - bsky: jacquard::client::BasicClient::unauthenticated(), + bsky: Arc::new(jacquard::client::BasicClient::unauthenticated()), + oauth: Arc::new(bootstrap.client), + oauth_config: bootstrap.config, + cookie_key: bootstrap.cookie_key, } } } + +/// Expose the OAuth client to `jacquard_axum::oauth` routes/extractors. +impl OAuthWebState for AppState { + fn oauth_client(&self) -> &OAuthClient { + self.oauth.as_ref() + } +} + +impl FromRef for OAuthWebConfig { + fn from_ref(state: &AppState) -> Self { + state.oauth_config.clone() + } +} + +impl FromRef for Key { + fn from_ref(state: &AppState) -> Self { + state.cookie_key.clone() + } +} diff --git a/src/appview/tests.rs b/src/appview/tests.rs index ba76ef6..44cb4f5 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -8,8 +8,6 @@ #![cfg(test)] -use std::sync::Arc; - use axum::body::Body; use axum::http::{Request, StatusCode}; use jacquard_common::types::string::Did; @@ -42,12 +40,14 @@ async fn pool() -> SqlitePool { pool } -async fn state() -> Arc { - Arc::new(AppState::new(pool().await)) +async fn state() -> AppState { + let pool = pool().await; + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap for tests"); + AppState::new(pool, bootstrap) } -// Seeding uses runtime queries so the committed .sqlx cache stays focused on -// production SQL. +// Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL. fn blob(cid: &str) -> serde_json::Value { json!({ @@ -317,12 +317,14 @@ async fn author_things_keyset_paginates_by_rkey() { seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await; - let page1 = views::author_things(&state, DID_A, 2, None).await.unwrap(); + let page1 = views::author_things(&state, DID_A, 2, None, None) + .await + .unwrap(); assert_eq!(page1.items.len(), 2); assert_eq!(page1.items[0].thing.name.as_str(), "t3"); let cursor = page1.cursor.expect("cursor present when more remain"); - let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref())) + let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref()), None) .await .unwrap(); assert_eq!(page2.items.len(), 1); @@ -339,9 +341,9 @@ async fn feed_hot_is_deterministic_with_tied_scores() { seed_thing(&state.pool, DID_A, "3aaaaaaaaaaaa", "a", &[], 5).await; seed_thing(&state.pool, DID_A, "3bbbbbbbbbbbb", "b", &[], 5).await; - let feed = views::feed_hot(&state, 10, None).await.unwrap(); + let feed = views::feed_hot(&state, 10, None, None).await.unwrap(); assert_eq!(feed.items.len(), 2); - let feed_again = views::feed_hot(&state, 10, None).await.unwrap(); + let feed_again = views::feed_hot(&state, 10, None, None).await.unwrap(); assert_eq!( feed.items[0].thing.uri.as_ref(), feed_again.items[0].thing.uri.as_ref() @@ -357,7 +359,7 @@ async fn search_things_uses_fts() { seed_thing(&state.pool, DID_A, "r1", "Calibration Cube", &[], 0).await; seed_thing(&state.pool, DID_A, "r2", "Friendly Sphere", &[], 0).await; - let feed = views::search_things(&state, "cube", 10, None) + let feed = views::search_things(&state, "cube", 10, None, None) .await .unwrap(); assert_eq!(feed.items.len(), 1); @@ -367,7 +369,7 @@ async fn search_things_uses_fts() { #[tokio::test] async fn malformed_ranked_cursor_is_rejected() { let state = state().await; - let err = views::search_things(&state, "x", 10, Some("not-a-number")) + let err = views::search_things(&state, "x", 10, Some("not-a-number"), None) .await .unwrap_err(); assert!(matches!(err, AppError::InvalidRequest(_))); @@ -387,7 +389,9 @@ async fn list_filters_non_thing_subjects() { .bind(DID_A).bind("i2").bind(list).bind(non_thing).bind(2) .execute(&state.pool).await.unwrap(); - let (items, _cursor) = views::list_things(&state, list, 10, None).await.unwrap(); + let (items, _cursor) = views::list_things(&state, list, 10, None, None) + .await + .unwrap(); assert_eq!(items.len(), 1, "non-thing list item must be filtered out"); assert_eq!(items[0].uri.as_ref(), thing); } @@ -457,7 +461,7 @@ async fn body_text(resp: axum::response::Response) -> String { #[tokio::test] async fn get_session_route_returns_unauthenticated_marker() { let state = state().await; - let app = super::router(state); + let app = super::router().with_state(state); let resp = app .oneshot( Request::builder() @@ -475,7 +479,7 @@ async fn get_session_route_returns_unauthenticated_marker() { #[tokio::test] async fn get_thing_route_decodes_query_and_404s_when_missing() { let state = state().await; - let app = super::router(state); + let app = super::router().with_state(state); let resp = app .oneshot( Request::builder() @@ -492,15 +496,18 @@ async fn get_thing_route_decodes_query_and_404s_when_missing() { async fn negative_offset_cursor_is_rejected() { let state = state().await; let errs = [ - views::search_things(&state, "x", 10, Some("-1")) + views::search_things(&state, "x", 10, Some("-1"), None) + .await + .unwrap_err(), + views::feed_hot(&state, 10, Some("-5"), None) .await .unwrap_err(), - views::feed_hot(&state, 10, Some("-5")).await.unwrap_err(), views::list_things( &state, "at://did:plc:l/space.polymodel.graph.list/self", 10, Some("-1"), + None, ) .await .unwrap_err(), @@ -521,11 +528,11 @@ async fn feed_recent_paginates_by_rkey() { seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await; - let page1 = views::feed_recent(&state, 2, None).await.unwrap(); + let page1 = views::feed_recent(&state, 2, None, None).await.unwrap(); assert_eq!(page1.items.len(), 2); assert_eq!(page1.items[0].thing.name.as_str(), "t3"); let cursor = page1.cursor.expect("more remain"); - let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref())) + let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref()), None) .await .unwrap(); assert_eq!(page2.items.len(), 1); diff --git a/src/appview/views.rs b/src/appview/views.rs index 18e8abc..b3ec78c 100644 --- a/src/appview/views.rs +++ b/src/appview/views.rs @@ -18,7 +18,9 @@ use std::cmp::Ordering; +use jacquard::identity::PublicResolver; use jacquard::identity::resolver::IdentityResolver; +use jacquard::oauth::client::OAuthSession; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; use jacquard_common::types::{ @@ -33,6 +35,7 @@ use sqlx::SqlitePool; use super::error::{AppResult, db, internal, invalid_request, not_found}; use super::state::AppState; +use crate::oauth::SqliteAuthStore; // --------------------------------------------------------------------------- // value-construction helpers @@ -260,12 +263,13 @@ async fn actor_for_did(state: &AppState, did: &str) -> AppResult { async fn viewer_state( pool: &SqlitePool, - viewer_did: Option<&str>, + viewer_did: Option>, subject_uri: &str, ) -> AppResult { let Some(did) = viewer_did else { return Ok(ViewerState::default()); }; + let did = did.as_str(); #[derive(sqlx::FromRow)] struct IdRow { rkey: String, @@ -302,6 +306,15 @@ async fn viewer_state( }) } +pub(super) async fn viewer_did( + session: Option<&OAuthSession>, +) -> Option { + match session { + Some(s) => Some(s.session_info().await.0), + None => None, + } +} + // --------------------------------------------------------------------------- // view builders // --------------------------------------------------------------------------- @@ -309,7 +322,7 @@ async fn viewer_state( async fn thing_view( state: &AppState, row: ThingRow, - viewer_did: Option<&str>, + viewer_did: Option>, ) -> AppResult { let author = actor_for_did(state, &row.did).await?; let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; @@ -337,7 +350,11 @@ async fn thing_view( }) } -async fn thing_basic_view(state: &AppState, row: ThingRow) -> AppResult { +async fn thing_basic_view( + state: &AppState, + row: ThingRow, + viewer_did: Option>, +) -> AppResult { let record = thing_record(row.record_json.as_deref())?; Ok(ThingViewBasic { uri: at_uri(&row.uri)?, @@ -352,7 +369,7 @@ async fn thing_basic_view(state: &AppState, row: ThingRow) -> AppResult AppResult, + viewer_did: Option>, ) -> AppResult { let author = actor_for_did(state, &row.did).await?; let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; @@ -384,7 +401,7 @@ async fn model_view( async fn part_view( state: &AppState, row: PartRow, - viewer_did: Option<&str>, + viewer_did: Option>, ) -> AppResult { let author = actor_for_did(state, &row.did).await?; let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; @@ -431,12 +448,12 @@ async fn fetch_thing_by_uri(state: &AppState, uri: &str) -> AppResult, + viewer_did: Option>, ) -> AppResult<(ThingView, Vec)> { let row = fetch_thing_by_uri(state, uri) .await? .ok_or_else(not_found)?; - let thing = thing_view(state, row, viewer_did).await?; + let thing = thing_view(state, row, viewer_did.clone()).await?; let models = models_for_thing(state, uri, viewer_did).await?; Ok((thing, models)) } @@ -444,7 +461,7 @@ pub(super) async fn get_thing( async fn models_for_thing( state: &AppState, thing_uri: &str, - viewer_did: Option<&str>, + viewer_did: Option>, ) -> AppResult> { let rows = db( sqlx::query_as!( @@ -462,7 +479,7 @@ async fn models_for_thing( )?; let mut views = Vec::with_capacity(rows.len()); for row in rows { - views.push(model_view(state, row, viewer_did).await?); + views.push(model_view(state, row, viewer_did.clone()).await?); } Ok(views) } @@ -476,7 +493,7 @@ pub(super) struct ModelDetail { pub(super) async fn get_model( state: &AppState, uri: &str, - viewer_did: Option<&str>, + viewer_did: Option>, ) -> AppResult { let row = db( sqlx::query_as!( @@ -492,7 +509,7 @@ pub(super) async fn get_model( .await, )? .ok_or_else(not_found)?; - let model = model_view(state, row, viewer_did).await?; + let model = model_view(state, row, viewer_did.clone()).await?; let parts = db(sqlx::query_as!( PartRow, @@ -507,7 +524,7 @@ pub(super) async fn get_model( .await)?; let mut part_views = Vec::with_capacity(parts.len()); for row in parts { - part_views.push(part_view(state, row, viewer_did).await?); + part_views.push(part_view(state, row, viewer_did.clone()).await?); } let thing = db( @@ -530,7 +547,7 @@ pub(super) async fn get_model( .await, )?; let thing = if let Some(row) = thing { - Some(thing_basic_view(state, row).await?) + Some(thing_basic_view(state, row, viewer_did).await?) } else { None }; @@ -550,11 +567,12 @@ async fn feed_view( state: &AppState, rows: Vec, cursor: Option, + viewer_did: Option>, ) -> AppResult { let mut items = Vec::with_capacity(rows.len()); for row in rows { items.push(FeedItem { - thing: thing_basic_view(state, row).await?, + thing: thing_basic_view(state, row, viewer_did.clone()).await?, extra_data: None, }); } @@ -570,6 +588,7 @@ pub(super) async fn author_things( did: &str, limit: i64, cursor: Option<&str>, + viewer_did: Option>, ) -> AppResult { let fetch = limit + 1; let rows = db( @@ -593,13 +612,14 @@ pub(super) async fn author_things( .fetch_all(&state.pool) .await, )?; - rkey_paged_feed(state, rows, limit).await + rkey_paged_feed(state, rows, limit, viewer_did).await } pub(super) async fn feed_recent( state: &AppState, limit: i64, cursor: Option<&str>, + viewer_did: Option>, ) -> AppResult { let fetch = limit + 1; let rows = db( @@ -622,7 +642,7 @@ pub(super) async fn feed_recent( .fetch_all(&state.pool) .await, )?; - rkey_paged_feed(state, rows, limit).await + rkey_paged_feed(state, rows, limit, viewer_did).await } /// Reduce an over-fetched (`limit+1`) rkey-ordered page into a feed, returning @@ -633,6 +653,7 @@ async fn rkey_paged_feed( state: &AppState, mut rows: Vec, limit: i64, + viewer_did: Option>, ) -> AppResult { let cursor = if rows.len() as i64 > limit { rows.get((limit - 1) as usize).map(|r| r.rkey.clone()) @@ -640,13 +661,14 @@ async fn rkey_paged_feed( None }; rows.truncate(limit as usize); - feed_view(state, rows, cursor).await + feed_view(state, rows, cursor, viewer_did).await } pub(super) async fn feed_hot( state: &AppState, limit: i64, cursor: Option<&str>, + viewer_did: Option>, ) -> AppResult { let offset = parse_offset(cursor)?; // Candidate window: the most recent things. Hot score is computed in Rust so @@ -690,7 +712,7 @@ pub(super) async fn feed_hot( None }; let page = page.into_iter().take(limit as usize).collect(); - feed_view(state, page, cursor).await + feed_view(state, page, cursor, viewer_did).await } fn hot_score(row: &ThingRow, now_millis: i64) -> f64 { @@ -704,6 +726,7 @@ pub(super) async fn search_things( q: &str, limit: i64, cursor: Option<&str>, + viewer_did: Option>, ) -> AppResult { let offset = parse_offset(cursor)?; let fetch = limit + 1; @@ -737,7 +760,7 @@ pub(super) async fn search_things( None }; let page: Vec = rows.into_iter().take(limit as usize).collect(); - feed_view(state, page, cursor).await + feed_view(state, page, cursor, viewer_did).await } fn parse_offset(cursor: Option<&str>) -> AppResult { @@ -766,6 +789,7 @@ pub(super) async fn list_things( list_uri: &str, limit: i64, cursor: Option<&str>, + viewer_did: Option>, ) -> AppResult<(Vec, Option)> { let offset = parse_offset(cursor)?; // Over-fetch subjects, then hydrate only those resolving to a thing; non-thing @@ -795,7 +819,7 @@ pub(super) async fn list_things( }; let mut items = Vec::with_capacity(rows.len().min(limit as usize)); for li in rows.into_iter().take(limit as usize) { - if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri).await? { + if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? { items.push(thing); } else { tracing::debug!(subject_uri = %li.subject_uri, "list item is not a thing; skipped"); @@ -804,9 +828,13 @@ pub(super) async fn list_things( Ok((items, cursor)) } -async fn thing_basic_by_uri(state: &AppState, uri: &str) -> AppResult> { +async fn thing_basic_by_uri( + state: &AppState, + uri: &str, + viewer_did: Option>, +) -> AppResult> { match fetch_thing_by_uri(state, uri).await? { - Some(row) => Ok(Some(thing_basic_view(state, row).await?)), + Some(row) => Ok(Some(thing_basic_view(state, row, viewer_did).await?)), None => Ok(None), } } @@ -859,7 +887,8 @@ pub(super) async fn profile_stats(state: &AppState, did: &str) -> AppResult AppResult> { +pub(super) async fn handle_for_did(state: &AppState, did: &Did) -> AppResult> { + let did = did.as_str(); #[derive(sqlx::FromRow)] struct HandleRow { handle: Option, @@ -879,7 +908,7 @@ pub(super) async fn handle_for_did(state: &AppState, did: &str) -> AppResult AppResult { - if let Some(h) = handle_for_did(state, did.as_ref()).await? { + if let Some(h) = handle_for_did(state, did).await? { return Ok(h); } let doc = state diff --git a/src/auth.rs b/src/auth.rs index 3d77ff6..43bedaa 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -44,54 +44,10 @@ pub fn handle_remote_sign_out(session: &mut SessionIdentity, toasts: &mut ToastS toasts.push("Signed out in another tab."); } -pub fn login_url(identifier: &str) -> String { - let identifier = identifier.trim(); - if identifier.is_empty() { - "/oauth/login".to_string() - } else { - format!( - "/oauth/start?identifier={}", - encode_query_component(identifier) - ) - } -} - pub fn logout_url() -> &'static str { "/oauth/logout" } -fn encode_query_component(value: &str) -> String { - #[cfg(all(target_family = "wasm", target_os = "unknown"))] - { - js_sys::encode_uri_component(value) - .as_string() - .unwrap_or_default() - } - - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] - { - value - .bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { - vec![byte as char] - } - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() - } -} - -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn navigate_to(url: &str) { - if let Some(window) = web_sys::window() { - let _ = window.location().set_href(url); - } -} - -#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn navigate_to(_url: &str) {} - #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn broadcast_sign_out() { if let Ok(channel) = web_sys::BroadcastChannel::new(AUTH_BROADCAST_CHANNEL) { @@ -108,19 +64,6 @@ mod tests { use jacquard::types::string::Handle; use jacquard_common::types::did::Did; - #[test] - fn login_url_uses_oauth_login_when_identifier_empty() { - assert_eq!(login_url(" "), "/oauth/login"); - } - - #[test] - fn login_url_uses_oauth_start_with_encoded_identifier() { - assert_eq!( - login_url("alice model.example"), - "/oauth/start?identifier=alice%20model.example" - ); - } - #[test] fn remote_sign_out_clears_session_and_adds_toast() { let mut session = SessionIdentity::Authenticated(crate::session::AuthenticatedIdentity { diff --git a/src/indexing/config.rs b/src/indexing/config.rs index 06a9d9e..5e4520c 100644 --- a/src/indexing/config.rs +++ b/src/indexing/config.rs @@ -1,8 +1,9 @@ //! Server runtime configuration. //! -//! Configuration that varies per deployment (e.g. `DATABASE_URL`) and is only -//! needed by the server binary. These values are read from the process -//! environment at startup, not baked into the WASM bundle. +//! Configuration that varies per deployment (e.g. `DATABASE_URL`, +//! `POLYMODEL_BASE_URL`) and is only needed by the server binary. These values +//! are read from the process environment at startup, not baked into the WASM +//! bundle. //! //! The server binary calls `dotenvy::dotenv()` in `main` before //! [`ServerConfig::load`]. Hydrant reads its own `HYDRANT_*` variables via its @@ -16,6 +17,13 @@ use std::env; pub struct ServerConfig { /// sqlx connect URL, e.g. `sqlite:./data/polymodel.db`. pub database_url: String, + /// Public origin of this server, e.g. `https://polymodel.example.com`. + /// + /// The OAuth `client_id` is `{base_url}/oauth-client-metadata.json` and the + /// redirect URI is `{base_url}/oauth/callback` (PM-27). When unset, bootstrap + /// defaults the OAuth client origin to `https://polymodel.space`, a functional + /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins. + pub base_url: Option, } impl ServerConfig { @@ -26,6 +34,13 @@ impl ServerConfig { pub fn load() -> Self { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./data/polymodel.db".to_string()); - Self { database_url } + let base_url = env::var("POLYMODEL_BASE_URL") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); + Self { + database_url, + base_url, + } } } diff --git a/src/main.rs b/src/main.rs index 392fd71..29dab14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,10 +19,14 @@ mod indexing; #[cfg(feature = "server")] mod appview; -use auth::{ToastState, broadcast_sign_out, login_url, logout_url, navigate_to}; +use auth::{ToastState, broadcast_sign_out, logout_url}; use client::PolymodelClient; use session::SessionIdentity; use thing_card::ThingCard; + +#[cfg(feature = "server")] +mod oauth; + use viewer::{ViewerPage, demo_model_for_route, demo_models}; const FAVICON: Asset = asset!("/assets/favicon.jpg"); @@ -51,10 +55,19 @@ fn main() { indexing::start_indexing(db.clone()) .await .expect("failed to start indexing pipeline"); - + let oauth = oauth::bootstrap_oauth(db.clone(), cfg.base_url.as_deref()) + .expect("failed to bootstrap OAuth client"); // The same SQLite pool backs both indexing and the appview read handlers. - let app_state = std::sync::Arc::new(appview::state::AppState::new(db)); - Ok(dioxus::server::router(App).merge(appview::router(app_state))) + let app_state = appview::state::AppState::new(db, oauth); + Ok(dioxus::server::router(App).merge( + appview::router() + .merge(jacquard_axum::oauth::routes::< + jacquard::identity::PublicResolver, + oauth::SqliteAuthStore, + appview::state::AppState, + >(&app_state.oauth_config.clone())) + .with_state(app_state), + )) }); } @@ -294,19 +307,18 @@ fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal) #[component] fn SessionControl(session: Signal, toasts: Signal) -> Element { let identity = session.read().clone(); - let mut identifier = use_signal(String::new); match identity { SessionIdentity::Anonymous => rsx! { form { class: "session-control", - onsubmit: move |_| navigate_to(&login_url(&identifier.read())), + action: "/oauth/start", + method: "get", input { r#type: "text", + name: "identifier", placeholder: "handle.test", aria_label: "ATProto handle", - value: "{identifier}", - oninput: move |event| identifier.set(event.value()) } button { class: "button button-primary", r#type: "submit", "Sign in" } } @@ -316,15 +328,19 @@ fn SessionControl(session: Signal, toasts: Signal) rsx! { div { class: "session-control session-control-authenticated", span { class: "status-pill", "{label}" } - button { - class: "button button-secondary", - onclick: move |_| { - broadcast_sign_out(); - session.set(SessionIdentity::Anonymous); - toasts.write().push("Signing out…"); - navigate_to(logout_url()); - }, - "Sign out" + form { + method: "post", + action: "{logout_url()}", + button { + class: "button button-secondary", + r#type: "submit", + onclick: move |_| { + broadcast_sign_out(); + session.set(SessionIdentity::Anonymous); + toasts.write().push("Signing out…"); + }, + "Sign out" + } } } } diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs new file mode 100644 index 0000000..665d361 --- /dev/null +++ b/src/oauth/mod.rs @@ -0,0 +1,956 @@ +//! SQLite-backed OAuth persistence + confidential-client bootstrap for Jacquard 0.12. +//! +//! [`SqliteAuthStore`] implements [`ClientAuthStore`] and +//! [`SessionSelector`] over the `oauth_sessions` and `oauth_auth_requests` +//! tables (see `migrations/001_projection_schema.sql`). Each row stores a +//! structured, queryable projection of the Jacquard session / auth-request types +//! alongside a canonical JSON `payload`. Structured columns power key +//! enumeration, by-DID/any selection, and deletes without the payload; only +//! `get_session` / `get_auth_req_info` deserialize the payload. +//! DPoP key material and nonces live inside the payload — the Jacquard +//! `SessionRegistry` mutex is in-process concurrency only and is not persisted, +//! so the two tables are the *complete* durable OAuth surface. +//! +//! [`bootstrap_oauth`] constructs the confidential [`OAuthClient`], the +//! single-origin [`OAuthWebConfig`], and the private cookie-jar [`Key`] from a +//! swappable [`SecretSource`] (keyring → env → ephemeral-with-warning). +//! +//! [`SessionSelector`]: jacquard::common::session::SessionSelector + +use jacquard::common::bos::BosStr; +use jacquard::common::session::{SessionHint, SessionKey, SessionSelector, SessionStoreError}; +use jacquard::common::types::did::Did; +use jacquard::oauth::authstore::{ClientAuthStore, OAuthSessionMatch}; +use jacquard::oauth::session::{AuthRequestData, ClientSessionData}; +use sqlx::SqlitePool; + +/// SQLite-backed implementation of [`ClientAuthStore`]. +#[derive(Debug, Clone)] +pub struct SqliteAuthStore { + pool: SqlitePool, +} + +impl SqliteAuthStore { + /// Wrap the shared projection pool as an OAuth auth store. + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + /// Exact-key lookup → session match (or `None`). + async fn match_for_key( + &self, + key: SessionKey, + ) -> Result, SessionStoreError> { + Ok(self + .get_session(&key.did, key.session_id.as_str()) + .await? + .map(|session| OAuthSessionMatch { key, session })) + } + + /// First session belonging to `did`, enumerated via [`ClientAuthStore::list_session_keys`]. + async fn match_for_did( + &self, + did: &Did, + ) -> Result, SessionStoreError> { + for key in self.list_session_keys().await? { + if key.did.as_str() == did.as_ref() + && let Some(matched) = self.match_for_key(key).await? + { + return Ok(Some(matched)); + } + } + Ok(None) + } +} + +impl ClientAuthStore for SqliteAuthStore { + async fn get_session( + &self, + did: &Did, + session_id: &str, + ) -> Result, SessionStoreError> { + let row = sqlx::query!( + "SELECT payload FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + did.as_str(), + session_id + ) + .fetch_optional(&self.pool) + .await + .map_err(store_err)?; + match row { + Some(r) => Ok(Some(serde_json::from_str(&r.payload)?)), + None => Ok(None), + } + } + + async fn upsert_session(&self, session: ClientSessionData) -> Result<(), SessionStoreError> { + let now = chrono::Utc::now().timestamp_millis(); + let payload = serde_json::to_string(&session)?; + sqlx::query!( + "INSERT INTO oauth_sessions + (account_did, session_id, host_url, authserver_url, token_endpoint, + revocation_endpoint, created_at, updated_at, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_did, session_id) DO UPDATE SET + host_url = excluded.host_url, + authserver_url = excluded.authserver_url, + token_endpoint = excluded.token_endpoint, + revocation_endpoint = excluded.revocation_endpoint, + updated_at = excluded.updated_at, + payload = excluded.payload", + session.account_did.as_str(), + session.session_id.as_str(), + session.host_url.as_str(), + session.authserver_url.as_str(), + session.authserver_token_endpoint.as_str(), + session + .authserver_revocation_endpoint + .as_ref() + .map(|s| s.as_str()), + now, + now, + payload, + ) + .execute(&self.pool) + .await + .map_err(store_err)?; + Ok(()) + } + + async fn delete_session( + &self, + did: &Did, + session_id: &str, + ) -> Result<(), SessionStoreError> { + sqlx::query!( + "DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + did.as_str(), + session_id + ) + .execute(&self.pool) + .await + .map_err(store_err)?; + Ok(()) + } + + async fn get_auth_req_info( + &self, + state: &str, + ) -> Result, SessionStoreError> { + let row = sqlx::query!( + "SELECT payload FROM oauth_auth_requests WHERE state = ?", + state + ) + .fetch_optional(&self.pool) + .await + .map_err(store_err)?; + match row { + Some(r) => Ok(Some(serde_json::from_str(&r.payload)?)), + None => Ok(None), + } + } + + async fn save_auth_req_info( + &self, + auth_req_info: &AuthRequestData, + ) -> Result<(), SessionStoreError> { + let now = chrono::Utc::now().timestamp_millis(); + let payload = serde_json::to_string(auth_req_info)?; + sqlx::query!( + "INSERT INTO oauth_auth_requests + (state, authserver_url, account_did, request_uri, token_endpoint, + revocation_endpoint, created_at, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(state) DO UPDATE SET + authserver_url = excluded.authserver_url, + account_did = excluded.account_did, + request_uri = excluded.request_uri, + token_endpoint = excluded.token_endpoint, + revocation_endpoint = excluded.revocation_endpoint, + payload = excluded.payload", + auth_req_info.state.as_str(), + auth_req_info.authserver_url.as_str(), + auth_req_info.account_did.as_ref().map(|d| d.as_str()), + auth_req_info.request_uri.as_str(), + auth_req_info.authserver_token_endpoint.as_str(), + auth_req_info + .authserver_revocation_endpoint + .as_ref() + .map(|s| s.as_str()), + now, + payload, + ) + .execute(&self.pool) + .await + .map_err(store_err)?; + Ok(()) + } + + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { + sqlx::query!("DELETE FROM oauth_auth_requests WHERE state = ?", state) + .execute(&self.pool) + .await + .map_err(store_err)?; + Ok(()) + } + + async fn list_session_keys(&self) -> Result, SessionStoreError> { + let rows = sqlx::query!("SELECT account_did, session_id FROM oauth_sessions") + .fetch_all(&self.pool) + .await + .map_err(store_err)?; + let mut keys: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let did = Did::new_owned(&row.account_did).map_err(store_err)?; + keys.push(SessionKey::new(did, row.session_id)); + } + keys.sort(); + Ok(keys) + } +} + +impl SessionSelector for SqliteAuthStore { + type Error = SessionStoreError; + + async fn select_session( + &self, + hint: &SessionHint, + ) -> Result, Self::Error> { + match hint { + SessionHint::Any => { + let Some(key) = self.list_session_keys().await?.into_iter().next() else { + return Ok(None); + }; + self.match_for_key(key).await + } + SessionHint::Did(did) => self.match_for_did(did).await, + SessionHint::Handle(_) | SessionHint::Identifier(_) => Ok(None), + SessionHint::Key(key) => self.match_for_key(key.clone()).await, + } + } +} + +/// Box any backend error into [`SessionStoreError::Other`]. +fn store_err(e: E) -> SessionStoreError +where + E: std::error::Error + Send + Sync + 'static, +{ + SessionStoreError::Other(Box::new(e)) +} + +// =========================================================================== +// Confidential OAuth client bootstrap (PM-27) +// =========================================================================== + +use anyhow::Context as _; +use axum_extra::extract::cookie::Key; +use base64::Engine as _; +use jacquard::common::deps::fluent_uri::Uri; +use jacquard::identity::PublicResolver; +use jacquard::oauth::{ + atproto::AtprotoClientMetadata, client::OAuthClient, keyset::Keyset, scopes::Scopes, + session::ClientData, +}; +use jacquard_axum::oauth::OAuthWebConfig; + +/// The OAuth client, web config, and cookie-jar key built once at startup. +/// +/// Constructed by [`bootstrap_oauth`] and stored in `AppState`. +pub struct OAuthBootstrap { + /// Confidential OAuth client over the SQLite auth store + public resolver. + pub client: OAuthClient, + /// Single-origin cookie/route config (`OAuthWebConfig::default()`). + pub config: OAuthWebConfig, + /// Signing key for the private `jacquard_oauth_session` cookie jar. + pub cookie_key: Key, +} + +/// Build the OAuth client, web config, and cookie key. +/// +/// `base_url` is the server's public origin (`POLYMODEL_BASE_URL`); the OAuth +/// `client_id` is `{base_url}/oauth-client-metadata.json` and the redirect URI +/// is `{base_url}/oauth/callback`, mirroring the jacquard-axum example. Only the +/// secret store (→ [`SqliteAuthStore`]) and key provisioning (→ +/// [`SecretSource`]) differ from the example. +pub fn bootstrap_oauth(pool: SqlitePool, base_url: Option<&str>) -> anyhow::Result { + let (keyset, cookie_key) = provision_oauth_secrets()?; + let metadata = hosted_client_metadata(base_url)?; + let client_data = ClientData::new(Some(keyset), metadata); + let client: OAuthClient = OAuthClient::new( + SqliteAuthStore::new(pool), + client_data, + reqwest::Client::new(), + ); + Ok(OAuthBootstrap { + client, + config: OAuthWebConfig::default(), + cookie_key, + }) +} + +/// Build hosted (non-loopback) client metadata from the public origin. +/// +/// Defaults to the deployment origin `https://polymodel.space` when +/// `POLYMODEL_BASE_URL` is unset; set that for local or alternate origins. This is +/// never the loopback client spec (`new_localhost`); it uses the hosted +/// `AtprotoClientMetadata::new` constructor for production origins. +fn hosted_client_metadata( + base_url: Option<&str>, +) -> anyhow::Result> { + let origin = base_url + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.trim_end_matches('/').to_owned()); + let origin = match origin { + Some(origin) => origin, + None => { + tracing::warn!( + "POLYMODEL_BASE_URL is not set; defaulting the OAuth client origin to the \ + deployment origin https://polymodel.space (used for client_id and redirect_uri). \ + Set POLYMODEL_BASE_URL for local or alternate origins." + ); + "https://polymodel.space".to_owned() + } + }; + let client_id = + Uri::parse(format!("{origin}/oauth-client-metadata.json")).map_err(|(_e, _)| { + anyhow::anyhow!("invalid OAuth client_id URL from POLYMODEL_BASE_URL") + })?; + let redirect_uri = Uri::parse(format!("{origin}/oauth/callback")) + .map_err(|(_e, _)| anyhow::anyhow!("invalid OAuth redirect URI from POLYMODEL_BASE_URL"))?; + let scopes = Scopes::builder() + .atproto() + .transition_generic() + .build() + .context("build OAuth scopes")?; + Ok(AtprotoClientMetadata::new( + vec![redirect_uri], + client_id, + Some(scopes), + )) +} + +/// Provision the OAuth signing keyset and cookie-jar key. +/// +/// Priority: (1) OS keyring — preferred, persists across restarts; on first run +/// a fresh keyset/key is generated and stored so subsequent restarts reuse it; +/// (2) environment override (`POLYMODEL_OAUTH_KEYSET_B64` / +/// `POLYMODEL_COOKIE_KEY_B64`) — read-only; (3) ephemeral in-memory generation +/// with a loud warning that identity + sessions are non-persistent and lost on +/// restart (the demo default). +fn provision_oauth_secrets() -> anyhow::Result<(Keyset, Key)> { + let keyring = KeyringSource; + let (keyset, generated_keyset) = provision_keyset(&keyring)?; + let (cookie, generated_cookie) = provision_cookie_key(&keyring)?; + if generated_keyset || generated_cookie { + tracing::warn!( + "Generated ephemeral OAuth secret material because no persisted keyring/env value was \ + available for at least one secret. OAuth identity and browser sessions are \ + NON-PERSISTENT for generated secrets and may be lost on restart. Provision both the \ + OAuth keyset and cookie key in keyring or environment for durable sessions." + ); + } + Ok((keyset, cookie)) +} + +fn provision_keyset(keyring: &KeyringSource) -> anyhow::Result<(Keyset, bool)> { + if !cfg!(test) + && let Some(keyset) = keyring.load_keyset()? + { + return Ok((keyset, false)); + } + if !cfg!(test) + && let Some(keyset) = EnvSource.load_keyset()? + { + return Ok((keyset, false)); + } + + let keyset = Keyset::generate_es256("polymodel").context("generate OAuth keyset")?; + // Best-effort: persist to keyring so a later restart (on a host with a usable + // secret store) reuses this instead of regenerating. + if !cfg!(test) { + let _ = keyring.store_keyset(&keyset); + } + Ok((keyset, true)) +} + +fn provision_cookie_key(keyring: &KeyringSource) -> anyhow::Result<(Key, bool)> { + if !cfg!(test) + && let Some(cookie) = keyring.load_cookie_key()? + { + return Ok((cookie, false)); + } + if !cfg!(test) + && let Some(cookie) = EnvSource.load_cookie_key()? + { + return Ok((cookie, false)); + } + + let cookie = Key::generate(); + // Best-effort: persist to keyring so a later restart (on a host with a usable + // secret store) reuses this instead of regenerating. The cookie key must stay + // stable for OAuth session restore. + if !cfg!(test) { + let _ = keyring.store_cookie_key(&cookie); + } + Ok((cookie, true)) +} + +/// Swappable source of the OAuth signing keyset and cookie-jar key. +/// +/// Sources are tried in priority order by [`provision_oauth_secrets`]; the +/// ephemeral fallback is intentionally not a `SecretSource` because it is +/// non-persistent by design. +trait SecretSource { + fn load_keyset(&self) -> anyhow::Result>; + fn store_keyset(&self, keyset: &Keyset) -> anyhow::Result<()>; + fn load_cookie_key(&self) -> anyhow::Result>; + fn store_cookie_key(&self, key: &Key) -> anyhow::Result<()>; +} + +/// OS keyring-backed source. On a host without an accessible secret store, every +/// operation degrades to `None` / no-op so provisioning falls through to env. +struct KeyringSource; + +impl KeyringSource { + const SERVICE: &'static str = "polymodel"; + const KEYSET_USER: &'static str = "oauth-client-keyset"; + const COOKIE_USER: &'static str = "oauth-cookie-key"; + + fn entry(user: &'static str) -> Option { + keyring::Entry::new(Self::SERVICE, user).ok() + } +} + +impl SecretSource for KeyringSource { + fn load_keyset(&self) -> anyhow::Result> { + let Some(entry) = Self::entry(Self::KEYSET_USER) else { + return Ok(None); + }; + match entry.get_password() { + Ok(b64) => { + let bytes = decode_b64(&b64).context("keyring OAuth keyset")?; + Ok(Some(serde_json::from_slice(&bytes).context( + "keyring OAuth keyset is not a valid serialized Keyset", + )?)) + } + // Not stored yet, or no usable secret-store backend → fall through. + Err(_) => Ok(None), + } + } + + fn store_keyset(&self, keyset: &Keyset) -> anyhow::Result<()> { + if let Some(entry) = Self::entry(Self::KEYSET_USER) { + let bytes = serde_json::to_vec(keyset)?; + let _ = entry.set_password(&b64::STANDARD.encode(&bytes)); + } + Ok(()) + } + + fn load_cookie_key(&self) -> anyhow::Result> { + let Some(entry) = Self::entry(Self::COOKIE_USER) else { + return Ok(None); + }; + match entry.get_password() { + Ok(b64) => decode_cookie_key(&b64).map(Some), + Err(_) => Ok(None), + } + } + + fn store_cookie_key(&self, key: &Key) -> anyhow::Result<()> { + if let Some(entry) = Self::entry(Self::COOKIE_USER) { + let _ = entry.set_password(&b64::STANDARD.encode(key.master())); + } + Ok(()) + } +} + +/// Environment-variable source (`POLYMODEL_OAUTH_KEYSET_B64`, +/// `POLYMODEL_COOKIE_KEY_B64`). Base64 of a JSON-serialized `Keyset` and of 64 +/// raw cookie-key bytes respectively. Read-only. +struct EnvSource; + +impl SecretSource for EnvSource { + fn load_keyset(&self) -> anyhow::Result> { + let Some(b64) = env_get("POLYMODEL_OAUTH_KEYSET_B64")? else { + return Ok(None); + }; + let bytes = decode_b64(&b64).context("POLYMODEL_OAUTH_KEYSET_B64")?; + Ok(Some(serde_json::from_slice(&bytes).context( + "POLYMODEL_OAUTH_KEYSET_B64 is not a valid serialized Keyset", + )?)) + } + + fn store_keyset(&self, _keyset: &Keyset) -> anyhow::Result<()> { + Ok(()) + } + + fn load_cookie_key(&self) -> anyhow::Result> { + let Some(b64) = env_get("POLYMODEL_COOKIE_KEY_B64")? else { + return Ok(None); + }; + decode_cookie_key(&b64) + .context("POLYMODEL_COOKIE_KEY_B64") + .map(Some) + } + + fn store_cookie_key(&self, _key: &Key) -> anyhow::Result<()> { + Ok(()) + } +} + +fn env_get(name: &str) -> anyhow::Result> { + match std::env::var(name) { + Ok(v) => Ok(Some(v)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + anyhow::bail!("{name} is not valid UTF-8") + } + } +} + +fn decode_b64(b64: &str) -> anyhow::Result> { + Ok(b64::STANDARD.decode(b64)?) +} + +fn decode_cookie_key(b64: &str) -> anyhow::Result { + let bytes = decode_b64(b64)?; + let arr: [u8; 64] = bytes + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("cookie key must decode to exactly 64 bytes"))?; + Ok(Key::from(&arr)) +} + +/// Short alias for standard base64 to keep the keyring/env code compact. +mod b64 { + pub(crate) use base64::engine::general_purpose::STANDARD; +} + +#[cfg(test)] +mod tests { + //! Store behavior, not serde: keyed upsert/get, overwrite-on-conflict, + //! delete exactness, key enumeration + by-DID querying (structured column, + //! no payload load), `select_session` for `Key`/`Did`/`Any`/no-match, + //! auth-request lifecycle, and cross-session isolation. + + use super::*; + use jacquard::common::deps::fluent_uri::Uri; + use jacquard::common::deps::smol_str::SmolStr; + use jacquard::oauth::keyset::Keyset; + use jacquard::oauth::scopes::Scopes; + use jacquard::oauth::session::{DpopClientData, DpopReqData}; + use jacquard::oauth::types::{OAuthTokenType, TokenSet}; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + const ALICE: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; + const BOB: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; + + fn did(s: &str) -> Did { + Did::new_owned(s).unwrap() + } + + /// Build a minimal valid session. The DPoP key is minted via the public + /// keyset API and moved straight into the holder without naming `jose_jwk`; + /// its contents are never asserted (the store never validates it). + fn client_session( + account_did: &str, + session_id: &str, + access_token: &str, + ) -> ClientSessionData { + let dpop_key = Keyset::generate_es256("test") + .unwrap() + .public_jwks() + .keys + .into_iter() + .next() + .unwrap() + .key; + ClientSessionData { + account_did: did(account_did), + session_id: SmolStr::new(session_id), + host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), + authserver_url: SmolStr::new("https://issuer.example.com"), + authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), + authserver_revocation_endpoint: None, + scopes: Scopes::empty(), + dpop_data: DpopClientData { + dpop_key, + dpop_authserver_nonce: SmolStr::default(), + dpop_host_nonce: SmolStr::default(), + }, + token_set: TokenSet { + iss: SmolStr::new("https://issuer.example.com"), + sub: did(account_did), + aud: SmolStr::new("https://pds.example.com"), + scope: None, + refresh_token: None, + access_token: SmolStr::new(access_token), + token_type: OAuthTokenType::DPoP, + expires_at: None, + }, + resolved_scopes: None, + } + } + + fn auth_request(state: &str, account_did: Option<&str>) -> AuthRequestData { + let dpop_key = Keyset::generate_es256("test") + .unwrap() + .public_jwks() + .keys + .into_iter() + .next() + .unwrap() + .key; + AuthRequestData { + state: SmolStr::new(state), + authserver_url: SmolStr::new("https://issuer.example.com"), + account_did: account_did.map(did), + scopes: Scopes::empty(), + request_uri: SmolStr::new("urn:ietf:params:oauth:request_uri:abc"), + authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), + authserver_revocation_endpoint: None, + pkce_verifier: SmolStr::new("verifier"), + dpop_data: DpopReqData { + dpop_key, + dpop_authserver_nonce: None, + }, + } + } + + async fn store() -> SqliteAuthStore { + let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + SqliteAuthStore::new(pool) + } + + #[tokio::test] + async fn upsert_get_roundtrip_returns_the_stored_session() { + let store = store().await; + assert!( + store + .get_session(&did(ALICE), "s1") + .await + .unwrap() + .is_none() + ); + + store + .upsert_session(client_session(ALICE, "s1", "tok")) + .await + .unwrap(); + + let got = store.get_session(&did(ALICE), "s1").await.unwrap().unwrap(); + assert_eq!(got.account_did, did(ALICE)); + assert_eq!(got.session_id.as_str(), "s1"); + assert_eq!(got.token_set.access_token.as_str(), "tok"); + } + + #[tokio::test] + async fn upsert_overwrites_same_key_latest_wins() { + let store = store().await; + store + .upsert_session(client_session(ALICE, "s1", "first")) + .await + .unwrap(); + let first_created_at: i64 = sqlx::query_scalar( + "SELECT created_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + ) + .bind(ALICE) + .bind("s1") + .fetch_one(&store.pool) + .await + .unwrap(); + let first_updated_at: i64 = sqlx::query_scalar( + "SELECT updated_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + ) + .bind(ALICE) + .bind("s1") + .fetch_one(&store.pool) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + store + .upsert_session(client_session(ALICE, "s1", "second")) + .await + .unwrap(); + let second_created_at: i64 = sqlx::query_scalar( + "SELECT created_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + ) + .bind(ALICE) + .bind("s1") + .fetch_one(&store.pool) + .await + .unwrap(); + let second_updated_at: i64 = sqlx::query_scalar( + "SELECT updated_at FROM oauth_sessions WHERE account_did = ? AND session_id = ?", + ) + .bind(ALICE) + .bind("s1") + .fetch_one(&store.pool) + .await + .unwrap(); + + assert_eq!( + second_created_at, first_created_at, + "upsert must preserve original created_at" + ); + assert!( + second_updated_at > first_updated_at, + "upsert must advance updated_at" + ); + + // Exactly one row for the key (no duplicate), and the payload reflects + // the latest write — proving upsert-overwrite + payload round-trip. + assert_eq!(store.list_session_keys().await.unwrap().len(), 1); + let got = store.get_session(&did(ALICE), "s1").await.unwrap().unwrap(); + assert_eq!(got.token_set.access_token.as_str(), "second"); + } + + #[tokio::test] + async fn delete_removes_only_the_target_session() { + let store = store().await; + store + .upsert_session(client_session(ALICE, "s1", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(ALICE, "s2", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(BOB, "s3", "t")) + .await + .unwrap(); + + store.delete_session(&did(ALICE), "s1").await.unwrap(); + + let keys = store.list_session_keys().await.unwrap(); + assert_eq!(keys.len(), 2); + assert!( + store + .get_session(&did(ALICE), "s1") + .await + .unwrap() + .is_none() + ); + assert!( + store + .get_session(&did(ALICE), "s2") + .await + .unwrap() + .is_some() + ); + assert!(store.get_session(&did(BOB), "s3").await.unwrap().is_some()); + } + + #[tokio::test] + async fn list_session_keys_enumerates_and_is_filterable_by_did() { + let store = store().await; + store + .upsert_session(client_session(ALICE, "s1", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(ALICE, "s2", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(BOB, "s3", "t")) + .await + .unwrap(); + + // The structured `account_did` column is queryable *without* loading the + // payload: a direct COUNT groups Alice's sessions by DID alone. + let alice_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM oauth_sessions WHERE account_did = ?") + .bind(ALICE) + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(alice_count, 2); + + let keys = store.list_session_keys().await.unwrap(); + assert_eq!(keys.len(), 3); + assert!( + keys.iter() + .any(|k| k.did == did(ALICE) && k.session_id == "s1") + ); + assert!( + keys.iter() + .any(|k| k.did == did(ALICE) && k.session_id == "s2") + ); + assert!( + keys.iter() + .any(|k| k.did == did(BOB) && k.session_id == "s3") + ); + } + + #[tokio::test] + async fn select_session_resolves_key_did_and_any() { + let store = store().await; + store + .upsert_session(client_session(ALICE, "s1", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(ALICE, "s2", "t")) + .await + .unwrap(); + store + .upsert_session(client_session(BOB, "s3", "t")) + .await + .unwrap(); + + // Exact key. + let m = store + .select_session(&SessionHint::key(SessionKey::new(did(ALICE), "s2"))) + .await + .unwrap() + .unwrap(); + assert_eq!(m.key.session_id.as_str(), "s2"); + + // By DID — must be one of Alice's, never Bob's. + let m = store + .select_session(&SessionHint::did(did(ALICE))) + .await + .unwrap() + .unwrap(); + assert_eq!(m.key.did, did(ALICE)); + + // Any — some session. + let m = store + .select_session(&SessionHint::any()) + .await + .unwrap() + .unwrap(); + assert!( + store + .get_session(&m.key.did, m.key.session_id.as_str()) + .await + .unwrap() + .is_some() + ); + + // Handle/identifier hints never resolve against a resolver-free store. + assert!( + store + .select_session(&SessionHint::identifier("alice.example.com".into())) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn select_session_returns_none_when_no_match() { + let store = store().await; + assert!( + store + .select_session(&SessionHint::any()) + .await + .unwrap() + .is_none() + ); + assert!( + store + .select_session(&SessionHint::key(SessionKey::new(did(ALICE), "x"))) + .await + .unwrap() + .is_none() + ); + assert!( + store + .select_session(&SessionHint::did(did(ALICE))) + .await + .unwrap() + .is_none() + ); + + store + .upsert_session(client_session(BOB, "s3", "t")) + .await + .unwrap(); + // A DID with no session still resolves to None even when others exist. + assert!( + store + .select_session(&SessionHint::did(did(ALICE))) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn auth_request_lifecycle_save_get_delete() { + let store = store().await; + assert!(store.get_auth_req_info("state-1").await.unwrap().is_none()); + + store + .save_auth_req_info(&auth_request("state-1", Some(ALICE))) + .await + .unwrap(); + let got = store.get_auth_req_info("state-1").await.unwrap().unwrap(); + assert_eq!(got.state.as_str(), "state-1"); + assert_eq!(got.account_did.as_ref().unwrap(), &did(ALICE)); + + store.delete_auth_req_info("state-1").await.unwrap(); + assert!(store.get_auth_req_info("state-1").await.unwrap().is_none()); + + // A second request is unaffected by deleting the first. + store + .save_auth_req_info(&auth_request("state-2", None)) + .await + .unwrap(); + store.delete_auth_req_info("state-1").await.unwrap(); + assert!(store.get_auth_req_info("state-2").await.unwrap().is_some()); + } + + #[tokio::test] + async fn cross_session_isolation() { + let store = store().await; + store + .upsert_session(client_session(ALICE, "s1", "a1")) + .await + .unwrap(); + store + .upsert_session(client_session(ALICE, "s2", "a2")) + .await + .unwrap(); + store + .upsert_session(client_session(BOB, "s1", "b1")) + .await + .unwrap(); + + // Two sessions share session_id "s1" under different DIDs. + assert_eq!( + store + .get_session(&did(ALICE), "s1") + .await + .unwrap() + .unwrap() + .token_set + .access_token + .as_str(), + "a1" + ); + assert_eq!( + store + .get_session(&did(BOB), "s1") + .await + .unwrap() + .unwrap() + .token_set + .access_token + .as_str(), + "b1" + ); + } +}