Implement server-side ATProto OAuth login flow #68

Merged
puregarlic merged 22 commits from issue/9-server into main 2026-03-03 22:37:41 -08:00
Contributor

Implements the server-side ATProto OAuth login flow as tracked in #9.

What's included

New HTTP listener (HTTP_ADDR, default [::]:3000)

  • GET /oauth-client-metadata.json — serves ATProto OAuthClientMetadata for the server's OAuth identity
  • GET / — OAuth callback handler; exchanges the code for a session, mints a microclimate JWT, and resolves the waiting AwaitLogin stream

New gRPC service (AuthService, registered on the existing gRPC listener)

  • InitiateLogin(identifier) — resolves the ATProto identity, performs PAR, returns { oauth_url, session_id }
  • AwaitLogin(session_id) — server-streaming; blocks until the OAuth callback completes, then emits the signed JWT and closes

Session management

  • In-flight logins tracked in a DashMap keyed by session_id (which doubles as the OAuth state parameter — no separate correlation needed)
  • watch channel per session handles reconnection: if AwaitLogin drops and reconnects after the callback has already fired, it gets the JWT immediately
  • Background TTL sweeper clears sessions abandoned before the flow completes (30-minute TTL, 5-minute sweep interval)

JWT minting

  • HMAC-SHA256, signed with JWT_SECRET from the environment
  • Claims: sub (ATProto DID), iat, exp (24-hour expiry)

ATProto loopback dev requirements

  • client_id is http://localhost in dev (no port, no path)
  • redirect_uri is http://[::1]:{port} (IPv6 loopback, no path) — callback lands at /
  • build_atproto_metadata() branches on localhost in the host to apply these rules automatically

New env var

JWT_SECRET — secret used to sign session tokens. Documented in README with a generation example (openssl rand -base64 32). Already present in .env.example.

Checklist

  • User handle/identifier input (InitiateLogin)
  • Handle OAuth2 callback (GET /)
  • Session token generation (JWT minted from ATProto DID)
Implements the server-side ATProto OAuth login flow as tracked in #9. ## What's included **New HTTP listener** (HTTP_ADDR, default [::]:3000) - GET /oauth-client-metadata.json — serves ATProto OAuthClientMetadata for the server's OAuth identity - GET / — OAuth callback handler; exchanges the code for a session, mints a microclimate JWT, and resolves the waiting AwaitLogin stream **New gRPC service** (AuthService, registered on the existing gRPC listener) - InitiateLogin(identifier) — resolves the ATProto identity, performs PAR, returns { oauth_url, session_id } - AwaitLogin(session_id) — server-streaming; blocks until the OAuth callback completes, then emits the signed JWT and closes **Session management** - In-flight logins tracked in a DashMap keyed by session_id (which doubles as the OAuth state parameter — no separate correlation needed) - watch channel per session handles reconnection: if AwaitLogin drops and reconnects after the callback has already fired, it gets the JWT immediately - Background TTL sweeper clears sessions abandoned before the flow completes (30-minute TTL, 5-minute sweep interval) **JWT minting** - HMAC-SHA256, signed with JWT_SECRET from the environment - Claims: sub (ATProto DID), iat, exp (24-hour expiry) **ATProto loopback dev requirements** - client_id is http://localhost in dev (no port, no path) - redirect_uri is http://[::1]:{port} (IPv6 loopback, no path) — callback lands at / - build_atproto_metadata() branches on localhost in the host to apply these rules automatically ## New env var JWT_SECRET — secret used to sign session tokens. Documented in README with a generation example (openssl rand -base64 32). Already present in .env.example. ## Checklist - [x] User handle/identifier input (InitiateLogin) - [x] Handle OAuth2 callback (GET /) - [x] Session token generation (JWT minted from ATProto DID)
puregarlic 2026-03-03 15:27:05 -08:00
puregarlic added this to the Ludwig (MVP) milestone 2026-03-03 15:27:12 -08:00
seb approved these changes 2026-03-03 22:13:33 -08:00
seb left a comment
Collaborator

gud

gud
@ -0,0 +50,4 @@
}
impl AuthState {
#[instrument(name = "auth_state_new", fields(base_url = base_url.to_string()))]
Collaborator

I think URL implements Display so you might not need to to_string() it

I think URL implements Display so you might not need to to_string() it
Sign in to join this conversation.
No description provided.