defmodule BearCli.Api do @moduledoc """ Bear 伺服器 HTTP 介面(一律使用 `Req`)。 - `GET /userinfo`:PAT 模式身分驗證(issue #6)。 - App 管理 API `/api/v1/apps`(alterminal/bear#28;PAT Bearer、admin 限定)。 - 個人自助 API `/api/v1/profile*`(alterminal/bear#35;PAT Bearer、 任何有效帳號):profile/密碼/email/sessions/PAT/MFA。 - 管理端 API `/api/v1/jwks`/`/api/v1/accounts`/`/api/v1/audit-logs` (alterminal/bear#36; PAT Bearer, admin only). """ @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 # -- 個人自助 API(alterminal/bear#35;PAT Bearer、任何有效帳號)-- @doc "`GET /api/v1/profile`:完整個人資料(含 MFA 狀態)。" def profile_get(issuer, token) do api_request(issuer, token, :get, "/api/v1/profile") end @doc "`PUT /api/v1/profile`:更新個人資料欄位(只送有給的欄位)。" def profile_update(issuer, token, attrs) do api_request(issuer, token, :put, "/api/v1/profile", json: attrs) end @doc """ `POST /api/v1/profile/password`:變更密碼(body:`current_password`、 `new_password`、`password_confirmation`;伺服器驗舊密碼,不因 CLI 放寬)。 """ def profile_change_password(issuer, token, current_password, new_password) do api_request(issuer, token, :post, "/api/v1/profile/password", json: %{ current_password: current_password, new_password: new_password, password_confirmation: new_password } ) end @doc "`POST /api/v1/profile/email/request-codes`:寄發 email 變更雙向驗證碼。" def profile_email_request_codes(issuer, token, new_email) do api_request(issuer, token, :post, "/api/v1/profile/email/request-codes", json: %{new_email: new_email} ) end @doc """ `POST /api/v1/profile/email`:確認變更 email(body:`new_email`、 `current_code`、`new_code`)。 """ def profile_email_change(issuer, token, new_email, current_code, new_code) do api_request(issuer, token, :post, "/api/v1/profile/email", json: %{new_email: new_email, current_code: current_code, new_code: new_code} ) end @doc "`GET /api/v1/profile/sessions`:列出作用中 session(新到舊)。" def sessions_list(issuer, token) do api_request(issuer, token, :get, "/api/v1/profile/sessions") end @doc "`POST /api/v1/profile/sessions/{id}/revoke`:撤銷單一 session。" def sessions_revoke(issuer, token, id) do api_request(issuer, token, :post, "/api/v1/profile/sessions/" <> URI.encode(id) <> "/revoke", json: %{} ) end @doc """ `POST /api/v1/profile/sessions/revoke-others`:撤銷其他 session (body `keep` 可省略=全部撤銷)。 """ def sessions_revoke_others(issuer, token, keep \\ nil) do api_request(issuer, token, :post, "/api/v1/profile/sessions/revoke-others", json: %{keep: keep} |> Map.reject(fn {_k, v} -> is_nil(v) end) ) end @doc "`GET /api/v1/profile/tokens`:列出未撤銷的 PAT(新到舊)。" def tokens_list(issuer, token) do api_request(issuer, token, :get, "/api/v1/profile/tokens") end @doc """ `POST /api/v1/profile/tokens`:建立 PAT;明文 `token` 只在成功回應 出現一次(body:`name`、`expires_in`=天數或 `never`)。 """ def tokens_create(issuer, token, name, expires_in) do api_request(issuer, token, :post, "/api/v1/profile/tokens", json: %{name: name, expires_in: expires_in} ) end @doc "`POST /api/v1/profile/tokens/{id}/revoke`:撤銷單一 PAT。" def tokens_revoke(issuer, token, id) do api_request(issuer, token, :post, "/api/v1/profile/tokens/" <> URI.encode(id) <> "/revoke", json: %{} ) end @doc """ `POST /api/v1/profile/mfa/setup`(無 body):開始 TOTP 設定,回 `%{"data" => %{"secret" => ..., "otpauth_uri" => ...}}`。已啟用 MFA 回 422 `already_enabled`。 """ def mfa_setup(issuer, token) do api_request(issuer, token, :post, "/api/v1/profile/mfa/setup", json: %{}) end @doc """ `POST /api/v1/profile/mfa/setup/confirm`(body:`code`):確認設定並 啟用 MFA,回一次性 recovery codes。 """ def mfa_setup_confirm(issuer, token, code) do api_request(issuer, token, :post, "/api/v1/profile/mfa/setup/confirm", json: %{code: code}) end @doc """ `POST /api/v1/profile/mfa/disable`(body:`code`=目前 TOTP 或 recovery code):停用 MFA。 """ def mfa_disable(issuer, token, code) do api_request(issuer, token, :post, "/api/v1/profile/mfa/disable", json: %{code: code}) end @doc """ `POST /api/v1/profile/mfa/recovery-codes`(body:`code`):重產一組 一次性 recovery codes(舊組全部失效)。 """ def mfa_recovery_codes(issuer, token, code) do api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code}) end # -- 共用請求輔助 -- # 回傳: # {:ok, body} | {:error, :unauthorized, desc} | {:error, :forbidden, desc} # | {:error, :not_found, desc} | {:error, :unprocessable_entity, body} # | {:error, :too_many_requests, 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: 429, body: body}} -> {:error, :too_many_requests, 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" => "", "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