Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions lib/goth/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, []}`
Expand Down Expand Up @@ -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" => "<IMPERSONATED_ACCOUNT_EMAIL>"}
...> 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: "<IMPERSONATED_ACCOUNT_EMAIL>"]})
{:ok, %Goth.Token{...}}

#### Retrieve the token using a refresh token:

iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
Expand Down Expand Up @@ -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)

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(" ")
Expand All @@ -315,13 +328,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 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"
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"], " ")})
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"}]
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)
Expand Down
35 changes: 34 additions & 1 deletion test/goth/token_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()

Expand Down
Loading