- docs/commands.md 新增 §3.8 jwks、§3.9 accounts、§3.10 audit-logs 規格 - Api 新增 jwks/accounts/audit-logs 端點(共用 api_request) - 新增 Jwks/Accounts/AuditLogs/Admin 模組;CLI 接線三個指令群 - 403 → 退出碼 8 提示需 admin;一次性密碼只在成功當下輸出 - 測試 100 例全綠(fake API 注入,比照 cli_test.exs)
221 lines
7.7 KiB
Elixir
221 lines
7.7 KiB
Elixir
defmodule BearCli.Api do
|
||
@moduledoc """
|
||
Bear 伺服器 HTTP 介面(一律使用 `Req`)。
|
||
|
||
目前僅實作 PAT 模式所需的 `GET /userinfo`。
|
||
"""
|
||
|
||
@finch BearCli.Finch
|
||
|
||
@doc """
|
||
呼叫 `GET {issuer}/userinfo`(Bearer token)。
|
||
|
||
回傳:
|
||
- `{:ok, claims}` — 200,claims 為 JSON map
|
||
- `{:error, :unauthorized, description}` — 401
|
||
- `{:error, :server_error, status}` — 其他非 2xx
|
||
- `{:error, :network, reason}` — 傳輸層錯誤
|
||
"""
|
||
def userinfo(issuer, token) do
|
||
url = String.trim_trailing(issuer, "/") <> "/userinfo"
|
||
|
||
case Req.get(url,
|
||
headers: [authorization: "Bearer " <> token, accept: "application/json"],
|
||
retry: false,
|
||
finch: [name: @finch]
|
||
) do
|
||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||
{:ok, decode_body(body)}
|
||
|
||
{:ok, %Req.Response{status: 401, body: body}} ->
|
||
{:error, :unauthorized, error_description(body)}
|
||
|
||
{:ok, %Req.Response{status: status}} ->
|
||
{:error, :server_error, status}
|
||
|
||
{:error, exception} ->
|
||
{:error, :network, Exception.message(exception)}
|
||
end
|
||
end
|
||
|
||
defp decode_body(%{} = body), do: body
|
||
defp decode_body(body) when is_binary(body), do: Jason.decode!(body)
|
||
defp decode_body(_), do: %{}
|
||
|
||
defp error_description(body) when is_binary(body) do
|
||
case Jason.decode(body) do
|
||
{:ok, %{"error_description" => desc}} when is_binary(desc) -> desc
|
||
{:ok, %{"error" => err}} when is_binary(err) -> err
|
||
_ -> "invalid_token"
|
||
end
|
||
end
|
||
|
||
defp error_description(_), do: "invalid_token"
|
||
|
||
# -- App 管理 API(alterminal/bear#28;PAT Bearer、admin 限定)--
|
||
|
||
@doc """
|
||
`GET /api/v1/apps`:列出 App(分頁)。
|
||
|
||
`params` 為 keyword(`page:`/`per_page:`,可加 `visibility:`/`status:` 過濾)。
|
||
成功回 `{:ok, %{"data" => [apps], "page" => n, "per_page" => n, "total" => n}}`。
|
||
"""
|
||
def apps_list(issuer, token, params \\ []) do
|
||
api_request(issuer, token, :get, "/api/v1/apps", params: params)
|
||
end
|
||
|
||
@doc "`GET /api/v1/apps/{id}`:單一 App(`client_secret` 永不回傳)。"
|
||
def apps_get(issuer, token, id) do
|
||
api_request(issuer, token, :get, "/api/v1/apps/" <> URI.encode(id))
|
||
end
|
||
|
||
@doc """
|
||
`POST /api/v1/apps`:建立 App。`method=client_secret` 且未給
|
||
`client_secret` 時由伺服器產生,成功回應內含一次性明文
|
||
(`%{"data" => app, "client_secret" => secret}`)。
|
||
"""
|
||
def apps_create(issuer, token, attrs) do
|
||
api_request(issuer, token, :post, "/api/v1/apps", json: attrs)
|
||
end
|
||
|
||
@doc "`PUT /api/v1/apps/{id}`:部分更新(只送有給的欄位)。"
|
||
def apps_update(issuer, token, id, attrs) do
|
||
api_request(issuer, token, :put, "/api/v1/apps/" <> URI.encode(id), json: attrs)
|
||
end
|
||
|
||
@doc """
|
||
`POST /api/v1/apps/{id}/rotate-secret`:輪轉 client secret,回應內含
|
||
一次性明文。PKCE App 回 422 `%{"error" => "pkce_app"}`。
|
||
"""
|
||
def apps_rotate_secret(issuer, token, id) do
|
||
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/rotate-secret",
|
||
json: %{}
|
||
)
|
||
end
|
||
|
||
@doc "`POST /api/v1/apps/{id}/toggle`:切換 active ↔ inactive。"
|
||
def apps_toggle(issuer, token, id) do
|
||
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/toggle", json: %{})
|
||
end
|
||
|
||
# App 管理 API 共用請求。回傳:
|
||
# {:ok, body} | {:error, :unauthorized, desc} | {:error, :forbidden, desc}
|
||
# | {:error, :not_found, desc} | {:error, :unprocessable_entity, body}
|
||
# | {:error, :server_error, status} | {:error, :network, reason}
|
||
defp api_request(issuer, token, method, path, extra \\ []) do
|
||
url = String.trim_trailing(issuer, "/") <> path
|
||
|
||
opts =
|
||
[
|
||
headers: [authorization: "Bearer " <> token, accept: "application/json"],
|
||
retry: false,
|
||
finch: [name: @finch]
|
||
]
|
||
|> Keyword.merge(extra)
|
||
|
||
case apply(Req, method, [url, opts]) do
|
||
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
||
{:ok, decode_body(body)}
|
||
|
||
{:ok, %Req.Response{status: 401, body: body}} ->
|
||
{:error, :unauthorized, error_description(decode_body(body))}
|
||
|
||
{:ok, %Req.Response{status: 403, body: body}} ->
|
||
{:error, :forbidden, error_description(decode_body(body))}
|
||
|
||
{:ok, %Req.Response{status: 404, body: body}} ->
|
||
{:error, :not_found, error_description(decode_body(body))}
|
||
|
||
{:ok, %Req.Response{status: 422, body: body}} ->
|
||
{:error, :unprocessable_entity, decode_body(body)}
|
||
|
||
{:ok, %Req.Response{status: status}} ->
|
||
{:error, :server_error, status}
|
||
|
||
{:error, exception} ->
|
||
{:error, :network, Exception.message(exception)}
|
||
end
|
||
end
|
||
|
||
@doc "`GET /api/v1/jwks`: list all keys (active and inactive), newest first (no pagination)."
|
||
def jwks_list(issuer, token) do
|
||
api_request(issuer, token, :get, "/api/v1/jwks")
|
||
end
|
||
|
||
@doc """
|
||
`POST /api/v1/jwks`: create a new signing key. attrs: `%{"kid" => "...", "alg" => "RS256"}`
|
||
(`alg` optional; RS256/384/512, ES256/384/512). Key material is never serialized.
|
||
"""
|
||
def jwks_create(issuer, token, attrs) do
|
||
api_request(issuer, token, :post, "/api/v1/jwks", json: attrs)
|
||
end
|
||
|
||
@doc "`GET /api/v1/jwks/{id}`: single key (public metadata only)."
|
||
def jwks_get(issuer, token, id) do
|
||
api_request(issuer, token, :get, "/api/v1/jwks/" <> URI.encode(id))
|
||
end
|
||
|
||
@doc "`POST /api/v1/jwks/{id}/toggle`: switch key between active and inactive."
|
||
def jwks_toggle(issuer, token, id) do
|
||
api_request(issuer, token, :post, "/api/v1/jwks/" <> URI.encode(id) <> "/toggle", json: %{})
|
||
end
|
||
|
||
# -- Accounts API (alterminal/bear#46; PAT Bearer, admin only) --
|
||
|
||
@doc """
|
||
`GET /api/v1/accounts`: paginated list (newest first; per_page max 100).
|
||
Returns `{:ok, %{"data" => [accounts], "page" => n, "per_page" => n, "total" => n}}`.
|
||
"""
|
||
def accounts_list(issuer, token, params \\ []) do
|
||
api_request(issuer, token, :get, "/api/v1/accounts", params: params)
|
||
end
|
||
|
||
@doc """
|
||
`POST /api/v1/accounts`: create an account directly (no email verification).
|
||
attrs: `%{"email" => "...", "hash_password" => "<plaintext>", "role" => "user|admin"}`
|
||
(`role` optional, default `user`).
|
||
"""
|
||
def accounts_create(issuer, token, attrs) do
|
||
api_request(issuer, token, :post, "/api/v1/accounts", json: attrs)
|
||
end
|
||
|
||
@doc "`GET /api/v1/accounts/{id}`: single account (no credential fields)."
|
||
def accounts_get(issuer, token, id) do
|
||
api_request(issuer, token, :get, "/api/v1/accounts/" <> URI.encode(id))
|
||
end
|
||
|
||
@doc """
|
||
`PUT /api/v1/accounts/{id}`: update role; attrs: `%{"role" => "user|admin"}`.
|
||
"""
|
||
def accounts_update_role(issuer, token, id, attrs) do
|
||
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id), json: attrs)
|
||
end
|
||
|
||
@doc """
|
||
`PUT /api/v1/accounts/{id}/password`: admin sets a new password (no old password
|
||
needed); attrs: `%{"hash_password" => "<plaintext>"}`.
|
||
"""
|
||
def accounts_set_password(issuer, token, id, attrs) do
|
||
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id) <> "/password",
|
||
json: attrs
|
||
)
|
||
end
|
||
|
||
@doc "`DELETE /api/v1/accounts/{id}`: hard-delete an account (admin path). Returns `{:ok, %{}}` (204 empty)."
|
||
def accounts_delete(issuer, token, id) do
|
||
api_request(issuer, token, :delete, "/api/v1/accounts/" <> URI.encode(id))
|
||
end
|
||
|
||
# -- Audit logs API (alterminal/bear#46; PAT Bearer, admin only) --
|
||
|
||
@doc """
|
||
`GET /api/v1/audit-logs`: paginated list (newest first). params: `page:` /
|
||
`per_page:` (max 200) / `category:` filter. Returns `{:ok, %{"data" => [entries],
|
||
"page" => n, "per_page" => n, "total" => n, "total_pages" => n,
|
||
"emails" => %{id => email}}}`.
|
||
"""
|
||
def audit_logs_list(issuer, token, params \\ []) do
|
||
api_request(issuer, token, :get, "/api/v1/audit-logs", params: params)
|
||
end
|
||
end
|