···18181919### Added
20202121+- `Atex.OAuth.SessionStore` behaviour and `Atex.OAuth.Session` struct for
2222+ managing OAuth sessions with pluggable storage backends.
2323+ - `Atex.OAuth.SessionStore.ETS` - in-memory session store implementation.
2424+ - `Atex.OAuth.SessionStore.DETS` - persistent disk-based session store
2525+ implementation.
2126- `Atex.OAuth.Plug` now requires a `:callback` option that is a MFA tuple
2227 (Module, Function, Args), denoting a callback function to be invoked by after
2328 a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a
···1818 - `:token_validation_failed` - Failed to validate the authorization code or
1919 token
2020 - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server
2121+ - `:session_store_failed` - OAuth succeeded but failed to store the session
2122 """
22232324 defexception [:message, :reason]
···11+defmodule Atex.OAuth.Session do
22+ @moduledoc """
33+ Struct representing an active OAuth session for an AT Protocol user.
44+55+ Contains all the necessary credentials and metadata to make authenticated
66+ requests to a user's PDS using OAuth with DPoP.
77+88+ ## Fields
99+1010+ - `:iss` - Authorization server issuer URL
1111+ - `:aud` - PDS endpoint URL (audience)
1212+ - `:sub` - User's DID (subject), used as the session key
1313+ - `:access_token` - OAuth access token for authenticating requests
1414+ - `:refresh_token` - OAuth refresh token for obtaining new access tokens
1515+ - `:expires_at` - When the current access token expires (NaiveDateTime in UTC)
1616+ - `:dpop_key` - DPoP signing key (Demonstrating Proof-of-Possession)
1717+ - `:dpop_nonce` - Server-provided nonce for DPoP proofs (optional, updated per-request)
1818+1919+ ## Usage
2020+2121+ Sessions are typically created during the OAuth flow and stored in a `SessionStore`.
2222+ They should not be created manually in most cases.
2323+2424+ session = %Atex.OAuth.Session{
2525+ iss: "https://bsky.social",
2626+ aud: "https://puffball.us-east.host.bsky.network",
2727+ sub: "did:plc:abc123",
2828+ access_token: "...",
2929+ refresh_token: "...",
3030+ expires_at: ~N[2026-01-04 12:00:00],
3131+ dpop_key: dpop_key,
3232+ dpop_nonce: "server-nonce"
3333+ }
3434+ """
3535+ use TypedStruct
3636+3737+ typedstruct enforce: true do
3838+ # Authz server issuer
3939+ field :iss, String.t()
4040+ # PDS endpoint
4141+ field :aud, String.t()
4242+ # User's DID
4343+ field :sub, String.t()
4444+ field :access_token, String.t()
4545+ field :refresh_token, String.t()
4646+ field :expires_at, NaiveDateTime.t()
4747+ field :dpop_key, JOSE.JWK.t()
4848+ field :dpop_nonce, String.t() | nil, enforce: false
4949+ end
5050+end
+119
lib/atex/oauth/session_store.ex
···11+defmodule Atex.OAuth.SessionStore do
22+ @moduledoc """
33+ Storage interface for OAuth sessions.
44+55+ Provides a behaviour for implementing session storage backends, and functions
66+ to operate the backend using `Atex.OAuth.Session`
77+88+ ## Configuration
99+1010+ The default implementation for the store is `Atex.OAuth.SessionStore.DETS`;
1111+ this can be changed to a custom implementation in your config.exs:
1212+1313+ config :atex, :session_store, Atex.OAuth.SessionStore.ETS
1414+1515+ DETS is the default implementation as it provides simple, on-disk storage for
1616+ sessions so they don't get discarded on an application restart, but a regular
1717+ ETS implementation is also provided out-of-the-box for testing or other
1818+ circumstances.
1919+2020+ For multi-node deployments, you can write your own implementation using a
2121+ custom backend, such as Redis, by implementing the behaviour callbacks.
2222+2323+ ## Usage
2424+2525+ Sessions are keyed by the user's DID (`sub` field).
2626+2727+ session = %Atex.OAuth.Session{
2828+ iss: "https://bsky.social",
2929+ aud: "https://puffball.us-east.host.bsky.network",
3030+ sub: "did:plc:abc123",
3131+ access_token: "...",
3232+ refresh_token: "...",
3333+ expires_at: ~N[2026-01-04 12:00:00],
3434+ dpop_key: dpop_key,
3535+ dpop_nonce: "server-nonce"
3636+ }
3737+3838+ # Insert a new session
3939+ :ok = Atex.OAuth.SessionStore.insert(session)
4040+4141+ # Retrieve a session
4242+ {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123")
4343+4444+ # Update an existing session (e.g., after token refresh)
4545+ updated_session = %{session | access_token: new_token}
4646+ :ok = Atex.OAuth.SessionStore.update(updated_session)
4747+4848+ # Delete a session
4949+ Atex.OAuth.SessionStore.delete(session)
5050+ """
5151+5252+ @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS)
5353+5454+ @doc """
5555+ Retrieve a session by DID.
5656+5757+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
5858+ """
5959+ @callback get(key :: String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()}
6060+6161+ @doc """
6262+ Insert a new session.
6363+6464+ The key is the user's DID (`session.sub`). Returns `:ok` on success.
6565+ """
6666+ @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) ::
6767+ :ok | {:error, atom()}
6868+6969+ @doc """
7070+ Update an existing session.
7171+7272+ Replaces the existing session data for the given key. Returns `:ok` on success.
7373+ """
7474+ @callback update(key :: String.t(), session :: Atex.OAuth.Session.t()) ::
7575+ :ok | {:error, atom()}
7676+7777+ @doc """
7878+ Delete a session.
7979+8080+ Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed.
8181+ """
8282+ @callback delete(key :: String.t()) :: :ok | :error | :noop
8383+8484+ @callback child_spec(any()) :: Supervisor.child_spec()
8585+8686+ defdelegate child_spec(opts), to: @store
8787+8888+ @doc """
8989+ Retrieve a session by DID.
9090+ """
9191+ @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()}
9292+ def get(key) do
9393+ @store.get(key)
9494+ end
9595+9696+ @doc """
9797+ Insert a new session.
9898+ """
9999+ @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
100100+ def insert(session) do
101101+ @store.insert(session.sub, session)
102102+ end
103103+104104+ @doc """
105105+ Update an existing session.
106106+ """
107107+ @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
108108+ def update(session) do
109109+ @store.update(session.sub, session)
110110+ end
111111+112112+ @doc """
113113+ Delete a session.
114114+ """
115115+ @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop
116116+ def delete(session) do
117117+ @store.delete(session.sub)
118118+ end
119119+end
+121
lib/atex/oauth/session_store/dets.ex
···11+defmodule Atex.OAuth.SessionStore.DETS do
22+ @moduledoc """
33+ DETS implementation for `Atex.OAuth.SessionStore`.
44+55+ This is recommended for single-node production deployments, as sessions will
66+ persist on disk between application restarts. For more complex, multi-node
77+ deployments, consider making a custom implementation using Redis or some other
88+ distributed store.
99+1010+ ## Configuration
1111+1212+ By default the DETS file is stored at `priv/dets/atex_oauth_sessions.dets`
1313+ relative to where your application is running. You can configure the file path
1414+ in your `config.exs`:
1515+1616+ config :atex, Atex.OAuth.SessionStore.DETS,
1717+ file_path: "/var/lib/myapp/sessions.dets"
1818+1919+ Parent directories will be created as necessary if possible.
2020+ """
2121+2222+ alias Atex.OAuth.Session
2323+ require Logger
2424+ use Supervisor
2525+2626+ @behaviour Atex.OAuth.SessionStore
2727+ @table :atex_oauth_sessions
2828+ @default_file "priv/dets/atex_oauth_sessions.dets"
2929+3030+ def start_link(opts) do
3131+ Supervisor.start_link(__MODULE__, opts)
3232+ end
3333+3434+ @impl Supervisor
3535+ def init(_opts) do
3636+ dets_file =
3737+ case Application.get_env(:atex, __MODULE__, [])[:file_path] do
3838+ nil ->
3939+ @default_file
4040+4141+ path ->
4242+ path
4343+ end
4444+4545+ # Ensure parent directory exists
4646+ dets_file
4747+ |> Path.dirname()
4848+ |> File.mkdir_p!()
4949+5050+ case :dets.open_file(@table, file: String.to_charlist(dets_file), type: :set) do
5151+ {:ok, @table} ->
5252+ Logger.info("DETS session store opened: #{dets_file}")
5353+ Supervisor.init([], strategy: :one_for_one)
5454+5555+ {:error, reason} ->
5656+ Logger.error("Failed to open DETS file: #{inspect(reason)}")
5757+ raise "Failed to initialize DETS session store: #{inspect(reason)}"
5858+ end
5959+ end
6060+6161+ @doc """
6262+ Insert a session into the DETS table.
6363+6464+ Returns `:ok` on success, `{:error, reason}` if an unexpected error occurs.
6565+ """
6666+ @impl Atex.OAuth.SessionStore
6767+ @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()}
6868+ def insert(key, session) do
6969+ case :dets.insert(@table, {key, session}) do
7070+ :ok ->
7171+ :ok
7272+7373+ {:error, reason} ->
7474+ Logger.error("DETS insert failed: #{inspect(reason)}")
7575+ {:error, reason}
7676+ end
7777+ end
7878+7979+ @doc """
8080+ Update a session in the DETS table.
8181+8282+ In DETS, this is the same as insert - it replaces the existing entry.
8383+ """
8484+ @impl Atex.OAuth.SessionStore
8585+ @spec update(String.t(), Session.t()) :: :ok | {:error, atom()}
8686+ def update(key, session) do
8787+ insert(key, session)
8888+ end
8989+9090+ @doc """
9191+ Retrieve a session from the DETS table.
9292+9393+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
9494+ """
9595+ @impl Atex.OAuth.SessionStore
9696+ @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()}
9797+ def get(key) do
9898+ case :dets.lookup(@table, key) do
9999+ [{_key, session}] -> {:ok, session}
100100+ [] -> {:error, :not_found}
101101+ end
102102+ end
103103+104104+ @doc """
105105+ Delete a session from the DETS table.
106106+107107+ Returns `:ok` if deleted, `:noop` if the session didn't exist.
108108+ """
109109+ @impl Atex.OAuth.SessionStore
110110+ @spec delete(String.t()) :: :ok | :error | :noop
111111+ def delete(key) do
112112+ case get(key) do
113113+ {:ok, _session} ->
114114+ :dets.delete(@table, key)
115115+ :ok
116116+117117+ {:error, :not_found} ->
118118+ :noop
119119+ end
120120+ end
121121+end
+88
lib/atex/oauth/session_store/ets.ex
···11+defmodule Atex.OAuth.SessionStore.ETS do
22+ @moduledoc """
33+ In-memory, ETS implementation for `Atex.OAuth.SessionStore`.
44+55+ This is moreso intended for testing or some occasion where you want the
66+ session store to be volatile for some reason. It's recommended you use
77+ `Atex.OAuth.SessionStore.DETS` for single-node production deployments.
88+ """
99+1010+ alias Atex.OAuth.Session
1111+ require Logger
1212+ use Supervisor
1313+1414+ @behaviour Atex.OAuth.SessionStore
1515+ @table :atex_oauth_sessions
1616+1717+ def start_link(opts) do
1818+ Supervisor.start_link(__MODULE__, opts)
1919+ end
2020+2121+ @impl Supervisor
2222+ def init(_opts) do
2323+ :ets.new(@table, [:set, :public, :named_table])
2424+ Supervisor.init([], strategy: :one_for_one)
2525+ end
2626+2727+ @doc """
2828+ Insert a session into the ETS table.
2929+3030+ Returns `:ok` on success, `{:error, :ets}` if an unexpected error occurs.
3131+ """
3232+ @impl Atex.OAuth.SessionStore
3333+ @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()}
3434+ def insert(key, session) do
3535+ try do
3636+ :ets.insert(@table, {key, session})
3737+ :ok
3838+ rescue
3939+ # Freak accidents can occur
4040+ e ->
4141+ Logger.error(Exception.format(:error, e, __STACKTRACE__))
4242+ {:error, :ets}
4343+ end
4444+ end
4545+4646+ @doc """
4747+ Update a session in the ETS table.
4848+4949+ In ETS, this is the same as insert - it replaces the existing entry.
5050+ """
5151+ @impl Atex.OAuth.SessionStore
5252+ @spec update(String.t(), Session.t()) :: :ok | {:error, atom()}
5353+ def update(key, session) do
5454+ insert(key, session)
5555+ end
5656+5757+ @doc """
5858+ Retrieve a session from the ETS table.
5959+6060+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
6161+ """
6262+ @impl Atex.OAuth.SessionStore
6363+ @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()}
6464+ def get(key) do
6565+ case :ets.lookup(@table, key) do
6666+ [{_key, session}] -> {:ok, session}
6767+ [] -> {:error, :not_found}
6868+ end
6969+ end
7070+7171+ @doc """
7272+ Delete a session from the ETS table.
7373+7474+ Returns `:ok` if deleted, `:noop` if the session didn't exist.
7575+ """
7676+ @impl Atex.OAuth.SessionStore
7777+ @spec delete(String.t()) :: :ok | :error | :noop
7878+ def delete(key) do
7979+ case get(key) do
8080+ {:ok, _session} ->
8181+ :ets.delete(@table, key)
8282+ :ok
8383+8484+ {:error, :not_found} ->
8585+ :noop
8686+ end
8787+ end
8888+end