defmodule BearCli.Jwks do @moduledoc """ `bear jwks` 指令群(簽章金鑰管理;docs/commands.md §3.8、issue #12)。 對應伺服器端 JWK 管理 JSON API(alterminal/bear#46,`/api/v1/jwks`、 PAT Bearer、admin 限定)。認證沿用既有 PAT 憑證(`BEAR_TOKEN` 或憑證檔)。 金鑰材質(`key_data`)永不經 CLI 顯示(API 端即不序列化);本群組只管理 公開中繼資料(kid/kty/alg/active 等)。 退出碼:0 成功;1 404/422;2 用法錯誤;3 未登入或 401;6 網路/伺服器 錯誤;8 權限不足(403,僅限 admin)。 """ import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2] alias BearCli.Api # -- 入口 -- @doc """ 執行 `bear jwks <動詞>`,回傳退出碼。`opts[:jwks_api]` 可注入假 API 模組 供測試(需實作 `jwks_list/2`、`jwks_create/3`、`jwks_get/3`、`jwks_toggle/3`)。 """ def run(verb, opts) do api = opts[:jwks_api] || Api with {:ok, ctx} <- context(opts) do execute(verb, ctx, opts, api) else {:error, message, code} -> fail(opts, message, code) end end # -- 各動詞 -- defp execute(:list, ctx, opts, api) do case api.jwks_list(ctx.issuer, ctx.token) do {:ok, body} -> render_list(opts, body["data"] || []) 0 {:error, _kind, _detail} = error -> fail_api(opts, ctx, error) end end defp execute(:show, ctx, opts, api) do with {:ok, key} <- resolve_key(ctx, opts[:kid_arg], api) do case api.jwks_get(ctx.issuer, ctx.token, key["id"]) do {:ok, body} -> show_key(body["data"] || %{}, opts) 0 {:error, _kind, _detail} = error -> fail_api(opts, ctx, error) end else {:error, message, code} -> fail(opts, message, code) end end defp execute(:create, ctx, opts, api) do attrs = %{"kid" => opts[:kid], "alg" => opts[:alg] || "RS256"} case api.jwks_create(ctx.issuer, ctx.token, attrs) do {:ok, body} -> key = body["data"] || %{} if opts[:json] do IO.puts(Jason.encode!(%{ok: true, jwk: key})) else IO.puts("JWK 已建立:#{key["kid"]}(#{key["alg"]}/#{status_label(key)})") end 0 {:error, _kind, _detail} = error -> fail_api(opts, ctx, error) end end defp execute(:toggle, ctx, opts, api) do with {:ok, key} <- resolve_key(ctx, opts[:kid_arg], api) do case api.jwks_toggle(ctx.issuer, ctx.token, key["id"]) do {:ok, body} -> updated = body["data"] || %{} if opts[:json] do IO.puts( Jason.encode!(%{ ok: true, jwk: %{id: updated["id"], kid: updated["kid"], active: updated["active"]} }) ) else IO.puts("#{key["kid"]}:#{status_label(key)} → #{status_label(updated)}") end 0 {:error, _kind, _detail} = error -> fail_api(opts, ctx, error) end else {:error, message, code} -> fail(opts, message, code) end end # -- kid → UUID 解析(API 路徑參數為 UUID,CLI 對外介面用 kid)-- defp resolve_key(_ctx, nil, _api), do: {:error, "缺少 參數", 2} defp resolve_key(ctx, kid, api) do case api.jwks_list(ctx.issuer, ctx.token) do {:ok, body} -> case Enum.find(body["data"] || [], &(&1["kid"] == kid)) do nil -> {:error, "找不到 kid 為 #{kid} 的金鑰", 1} key -> {:ok, key} end {:error, _kind, _detail} = error -> {message, code} = format_api_error(ctx, error) {:error, message, code} end end # -- 輸出 -- defp render_list(opts, keys) do if opts[:json] do IO.puts(Jason.encode!(%{ok: true, jwks: keys})) else print_table(keys) end end @table_headers ["ID", "KID", "KTY", "ALG", "ACTIVE", "CREATED AT"] defp print_table(keys) do rows = Enum.map(keys, fn key -> [ String.slice(key["id"] || "", 0, 8), key["kid"] || "", key["kty"] || "", key["alg"] || "", status_label(key), key["created_at"] || "" ] end) widths = Enum.with_index(@table_headers, fn _header, i -> Enum.max([ String.length(Enum.at(@table_headers, i)) | Enum.map(rows, &String.length(Enum.at(&1, i))) ]) end) render_row = fn cells -> cells |> Enum.with_index() |> Enum.map(fn {cell, i} -> String.pad_trailing(cell, Enum.at(widths, i)) end) |> Enum.join(" ") |> String.trim_trailing() end IO.puts(render_row.(@table_headers)) Enum.each(rows, fn cells -> IO.puts(render_row.(cells)) end) end @key_fields ~w(id kid kty alg use active created_at) defp show_key(key, opts) do if opts[:json] do IO.puts(Jason.encode!(%{ok: true, jwk: key})) else Enum.each(@key_fields, fn field -> IO.puts("#{String.pad_trailing(field, 14)}: #{format_key_value(key[field])}") end) end end defp format_key_value(nil), do: "(無)" defp format_key_value(value) when is_binary(value), do: value defp format_key_value(value), do: Jason.encode!(value) defp status_label(%{"active" => true}), do: "active" defp status_label(%{"active" => false}), do: "inactive" defp status_label(_), do: "(未知)" # -- 錯誤處理 -- defp fail_api(opts, ctx, error) do {message, code} = format_api_error(ctx, error) fail(opts, message, code) end end