cover/Elixir.WalletAuth.Commands.RefreshTokens.html

1 defmodule WalletAuth.Commands.RefreshTokens do
2 @moduledoc """
3 Refresh token rotation command.
4
5 Per ADR 0006:
6 - One-time-use semantics: old refresh token is consumed, new one is issued.
7 - Replay rejection: replayed old token returns error + emits suspicious activity.
8 - New access token issued on every successful rotation.
9 - Session must still be active for rotation to succeed.
10 """
11
12 alias WalletAuth.Token.{AccessToken, RefreshToken, TokenStore}
13 alias WalletAuth.Session.SessionStore
14 alias WalletAuth.Events.{TokenRefreshed, SuspiciousActivityDetected}
15 alias WalletObservability.{AuditEvent, Telemetry}
16
17 @type result :: %{
18 access_token: String.t(),
19 refresh_token: String.t()
20 }
21
22 @doc """
23 Rotates the refresh token and issues a new access token.
24
25 Returns `{:ok, result()}` or
26 `{:error, :not_found | :consumed | :revoked | :expired | :session_revoked}`.
27 """
28 @spec execute(old_refresh_token :: String.t(), keyword()) ::
29 {:ok, result()} | {:error, term()}
30 6 def execute(old_token, opts \\ []) do
31 6 correlation_id = Keyword.get(opts, :correlation_id, WalletSharedKernel.Correlation.new_correlation_id())
32
33 6 Telemetry.span([:wallet_auth, :refresh, :execute], %{}, fn ->
34 6 with {:ok, record} <- TokenStore.lookup(old_token),
35 4 :ok <- check_session(record.session_id),
36 3 new_refresh_token <- RefreshToken.generate(),
37 3 {:ok, ^new_refresh_token} <- TokenStore.rotate(old_token, new_refresh_token),
38 3 {:ok, access_token, _claims} <- AccessToken.issue(record.sub, correlation_id: correlation_id) do
39 3 emit_event(TokenRefreshed.build(record.session_id, record.sub, correlation_id: correlation_id))
40 3 emit_audit(record.sub, record.session_id, correlation_id)
41
42 {:ok, %{access_token: access_token, refresh_token: new_refresh_token}}
43 else
44 {:error, :consumed} = err ->
45 1 emit_replay_alert(old_token, correlation_id)
46 1 err
47
48 2 {:error, reason} ->
49 {:error, reason}
50 end
51 end)
52 end
53
54 defp check_session(session_id) do
55 4 case SessionStore.lookup(session_id) do
56 3 {:ok, _session} -> :ok
57 1 {:error, :revoked} -> {:error, :session_revoked}
58
:-(
{:error, :not_found} -> {:error, :session_revoked}
59 end
60 end
61
62 defp emit_replay_alert(token, correlation_id) do
63 1 prefix = String.slice(token, 0, 8)
64 1 event = SuspiciousActivityDetected.build("unknown", :refresh_replay_detected,
65 correlation_id: correlation_id,
66 metadata: %{token_prefix: prefix}
67 )
68 1 emit_event(event)
69 end
70
71 defp emit_audit(user_id, session_id, correlation_id) do
72 3 event = AuditEvent.build(:auth, "token_refreshed", "session", session_id, :success,
73 actor_id: user_id,
74 correlation_id: correlation_id
75 )
76 3 :telemetry.execute([:wallet_auth, :audit], %{}, event)
77 end
78
79 4 defp emit_event(event) do
80 4 pubsub = Application.get_env(:wallet_auth, :pubsub, WalletWeb.PubSub)
81 4 apply(Phoenix.PubSub, :broadcast, [pubsub, "wallet_auth:events", {:domain_event, event}])
82 rescue
83
:-(
_ -> :ok
84 end
85 end
Line Hits Source