Configure OAuth sessions

idiolect-oauth provides the token-store trait and three shipped implementations. The crate does not implement the OAuth dance itself: that lives in atrium-oauth-client. The crate also does not ship a refresh_if_needed helper or a CLI login command; consumers drive the dance and the refresh decision in their own code, using OAuthSession's helpers (is_expired, needs_refresh, refresh_expired) for timing.

When you need it

Anything that publishes records (encounter, recommendation, verification, observation, lens, dialect, vocab, ...) needs an authenticated PDS session. Reading records does not.

Pick a store

StoreFeatureUse when
InMemoryOAuthTokenStore(always)Tests and fixtures.
FilesystemOAuthTokenStorestore-filesystemA single operator process running on one host. Sessions live under a directory; one file per DID.
SqliteOAuthTokenStorestore-sqliteMulti-process or multi-tenant deployments. Concurrent reads, fsync per write.

All three implement OAuthTokenStore. Anything that takes Arc<dyn OAuthTokenStore> accepts any of them.

idiolect-oauth is publish = false; depend via git.

Filesystem store

idiolect-oauth = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["store-filesystem"] }
#![allow(unused)]
fn main() {
use idiolect_oauth::{FilesystemOAuthTokenStore, OAuthTokenStore};

let store = FilesystemOAuthTokenStore::open("./sessions/")?;

// Write a session (returned by the OAuth dance, not by this crate):
store.save(&session).await?;

// Read it back later:
let recovered = store.load(&session.did).await?;
}

The directory contains one JSON file per session keyed by DID.

SQLite store

idiolect-oauth = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["store-sqlite"] }
#![allow(unused)]
fn main() {
use idiolect_oauth::{SqliteOAuthTokenStore, OAuthTokenStore};

let store = SqliteOAuthTokenStore::open("sessions.sqlite").await?;
}

Drive the OAuth dance

The dance itself is atrium-oauth-client's job; the crate returns an authenticated session you store via OAuthTokenStore::save. The session shape (OAuthSession) is documented in the crate's source: it carries the DID, PDS URL, access JWT, refresh JWT, DPoP private key (JWK-serialized), DPoP nonce, and expiry timestamps as public fields.

For session-staleness decisions, read OAuthSession::is_expired and OAuthSession::needs_refresh(now, threshold) in your own refresh path; the crate does not ship a refresh_if_needed helper, and the application decides how to drive the refresh endpoint.

DPoP

The session's DPoP keypair is what makes the access token bound. The signer (the P256DpopProver in idiolect-lens under the dpop-p256 feature) consumes the keypair from the session and signs every PDS write through SigningPdsWriter.

Persisting the DPoP key with the session is the store's job. Both shipped persistent stores (FilesystemOAuthTokenStore, SqliteOAuthTokenStore) do; if you write a custom store, do the same.

idiolect oauth login (transitional)

The idiolect CLI ships an oauth login subcommand that exchanges a handle + app password for an access JWT via com.atproto.server.createSession and persists the resulting session as a JSON file under $IDIOLECT_SESSION_DIR (default ~/.config/idiolect/sessions/):

idiolect oauth login --handle yourhandle.bsky.social --pds-url https://bsky.social
# password from --app-password or ATPROTO_APP_PASSWORD / ATPROTO_PASSWORD env
idiolect oauth list
idiolect oauth logout --did did:plc:...

This path uses app passwords in legacy Bearer mode. ATProto is moving off app passwords in favour of OAuth + DPoP; the next-iteration login UX wraps atrium-oauth's browser-handoff dance and persists the resulting DPoP-bound session through the same OAuthTokenStore. The OAuthSession shape already carries the DPoP private key field; switching flows is a CLI substitution, not a session-shape change.

refresh_if_needed

idiolect_oauth::refresh_if_needed(&store, &refresher, did) loads a session, decides whether to refresh based on the current wall clock plus a 60-second buffer, drives the caller- supplied Refresher::refresh if so, persists the result, and returns the live session. Callers who want to drive the decision themselves read OAuthSession::needs_refresh and OAuthSession::is_expired directly.

#![allow(unused)]
fn main() {
use idiolect_oauth::{refresh_if_needed, Refresher, RefreshError, OAuthSession};

struct MyRefresher { /* http client, auth-server URL, ... */ }

impl Refresher for MyRefresher {
    async fn refresh(&self, session: &OAuthSession) -> Result<OAuthSession, RefreshError> {
        // POST refresh_token to the auth-server's token_endpoint.
        // Return a fresh OAuthSession with new access_jwt / expires_at.
        todo!()
    }
}

let fresh = refresh_if_needed(&store, &MyRefresher { /* ... */ }, "did:plc:...").await?;
}

The trait is narrow on purpose: the refresh HTTP call lives in whatever OAuth client the application uses (atrium-oauth, a hand-rolled reqwest call, an in-memory fake for tests). idiolect-oauth owns the storage and timing decision around it.