From 23e77d38ce3acb35d949cf65fcb4331e0d14ab25 Mon Sep 17 00:00:00 2001 From: TzeYiing Date: Fri, 20 Jun 2025 17:12:27 +0800 Subject: [PATCH 1/2] feat: service account impersonation --- lib/goth/token.ex | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 5356ee5..542fd65 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -303,7 +303,7 @@ defmodule Goth.Token do defp request(%{source: {:service_account, credentials, options}} = config) when is_map(credentials) and is_list(options) do url = Keyword.get(options, :url, @default_url) - + sub = Keyword.get(options, :sub) claims = Keyword.get_lazy(options, :claims, fn -> scope = options |> Keyword.get(:scopes, @default_scopes) |> Enum.join(" ") @@ -315,13 +315,26 @@ defmodule Goth.Token do jwt = jwt_encode(claims, credentials) - headers = [{"content-type", "application/x-www-form-urlencoded"}] - grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer" - body = "grant_type=#{grant_type}&assertion=#{jwt}" - - response = request(config.http_client, method: :post, url: url, headers: headers, body: body) + if sub do + # handle SA impersonation + caller_headers = [{"content-type", "application/x-www-form-urlencoded"}] + caller_grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer" + caller_body = "grant_type=#{caller_grant_type}&assertion=#{jwt}" + with {:ok, %{token: token}} <- request(config.http_client, method: :post, url: url, headers: caller_headers, body: caller_body) |> handle_response() do + headers = [{"content-type", "application/json"}, {"Authorization", "Bearer #{token}"}] + body = Jason.encode!(%{scope: String.split(claims["scope"], " ")}) + url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{sub}:generateAccessToken" + request(config.http_client, method: :post, url: url, headers: headers, body: body) + end + else + headers = [{"content-type", "application/x-www-form-urlencoded"}] + grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer" + body = "grant_type=#{grant_type}&assertion=#{jwt}" - case handle_response(response) do + request(config.http_client, method: :post, url: url, headers: headers, body: body) + end + |> handle_response() + |> case do {:ok, token} -> sub = Map.get(claims, "sub", token.sub) scope = Map.get(claims, "scope", token.scope) From da52290dda9f0bc6bcefa86a77622d9251d4f527 Mon Sep 17 00:00:00 2001 From: TzeYiing Date: Thu, 25 Sep 2025 13:38:23 +0800 Subject: [PATCH 2/2] docs: add documentation and tests, add :iam_url option for testing --- lib/goth/token.ex | 23 ++++++++++++++++++----- test/goth/token_test.exs | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 542fd65..6965e79 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -101,6 +101,11 @@ defmodule Goth.Token do * `:claims` - self-signed JWT extra claims. Should be a map with string keys only. A self-signed JWT will be [exchanged for a Google-signed ID token](https://cloud.google.com/functions/docs/securing/authenticating#exchanging_a_self-signed_jwt_for_a_google-signed_id_token) + * `:impersonate_service_account` - the email of the service account to impersonate + + * `:iam_url` - the base URL of the IAM credentials service, defaults to: + `"https://iamcredentials.googleapis.com"` (used only when `:impersonate_service_account` is set) + #### Refresh token - `{:refresh_token, credentials}` Same as `{:refresh_token, credentials, []}` @@ -201,13 +206,20 @@ defmodule Goth.Token do ...> Goth.Token.fetch(source: {:service_account, credentials, [claims: claims]}) {:ok, %Goth.Token{...}} - #### Generate an impersonated token using a service account credentials file: + #### Generate an impersonated token using a service account credentials file via claims: iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!() ...> claims = %{"sub" => ""} ...> Goth.Token.fetch(source: {:service_account, credentials, [claims: claims]}) {:ok, %Goth.Token{...}} + + #### Generate an impersonated token using a service account credentials file via [`projects.serviceAccounts.generateAccessToken`](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken): + + iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!() + ...> Goth.Token.fetch(source: {:service_account, credentials, [impersonate_service_account: ""]}) + {:ok, %Goth.Token{...}} + #### Retrieve the token using a refresh token: iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!() @@ -303,7 +315,8 @@ defmodule Goth.Token do defp request(%{source: {:service_account, credentials, options}} = config) when is_map(credentials) and is_list(options) do url = Keyword.get(options, :url, @default_url) - sub = Keyword.get(options, :sub) + impersonate_service_account = Keyword.get(options, :impersonate_service_account) + iam_url = Keyword.get(options, :iam_url, "https://iamcredentials.googleapis.com") claims = Keyword.get_lazy(options, :claims, fn -> scope = options |> Keyword.get(:scopes, @default_scopes) |> Enum.join(" ") @@ -315,7 +328,7 @@ defmodule Goth.Token do jwt = jwt_encode(claims, credentials) - if sub do + if impersonate_service_account do # handle SA impersonation caller_headers = [{"content-type", "application/x-www-form-urlencoded"}] caller_grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer" @@ -323,8 +336,8 @@ defmodule Goth.Token do with {:ok, %{token: token}} <- request(config.http_client, method: :post, url: url, headers: caller_headers, body: caller_body) |> handle_response() do headers = [{"content-type", "application/json"}, {"Authorization", "Bearer #{token}"}] body = Jason.encode!(%{scope: String.split(claims["scope"], " ")}) - url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{sub}:generateAccessToken" - request(config.http_client, method: :post, url: url, headers: headers, body: body) + impersonation_url = "#{iam_url}/v1/projects/-/serviceAccounts/#{impersonate_service_account}:generateAccessToken" + request(config.http_client, method: :post, url: impersonation_url, headers: headers, body: body) end else headers = [{"content-type", "application/x-www-form-urlencoded"}] diff --git a/test/goth/token_test.exs b/test/goth/token_test.exs index 4aee720..faea56c 100644 --- a/test/goth/token_test.exs +++ b/test/goth/token_test.exs @@ -32,7 +32,7 @@ defmodule Goth.TokenTest do assert token.sub == nil end - test "fetch/1 with service account and impersonating user" do + test "fetch/1 with service account and impersonating user via claims" do bypass = Bypass.open() default_scope = "https://www.googleapis.com/auth/cloud-platform" @@ -64,6 +64,39 @@ defmodule Goth.TokenTest do assert token.sub == "bob@example.com" end + + test "fetch/1 with service account and impersonating service account" do + oauth_bypass = Bypass.open() + iam_bypass = Bypass.open() + scope = "https://www.googleapis.com/auth/cloud-platform" + target = "target@example.com" + + Bypass.expect(oauth_bypass, fn conn -> + assert %{"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion} = featch_request_body(conn) + assert %{"iss" => "alice@example.com", "scope" => ^scope} = jwt_decode(assertion) + Plug.Conn.resp(conn, 200, ~s|{"access_token":"caller_token","scope":"#{scope}","expires_in":3599,"token_type":"Bearer"}|) + end) + + Bypass.expect(iam_bypass, fn conn -> + assert conn.request_path == "/v1/projects/-/serviceAccounts/#{target}:generateAccessToken" + assert {"authorization", "Bearer caller_token"} in conn.req_headers + assert {:ok, req_body, _} = Plug.Conn.read_body(conn) + assert %{"scope" => [^scope]} = Jason.decode!(req_body) + Plug.Conn.resp(conn, 200, ~s|{"accessToken":"impersonated_token","expireTime":"2024-06-30T00:00:00Z"}|) + end) + + config = %{ + source: {:service_account, random_service_account_credentials(), + url: "http://localhost:#{oauth_bypass.port}", + iam_url: "http://localhost:#{iam_bypass.port}", + impersonate_service_account: target} + } + + {:ok, token} = Goth.Token.fetch(config) + assert token.token == "impersonated_token" + assert token.scope == scope + end + test "fetch/1 with service account and multiple scopes" do bypass = Bypass.open()