Implement client-side ATProto OAuth login flow #74

Merged
puregarlic merged 10 commits from issue/9 into main 2026-03-06 16:05:10 -08:00
Contributor

Implements the Tauri client-side login flow, completes the authentication loop started in #68, and closes #9 and #70.

What's included

Auth gRPC integration (src-tauri/src/auth/)

AuthManager — new manager responsible for the full session lifecycle:

  • start_login(server_url, identifier) — persists the server URL to the store, connects the auth gRPC client, calls InitiateLogin, and returns { oauth_url, session_id }
  • await_login(session_id) — opens the AwaitLogin server-streaming call and blocks until the JWT arrives, then stores it in tauri-plugin-store
  • get_session() — single authoritative check for "is the user authenticated?"; returns Some(Session) only when both server-url and jwt are present in the store
  • clear_session() — atomically removes both store keys on logout

Session struct — pairs server_url and jwt as a first-class value. The JWT and server URL are two fields of one fact ("I am logged into this server"), so they are managed together and checked together.

start_login command — opens the OAuth URL in the system browser via tauri-plugin-opener after InitiateLogin succeeds.

await_login command — coordination point between managers: once the JWT lands, triggers channels.connect(session.server_url) so the home screen is fully ready without a separate step.

get_session / logout commands — replace get_server_url / set_server_url for session management.

Session ownership refactor

ChannelsManager previously owned the server URL (stored it in tauri-plugin-store, exposed get_server_url / set_server_url). This was incorrect: the server URL is only meaningful in the context of an authenticated session, and treating it as independent mutable state allowed the app to be in inconsistent positions (URL set, no JWT; JWT set, wrong URL).

Changes:

  • ChannelsManager no longer holds a store reference or knows about the server URL. new() takes no arguments. It is now pure infrastructure: gRPC connectivity, LiveKit rooms, audio.
  • AuthManager takes over CONFIG_KEY_SERVER_URL from the store and persists it in start_login (before the browser opens, so it survives a crash mid-flow).
  • set_server_url / get_server_url Tauri commands removed.
  • lib.rs startup now calls auth_manager.get_session() to check for a persisted session and connect channels if one is found.

build.rs

InitiateLoginResponse gains #[serde(rename_all = "camelCase")] so it serialises as { oauthUrl, sessionId }, matching what the XState actor expects.

Frontend

connect.tsx / loginMachine — drives the full login flow:

  1. User submits server URL + ATProto identifier
  2. start_login invoke → { oauthUrl, sessionId } returned, browser opens to OAuth URL
  3. await_login(sessionId) invoke blocks in the background while the user completes OAuth
  4. On completion, machine transitions and navigates to /server

authenticated.tsxclientLoader now calls get_session instead of get_server_url; redirects to /connect if no session.

settings.tsx — "Change Server" calls logout and redirects to /connect directly.

Closes

  • Closes #9 (server-side OAuth flow was #68; this PR adds the client-side half)
  • Closes #70 (login UI in Tauri client)
  • Closes #69 (verify JWTs server-side)

Notes for reviewer

  • The ChannelsManager will gain an Arc<AuthManager> reference when the JWT interceptor is implemented (#69). The dependency direction is ChannelsManager → AuthManager (channels needs the JWT, not the other way around). The current refactor intentionally prepares for this without introducing the circular reference that would result from the opposite arrangement.
  • Session is not yet exported as a TypeScript binding — the authenticated.tsx loader uses an inline type. This can be formalised with ts-rs when the session shape stabilises.
Implements the Tauri client-side login flow, completes the authentication loop started in #68, and closes #9 and #70. ## What's included ### Auth gRPC integration (`src-tauri/src/auth/`) **`AuthManager`** — new manager responsible for the full session lifecycle: - `start_login(server_url, identifier)` — persists the server URL to the store, connects the auth gRPC client, calls `InitiateLogin`, and returns `{ oauth_url, session_id }` - `await_login(session_id)` — opens the `AwaitLogin` server-streaming call and blocks until the JWT arrives, then stores it in `tauri-plugin-store` - `get_session()` — single authoritative check for "is the user authenticated?"; returns `Some(Session)` only when both `server-url` and `jwt` are present in the store - `clear_session()` — atomically removes both store keys on logout **`Session` struct** — pairs `server_url` and `jwt` as a first-class value. The JWT and server URL are two fields of one fact ("I am logged into this server"), so they are managed together and checked together. **`start_login` command** — opens the OAuth URL in the system browser via `tauri-plugin-opener` after `InitiateLogin` succeeds. **`await_login` command** — coordination point between managers: once the JWT lands, triggers `channels.connect(session.server_url)` so the home screen is fully ready without a separate step. **`get_session` / `logout` commands** — replace `get_server_url` / `set_server_url` for session management. ### Session ownership refactor `ChannelsManager` previously owned the server URL (stored it in `tauri-plugin-store`, exposed `get_server_url` / `set_server_url`). This was incorrect: the server URL is only meaningful in the context of an authenticated session, and treating it as independent mutable state allowed the app to be in inconsistent positions (URL set, no JWT; JWT set, wrong URL). Changes: - `ChannelsManager` no longer holds a store reference or knows about the server URL. `new()` takes no arguments. It is now pure infrastructure: gRPC connectivity, LiveKit rooms, audio. - `AuthManager` takes over `CONFIG_KEY_SERVER_URL` from the store and persists it in `start_login` (before the browser opens, so it survives a crash mid-flow). - `set_server_url` / `get_server_url` Tauri commands removed. - `lib.rs` startup now calls `auth_manager.get_session()` to check for a persisted session and connect channels if one is found. ### `build.rs` `InitiateLoginResponse` gains `#[serde(rename_all = "camelCase")]` so it serialises as `{ oauthUrl, sessionId }`, matching what the XState actor expects. ### Frontend **`connect.tsx` / `loginMachine`** — drives the full login flow: 1. User submits server URL + ATProto identifier 2. `start_login` invoke → `{ oauthUrl, sessionId }` returned, browser opens to OAuth URL 3. `await_login(sessionId)` invoke blocks in the background while the user completes OAuth 4. On completion, machine transitions and navigates to `/server` **`authenticated.tsx`** — `clientLoader` now calls `get_session` instead of `get_server_url`; redirects to `/connect` if no session. **`settings.tsx`** — "Change Server" calls `logout` and redirects to `/connect` directly. ## Closes - Closes #9 (server-side OAuth flow was #68; this PR adds the client-side half) - Closes #70 (login UI in Tauri client) - Closes #69 (verify JWTs server-side) ## Notes for reviewer - The `ChannelsManager` will gain an `Arc<AuthManager>` reference when the JWT interceptor is implemented (#69). The dependency direction is `ChannelsManager → AuthManager` (channels needs the JWT, not the other way around). The current refactor intentionally prepares for this without introducing the circular reference that would result from the opposite arrangement. - `Session` is not yet exported as a TypeScript binding — the `authenticated.tsx` loader uses an inline type. This can be formalised with `ts-rs` when the session shape stabilises.
puregarlic 2026-03-04 12:38:04 -08:00
puregarlic added this to the Ludwig (MVP) milestone 2026-03-04 12:38:12 -08:00
seb requested changes 2026-03-05 13:08:32 -08:00
Dismissed
@ -0,0 +63,4 @@
}
#[instrument(skip(self))]
pub async fn start_login(
Collaborator

is it worth guarding against calling this more than once at a time? if you have an in-progress login and this gets called again, would it overwrite the current client. does that have any implications?

is it worth guarding against calling this more than once at a time? if you have an in-progress login and this gets called again, would it overwrite the current client. does that have any implications?
@ -0,0 +89,4 @@
#[instrument(skip(self))]
pub async fn await_login(&self, session_id: String) -> Result<(), Box<dyn Error>> {
let mut guard = self.client.lock().await;
Collaborator

since this locks client, if there's a reason the user would want to call start_login again (retrying? maybe logging into another server?) this await_login call would block the subsequent start_login call until the RPC call returns (eventually, most likely because of timeout)

maybe you could

let client = guard.as_mut().ok_or(...)?.clone();
drop(guard);
let mut stream = client.await_login(...).await?...

if AuthServiceClient implements Clone

since this locks client, if there's a reason the user would want to call start_login again (retrying? maybe logging into another server?) this await_login call would block the subsequent start_login call until the RPC call returns (eventually, most likely because of timeout) maybe you could ``` let client = guard.as_mut().ok_or(...)?.clone(); drop(guard); let mut stream = client.await_login(...).await?... ``` if AuthServiceClient<Channel> implements Clone
@ -0,0 +57,4 @@
/// Clears the stored session (server URL + JWT). Called on logout.
#[instrument(skip(self))]
pub fn clear_session(&self) {
Collaborator

should we do something with the client here? as it is, you could call await_login again without first calling start_login, since you still have a valid client, right?

should we do something with the client here? as it is, you could call await_login again without first calling start_login, since you still have a valid client, right?
Owner

Yeah, I caught this too, I've got a local fix for this.

Yeah, I caught this too, I've got a local fix for this.
Owner

I've added code addressing #69--so not only is the client performing the auth process, the server now requires a Bearer token for the channels service, and uses the user's resolved DID in place of a randomly-generated name.

I've added code addressing #69--so not only is the client performing the auth process, the server now requires a Bearer token for the `channels` service, and uses the user's resolved DID in place of a randomly-generated name.
puregarlic requested review from seb 2026-03-05 20:57:05 -08:00
seb requested changes 2026-03-06 15:49:51 -08:00
Dismissed
@ -0,0 +108,4 @@
) -> Result<InitiateLoginResponse, AuthError> {
if self
.client
.try_lock()
Collaborator

this is a race condition, I think. if you call start_login twice and they both pass this first if statement, the two invocations race to the *self.client.lock().await call at the bottom. maybe this function should hold the lock continuously through to the end?

this is a race condition, I think. if you call start_login twice and they both pass this first if statement, the two invocations race to the *self.client.lock().await call at the bottom. maybe this function should hold the lock continuously through to the end?
seb marked this conversation as resolved
puregarlic requested review from seb 2026-03-06 16:03:14 -08:00
seb approved these changes 2026-03-06 16:04:55 -08:00
Sign in to join this conversation.
No description provided.