Current section
Files
Jump to
Current section
Files
lib/gemini/auth/vertex_strategy.ex
defmodule Gemini.Auth.VertexStrategy do
@moduledoc """
Authentication strategy for Google Vertex AI using OAuth2/Service Account.
This strategy supports multiple authentication methods:
- Service Account JSON file (via VERTEX_JSON_FILE environment variable)
- OAuth2 access tokens
- Application Default Credentials (ADC)
Based on the Vertex AI documentation, this strategy can generate self-signed JWTs
for authenticated endpoints and standard Bearer tokens for regular API calls.
"""
@behaviour Gemini.Auth.Strategy
alias Gemini.Auth.JWT
@vertex_ai_scopes [
"https://www.googleapis.com/auth/cloud-platform"
]
@doc """
Get authentication headers for Vertex AI requests.
Supports multiple credential types:
- %{access_token: token} - Direct access token
- %{service_account_key: path} - Service account JSON file path
- %{service_account_data: data} - Service account JSON data
- %{jwt_token: token} - Pre-signed JWT token
"""
@impl true
def headers(%{access_token: access_token}) do
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer #{access_token}"}
]
end
def headers(%{jwt_token: jwt_token}) do
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer #{jwt_token}"}
]
end
def headers(%{service_account_key: _key_path} = credentials) do
case generate_access_token(credentials) do
{:ok, access_token} ->
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer #{access_token}"}
]
{:error, _reason} ->
# Fallback to placeholder - in production this should be handled properly
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer service-account-error-token"}
]
end
end
def headers(%{service_account_data: _data} = credentials) do
case generate_access_token(credentials) do
{:ok, access_token} ->
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer #{access_token}"}
]
{:error, _reason} ->
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer service-account-error-token"}
]
end
end
def headers(_credentials) do
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer default-credentials-token"}
]
end
@impl true
def base_url(%{project_id: _project_id, location: location}) do
"https://#{location}-aiplatform.googleapis.com/v1"
end
def base_url(%{project_id: _project_id}) do
{:error, "Location is required for Vertex AI base URL"}
end
def base_url(%{location: _location}) do
{:error, "Project ID is required for Vertex AI base URL"}
end
def base_url(_config) do
{:error, "Project ID and Location are required for Vertex AI base URL"}
end
@impl true
def build_path(model, endpoint, %{project_id: project_id, location: location}) do
# Vertex AI uses a different path structure
# Format: projects/{project}/locations/{location}/publishers/google/models/{model}:{endpoint}
normalized_model =
if String.starts_with?(model, "models/") do
String.replace_prefix(model, "models/", "")
else
model
end
"projects/#{project_id}/locations/#{location}/publishers/google/models/#{normalized_model}:#{endpoint}"
end
@impl true
def refresh_credentials(%{refresh_token: refresh_token} = credentials)
when is_binary(refresh_token) do
# TODO: Implement OAuth2 token refresh
# This would typically involve making a request to Google's OAuth2 token endpoint
# For now, return the existing credentials
{:ok, credentials}
end
def refresh_credentials(%{service_account_key: _key_path} = credentials) do
case generate_access_token(credentials) do
{:ok, access_token} ->
updated_credentials = Map.put(credentials, :access_token, access_token)
{:ok, updated_credentials}
{:error, reason} ->
{:error, reason}
end
end
def refresh_credentials(%{service_account_data: _data} = credentials) do
case generate_access_token(credentials) do
{:ok, access_token} ->
updated_credentials = Map.put(credentials, :access_token, access_token)
{:ok, updated_credentials}
{:error, reason} ->
{:error, reason}
end
end
def refresh_credentials(credentials) do
# For other credential types, return as-is
{:ok, credentials}
end
@doc """
Create a signed JWT for authenticated Vertex AI endpoints.
This is used for Vector Search endpoints with JWT authentication as described in v1.md.
## Parameters
- `service_account_email`: The service account email (issuer)
- `audience`: The audience specified during index deployment
- `credentials`: The credentials map containing authentication info
- `opts`: Additional options for JWT creation
## Examples
iex> credentials = %{service_account_key: "/path/to/key.json"}
iex> {:ok, jwt} = Gemini.Auth.VertexStrategy.create_signed_jwt(
...> "my-service@project.iam.gserviceaccount.com",
...> "my-app-audience",
...> credentials
...> )
"""
@spec create_signed_jwt(String.t(), String.t(), map(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def create_signed_jwt(service_account_email, audience, credentials, opts \\ []) do
cond do
service_account_key = Map.get(credentials, :service_account_key) ->
JWT.create_signed_token(
service_account_email,
audience,
Keyword.put(opts, :service_account_key, service_account_key)
)
service_account_data = Map.get(credentials, :service_account_data) ->
JWT.create_signed_token(
service_account_email,
audience,
Keyword.put(opts, :service_account_data, service_account_data)
)
access_token = Map.get(credentials, :access_token) ->
JWT.create_signed_token(
service_account_email,
audience,
Keyword.put(opts, :access_token, access_token)
)
true ->
{:error, "No suitable credentials found for JWT signing"}
end
end
@doc """
Authenticate with Vertex AI using various methods.
Supports the following authentication methods:
- OAuth2 with project_id and location
- Service Account with key file path
- Service Account with key data
- Direct access token
"""
def authenticate(%{project_id: project_id, location: location, auth_method: :oauth2})
when is_binary(project_id) and is_binary(location) do
# For OAuth2, we would typically refresh/validate the access token
{:ok,
%{
project_id: project_id,
location: location,
access_token: "oauth2-placeholder-token"
}}
end
def authenticate(%{
project_id: project_id,
location: location,
service_account_key: key_path,
auth_method: :service_account
})
when is_binary(project_id) and is_binary(location) and is_binary(key_path) do
case generate_access_token(%{service_account_key: key_path}) do
{:ok, access_token} ->
{:ok,
%{
project_id: project_id,
location: location,
access_token: access_token,
service_account_key: key_path
}}
{:error, reason} ->
{:error, reason}
end
end
def authenticate(%{
project_id: project_id,
location: location,
service_account_data: data,
auth_method: :service_account
})
when is_binary(project_id) and is_binary(location) and is_map(data) do
case generate_access_token(%{service_account_data: data}) do
{:ok, access_token} ->
{:ok,
%{
project_id: project_id,
location: location,
access_token: access_token,
service_account_data: data
}}
{:error, reason} ->
{:error, reason}
end
end
def authenticate(%{project_id: project_id, location: location, access_token: access_token})
when is_binary(project_id) and is_binary(location) and is_binary(access_token) do
{:ok,
%{
project_id: project_id,
location: location,
access_token: access_token
}}
end
def authenticate(%{project_id: project_id, location: location})
when is_binary(project_id) and is_binary(location) do
# Default to OAuth2 if no auth_method specified
authenticate(%{project_id: project_id, location: location, auth_method: :oauth2})
end
def authenticate(%{}) do
{:error, "Missing required fields: project_id and location"}
end
def authenticate(_config) do
{:error, "Invalid configuration for Vertex AI authentication"}
end
# Private helper functions
defp generate_access_token(%{service_account_key: key_path}) do
case JWT.load_service_account_key(key_path) do
{:ok, key_data} ->
generate_access_token_from_key(key_data)
{:error, reason} ->
{:error, "Failed to load service account key: #{reason}"}
end
end
defp generate_access_token(%{service_account_data: key_data}) do
generate_access_token_from_key(key_data)
end
defp generate_access_token(_credentials) do
{:error, "No service account credentials available"}
end
defp generate_access_token_from_key(key_data) do
# Create OAuth2 JWT for token exchange
# This follows the OAuth2 service account flow
now = System.system_time(:second)
# Create a JWT payload (without the scope - it goes in the token exchange request)
jwt_payload = %{
iss: key_data.client_email,
sub: key_data.client_email,
aud: key_data.token_uri,
iat: now,
exp: now + 3600
}
case JWT.sign_with_key(jwt_payload, key_data) do
{:ok, assertion} ->
# Include scope in the token exchange request
exchange_jwt_for_access_token(assertion, key_data.token_uri)
{:error, reason} ->
{:error, "Failed to sign OAuth2 JWT: #{inspect(reason)}"}
end
end
defp exchange_jwt_for_access_token(assertion, token_uri) do
headers = [
{"Content-Type", "application/x-www-form-urlencoded"}
]
body =
URI.encode_query(%{
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion" => assertion,
"scope" => Enum.join(@vertex_ai_scopes, " ")
})
case Req.post(token_uri, headers: headers, body: body) do
{:ok, %Req.Response{status: 200, body: response_body}} ->
case response_body do
%{"access_token" => access_token} ->
{:ok, access_token}
_ ->
case Jason.decode(response_body) do
{:ok, %{"access_token" => access_token}} ->
{:ok, access_token}
{:ok, response} ->
{:error, "Unexpected token response: #{inspect(response)}"}
{:error, reason} ->
{:error, "Failed to parse token response: #{reason}"}
end
end
{:ok, %Req.Response{status: status, body: body}} ->
error_body = if is_binary(body), do: body, else: inspect(body)
{:error, "Token exchange failed with HTTP #{status}: #{error_body}"}
{:error, reason} ->
{:error, "Token exchange request failed: #{reason}"}
end
end
end