Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225defmodule GitSync.OidcProvider do @moduledoc """ A minimal OpenID provider, enough for `oidcc` to complete a code flow against in tests. It signs ID tokens with a throwaway RSA key published at its own JWKS endpoint, and refuses a token request whose PKCE verifier does not match the challenge it saw on the authorization request. """
use Plug.Router
@state __MODULE__.State
plug :match plug :fetch_query_params plug Plug.Parsers, parsers: [:urlencoded] plug :dispatch
@doc """ Starts the provider on a random port and returns its issuer URL. """ def start(subject: subject, client_id: client_id) do ExUnit.Callbacks.start_supervised!(%{ id: @state, start: {Agent, :start_link, [ fn -> %{ jwk: JOSE.JWK.generate_key({:rsa, 2048}), subject: subject, client_id: client_id, refresh_token: "forgejo-refresh-token", generation: 0, refresh_observer: nil } end, [name: @state] ]} })
server = ExUnit.Callbacks.start_supervised!({Bandit, plug: __MODULE__, port: 0, startup_log: false})
{:ok, {_ip, port}} = ThousandIsland.listener_info(server)
issuer = "http://127.0.0.1:#{port}" Agent.update(@state, &Map.put(&1, :issuer, issuer))
issuer end
@doc """ The PKCE code challenge seen on the authorization request. """ def code_challenge, do: Agent.get(@state, & &1[:code_challenge])
@doc """ The PKCE code verifier seen on the token request. """ def code_verifier, do: Agent.get(@state, & &1[:code_verifier])
def pause_refresh(observer), do: Agent.update(@state, &%{&1 | refresh_observer: observer})
get "/.well-known/openid-configuration" do issuer = get(:issuer)
json(conn, %{ issuer: issuer, authorization_endpoint: issuer <> "/authorize", token_endpoint: issuer <> "/token", userinfo_endpoint: issuer <> "/userinfo", jwks_uri: issuer <> "/jwks", scopes_supported: ~w(openid profile email), response_types_supported: ~w(code), subject_types_supported: ~w(public), id_token_signing_alg_values_supported: ~w(RS256), grant_types_supported: ~w(authorization_code refresh_token), code_challenge_methods_supported: ~w(S256 plain) }) end
get "/jwks" do {_modules, public} = get(:jwk) |> JOSE.JWK.to_public() |> JOSE.JWK.to_map()
json(conn, %{keys: [Map.merge(public, %{"use" => "sig", "alg" => "RS256"})]}) end
get "/authorize" do %{"redirect_uri" => redirect_uri, "state" => state, "nonce" => nonce} = conn.query_params
Agent.update(@state, fn provider -> provider |> Map.put(:nonce, nonce) |> Map.put(:code_challenge, conn.query_params["code_challenge"]) end)
conn |> put_resp_header("location", "#{redirect_uri}?code=the-code&state=#{URI.encode(state)}") |> send_resp(302, "") end
post "/token" do if conn.body_params["grant_type"] == "refresh_token", do: refresh(conn), else: authorization_code(conn) end
defp refresh(conn) do result = Agent.get_and_update(@state, fn provider -> if conn.body_params["refresh_token"] == provider.refresh_token do generation = provider.generation + 1 refresh_token = "forgejo-refresh-token-#{generation}"
{{:ok, generation, refresh_token, provider.refresh_observer}, %{ provider | generation: generation, refresh_token: refresh_token, refresh_observer: nil }} else {:error, provider} end end)
case result do {:ok, generation, refresh_token, observer} -> if observer do ref = make_ref() send(observer, {:refresh_started, self(), ref})
receive do {^ref, :continue} -> :ok end end
json(conn, %{ access_token: "forgejo-access-token-#{generation}", refresh_token: refresh_token, expires_in: 3600, token_type: "Bearer", id_token: id_token(), scope: "openid profile email" })
:error -> conn |> put_status(400) |> json(%{error: "invalid_grant", error_description: "cannot increase the grant counter"}) end end
defp authorization_code(conn) do verifier = conn.body_params["code_verifier"] Agent.update(@state, &Map.put(&1, :code_verifier, verifier))
if verified_challenge?(verifier) do json(conn, %{ access_token: "forgejo-access-token", refresh_token: "forgejo-refresh-token", expires_in: 3600, token_type: "Bearer", id_token: id_token(), scope: "openid profile email" }) else conn |> put_status(400) |> json(%{error: "invalid_grant"}) end end
get "/userinfo" do json(conn, %{sub: get(:subject), preferred_username: get(:subject)}) end
match _ do send_resp(conn, 404, "") end
defp verified_challenge?(nil), do: false
defp verified_challenge?(verifier) do challenge = Base.url_encode64(:crypto.hash(:sha256, verifier), padding: false)
challenge == code_challenge() end
defp id_token do now = System.system_time(:second)
claims = %{ "iss" => get(:issuer), "sub" => get(:subject), "aud" => get(:client_id), "exp" => now + 300, "iat" => now }
claims = Map.merge(claims, nonce_claim())
{_modules, token} = get(:jwk) |> JOSE.JWT.sign(%{"alg" => "RS256"}, claims) |> JOSE.JWS.compact()
token end
defp nonce_claim do case Agent.get(@state, & &1[:nonce]) do nil -> %{} nonce -> %{"nonce" => nonce} end end
defp get(key), do: Agent.get(@state, &Map.fetch!(&1, key))
defp json(conn, body) do conn |> put_resp_content_type("application/json") |> send_resp(conn.status || 200, Jason.encode!(body)) endend