cover/Elixir.WalletAuth.Token.TokenStore.html

1 defmodule WalletAuth.Token.TokenStore do
2 @moduledoc """
3 ETS-backed GenServer for refresh token state management.
4
5 Manages:
6 - Storing issued refresh tokens with their bound session_id, sub, and expiry.
7 - One-time-use rotation: consuming a token atomically stores the new token
8 and invalidates the old one.
9 - Replay rejection: an already-consumed or revoked token returns an error.
10 - Revocation: explicit session or per-token revocation support.
11
12 Per ADR 0006: refresh tokens have one-time-use semantics and replay rejection.
13
14 Storage: ETS `:set` table (in-memory; in production back with persistent store).
15 """
16
17 use GenServer
18
19 require Logger
20
21 @table :wallet_auth_token_store
22 @default_ttl_seconds 86_400 * 7
23
24 # --- Types ---
25
26 @type token :: String.t()
27 @type session_id :: String.t()
28
29 @type token_record :: %{
30 token: token(),
31 sub: String.t(),
32 session_id: session_id(),
33 expires_at: integer(),
34 status: :active | :consumed | :revoked,
35 created_at: integer()
36 }
37
38 # --- Client API ---
39
40 def start_link(opts) do
41
:-(
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
42 end
43
44 @doc "Stores a new refresh token. Returns :ok."
45 @spec store(token(), String.t(), session_id(), keyword()) :: :ok
46 13 def store(token, sub, session_id, opts \\ []) do
47 13 GenServer.call(__MODULE__, {:store, token, sub, session_id, opts})
48 end
49
50 @doc """
51 Rotates a refresh token: validates, marks old token as consumed, stores new token.
52
53 Returns `{:ok, new_token}` or `{:error, :not_found | :consumed | :revoked | :expired}`.
54 """
55 @spec rotate(old_token :: token(), new_token :: token()) ::
56 {:ok, token()} | {:error, :not_found | :consumed | :revoked | :expired}
57 def rotate(old_token, new_token) do
58 8 GenServer.call(__MODULE__, {:rotate, old_token, new_token})
59 end
60
61 @doc """
62 Looks up a token record.
63 Returns `{:ok, token_record()}` or `{:error, :not_found | :consumed | :revoked | :expired}`.
64 """
65 @spec lookup(token()) ::
66 {:ok, token_record()} | {:error, :not_found | :consumed | :revoked | :expired}
67 def lookup(token) do
68 15 GenServer.call(__MODULE__, {:lookup, token})
69 end
70
71 @doc "Revokes a specific token. Returns :ok (idempotent)."
72 @spec revoke(token()) :: :ok
73 def revoke(token) do
74 3 GenServer.call(__MODULE__, {:revoke_token, token})
75 end
76
77 @doc "Revokes all tokens for a session. Returns :ok."
78 @spec revoke_session(session_id()) :: :ok
79 def revoke_session(session_id) do
80 1 GenServer.call(__MODULE__, {:revoke_session, session_id})
81 end
82
83 @doc "Resets all token store state. For test use only."
84 20 def reset, do: GenServer.call(__MODULE__, :reset)
85
86 # --- Server Callbacks ---
87
88 @impl true
89 def init(_opts) do
90
:-(
table = :ets.new(@table, [:set, :protected, :named_table])
91 {:ok, %{table: table}}
92 end
93
94 @impl true
95 def handle_call({:store, token, sub, session_id, opts}, _from, state) do
96 13 ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
97 13 now = System.system_time(:second)
98
99 13 record = %{
100 token: token,
101 sub: sub,
102 session_id: session_id,
103 expires_at: now + ttl,
104 status: :active,
105 created_at: now
106 }
107
108 13 :ets.insert(@table, {token, record})
109 13 {:reply, :ok, state}
110 end
111
112 @impl true
113 def handle_call({:rotate, old_token, new_token}, _from, state) do
114 8 now = System.system_time(:second)
115
116 8 result =
117 case :ets.lookup(@table, old_token) do
118 1 [] ->
119 {:error, :not_found}
120
121 [{_key, %{status: :consumed}}] ->
122 1 Logger.warning("Refresh token replay detected",
123 token_prefix: String.slice(old_token, 0, 8)
124 )
125
126 {:error, :consumed}
127
128 1 [{_key, %{status: :revoked}}] ->
129 {:error, :revoked}
130
131
:-(
[{_key, %{expires_at: exp}}] when exp <= now ->
132 {:error, :expired}
133
134 [{_key, record}] ->
135 # Mark old token as consumed
136 5 :ets.insert(@table, {old_token, %{record | status: :consumed}})
137
138 # Store new token inheriting session binding
139 5 ttl = record.expires_at - record.created_at
140 5 new_record = %{record | token: new_token, status: :active, created_at: now, expires_at: now + ttl}
141 5 :ets.insert(@table, {new_token, new_record})
142 {:ok, new_token}
143 end
144
145 8 {:reply, result, state}
146 end
147
148 @impl true
149 def handle_call({:lookup, token}, _from, state) do
150 15 now = System.system_time(:second)
151
152 15 result =
153 case :ets.lookup(@table, token) do
154 2 [] ->
155 {:error, :not_found}
156
157 [{_key, %{status: :consumed} = record}] ->
158 3 _ = record
159 {:error, :consumed}
160
161 3 [{_key, %{status: :revoked}}] ->
162 {:error, :revoked}
163
164
:-(
[{_key, %{expires_at: exp}}] when exp <= now ->
165 {:error, :expired}
166
167 7 [{_key, record}] ->
168 {:ok, record}
169 end
170
171 15 {:reply, result, state}
172 end
173
174 @impl true
175 def handle_call({:revoke_token, token}, _from, state) do
176 3 case :ets.lookup(@table, token) do
177 [{_key, record}] ->
178 2 :ets.insert(@table, {token, %{record | status: :revoked}})
179
180 1 [] ->
181 :ok
182 end
183
184 3 {:reply, :ok, state}
185 end
186
187 @impl true
188 def handle_call({:revoke_session, session_id}, _from, state) do
189 1 :ets.foldl(
190 fn {token, record}, _acc ->
191 3 if record.session_id == session_id do
192 2 :ets.insert(@table, {token, %{record | status: :revoked}})
193 end
194 end,
195 :ok,
196 @table
197 )
198
199 1 {:reply, :ok, state}
200 end
201
202 @impl true
203 def handle_call(:reset, _from, state) do
204 20 :ets.delete_all_objects(@table)
205 20 {:reply, :ok, state}
206 end
207 end
Line Hits Source