An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: better oauth sessions

- Add nonce to have separate session per-device
- Add support for multiple account sessions per-device

+210 -67
+82
lib/atex/oauth.ex
··· 73 73 74 74 alias Atex.Config.OAuth, as: Config 75 75 76 + @session_keys_name :atex_sessions 77 + @session_active_name :atex_active_session 78 + 79 + @doc """ 80 + Returns the composite session key (`"<did>:<nonce>"`) for the currently active 81 + OAuth session on the given conn. 82 + 83 + This is the primary way to identify which session is active for a request. The 84 + returned key can be passed directly to `Atex.OAuth.SessionStore.get/1` or used 85 + to construct an `Atex.XRPC.OAuthClient`. 86 + 87 + ## Returns 88 + 89 + - `{:ok, session_key}` - The composite key for the active session 90 + - `:error` - No active session found in the conn 91 + 92 + ## Examples 93 + 94 + case Atex.OAuth.current_session_key(conn) do 95 + {:ok, key} -> {:ok, client} = Atex.XRPC.OAuthClient.new(key) 96 + :error -> redirect_to_login(conn) 97 + end 98 + 99 + """ 100 + @spec current_session_key(Plug.Conn.t()) :: {:ok, String.t()} | :error 101 + def current_session_key(%Plug.Conn{} = conn) do 102 + case Plug.Conn.get_session(conn, @session_active_name) do 103 + key when is_binary(key) -> {:ok, key} 104 + _ -> :error 105 + end 106 + end 107 + 108 + @doc """ 109 + Returns all composite session keys stored for this device's conn session. 110 + 111 + Each key corresponds to a distinct authenticated account on this device. The 112 + list is ordered with the most recently logged-in account first. 113 + 114 + ## Examples 115 + 116 + keys = Atex.OAuth.list_session_keys(conn) 117 + # => ["did:plc:abc:nonce1", "did:plc:xyz:nonce2"] 118 + 119 + """ 120 + @spec list_session_keys(Plug.Conn.t()) :: [String.t()] 121 + def list_session_keys(%Plug.Conn{} = conn) do 122 + Plug.Conn.get_session(conn, @session_keys_name) || [] 123 + end 124 + 125 + @doc """ 126 + Switches the active session to the given composite session key. 127 + 128 + Validates that the key is present in the conn's session list and that the 129 + corresponding session still exists in the store before updating the conn. 130 + 131 + ## Returns 132 + 133 + - `{:ok, conn}` - Active session switched; the returned conn has the updated 134 + session and should be used for subsequent operations 135 + - `{:error, :not_found}` - The key is not in the session list or the session 136 + no longer exists in the store 137 + 138 + ## Examples 139 + 140 + case Atex.OAuth.switch_session(conn, "did:plc:xyz:nonce2") do 141 + {:ok, conn} -> send_resp(conn, 200, "Switched accounts") 142 + {:error, :not_found} -> send_resp(conn, 404, "Session not found") 143 + end 144 + 145 + """ 146 + @spec switch_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found} 147 + def switch_session(%Plug.Conn{} = conn, session_key) when is_binary(session_key) do 148 + stored_keys = list_session_keys(conn) 149 + 150 + with true <- session_key in stored_keys, 151 + {:ok, _session} <- Atex.OAuth.SessionStore.get(session_key) do 152 + {:ok, Plug.Conn.put_session(conn, @session_active_name, session_key)} 153 + else 154 + _ -> {:error, :not_found} 155 + end 156 + end 157 + 76 158 @doc """ 77 159 Get a map containing the client metadata information needed for an 78 160 authorization server to validate this client.
+23 -9
lib/atex/oauth/plug.ex
··· 83 83 84 84 ## Session Storage 85 85 86 - After successful authentication, the plug stores these in the session: 86 + After successful authentication, the plug stores the following in 87 + `conn.session`: 87 88 88 - - `:tokens` - The access token response containing access_token, 89 - refresh_token, did, and expires_at 90 - - `:dpop_nonce` - 91 - - `:dpop_key` - The DPoP JWK for generating DPoP proofs 89 + - `:atex_sessions` - A list of composite session keys 90 + (`"<did>:<nonce>"`) for all accounts logged in on this device. 91 + - `:atex_active_session` - The composite session key of the currently 92 + active account. Use `Atex.OAuth.current_session_key/1` to read this, 93 + and `Atex.OAuth.switch_session/2` to change it. 94 + 95 + The full session credentials (tokens, DPoP key, etc.) are stored in 96 + `Atex.OAuth.SessionStore` and looked up by the composite key. 92 97 """ 93 98 require Logger 94 99 use Plug.Router ··· 96 101 alias Atex.{DID, IdentityResolver, OAuth} 97 102 98 103 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 99 - @session_name :atex_session 104 + @session_keys_name :atex_sessions 105 + @session_active_name :atex_active_session 100 106 101 107 def init(opts) do 102 108 callback = Keyword.get(opts, :callback, nil) ··· 186 192 187 193 with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer), 188 194 dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}), 189 - {:ok, tokens, nonce} <- 195 + {:ok, tokens, dpop_nonce} <- 190 196 OAuth.validate_authorization_code( 191 197 authz_metadata, 192 198 dpop_key, ··· 198 204 pds <- DID.Document.get_pds_endpoint(identity.document), 199 205 {:ok, authz_server} <- OAuth.get_authorization_server(pds), 200 206 true <- authz_server == stored_issuer do 207 + device_nonce = OAuth.create_nonce() 208 + 201 209 session = %OAuth.Session{ 202 210 iss: authz_server, 203 211 aud: pds, 204 212 sub: tokens.did, 213 + nonce: device_nonce, 205 214 access_token: tokens.access_token, 206 215 refresh_token: tokens.refresh_token, 207 216 expires_at: tokens.expires_at, 208 217 dpop_key: dpop_key, 209 - dpop_nonce: nonce 218 + dpop_nonce: dpop_nonce 210 219 } 211 220 221 + session_key = OAuth.SessionStore.session_key(session) 222 + 212 223 case OAuth.SessionStore.insert(session) do 213 224 :ok -> 225 + existing_keys = get_session(conn, @session_keys_name) || [] 226 + 214 227 conn = 215 228 conn 216 229 |> delete_resp_cookie("state", @oauth_cookie_opts) 217 230 |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 218 231 |> delete_resp_cookie("issuer", @oauth_cookie_opts) 219 - |> put_session(@session_name, tokens.did) 232 + |> put_session(@session_keys_name, [session_key | existing_keys]) 233 + |> put_session(@session_active_name, session_key) 220 234 221 235 {mod, func, args} = callback 222 236 apply(mod, func, [conn | args])
+7 -1
lib/atex/oauth/session.ex
··· 9 9 10 10 - `:iss` - Authorization server issuer URL 11 11 - `:aud` - PDS endpoint URL (audience) 12 - - `:sub` - User's DID (subject), used as the session key 12 + - `:sub` - User's DID (subject) 13 + - `:nonce` - Per-device, per-account random nonce generated at login time. 14 + Combined with `:sub` to form the session store key (`"<sub>:<nonce>"`), 15 + enabling per-device session isolation and granular revocation. 13 16 - `:access_token` - OAuth access token for authenticating requests 14 17 - `:refresh_token` - OAuth refresh token for obtaining new access tokens 15 18 - `:expires_at` - When the current access token expires (NaiveDateTime in UTC) ··· 25 28 iss: "https://bsky.social", 26 29 aud: "https://puffball.us-east.host.bsky.network", 27 30 sub: "did:plc:abc123", 31 + nonce: "random-device-nonce", 28 32 access_token: "...", 29 33 refresh_token: "...", 30 34 expires_at: ~N[2026-01-04 12:00:00], ··· 41 45 field :aud, String.t() 42 46 # User's DID 43 47 field :sub, String.t() 48 + # Per-account & per-device nonce 49 + field :nonce, String.t() 44 50 field :access_token, String.t() 45 51 field :refresh_token, String.t() 46 52 field :expires_at, NaiveDateTime.t()
+34 -14
lib/atex/oauth/session_store.ex
··· 22 22 23 23 ## Usage 24 24 25 - Sessions are keyed by the user's DID (`sub` field). 25 + Sessions are keyed by a composite `"<sub>:<nonce>"` string, where `sub` is 26 + the user's DID and `nonce` is the per-device, per-account random value stored 27 + on the session. This allows multiple independent sessions for the same user 28 + (e.g. different devices or accounts) to coexist in the store. 26 29 27 30 session = %Atex.OAuth.Session{ 28 31 iss: "https://bsky.social", 29 32 aud: "https://puffball.us-east.host.bsky.network", 30 33 sub: "did:plc:abc123", 34 + nonce: "random-device-nonce", 31 35 access_token: "...", 32 36 refresh_token: "...", 33 37 expires_at: ~N[2026-01-04 12:00:00], ··· 38 42 # Insert a new session 39 43 :ok = Atex.OAuth.SessionStore.insert(session) 40 44 41 - # Retrieve a session 42 - {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123") 45 + # Retrieve a session by composite key 46 + {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:random-device-nonce") 43 47 44 48 # Update an existing session (e.g., after token refresh) 45 49 updated_session = %{session | access_token: new_token} ··· 52 56 @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS) 53 57 54 58 @doc """ 55 - Retrieve a session by DID. 59 + Retrieve a session by composite key (`"<sub>:<nonce>"`). 56 60 57 61 Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 58 62 """ ··· 61 65 @doc """ 62 66 Insert a new session. 63 67 64 - The key is the user's DID (`session.sub`). Returns `:ok` on success. 68 + The key is derived from the session's `sub` and `nonce` fields. Returns `:ok` on success. 65 69 """ 66 70 @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) :: 67 71 :ok | {:error, atom()} ··· 77 81 @doc """ 78 82 Delete a session. 79 83 80 - Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed. 84 + Returns `:ok` if deleted, `:noop` if the session didn't exist, `:error` if it failed. 81 85 """ 82 86 @callback delete(key :: String.t()) :: :ok | :error | :noop 83 87 ··· 86 90 defdelegate child_spec(opts), to: @store 87 91 88 92 @doc """ 89 - Retrieve a session by DID. 93 + Retrieve a session by composite key (`"<sub>:<nonce>"`). 90 94 """ 91 95 @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()} 92 96 def get(key) do ··· 94 98 end 95 99 96 100 @doc """ 97 - Insert a new session. 101 + Insert a new session. The store key is derived from `session.sub` and `session.nonce`. 98 102 """ 99 103 @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 100 104 def insert(session) do 101 - @store.insert(session.sub, session) 105 + @store.insert(session_key(session), session) 102 106 end 103 107 104 108 @doc """ 105 - Update an existing session. 109 + Update an existing session. The store key is derived from `session.sub` and `session.nonce`. 106 110 """ 107 111 @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 108 112 def update(session) do 109 - @store.update(session.sub, session) 113 + @store.update(session_key(session), session) 110 114 end 111 115 112 116 @doc """ 113 - Delete a session. 117 + Delete a session. The store key is derived from `session.sub` and `session.nonce`. 114 118 """ 115 - @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop 119 + @spec delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop 116 120 def delete(session) do 117 - @store.delete(session.sub) 121 + @store.delete(session_key(session)) 118 122 end 123 + 124 + @doc """ 125 + Build the composite store key for a session. 126 + 127 + The key is `"<sub>:<nonce>"`, combining the user's DID with the per-device 128 + nonce to allow multiple independent sessions for the same user. 129 + 130 + ## Examples 131 + 132 + iex> session = %Atex.OAuth.Session{sub: "did:plc:abc123", nonce: "xyz", ...} 133 + iex> Atex.OAuth.SessionStore.session_key(session) 134 + "did:plc:abc123:xyz" 135 + 136 + """ 137 + @spec session_key(Atex.OAuth.Session.t()) :: String.t() 138 + def session_key(%Atex.OAuth.Session{sub: sub, nonce: nonce}), do: "#{sub}:#{nonce}" 119 139 end
+64 -43
lib/atex/xrpc/oauth_client.ex
··· 2 2 @moduledoc """ 3 3 OAuth client for making authenticated XRPC requests to AT Protocol servers. 4 4 5 - The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to 6 - retrieve sessions internally to make requests. As a result, it will only work 7 - for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an 8 - existing method of doing that. 5 + The client holds a composite session key (`"<did>:<nonce>"`) and talks to 6 + `Atex.OAuth.SessionStore` to retrieve sessions internally to make requests. 7 + It only works for users that have completed an OAuth flow; see 8 + `Atex.OAuth.Plug` for an existing method of doing that. 9 9 10 10 The entire OAuth session lifecycle is handled transparently, with the access 11 11 token being refreshed automatically as required. 12 12 13 13 ## Usage 14 14 15 - # Create from an existing OAuth session 16 - {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123") 15 + # Create from an existing composite session key 16 + {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123:device-nonce") 17 17 18 18 # Or extract from a Plug.Conn after OAuth flow 19 19 {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 20 20 21 + # Retrieve just the DID from a client 22 + "did:plc:abc123" = Atex.XRPC.OAuthClient.did(client) 23 + 21 24 # Make XRPC requests 22 25 {:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords") 23 26 """ ··· 28 31 @behaviour Atex.XRPC.Client 29 32 30 33 typedstruct enforce: true do 31 - field :did, String.t() 34 + field :session_key, String.t() 32 35 end 33 36 34 37 @doc """ 35 - Create a new OAuthClient from a DID. 38 + Returns the DID portion of the client's composite session key. 39 + 40 + The session key has the form `"<did>:<nonce>"`. This function extracts and 41 + returns everything up to the final `:` separator, which is the user's DID. 36 42 37 - Validates that an OAuth session exists for the given DID in the session store 43 + ## Examples 44 + 45 + iex> client = %Atex.XRPC.OAuthClient{session_key: "did:plc:abc123:mynonce"} 46 + iex> Atex.XRPC.OAuthClient.did(client) 47 + "did:plc:abc123" 48 + 49 + """ 50 + @spec did(t()) :: String.t() 51 + def did(%__MODULE__{session_key: session_key}) do 52 + session_key 53 + |> String.split(":") 54 + |> Enum.drop(-1) 55 + |> Enum.join(":") 56 + end 57 + 58 + @doc """ 59 + Create a new OAuthClient from a composite session key (`"<did>:<nonce>"`). 60 + 61 + Validates that an OAuth session exists for the given key in the session store 38 62 before returning the client struct. 39 63 40 64 ## Examples 41 65 42 - iex> Atex.XRPC.OAuthClient.new("did:plc:abc123") 43 - {:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}} 66 + iex> Atex.XRPC.OAuthClient.new("did:plc:abc123:mynonce") 67 + {:ok, %Atex.XRPC.OAuthClient{session_key: "did:plc:abc123:mynonce"}} 44 68 45 - iex> Atex.XRPC.OAuthClient.new("did:plc:nosession") 69 + iex> Atex.XRPC.OAuthClient.new("did:plc:nosession:nonce") 46 70 {:error, :not_found} 47 71 48 72 """ 49 73 @spec new(String.t()) :: {:ok, t()} | {:error, atom()} 50 - def new(did) do 51 - # Make sure session exists before returning a struct 52 - case Atex.OAuth.SessionStore.get(did) do 74 + def new(session_key) do 75 + case Atex.OAuth.SessionStore.get(session_key) do 53 76 {:ok, _session} -> 54 - {:ok, %__MODULE__{did: did}} 77 + {:ok, %__MODULE__{session_key: session_key}} 55 78 56 79 err -> 57 80 err ··· 61 84 @doc """ 62 85 Create an OAuthClient from a `Plug.Conn`. 63 86 64 - Extracts the DID from the session (stored under `:atex_session` key) and validates 65 - that the OAuth session is still valid. If the token is expired or expiring soon, 66 - it attempts to refresh it. 87 + Reads the active session key from `conn.session` (stored under 88 + `:atex_active_session`) and validates that the OAuth session is still valid. 89 + If the token is expired or expiring soon, it attempts to refresh it. 67 90 68 - Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`. 91 + Requires the conn to have passed through `Plug.Session` and 92 + `Plug.Conn.fetch_session/2`. 69 93 70 94 ## Returns 71 95 72 96 - `{:ok, client}` - Successfully created client 73 - - `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate 74 - - `:error` - No session found in conn 97 + - `{:error, :reauth}` - Session exists but refresh failed; user needs to 98 + re-authenticate 99 + - `:error` - No active session found in conn 75 100 76 101 ## Examples 77 102 78 103 # After OAuth flow completes 79 - conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123") 80 104 {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 81 105 82 106 """ 83 107 @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()} 84 108 def from_conn(%Plug.Conn{} = conn) do 85 - oauth_did = Plug.Conn.get_session(conn, :atex_session) 86 - 87 - case oauth_did do 88 - did when is_binary(did) -> 89 - client = %__MODULE__{did: did} 109 + case OAuth.current_session_key(conn) do 110 + {:ok, session_key} -> 111 + client = %__MODULE__{session_key: session_key} 90 112 91 113 with_session_lock(client, fn -> 92 114 case maybe_refresh(client) do ··· 95 117 end 96 118 end) 97 119 98 - _ -> 120 + :error -> 99 121 :error 100 122 end 101 123 end ··· 119 141 end 120 142 121 143 @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 122 - defp do_refresh(%__MODULE__{did: did}) do 123 - with {:ok, session} <- OAuth.SessionStore.get(did), 144 + defp do_refresh(%__MODULE__{session_key: session_key}) do 145 + with {:ok, session} <- OAuth.SessionStore.get(session_key), 124 146 {:ok, authz_server} <- OAuth.get_authorization_server(session.aud), 125 147 {:ok, %{token_endpoint: token_endpoint}} <- 126 148 OAuth.get_authorization_server_metadata(authz_server) do ··· 130 152 session.iss, 131 153 token_endpoint 132 154 ) do 133 - {:ok, tokens, nonce} -> 155 + {:ok, tokens, dpop_nonce} -> 134 156 new_session = %OAuth.Session{ 135 157 iss: session.iss, 136 158 aud: session.aud, 137 159 sub: tokens.did, 160 + nonce: session.nonce, 138 161 access_token: tokens.access_token, 139 162 refresh_token: tokens.refresh_token, 140 163 expires_at: tokens.expires_at, 141 164 dpop_key: session.dpop_key, 142 - dpop_nonce: nonce 165 + dpop_nonce: dpop_nonce 143 166 } 144 167 145 168 case OAuth.SessionStore.update(new_session) do ··· 154 177 end 155 178 156 179 @spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()} 157 - defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do 158 - with {:ok, session} <- OAuth.SessionStore.get(did) do 180 + defp maybe_refresh(%__MODULE__{session_key: session_key} = client, buffer_minutes \\ 5) do 181 + with {:ok, session} <- OAuth.SessionStore.get(session_key) do 159 182 if token_expiring_soon?(session.expires_at, buffer_minutes) do 160 183 do_refresh(client) 161 184 else ··· 179 202 """ 180 203 @impl true 181 204 def get(%__MODULE__{} = client, resource, opts \\ []) do 182 - # TODO: Keyword.valiate to make sure :method isn't passed? 183 205 request(client, resource, opts ++ [method: :get]) 184 206 end 185 207 ··· 190 212 """ 191 213 @impl true 192 214 def post(%__MODULE__{} = client, resource, opts \\ []) do 193 - # Ditto 194 215 request(client, resource, opts ++ [method: :post]) 195 216 end 196 217 ··· 232 253 end 233 254 234 255 # Execute a function with an exclusive lock on the session identified by the 235 - # client's DID. This ensures that concurrent requests for the same user don't 236 - # race during token refresh. 256 + # composite session key. This ensures that concurrent requests for the same 257 + # session don't race during token refresh. 237 258 @spec with_session_lock(t(), (-> result)) :: result when result: any() 238 - defp with_session_lock(%__MODULE__{did: did}, fun) do 239 - Mutex.with_lock(Atex.SessionMutex, did, fun) 259 + defp with_session_lock(%__MODULE__{session_key: session_key}, fun) do 260 + Mutex.with_lock(Atex.SessionMutex, session_key, fun) 240 261 end 241 262 242 263 defp handle_failure(client, request, response) do ··· 256 277 257 278 {:ok, response, _nonce} -> 258 279 if auth_error?(response) do 259 - # We tried to refresh the token once but it's still failing 260 - # Clear session and prompt dev to reauth or something 280 + # We tried to refresh the token once but it's still failing; 281 + # clear the session and prompt re-authentication. 261 282 OAuth.SessionStore.delete(session) 262 283 {:error, response, :expired} 263 284 else