From cb7a4fba1e0376974cfc47c24dfb3e1e0e7bfaca Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sat, 18 Jul 2026 21:16:07 +0100 Subject: [PATCH] Add `delete_session` You can now invalidate a session when a user logs out. This commit also stops leaking %Session{} on `callback`, instead returning the more relevant did+handle. --- lib/latch.ex | 71 +++++++++++++++++++++++--------------- lib/latch/error/store.ex | 2 -- lib/latch/store.ex | 7 ---- test/latch_test.exs | 4 +-- test/support/test_store.ex | 2 -- 5 files changed, 45 insertions(+), 41 deletions(-) diff --git a/lib/latch.ex b/lib/latch.ex index 462b199..04c6654 100644 --- a/lib/latch.ex +++ b/lib/latch.ex @@ -37,8 +37,10 @@ defmodule Latch do 2. The user authorizes, their authorization server redirects back to your `redirect_uri`. 3. `callback/2` validates the callback params, exchanges the code, and - returns a `Latch.Session` for persisting, probably via the same module - that implements your `Latch.Store`. + stores the session for you using your `Latch.Store` implementation. + Returns identity information for the user. + + When a user logs out, call `delete_session` to clear their session. ## Authenticated requests @@ -81,7 +83,6 @@ defmodule Latch do alias Latch.Identity alias Latch.PKCE alias Latch.Request - alias Latch.Session @type name :: atom() | pid() @@ -213,12 +214,13 @@ defmodule Latch do @doc """ Completes an authorization flow from the OAuth callback params. - Consumes the stored request — single use, so a replayed callback fails - with `%Latch.Error.SecurityViolation{}` — verifies the issuer, exchanges - the code, and returns the established `Latch.Session`. + Consumes a stored request. Single use, so a replayed callback fails + with `%Latch.Error.SecurityViolation{}`. Verifies the issuer, exchanges + the code, stores the session using the `Latch.Store` implementation, and + returns identity information. """ @spec callback(name(), map()) :: - {:ok, Session.t()} + {:ok, %{did: String.t(), handle: String.t()}} | {:error, InvalidResponse.t() | MissingDPoPNonce.t() @@ -229,8 +231,7 @@ defmodule Latch do def callback(name, %{"state" => state} = params) when is_binary(state) do %Config{} = config = config(name) - with {:ok, request} <- take_request(config, state), - :ok <- verify_state(request, state) do + with {:ok, request} <- take_request(config, state) do complete_callback(params, request, config) end end @@ -239,6 +240,17 @@ defmodule Latch do {:error, %InvalidResponse{reason: :unexpected_response}} end + @doc """ + """ + @spec delete_session(name(), String.t()) :: :ok | {:error, StoreError.t()} + def delete_session(name, did) do + %Config{} = config = config(name) + + with {:error, reason} <- config.store.delete_session(did) do + {:error, %StoreError{action: :delete_session, did: did, reason: reason}} + end + end + @doc """ Query the user's PDS using their DID's session. @@ -339,19 +351,22 @@ defmodule Latch do config ) when is_binary(code) do - with :ok <- verify_issuer(request, issuer) do - Flow.exchange_code(config, - client_id: config.client_id, - client_jwk: config.signing_key, - redirect_uri: config.redirect_uri, - code: code, - code_verifier: request.pkce_verifier, - dpop_key: request.dpop_key, - expected_did: request.did, - pds_endpoint: request.pds_endpoint, - issuer: request.issuer, - token_endpoint: request.token_endpoint - ) + with :ok <- verify_issuer(request, issuer), + {:ok, session} <- + Flow.exchange_code(config, + client_id: config.client_id, + client_jwk: config.signing_key, + redirect_uri: config.redirect_uri, + code: code, + code_verifier: request.pkce_verifier, + dpop_key: request.dpop_key, + expected_did: request.did, + pds_endpoint: request.pds_endpoint, + issuer: request.issuer, + token_endpoint: request.token_endpoint + ), + :ok <- store_session(config, session) do + {:ok, %{did: session.did, handle: request.handle}} end end @@ -370,6 +385,12 @@ defmodule Latch do end end + defp store_session(config, session) do + with {:error, reason} <- config.store.put_session(session.did, session) do + {:error, %StoreError{action: :put_session, did: session.did, reason: reason}} + end + end + defp take_request(config, state) do case config.store.take_request(state) do {:ok, %Request{} = request} -> @@ -383,12 +404,6 @@ defmodule Latch do end end - defp verify_state(%Request{state: state}, state), do: :ok - - defp verify_state(_request, _state) do - {:error, %SecurityViolation{reason: :state_mismatch}} - end - defp verify_issuer(%Request{issuer: issuer}, issuer), do: :ok defp verify_issuer(_request, _issuer) do diff --git a/lib/latch/error/store.ex b/lib/latch/error/store.ex index 6805296..de17f4b 100644 --- a/lib/latch/error/store.ex +++ b/lib/latch/error/store.ex @@ -11,7 +11,6 @@ defmodule Latch.Error.Store do * `:update_session` * `:take_request` * `:put_request` - * `:delete_expired_requests` """ alias Latch.Store, as: StoreBehavior @@ -25,7 +24,6 @@ defmodule Latch.Error.Store do | :update_session | :take_request | :put_request - | :delete_expired_requests @type t :: %__MODULE__{ action: action(), diff --git a/lib/latch/store.ex b/lib/latch/store.ex index f6b2869..f50e714 100644 --- a/lib/latch/store.ex +++ b/lib/latch/store.ex @@ -34,13 +34,6 @@ defmodule Latch.Store do """ @callback take_request(state()) :: {:ok, Request.t()} | store_error() - @doc """ - Remove requests older than `max_age_seconds`. - - Optional housekeeping: return the count deleted. `:ok` also fine. - """ - @callback delete_expired_requests(max_age_seconds :: pos_integer()) :: non_neg_integer() | :ok - @doc """ Fetch the session for `did`. """ diff --git a/test/latch_test.exs b/test/latch_test.exs index 178a8f9..479aec5 100644 --- a/test/latch_test.exs +++ b/test/latch_test.exs @@ -12,7 +12,7 @@ defmodule LatchTest do alias Latch.Session @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" - @handle "alice.example.com" + @handle "jola.dev" @pds "https://pds.example.com" @issuer "https://issuer.example.com" @client_id "https://client.example.com/oauth-client-metadata.json" @@ -126,7 +126,7 @@ defmodule LatchTest do {:ok, session} end) - assert {:ok, ^session} = + assert {:ok, %{did: @did, handle: @handle}} = Latch.callback( pid, %{ diff --git a/test/support/test_store.ex b/test/support/test_store.ex index 92886ca..7fcc208 100644 --- a/test/support/test_store.ex +++ b/test/support/test_store.ex @@ -29,8 +29,6 @@ defmodule Latch.TestStore do end end - def delete_expired_requests(_max_age_seconds), do: :ok - def fetch_session(did) do case Process.get(@sessions_key, %{}) do %{^did => %Session{} = session} -> {:ok, session} -- 2.51.2