defmodule GitGudWeb.Api.Mobile.Auth do
@moduledoc """
Personal Access Token authentication for the mobile REST API.
Used two ways:
* as a **plug** in the `:mobile_api` pipeline — verifies the token,
assigns `:api_user` and `:api_scopes`, and 401s otherwise, so no
controller ever sees an unauthenticated request;
* as a **helper** inside each action — `require_scope/2` narrows to
the one scope that action needs.
The split matters: the pipeline can't know whether a request is a read
or a write, and a token scoped to `issues:read` must still be able to
reach `GET /issues` while being turned away from `POST /comments`.
The token arrives as `Authorization: token ggp_…` or
`Authorization: Bearer ggp_…` — the app sends the latter, the docs
show the former, and both are cheap to accept.
A CI **job token** (`GitGud.Workflows.RunnerJwt`, a job's
`GITHUB_TOKEN`) is accepted too, while its job runs. Its workflow's
`issues` / `pull-requests` permissions become the `issues:*` /
`pulls:*` scopes, and nothing else; the user is the forge's CI account
(`Accounts.ci_user!/0`) and `:api_job` carries the token's claims —
`Helpers.fetch_repo/5` limits it to the job's own repository, and
`refuse_job/2` keeps it off what no pipeline should do.
"""
import Plug.Conn
alias GitGud.Accounts
alias GitGud.Workflows.RunnerJwt
@behaviour Plug
@impl Plug
def init(opts), do: opts
@impl Plug
def call(conn, _opts) do
with token when is_binary(token) <- bearer(conn),
{:ok, conn} <- authenticate(conn, token) do
conn
else
_ -> deny(conn, 401, "invalid or missing API token")
end
end
defp authenticate(conn, token) do
case Accounts.verify_personal_access_token(token) do
{:ok, user, scopes} ->
{:ok, conn |> assign(:api_user, user) |> assign(:api_scopes, scopes)}
:error ->
case RunnerJwt.verify_active(token) do
{:ok, %{"pid" => pid} = claims} when is_integer(pid) ->
{:ok,
conn
|> assign(:api_user, Accounts.ci_user!())
|> assign(:api_scopes, job_scopes(claims))
|> assign(:api_job, claims)}
_ ->
:error
end
end
end
# What a job's permissions come to in this API's scopes.
defp job_scopes(claims) do
for {permission, scope} <- [{"issues", "issues"}, {"pull-requests", "pulls"}],
level <- [:read, :write],
RunnerJwt.permits?(claims, permission, level),
do: "#{scope}:#{level}"
end
@doc """
`:ok` for a person's token; a halted 403 for a job's, on what a
pipeline may not do at any permission level (`what` says what).
"""
def refuse_job(conn, what) do
if conn.assigns[:api_job],
do: {:error, deny(conn, 403, "a job token can't #{what}")},
else: :ok
end
@doc """
`{:ok, user}` when the presented token carries `scope`, otherwise a
halted 403 conn. Written to be used in a `with`, so an action reads as
a chain of the things that have to be true.
"""
def require_scope(conn, scope) do
if scope in (conn.assigns[:api_scopes] || []) do
{:ok, conn.assigns.api_user}
else
{:error, deny(conn, 403, "token is missing the '#{scope}' scope")}
end
end
defp bearer(conn) do
case get_req_header(conn, "authorization") do
["token " <> t | _] -> t
["Bearer " <> t | _] -> t
_ -> nil
end
end
defp deny(conn, status, message) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{"message" => message}))
|> halt()
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
3.6 KiB · text
History
b868907