fix: bridge browser OAuth to server-side session cookie The frontend uses @atproto/oauth-client-browser (tokens in IndexedDB) but server-side features (push tokens, form actions) need a session cookie. Previously these were two separate, unconnected auth systems. New /oauth/bridge endpoint: after browser OAuth completes, the frontend POSTs the authenticated DID. The appview creates a lightweight server-side session in Redis and sets the cospan_session cookie. This bridges the two systems so form actions can see the user. On logout, DELETE /oauth/bridge clears both the cookie and the Redis session.
Author: Aaron Steven White
Commit
6a965315e7e650c0b1316313cbe64713a3459ac5Parent: 56dcd9a886
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan2 files changed +108 -1
@@ -38,6 +38,9 @@ export async function initAuth(serverUser?: ServerUser | null): Promise<void> {
3838 avatar: result.avatar, 3939 loading: false, 4040 }; 41+ // Bridge the browser OAuth session to a server-side cookie so 42+ // server-rendered pages and form actions can see the session. 43+ bridgeSession(result.did, result.handle).catch(() => {}); 4144 } else if (serverUser) { 4245 // IndexedDB session lost but server cookie still valid 4346 state = {
@@ -75,6 +78,20 @@ export function getAuth(): AuthState {
7578 7679 export async function doLogout(): Promise<void> { 7780 await oauthLogout(); 81+ // Clear the server-side session cookie too. 82+ fetch('/oauth/bridge', { method: 'DELETE', credentials: 'include' }).catch(() => {}); 7883 state = { authenticated: false, loading: false }; 7984 initialized = false; 8085 } 86+ 87+/// Bridge the browser OAuth session to a server-side session cookie. 88+/// Called after every successful browser OAuth init so that form 89+/// actions and server-side rendering can see the authenticated user. 90+async function bridgeSession(did: string, handle?: string): Promise<void> { 91+ await fetch('/oauth/bridge', { 92+ method: 'POST', 93+ headers: { 'Content-Type': 'application/json' }, 94+ body: JSON.stringify({ did, handle }), 95+ credentials: 'include', 96+ }); 97+}
@@ -13,7 +13,7 @@ use std::sync::Arc;
1313 use axum::extract::{Query, State}; 1414 use axum::http::{StatusCode, header}; 1515 use axum::response::{IntoResponse, Redirect, Response}; 16-use axum::routing::{get, post}; 16+use axum::routing::{delete, get, post}; 1717 use axum::{Json, Router}; 1818 use serde::{Deserialize, Serialize}; 1919 use serde_json::json;
@@ -34,6 +34,8 @@ pub fn router() -> Router<Arc<AppState>> {
3434 .route("/oauth/callback", get(callback)) 3535 .route("/oauth/logout", post(logout)) 3636 .route("/oauth/session", get(session_info)) 37+ .route("/oauth/bridge", post(bridge_session)) 38+ .route("/oauth/bridge", delete(bridge_delete)) 3739 } 3840 3941 // -- GET /oauth/client-metadata.json --
@@ -713,3 +715,91 @@ impl IntoResponse for OAuthError {
713715 .into_response() 714716 } 715717 } 718+ 719+// -- Bridge: connect browser OAuth to server-side session -- 720+// 721+// The frontend uses @atproto/oauth-client-browser which stores tokens 722+// in IndexedDB. Server-side features (form actions, SSR) need a 723+// session cookie. This endpoint bridges the two: the frontend POSTs 724+// the authenticated DID after browser OAuth completes, and we create 725+// a lightweight server-side session + cookie. 726+ 727+#[derive(Deserialize)] 728+struct BridgeInput { 729+ did: String, 730+ handle: Option<String>, 731+} 732+ 733+async fn bridge_session( 734+ State(state): State<Arc<AppState>>, 735+ Json(input): Json<BridgeInput>, 736+) -> impl IntoResponse { 737+ let session_id = uuid::Uuid::new_v4().to_string(); 738+ let session = Session { 739+ did: input.did.clone(), 740+ handle: input.handle.clone(), 741+ // Bridge sessions don't have PDS tokens (the browser has them). 742+ // They only exist so the server can identify the user for form 743+ // actions like createPushToken. 744+ access_token: String::new(), 745+ refresh_token: String::new(), 746+ dpop_private_key_b64: String::new(), 747+ auth_server_issuer: String::new(), 748+ pds_url: String::new(), 749+ dpop_nonce: None, 750+ expires_at: chrono::Utc::now() + chrono::Duration::days(7), 751+ created_at: chrono::Utc::now(), 752+ }; 753+ 754+ if let Err(e) = state.session_store.put_session(&session_id, session).await { 755+ tracing::error!(error = %e, "bridge: failed to store session"); 756+ return ( 757+ StatusCode::INTERNAL_SERVER_ERROR, 758+ Json(json!({"error": "session store failed"})), 759+ ) 760+ .into_response(); 761+ } 762+ 763+ tracing::info!(did = %input.did, "bridge: created server session"); 764+ 765+ let cookie_value = format!( 766+ "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800{}", 767+ SESSION_COOKIE, 768+ session_id, 769+ if state.oauth_config.public_url.starts_with("https://") { 770+ "; Secure" 771+ } else { 772+ "" 773+ } 774+ ); 775+ 776+ ( 777+ StatusCode::OK, 778+ [(header::SET_COOKIE, cookie_value)], 779+ Json(json!({"ok": true, "did": input.did})), 780+ ) 781+ .into_response() 782+} 783+ 784+async fn bridge_delete( 785+ State(state): State<Arc<AppState>>, 786+ headers: axum::http::HeaderMap, 787+) -> impl IntoResponse { 788+ if let Some(session_id) = extract_session_id(&headers) { 789+ let _ = state.session_store.delete_session(&session_id).await; 790+ } 791+ let clear_cookie = format!( 792+ "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", 793+ SESSION_COOKIE, 794+ if state.oauth_config.public_url.starts_with("https://") { 795+ "; Secure" 796+ } else { 797+ "" 798+ } 799+ ); 800+ ( 801+ StatusCode::OK, 802+ [(header::SET_COOKIE, clear_cookie)], 803+ Json(json!({"ok": true})), 804+ ) 805+}