| 1 |
|
defmodule WalletAuth.Plugs.VerifyAccessToken do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Plug for JWT access token validation in wallet_web pipelines. |
| 4 |
|
|
| 5 |
|
Per checklist Track D: enforces auth checks in wallet_web pipelines. |
| 6 |
|
Validates Bearer token from Authorization header against ADR 0006 rules. |
| 7 |
|
|
| 8 |
|
On success: |
| 9 |
|
- Sets `:current_claims` in `conn.assigns`. |
| 10 |
|
- Sets `:current_user_id` in `conn.assigns`. |
| 11 |
|
|
| 12 |
|
On failure: |
| 13 |
|
- Returns 401 with canonical error envelope (ADR 0005). |
| 14 |
|
- Halts the pipeline. |
| 15 |
|
|
| 16 |
|
## Usage in router.ex |
| 17 |
|
```elixir |
| 18 |
|
pipeline :authenticated do |
| 19 |
|
plug WalletAuth.Plugs.VerifyAccessToken |
| 20 |
|
end |
| 21 |
|
``` |
| 22 |
|
""" |
| 23 |
|
|
| 24 |
|
import Plug.Conn |
| 25 |
|
|
| 26 |
|
alias WalletAuth.Token.AccessToken |
| 27 |
|
alias WalletApiContracts.ErrorEnvelope |
| 28 |
|
alias WalletApiContracts.ErrorCodes |
| 29 |
|
|
| 30 |
:-( |
def init(opts), do: opts |
| 31 |
|
|
| 32 |
|
def call(conn, _opts) do |
| 33 |
5 |
with {:ok, token} <- extract_token(conn), |
| 34 |
4 |
{:ok, claims} <- AccessToken.validate(token) do |
| 35 |
|
conn |
| 36 |
|
|> assign(:current_claims, claims) |
| 37 |
1 |
|> assign(:current_user_id, claims["sub"]) |
| 38 |
|
else |
| 39 |
|
{:error, reason} -> |
| 40 |
|
conn |
| 41 |
|
|> put_resp_content_type("application/json") |
| 42 |
|
|> put_resp_header("x-request-id", get_or_generate_request_id(conn)) |
| 43 |
|
|> send_resp(401, build_error(reason)) |
| 44 |
4 |
|> halt() |
| 45 |
|
end |
| 46 |
|
end |
| 47 |
|
|
| 48 |
|
defp extract_token(conn) do |
| 49 |
5 |
case get_req_header(conn, "authorization") do |
| 50 |
4 |
["Bearer " <> token | _] -> |
| 51 |
|
{:ok, String.trim(token)} |
| 52 |
|
|
| 53 |
1 |
_ -> |
| 54 |
|
{:error, :missing_token} |
| 55 |
|
end |
| 56 |
|
end |
| 57 |
|
|
| 58 |
|
defp build_error(reason) do |
| 59 |
4 |
{code, message} = error_for(reason) |
| 60 |
|
|
| 61 |
|
ErrorEnvelope.build(code, message, :auth, false) |
| 62 |
4 |
|> Jason.encode!() |
| 63 |
|
end |
| 64 |
|
|
| 65 |
1 |
defp error_for(:expired), do: {ErrorCodes.unauthorized(), "Access token has expired"} |
| 66 |
1 |
defp error_for(:invalid_signature), do: {ErrorCodes.unauthorized(), "Token signature invalid"} |
| 67 |
:-( |
defp error_for(:invalid_iss), do: {ErrorCodes.unauthorized(), "Token issuer not accepted"} |
| 68 |
:-( |
defp error_for(:invalid_aud), do: {ErrorCodes.unauthorized(), "Token audience not accepted"} |
| 69 |
:-( |
defp error_for(:missing_claims), do: {ErrorCodes.unauthorized(), "Token missing required claims"} |
| 70 |
:-( |
defp error_for(:kid_not_found), do: {ErrorCodes.unauthorized(), "Token signing key not found"} |
| 71 |
:-( |
defp error_for(:key_revoked), do: {ErrorCodes.unauthorized(), "Token signing key revoked"} |
| 72 |
1 |
defp error_for(:malformed), do: {ErrorCodes.unauthorized(), "Token is malformed"} |
| 73 |
1 |
defp error_for(:missing_token), do: {ErrorCodes.unauthorized(), "Authorization header required"} |
| 74 |
:-( |
defp error_for(_), do: {ErrorCodes.unauthorized(), "Authentication required"} |
| 75 |
|
|
| 76 |
|
defp get_or_generate_request_id(conn) do |
| 77 |
4 |
case get_req_header(conn, "x-request-id") do |
| 78 |
:-( |
[id | _] -> id |
| 79 |
4 |
[] -> WalletSharedKernel.Correlation.new_request_id() |
| 80 |
|
end |
| 81 |
|
end |
| 82 |
|
end |