···73737474 alias Atex.Config.OAuth, as: Config
75757676+ @session_keys_name :atex_sessions
7777+ @session_active_name :atex_active_session
7878+7979+ @doc """
8080+ Returns the composite session key (`"<did>:<nonce>"`) for the currently active
8181+ OAuth session on the given conn.
8282+8383+ This is the primary way to identify which session is active for a request. The
8484+ returned key can be passed directly to `Atex.OAuth.SessionStore.get/1` or used
8585+ to construct an `Atex.XRPC.OAuthClient`.
8686+8787+ ## Returns
8888+8989+ - `{:ok, session_key}` - The composite key for the active session
9090+ - `:error` - No active session found in the conn
9191+9292+ ## Examples
9393+9494+ case Atex.OAuth.current_session_key(conn) do
9595+ {:ok, key} -> {:ok, client} = Atex.XRPC.OAuthClient.new(key)
9696+ :error -> redirect_to_login(conn)
9797+ end
9898+9999+ """
100100+ @spec current_session_key(Plug.Conn.t()) :: {:ok, String.t()} | :error
101101+ def current_session_key(%Plug.Conn{} = conn) do
102102+ case Plug.Conn.get_session(conn, @session_active_name) do
103103+ key when is_binary(key) -> {:ok, key}
104104+ _ -> :error
105105+ end
106106+ end
107107+108108+ @doc """
109109+ Returns all composite session keys stored for this device's conn session.
110110+111111+ Each key corresponds to a distinct authenticated account on this device. The
112112+ list is ordered with the most recently logged-in account first.
113113+114114+ ## Examples
115115+116116+ keys = Atex.OAuth.list_session_keys(conn)
117117+ # => ["did:plc:abc:nonce1", "did:plc:xyz:nonce2"]
118118+119119+ """
120120+ @spec list_session_keys(Plug.Conn.t()) :: [String.t()]
121121+ def list_session_keys(%Plug.Conn{} = conn) do
122122+ Plug.Conn.get_session(conn, @session_keys_name) || []
123123+ end
124124+125125+ @doc """
126126+ Switches the active session to the given composite session key.
127127+128128+ Validates that the key is present in the conn's session list and that the
129129+ corresponding session still exists in the store before updating the conn.
130130+131131+ ## Returns
132132+133133+ - `{:ok, conn}` - Active session switched; the returned conn has the updated
134134+ session and should be used for subsequent operations
135135+ - `{:error, :not_found}` - The key is not in the session list or the session
136136+ no longer exists in the store
137137+138138+ ## Examples
139139+140140+ case Atex.OAuth.switch_session(conn, "did:plc:xyz:nonce2") do
141141+ {:ok, conn} -> send_resp(conn, 200, "Switched accounts")
142142+ {:error, :not_found} -> send_resp(conn, 404, "Session not found")
143143+ end
144144+145145+ """
146146+ @spec switch_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found}
147147+ def switch_session(%Plug.Conn{} = conn, session_key) when is_binary(session_key) do
148148+ stored_keys = list_session_keys(conn)
149149+150150+ with true <- session_key in stored_keys,
151151+ {:ok, _session} <- Atex.OAuth.SessionStore.get(session_key) do
152152+ {:ok, Plug.Conn.put_session(conn, @session_active_name, session_key)}
153153+ else
154154+ _ -> {:error, :not_found}
155155+ end
156156+ end
157157+76158 @doc """
77159 Get a map containing the client metadata information needed for an
78160 authorization server to validate this client.
+23-9
lib/atex/oauth/plug.ex
···83838484 ## Session Storage
85858686- After successful authentication, the plug stores these in the session:
8686+ After successful authentication, the plug stores the following in
8787+ `conn.session`:
87888888- - `:tokens` - The access token response containing access_token,
8989- refresh_token, did, and expires_at
9090- - `:dpop_nonce` -
9191- - `:dpop_key` - The DPoP JWK for generating DPoP proofs
8989+ - `:atex_sessions` - A list of composite session keys
9090+ (`"<did>:<nonce>"`) for all accounts logged in on this device.
9191+ - `:atex_active_session` - The composite session key of the currently
9292+ active account. Use `Atex.OAuth.current_session_key/1` to read this,
9393+ and `Atex.OAuth.switch_session/2` to change it.
9494+9595+ The full session credentials (tokens, DPoP key, etc.) are stored in
9696+ `Atex.OAuth.SessionStore` and looked up by the composite key.
9297 """
9398 require Logger
9499 use Plug.Router
···96101 alias Atex.{DID, IdentityResolver, OAuth}
9710298103 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
9999- @session_name :atex_session
104104+ @session_keys_name :atex_sessions
105105+ @session_active_name :atex_active_session
100106101107 def init(opts) do
102108 callback = Keyword.get(opts, :callback, nil)
···186192187193 with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
188194 dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
189189- {:ok, tokens, nonce} <-
195195+ {:ok, tokens, dpop_nonce} <-
190196 OAuth.validate_authorization_code(
191197 authz_metadata,
192198 dpop_key,
···198204 pds <- DID.Document.get_pds_endpoint(identity.document),
199205 {:ok, authz_server} <- OAuth.get_authorization_server(pds),
200206 true <- authz_server == stored_issuer do
207207+ device_nonce = OAuth.create_nonce()
208208+201209 session = %OAuth.Session{
202210 iss: authz_server,
203211 aud: pds,
204212 sub: tokens.did,
213213+ nonce: device_nonce,
205214 access_token: tokens.access_token,
206215 refresh_token: tokens.refresh_token,
207216 expires_at: tokens.expires_at,
208217 dpop_key: dpop_key,
209209- dpop_nonce: nonce
218218+ dpop_nonce: dpop_nonce
210219 }
211220221221+ session_key = OAuth.SessionStore.session_key(session)
222222+212223 case OAuth.SessionStore.insert(session) do
213224 :ok ->
225225+ existing_keys = get_session(conn, @session_keys_name) || []
226226+214227 conn =
215228 conn
216229 |> delete_resp_cookie("state", @oauth_cookie_opts)
217230 |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
218231 |> delete_resp_cookie("issuer", @oauth_cookie_opts)
219219- |> put_session(@session_name, tokens.did)
232232+ |> put_session(@session_keys_name, [session_key | existing_keys])
233233+ |> put_session(@session_active_name, session_key)
220234221235 {mod, func, args} = callback
222236 apply(mod, func, [conn | args])
+7-1
lib/atex/oauth/session.ex
···991010 - `:iss` - Authorization server issuer URL
1111 - `:aud` - PDS endpoint URL (audience)
1212- - `:sub` - User's DID (subject), used as the session key
1212+ - `:sub` - User's DID (subject)
1313+ - `:nonce` - Per-device, per-account random nonce generated at login time.
1414+ Combined with `:sub` to form the session store key (`"<sub>:<nonce>"`),
1515+ enabling per-device session isolation and granular revocation.
1316 - `:access_token` - OAuth access token for authenticating requests
1417 - `:refresh_token` - OAuth refresh token for obtaining new access tokens
1518 - `:expires_at` - When the current access token expires (NaiveDateTime in UTC)
···2528 iss: "https://bsky.social",
2629 aud: "https://puffball.us-east.host.bsky.network",
2730 sub: "did:plc:abc123",
3131+ nonce: "random-device-nonce",
2832 access_token: "...",
2933 refresh_token: "...",
3034 expires_at: ~N[2026-01-04 12:00:00],
···4145 field :aud, String.t()
4246 # User's DID
4347 field :sub, String.t()
4848+ # Per-account & per-device nonce
4949+ field :nonce, String.t()
4450 field :access_token, String.t()
4551 field :refresh_token, String.t()
4652 field :expires_at, NaiveDateTime.t()
+34-14
lib/atex/oauth/session_store.ex
···22222323 ## Usage
24242525- Sessions are keyed by the user's DID (`sub` field).
2525+ Sessions are keyed by a composite `"<sub>:<nonce>"` string, where `sub` is
2626+ the user's DID and `nonce` is the per-device, per-account random value stored
2727+ on the session. This allows multiple independent sessions for the same user
2828+ (e.g. different devices or accounts) to coexist in the store.
26292730 session = %Atex.OAuth.Session{
2831 iss: "https://bsky.social",
2932 aud: "https://puffball.us-east.host.bsky.network",
3033 sub: "did:plc:abc123",
3434+ nonce: "random-device-nonce",
3135 access_token: "...",
3236 refresh_token: "...",
3337 expires_at: ~N[2026-01-04 12:00:00],
···3842 # Insert a new session
3943 :ok = Atex.OAuth.SessionStore.insert(session)
40444141- # Retrieve a session
4242- {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123")
4545+ # Retrieve a session by composite key
4646+ {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:random-device-nonce")
43474448 # Update an existing session (e.g., after token refresh)
4549 updated_session = %{session | access_token: new_token}
···5256 @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS)
53575458 @doc """
5555- Retrieve a session by DID.
5959+ Retrieve a session by composite key (`"<sub>:<nonce>"`).
56605761 Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
5862 """
···6165 @doc """
6266 Insert a new session.
63676464- The key is the user's DID (`session.sub`). Returns `:ok` on success.
6868+ The key is derived from the session's `sub` and `nonce` fields. Returns `:ok` on success.
6569 """
6670 @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) ::
6771 :ok | {:error, atom()}
···7781 @doc """
7882 Delete a session.
79838080- Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed.
8484+ Returns `:ok` if deleted, `:noop` if the session didn't exist, `:error` if it failed.
8185 """
8286 @callback delete(key :: String.t()) :: :ok | :error | :noop
8387···8690 defdelegate child_spec(opts), to: @store
87918892 @doc """
8989- Retrieve a session by DID.
9393+ Retrieve a session by composite key (`"<sub>:<nonce>"`).
9094 """
9195 @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()}
9296 def get(key) do
···9498 end
959996100 @doc """
9797- Insert a new session.
101101+ Insert a new session. The store key is derived from `session.sub` and `session.nonce`.
98102 """
99103 @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
100104 def insert(session) do
101101- @store.insert(session.sub, session)
105105+ @store.insert(session_key(session), session)
102106 end
103107104108 @doc """
105105- Update an existing session.
109109+ Update an existing session. The store key is derived from `session.sub` and `session.nonce`.
106110 """
107111 @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
108112 def update(session) do
109109- @store.update(session.sub, session)
113113+ @store.update(session_key(session), session)
110114 end
111115112116 @doc """
113113- Delete a session.
117117+ Delete a session. The store key is derived from `session.sub` and `session.nonce`.
114118 """
115115- @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop
119119+ @spec delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop
116120 def delete(session) do
117117- @store.delete(session.sub)
121121+ @store.delete(session_key(session))
118122 end
123123+124124+ @doc """
125125+ Build the composite store key for a session.
126126+127127+ The key is `"<sub>:<nonce>"`, combining the user's DID with the per-device
128128+ nonce to allow multiple independent sessions for the same user.
129129+130130+ ## Examples
131131+132132+ iex> session = %Atex.OAuth.Session{sub: "did:plc:abc123", nonce: "xyz", ...}
133133+ iex> Atex.OAuth.SessionStore.session_key(session)
134134+ "did:plc:abc123:xyz"
135135+136136+ """
137137+ @spec session_key(Atex.OAuth.Session.t()) :: String.t()
138138+ def session_key(%Atex.OAuth.Session{sub: sub, nonce: nonce}), do: "#{sub}:#{nonce}"
119139end
+64-43
lib/atex/xrpc/oauth_client.ex
···22 @moduledoc """
33 OAuth client for making authenticated XRPC requests to AT Protocol servers.
4455- The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to
66- retrieve sessions internally to make requests. As a result, it will only work
77- for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an
88- existing method of doing that.
55+ The client holds a composite session key (`"<did>:<nonce>"`) and talks to
66+ `Atex.OAuth.SessionStore` to retrieve sessions internally to make requests.
77+ It only works for users that have completed an OAuth flow; see
88+ `Atex.OAuth.Plug` for an existing method of doing that.
991010 The entire OAuth session lifecycle is handled transparently, with the access
1111 token being refreshed automatically as required.
12121313 ## Usage
14141515- # Create from an existing OAuth session
1616- {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123")
1515+ # Create from an existing composite session key
1616+ {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123:device-nonce")
17171818 # Or extract from a Plug.Conn after OAuth flow
1919 {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
20202121+ # Retrieve just the DID from a client
2222+ "did:plc:abc123" = Atex.XRPC.OAuthClient.did(client)
2323+2124 # Make XRPC requests
2225 {:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords")
2326 """
···2831 @behaviour Atex.XRPC.Client
29323033 typedstruct enforce: true do
3131- field :did, String.t()
3434+ field :session_key, String.t()
3235 end
33363437 @doc """
3535- Create a new OAuthClient from a DID.
3838+ Returns the DID portion of the client's composite session key.
3939+4040+ The session key has the form `"<did>:<nonce>"`. This function extracts and
4141+ returns everything up to the final `:` separator, which is the user's DID.
36423737- Validates that an OAuth session exists for the given DID in the session store
4343+ ## Examples
4444+4545+ iex> client = %Atex.XRPC.OAuthClient{session_key: "did:plc:abc123:mynonce"}
4646+ iex> Atex.XRPC.OAuthClient.did(client)
4747+ "did:plc:abc123"
4848+4949+ """
5050+ @spec did(t()) :: String.t()
5151+ def did(%__MODULE__{session_key: session_key}) do
5252+ session_key
5353+ |> String.split(":")
5454+ |> Enum.drop(-1)
5555+ |> Enum.join(":")
5656+ end
5757+5858+ @doc """
5959+ Create a new OAuthClient from a composite session key (`"<did>:<nonce>"`).
6060+6161+ Validates that an OAuth session exists for the given key in the session store
3862 before returning the client struct.
39634064 ## Examples
41654242- iex> Atex.XRPC.OAuthClient.new("did:plc:abc123")
4343- {:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}}
6666+ iex> Atex.XRPC.OAuthClient.new("did:plc:abc123:mynonce")
6767+ {:ok, %Atex.XRPC.OAuthClient{session_key: "did:plc:abc123:mynonce"}}
44684545- iex> Atex.XRPC.OAuthClient.new("did:plc:nosession")
6969+ iex> Atex.XRPC.OAuthClient.new("did:plc:nosession:nonce")
4670 {:error, :not_found}
47714872 """
4973 @spec new(String.t()) :: {:ok, t()} | {:error, atom()}
5050- def new(did) do
5151- # Make sure session exists before returning a struct
5252- case Atex.OAuth.SessionStore.get(did) do
7474+ def new(session_key) do
7575+ case Atex.OAuth.SessionStore.get(session_key) do
5376 {:ok, _session} ->
5454- {:ok, %__MODULE__{did: did}}
7777+ {:ok, %__MODULE__{session_key: session_key}}
55785679 err ->
5780 err
···6184 @doc """
6285 Create an OAuthClient from a `Plug.Conn`.
63866464- Extracts the DID from the session (stored under `:atex_session` key) and validates
6565- that the OAuth session is still valid. If the token is expired or expiring soon,
6666- it attempts to refresh it.
8787+ Reads the active session key from `conn.session` (stored under
8888+ `:atex_active_session`) and validates that the OAuth session is still valid.
8989+ If the token is expired or expiring soon, it attempts to refresh it.
67906868- Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`.
9191+ Requires the conn to have passed through `Plug.Session` and
9292+ `Plug.Conn.fetch_session/2`.
69937094 ## Returns
71957296 - `{:ok, client}` - Successfully created client
7373- - `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate
7474- - `:error` - No session found in conn
9797+ - `{:error, :reauth}` - Session exists but refresh failed; user needs to
9898+ re-authenticate
9999+ - `:error` - No active session found in conn
7510076101 ## Examples
7710278103 # After OAuth flow completes
7979- conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123")
80104 {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
8110582106 """
83107 @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()}
84108 def from_conn(%Plug.Conn{} = conn) do
8585- oauth_did = Plug.Conn.get_session(conn, :atex_session)
8686-8787- case oauth_did do
8888- did when is_binary(did) ->
8989- client = %__MODULE__{did: did}
109109+ case OAuth.current_session_key(conn) do
110110+ {:ok, session_key} ->
111111+ client = %__MODULE__{session_key: session_key}
9011291113 with_session_lock(client, fn ->
92114 case maybe_refresh(client) do
···95117 end
96118 end)
971199898- _ ->
120120+ :error ->
99121 :error
100122 end
101123 end
···119141 end
120142121143 @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
122122- defp do_refresh(%__MODULE__{did: did}) do
123123- with {:ok, session} <- OAuth.SessionStore.get(did),
144144+ defp do_refresh(%__MODULE__{session_key: session_key}) do
145145+ with {:ok, session} <- OAuth.SessionStore.get(session_key),
124146 {:ok, authz_server} <- OAuth.get_authorization_server(session.aud),
125147 {:ok, %{token_endpoint: token_endpoint}} <-
126148 OAuth.get_authorization_server_metadata(authz_server) do
···130152 session.iss,
131153 token_endpoint
132154 ) do
133133- {:ok, tokens, nonce} ->
155155+ {:ok, tokens, dpop_nonce} ->
134156 new_session = %OAuth.Session{
135157 iss: session.iss,
136158 aud: session.aud,
137159 sub: tokens.did,
160160+ nonce: session.nonce,
138161 access_token: tokens.access_token,
139162 refresh_token: tokens.refresh_token,
140163 expires_at: tokens.expires_at,
141164 dpop_key: session.dpop_key,
142142- dpop_nonce: nonce
165165+ dpop_nonce: dpop_nonce
143166 }
144167145168 case OAuth.SessionStore.update(new_session) do
···154177 end
155178156179 @spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()}
157157- defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do
158158- with {:ok, session} <- OAuth.SessionStore.get(did) do
180180+ defp maybe_refresh(%__MODULE__{session_key: session_key} = client, buffer_minutes \\ 5) do
181181+ with {:ok, session} <- OAuth.SessionStore.get(session_key) do
159182 if token_expiring_soon?(session.expires_at, buffer_minutes) do
160183 do_refresh(client)
161184 else
···179202 """
180203 @impl true
181204 def get(%__MODULE__{} = client, resource, opts \\ []) do
182182- # TODO: Keyword.valiate to make sure :method isn't passed?
183205 request(client, resource, opts ++ [method: :get])
184206 end
185207···190212 """
191213 @impl true
192214 def post(%__MODULE__{} = client, resource, opts \\ []) do
193193- # Ditto
194215 request(client, resource, opts ++ [method: :post])
195216 end
196217···232253 end
233254234255 # Execute a function with an exclusive lock on the session identified by the
235235- # client's DID. This ensures that concurrent requests for the same user don't
236236- # race during token refresh.
256256+ # composite session key. This ensures that concurrent requests for the same
257257+ # session don't race during token refresh.
237258 @spec with_session_lock(t(), (-> result)) :: result when result: any()
238238- defp with_session_lock(%__MODULE__{did: did}, fun) do
239239- Mutex.with_lock(Atex.SessionMutex, did, fun)
259259+ defp with_session_lock(%__MODULE__{session_key: session_key}, fun) do
260260+ Mutex.with_lock(Atex.SessionMutex, session_key, fun)
240261 end
241262242263 defp handle_failure(client, request, response) do
···256277257278 {:ok, response, _nonce} ->
258279 if auth_error?(response) do
259259- # We tried to refresh the token once but it's still failing
260260- # Clear session and prompt dev to reauth or something
280280+ # We tried to refresh the token once but it's still failing;
281281+ # clear the session and prompt re-authentication.
261282 OAuth.SessionStore.delete(session)
262283 {:error, response, :expired}
263284 else