cover/Elixir.WalletAuth.Token.AccessToken.html

1 defmodule WalletAuth.Token.AccessToken do
2 @moduledoc """
3 JWT access token issuance and validation.
4
5 Per ADR 0006:
6 - Short-lived access tokens signed with rotating asymmetric-key-compatible signing.
7 - Default: HS256 (HMAC-SHA256). Production target: RS256.
8 - Required claims: `iss`, `aud`, `sub`, `exp`, `iat`, `jti`.
9 - Optional domain claims: `tenant_id`, `roles`, `scopes`.
10 - Access token TTL: configurable, default 10 minutes.
11
12 Token format: standard JWT (base64url(header).base64url(payload).base64url(sig))
13 """
14
15 alias WalletAuth.Jwks.KeySet
16 alias WalletSharedKernel.Correlation
17
18 @default_ttl_seconds 600
19 @issuer "wallet_auth"
20 @audience "wallet_api"
21
22 @type claims :: %{
23 required(String.t()) => term()
24 }
25
26 @type validation_error ::
27 :expired
28 | :not_yet_valid
29 | :invalid_signature
30 | :invalid_iss
31 | :invalid_aud
32 | :missing_claims
33 | :malformed
34 | :kid_not_found
35 | :key_revoked
36
37 @doc """
38 Issues a signed JWT access token for the given subject (user ID).
39
40 Options:
41 - `ttl_seconds` — override default TTL (integer).
42 - `tenant_id` — include tenant claim.
43 - `roles` — list of role strings.
44 - `scopes` — list of scope strings.
45 - `correlation_id` — propagate into jti prefix.
46
47 Returns `{:ok, token_string, claims}`.
48 """
49 @spec issue(sub :: String.t(), opts :: keyword()) ::
50 {:ok, String.t(), claims()} | {:error, term()}
51 13 def issue(sub, opts \\ []) when is_binary(sub) do
52 18 {kid, key_bytes} = KeySet.current_signing_key()
53 18 now = System.system_time(:second)
54 18 ttl = Keyword.get(opts, :ttl_seconds, ttl_seconds())
55
56 18 claims =
57 %{
58 "iss" => @issuer,
59 "aud" => @audience,
60 "sub" => sub,
61 "iat" => now,
62 "exp" => now + ttl,
63 "jti" => Correlation.new_request_id()
64 }
65 |> maybe_put("tenant_id", Keyword.get(opts, :tenant_id))
66 |> maybe_put("roles", Keyword.get(opts, :roles))
67 |> maybe_put("scopes", Keyword.get(opts, :scopes))
68
69 18 header = %{"alg" => "HS256", "typ" => "JWT", "kid" => kid}
70
71 18 with header_b64 <- base64url_encode(Jason.encode!(header)),
72 18 payload_b64 <- base64url_encode(Jason.encode!(claims)),
73 18 signing_input <- "#{header_b64}.#{payload_b64}",
74 18 sig <- :crypto.mac(:hmac, :sha256, key_bytes, signing_input),
75 18 token <- "#{signing_input}.#{base64url_encode(sig)}" do
76 18 {:ok, token, claims}
77 end
78 end
79
80 @doc """
81 Validates a JWT access token string.
82
83 Performs:
84 1. Structural parse (3-part JWT).
85 2. Header kid lookup and key retrieval from KeySet.
86 3. Signature verification (timing-safe HMAC comparison).
87 4. Claims validation: iss, aud, exp, iat, jti, sub.
88
89 Returns `{:ok, claims}` or `{:error, validation_error()}`.
90 """
91 @spec validate(token :: String.t()) :: {:ok, claims()} | {:error, validation_error()}
92 def validate(token) when is_binary(token) do
93 12 with {:ok, {header, payload, sig_bytes, signing_input}} <- parse(token),
94 9 {:ok, key_bytes} <- resolve_key(header),
95 8 :ok <- verify_signature(signing_input, sig_bytes, key_bytes),
96 5 :ok <- validate_claims(payload) do
97 {:ok, payload}
98 end
99 end
100
101
:-(
def validate(_), do: {:error, :malformed}
102
103 # --- Private helpers ---
104
105 defp parse(token) do
106 12 case String.split(token, ".") do
107 [header_b64, payload_b64, sig_b64] ->
108 11 with {:ok, header_json} <- base64url_decode(header_b64),
109 11 {:ok, payload_json} <- base64url_decode(payload_b64),
110 9 {:ok, sig_bytes} <- base64url_decode(sig_b64),
111 9 {:ok, header} <- Jason.decode(header_json),
112 9 {:ok, payload} <- Jason.decode(payload_json) do
113 9 signing_input = "#{header_b64}.#{payload_b64}"
114 {:ok, {header, payload, sig_bytes, signing_input}}
115 else
116 _ -> {:error, :malformed}
117 end
118
119 1 _ ->
120 {:error, :malformed}
121 end
122 end
123
124 defp resolve_key(%{"kid" => kid}) do
125 9 case KeySet.find_validation_key(kid) do
126 8 {:ok, key_bytes} -> {:ok, key_bytes}
127 1 {:error, :kid_not_found} -> {:error, :kid_not_found}
128
:-(
{:error, :key_revoked} -> {:error, :key_revoked}
129 end
130 end
131
132
:-(
defp resolve_key(_), do: {:error, :missing_claims}
133
134 defp verify_signature(signing_input, sig_bytes, key_bytes) do
135 8 expected = :crypto.mac(:hmac, :sha256, key_bytes, signing_input)
136
137 8 if Plug.Crypto.secure_compare(expected, sig_bytes) do
138 :ok
139 else
140 {:error, :invalid_signature}
141 end
142 end
143
144 defp validate_claims(payload) do
145 5 now = System.system_time(:second)
146
147 5 with :ok <- require_claim(payload, "iss"),
148 5 :ok <- require_claim(payload, "aud"),
149 5 :ok <- require_claim(payload, "sub"),
150 5 :ok <- require_claim(payload, "exp"),
151 5 :ok <- require_claim(payload, "iat"),
152 5 :ok <- require_claim(payload, "jti"),
153 5 :ok <- check_iss(payload["iss"]),
154 5 :ok <- check_aud(payload["aud"]),
155 5 :ok <- check_exp(payload["exp"], now),
156 3 :ok <- check_iat(payload["iat"], now) do
157 :ok
158 end
159 end
160
161 defp require_claim(payload, key) do
162 30 if Map.has_key?(payload, key), do: :ok, else: {:error, :missing_claims}
163 end
164
165 5 defp check_iss(@issuer), do: :ok
166
:-(
defp check_iss(_), do: {:error, :invalid_iss}
167
168 5 defp check_aud(@audience), do: :ok
169
:-(
defp check_aud(_), do: {:error, :invalid_aud}
170
171 3 defp check_exp(exp, now) when is_integer(exp) and exp > now, do: :ok
172 2 defp check_exp(_, _), do: {:error, :expired}
173
174 3 defp check_iat(iat, now) when is_integer(iat) and iat <= now + 5, do: :ok
175
:-(
defp check_iat(_, _), do: {:error, :not_yet_valid}
176
177 defp base64url_encode(data) when is_binary(data) do
178 data
179 |> Base.encode64(padding: false)
180 |> String.replace("+", "-")
181 54 |> String.replace("/", "_")
182 end
183
184 defp base64url_decode(input) when is_binary(input) do
185 31 padded =
186 input
187 |> String.replace("-", "+")
188 |> String.replace("_", "/")
189 |> pad_base64()
190
191 31 Base.decode64(padded)
192 end
193
194 defp pad_base64(str) do
195 31 case rem(byte_size(str), 4) do
196 2 0 -> str
197 1 2 -> str <> "=="
198 26 3 -> str <> "="
199 2 _ -> str
200 end
201 end
202
203 51 defp maybe_put(map, _key, nil), do: map
204 3 defp maybe_put(map, key, value), do: Map.put(map, key, value)
205
206 defp ttl_seconds do
207 18 Application.get_env(:wallet_auth, :access_token_ttl_seconds, @default_ttl_seconds)
208 end
209 end
Line Hits Source