Implement Log In with Atmosphere #9

Closed
opened 2026-02-11 10:25:59 -08:00 by puregarlic · 3 comments
Owner

Users will sign in using AT Protocol OAuth2, and get a signed session cookie from the server. The server owner DID will need to be specified in environment variables, so they can log in initially to manage the server.

  • User handle/identifier input
  • Handle OAuth2 callback
  • Session token generation
Users will sign in using AT Protocol OAuth2, and get a signed session cookie from the server. The server owner DID will need to be specified in environment variables, so they can log in initially to manage the server. - [ ] User handle/identifier input - [ ] Handle OAuth2 callback - [ ] Session token generation
puregarlic added this to the Ludwig (MVP) milestone 2026-02-26 10:27:11 -08:00
Author
Owner

This issue is important for getting individual user identities; currently we're randomly generating a user identity every time you join a channel. There's no consistency, so no way to, for example, tie user preferences to a specific user, or a way to gate access to the microclimate server.

AT Protocol OAuth is fairly complicated to implement from scratch, but fortunately there's a comprehensive crate as part of the Jacquard project. We'll be able to use that to keep the implementation on the simpler side.

My proposal for the authentication process looks like this:

  1. The client presents the user with a form to fill in their server URL (as it does currently) and also their Atproto identifier. We might consider using Jake Lazaroff's actor-typeahead web component, or something custom inspired by it.
  2. On form submission, the client sends the actor information to the specified server, which will resolve the identifier to check its validity and form an OAuth login link to return to the client. This would also be where the server checks if the user's DID is in the list of allowed users, but that's probably enough scope for a separate issue like #12.
  3. The server returns the callback URL to the client, as well as a login session identifier. This identifier is used by the client to listen to the server for the status of the login, via a streaming RPC.
  4. The client opens the OAuth login link in their default browser. I'm also open to evaluating a remote page window in Tauri, but I felt that the default browser would be a better choice as the user is likely already logged in there.
  5. The user authenticates and approves the login request in the OAuth portal. As we don't intend to use their PDS for anything at this time, there won't be any scopes for them to review.
  6. The OAuth portal redirects to a callback URL hosted by the server. The alternative to this would be to redirect to the client via deep links, but the problem there is that custom URL schemes for native apps must match the client_id's domain in reverse-NSID format. We cannot predict the URL that a user might host their server at, so we cannot guarantee a matching deep-link scheme on platforms that require registration of custom URL schemes at compile time. Hence, the polling process by the client.
  7. The server collects the necessary information from the callback URL, and completes the authorization process. It will discard the returned token, as recommended by the specification, and generate a bespoke JWT.
  8. The server returns this JWT to the client, either in the pending streaming request, or immediately to the client upon request--the disconnection case is important for mobile devices, which might switch applications to complete the OAuth process and be forced to disconnect temporarily.
  9. The client stores the JWT using Stronghold (or similar) for future requests requiring authentications, such as get_channels.

That's broadly what I'm thinking. We'll probably want a refresh mechanism as well, and a rolling keyset system for our JWKS, but that's possibly another issue as well.

This issue is important for getting individual user identities; currently we're randomly generating a user identity every time you join a channel. There's no consistency, so no way to, for example, tie user preferences to a specific user, or a way to gate access to the microclimate server. AT Protocol OAuth is fairly complicated to implement from scratch, but fortunately there's a [comprehensive crate as part of the Jacquard project](https://docs.rs/jacquard-oauth/0.9.6/jacquard_oauth/). We'll be able to use that to keep the implementation on the simpler side. My proposal for the authentication process looks like this: 1. The client presents the user with a form to fill in their server URL (as it does currently) and also their Atproto identifier. We might consider using [Jake Lazaroff's `actor-typeahead`](https://tangled.org/jakelazaroff.com/actor-typeahead) web component, or something custom inspired by it. 2. On form submission, the client sends the actor information to the specified server, which will resolve the identifier to check its validity and form an OAuth login link to return to the client. This would also be where the server checks if the user's DID is in the list of allowed users, but that's probably enough scope for a separate issue like #12. 3. The server returns the callback URL to the client, as well as a login session identifier. This identifier is used by the client to listen to the server for the status of the login, via a streaming RPC. 4. The client opens the OAuth login link in their default browser. I'm also open to evaluating a remote page window in Tauri, but I felt that the default browser would be a better choice as the user is likely already logged in there. 5. The user authenticates and approves the login request in the OAuth portal. As we don't intend to use their PDS for anything at this time, there won't be any scopes for them to review. 6. The OAuth portal redirects to a callback URL _hosted by the server_. The alternative to this would be to redirect to the client via deep links, but the problem there is that [custom URL schemes for native apps must match the `client_id`'s domain in reverse-NSID format](https://atproto.com/specs/oauth#authorization-requests). We cannot predict the URL that a user might host their server at, so we cannot guarantee a matching deep-link scheme on [platforms that require registration of custom URL schemes at compile time](https://v2.tauri.app/plugin/deep-linking/). Hence, the polling process by the client. 7. The server collects the necessary information from the callback URL, and completes the authorization process. It will discard the returned token, [as recommended by the specification](https://atproto.com/specs/oauth#authorization-scopes), and generate a bespoke JWT. 8. The server returns this JWT to the client, either in the pending streaming request, or immediately to the client upon request--the disconnection case is important for mobile devices, which might switch applications to complete the OAuth process and be forced to disconnect temporarily. 9. The client stores the JWT using Stronghold (or similar) for future requests requiring authentications, such as `get_channels`. That's broadly what I'm thinking. We'll probably want a refresh mechanism as well, and a rolling keyset system for our JWKS, but that's possibly another issue as well.
Contributor

The overall design looks solid. A few things worth considering before implementation:

DID validation at callback time. In step 7, the server should verify that the DID returned by the OAuth callback matches the identifier the user provided in step 1. Without this check, a user could supply one handle in the form but complete the OAuth flow as a different account.

Pending session TTL. The server accumulates pending login sessions until they resolve. Worth building in an expiry (10 minutes seems reasonable) so stale sessions from abandoned flows get cleaned up. Especially relevant for the mobile disconnection case — if the client never reconnects, the session would otherwise sit indefinitely.

Stream timeout vs. session reuse. Related to the above: if the client's polling stream times out before the user completes the OAuth flow in the browser, what happens when the user does eventually approve it? If the session ID is still valid server-side, the client should be able to re-open the stream and receive the JWT. Worth making sure the session lifecycle is explicit about when it expires vs. when the stream does.

JWKS complexity. Since the server both issues and validates these JWTs, a rolling JWKS endpoint may be more infrastructure than the MVP needs. A stable asymmetric key pair (or a rotating HMAC secret) is simpler and sufficient as long as third parties never need to validate our tokens independently. The rolling keyset is worth revisiting if that changes.

Proto additions. This will need at least two new RPCs — something like InitiateLogin(identifier) -> { oauth_url, session_id } and either a server-streaming PollLoginStatus(session_id) -> { status, jwt } or a unary GetLoginResult(session_id) -> { jwt } for the reconnect case. Worth sketching those out early since they'll shape both the server implementation and the client flow.

The overall design looks solid. A few things worth considering before implementation: **DID validation at callback time.** In step 7, the server should verify that the DID returned by the OAuth callback matches the identifier the user provided in step 1. Without this check, a user could supply one handle in the form but complete the OAuth flow as a different account. **Pending session TTL.** The server accumulates pending login sessions until they resolve. Worth building in an expiry (10 minutes seems reasonable) so stale sessions from abandoned flows get cleaned up. Especially relevant for the mobile disconnection case — if the client never reconnects, the session would otherwise sit indefinitely. **Stream timeout vs. session reuse.** Related to the above: if the client's polling stream times out before the user completes the OAuth flow in the browser, what happens when the user does eventually approve it? If the session ID is still valid server-side, the client should be able to re-open the stream and receive the JWT. Worth making sure the session lifecycle is explicit about when it expires vs. when the stream does. **JWKS complexity.** Since the server both issues and validates these JWTs, a rolling JWKS endpoint may be more infrastructure than the MVP needs. A stable asymmetric key pair (or a rotating HMAC secret) is simpler and sufficient as long as third parties never need to validate our tokens independently. The rolling keyset is worth revisiting if that changes. **Proto additions.** This will need at least two new RPCs — something like InitiateLogin(identifier) -> { oauth_url, session_id } and either a server-streaming PollLoginStatus(session_id) -> { status, jwt } or a unary GetLoginResult(session_id) -> { jwt } for the reconnect case. Worth sketching those out early since they'll shape both the server implementation and the client flow.
Contributor

Implementation note: once the server interceptor is validating the JWT on ChannelService calls, GetChannelTokenRequest.username becomes redundant — the server should extract the user's identity from the validated token rather than trusting the client to supply it. This should be cleaned up as part of the PR for this issue.

Implementation note: once the server interceptor is validating the JWT on ChannelService calls, GetChannelTokenRequest.username becomes redundant — the server should extract the user's identity from the validated token rather than trusting the client to supply it. This should be cleaned up as part of the PR for this issue.
Sign in to join this conversation.
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Blocks
#12 Implement user invitations
puregarlic/microclimate
#14 Add user roles
puregarlic/microclimate
Reference
puregarlic/microclimate#9
No description provided.