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 6a965315e7e650c0b1316313cbe64713a3459ac5
Parent: 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-cospan
2 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+}
cospan · schematic version control on atproto built on AT Protocol