defmodule Gemini.Auth.ADC do @moduledoc """ Application Default Credentials (ADC) for Google Cloud authentication. This module implements Google's Application Default Credentials (ADC) strategy, which provides a standardized way to obtain credentials for Google Cloud APIs without hardcoding authentication details in your application. ## ADC Credential Search Order ADC searches for credentials in the following order: 1. **Environment Variable**: `GOOGLE_APPLICATION_CREDENTIALS` pointing to a service account JSON file 2. **User Credentials**: `~/.config/gcloud/application_default_credentials.json` (created via `gcloud auth application-default login`) 3. **GCP Metadata Server**: Automatic credentials for code running on GCP infrastructure (Compute Engine, GKE, Cloud Run, Cloud Functions, App Engine) ## Features - Automatic credential discovery following Google's ADC standard - Token caching with automatic refresh - Support for service account and user credentials - Metadata server authentication for GCP workloads - Thread-safe token management ## Usage # Load credentials using ADC case ADC.load_credentials() do {:ok, credentials} -> # Get an access token case ADC.get_access_token(credentials) do {:ok, token} -> # Use token for API calls make_api_call(token) {:error, reason} -> Logger.error("Failed to get token: \#{reason}") end {:error, reason} -> Logger.error("No credentials found: \#{reason}") end ## Setting Up ADC ### Option 1: Service Account Key (Development) export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json" ### Option 2: User Credentials (Development) gcloud auth application-default login ### Option 3: Metadata Server (Production on GCP) # No setup required - automatically works on GCP infrastructure ## Token Caching Access tokens are automatically cached with a 5-minute refresh buffer. This means a token with a 1-hour TTL will be refreshed after 55 minutes, ensuring your application never uses an expired token. """ require Logger alias Gemini.Auth.{JWT, MetadataServer, TokenCache} @default_user_credentials_path Path.expand( "~/.config/gcloud/application_default_credentials.json" ) @oauth2_token_uri "https://oauth2.googleapis.com/token" @vertex_ai_scopes ["https://www.googleapis.com/auth/cloud-platform"] @type credentials :: {:service_account, service_account_credentials()} | {:user, user_credentials()} | {:metadata_server, metadata_server_credentials()} @type service_account_credentials :: %{ type: String.t(), project_id: String.t(), private_key_id: String.t(), private_key: String.t(), client_email: String.t(), client_id: String.t(), auth_uri: String.t(), token_uri: String.t(), auth_provider_x509_cert_url: String.t(), client_x509_cert_url: String.t() } @type user_credentials :: %{ type: String.t(), client_id: String.t(), client_secret: String.t(), refresh_token: String.t(), quota_project_id: String.t() | nil } @type metadata_server_credentials :: %{ source: :metadata_server, project_id: String.t() | nil } @type access_token :: String.t() @type error_reason :: String.t() @doc """ Load credentials following the ADC chain. Searches for credentials in the standard ADC order: 1. GOOGLE_APPLICATION_CREDENTIALS environment variable 2. User credentials file (~/.config/gcloud/application_default_credentials.json) 3. GCP metadata server ## Returns - `{:ok, credentials}` - Credentials found and loaded - `{:error, reason}` - No credentials found or loading failed ## Examples case ADC.load_credentials() do {:ok, {:service_account, creds}} -> Logger.info("Using service account: \#{creds.client_email}") {:ok, {:user, creds}} -> Logger.info("Using user credentials") {:ok, {:metadata_server, creds}} -> Logger.info("Using metadata server") {:error, reason} -> Logger.error("No credentials found: \#{reason}") end """ @spec load_credentials() :: {:ok, credentials()} | {:error, error_reason()} def load_credentials do Logger.debug("[ADC] Starting credential discovery") # 1. Check GOOGLE_APPLICATION_CREDENTIALS environment variable case System.get_env("GOOGLE_APPLICATION_CREDENTIALS") do nil -> Logger.debug("[ADC] GOOGLE_APPLICATION_CREDENTIALS not set") load_user_credentials_or_metadata() "" -> Logger.debug("[ADC] GOOGLE_APPLICATION_CREDENTIALS is empty") load_user_credentials_or_metadata() path -> Logger.debug("[ADC] Found GOOGLE_APPLICATION_CREDENTIALS: #{path}") load_service_account_file(path) end end @doc """ Get an access token from loaded credentials. Attempts to retrieve a cached token first. If no cached token exists or the cached token is expired, generates a new token and caches it. ## Parameters - `credentials`: Credentials tuple from `load_credentials/0` - `opts`: Optional keyword list - `:force_refresh` - Skip cache and force token refresh (default: false) - `:cache_key` - Custom cache key (default: auto-generated) ## Returns - `{:ok, access_token}` - Access token retrieved successfully - `{:error, reason}` - Failed to get access token ## Examples {:ok, creds} = ADC.load_credentials() # Get token (uses cache if available) {:ok, token} = ADC.get_access_token(creds) # Force refresh {:ok, fresh_token} = ADC.get_access_token(creds, force_refresh: true) """ @spec get_access_token(credentials(), keyword()) :: {:ok, access_token()} | {:error, error_reason()} def get_access_token(credentials, opts \\ []) def get_access_token({:service_account, creds}, opts) do cache_key = Keyword.get(opts, :cache_key, cache_key_for_service_account(creds)) force_refresh = Keyword.get(opts, :force_refresh, false) if force_refresh do generate_service_account_token(creds, cache_key) else case TokenCache.get(cache_key) do {:ok, token} -> {:ok, token} :error -> generate_service_account_token(creds, cache_key) end end end def get_access_token({:user, creds}, opts) do cache_key = Keyword.get(opts, :cache_key, cache_key_for_user(creds)) force_refresh = Keyword.get(opts, :force_refresh, false) if force_refresh do refresh_user_token(creds, cache_key) else case TokenCache.get(cache_key) do {:ok, token} -> {:ok, token} :error -> refresh_user_token(creds, cache_key) end end end def get_access_token({:metadata_server, _creds}, opts) do cache_key = Keyword.get(opts, :cache_key, :metadata_server_token) force_refresh = Keyword.get(opts, :force_refresh, false) if force_refresh do fetch_metadata_server_token(cache_key) else case TokenCache.get(cache_key) do {:ok, token} -> {:ok, token} :error -> fetch_metadata_server_token(cache_key) end end end @doc """ Refresh an access token. Forces a token refresh regardless of whether a cached token exists. This is equivalent to calling `get_access_token/2` with `force_refresh: true`. ## Parameters - `credentials`: Credentials tuple from `load_credentials/0` ## Returns - `{:ok, access_token}` - New access token generated successfully - `{:error, reason}` - Failed to refresh token ## Examples {:ok, creds} = ADC.load_credentials() {:ok, fresh_token} = ADC.refresh_token(creds) """ @spec refresh_token(credentials()) :: {:ok, access_token()} | {:error, error_reason()} def refresh_token(credentials) do get_access_token(credentials, force_refresh: true) end @doc """ Get project ID from credentials if available. Extracts the project ID from the loaded credentials. Useful for configuring Vertex AI which requires a project ID. ## Parameters - `credentials`: Credentials tuple from `load_credentials/0` ## Returns - `{:ok, project_id}` - Project ID extracted successfully - `{:error, reason}` - No project ID available in credentials ## Examples {:ok, creds} = ADC.load_credentials() case ADC.get_project_id(creds) do {:ok, project_id} -> # Use for Vertex AI %{project_id: project_id, location: "us-central1"} {:error, _} -> # Prompt user or use environment variable System.get_env("VERTEX_PROJECT_ID") end """ @spec get_project_id(credentials()) :: {:ok, String.t()} | {:error, error_reason()} def get_project_id({:service_account, %{project_id: project_id}}) when is_binary(project_id) do {:ok, project_id} end def get_project_id({:user, %{quota_project_id: project_id}}) when is_binary(project_id) do {:ok, project_id} end def get_project_id({:metadata_server, %{project_id: project_id}}) when is_binary(project_id) do {:ok, project_id} end def get_project_id({:metadata_server, _creds}) do # Try to fetch from metadata server MetadataServer.get_project_id() end def get_project_id(_credentials) do {:error, "No project ID available in credentials"} end @doc """ Check if ADC credentials are available. Performs a quick check to see if any credentials can be found via ADC, without actually loading them. ## Returns - `true` - Credentials are available - `false` - No credentials found ## Examples if ADC.available?() do Logger.info("ADC credentials available") else Logger.warning("No ADC credentials found") end """ @spec available?() :: boolean() def available? do case load_credentials() do {:ok, _} -> true {:error, _} -> false end end # Private helper functions defp load_user_credentials_or_metadata do # 2. Check user credentials file if File.exists?(@default_user_credentials_path) do Logger.debug("[ADC] Found user credentials file: #{@default_user_credentials_path}") load_user_credentials_file(@default_user_credentials_path) else Logger.debug("[ADC] User credentials file not found") # 3. Try metadata server load_metadata_server_credentials() end end defp load_service_account_file(path) do case File.read(path) do {:ok, content} -> case Jason.decode(content) do {:ok, %{"type" => "service_account"} = data} -> # Convert string keys to atom keys creds = for {key, val} <- data, into: %{} do {String.to_atom(key), val} end Logger.info("[ADC] Loaded service account credentials from #{path}") {:ok, {:service_account, creds}} {:ok, %{"type" => type}} -> {:error, "Unsupported credential type: #{type}"} {:ok, _} -> {:error, "Invalid service account file format"} {:error, reason} -> {:error, "Failed to parse JSON: #{inspect(reason)}"} end {:error, reason} -> error_msg = "Failed to read service account file: #{inspect(reason)}" Logger.warning("[ADC] #{error_msg}") # Continue to next credential source load_user_credentials_or_metadata() end end defp load_user_credentials_file(path) do case File.read(path) do {:ok, content} -> case Jason.decode(content) do {:ok, %{"type" => "authorized_user"} = data} -> creds = %{ type: data["type"], client_id: data["client_id"], client_secret: data["client_secret"], refresh_token: data["refresh_token"], quota_project_id: data["quota_project_id"] } Logger.info("[ADC] Loaded user credentials from #{path}") {:ok, {:user, creds}} {:ok, %{"type" => type}} -> {:error, "Unsupported credential type: #{type}"} {:ok, _} -> {:error, "Invalid user credentials file format"} {:error, reason} -> {:error, "Failed to parse JSON: #{inspect(reason)}"} end {:error, reason} -> error_msg = "Failed to read user credentials file: #{inspect(reason)}" Logger.warning("[ADC] #{error_msg}") # Continue to metadata server load_metadata_server_credentials() end end defp load_metadata_server_credentials do if MetadataServer.available?() do Logger.info("[ADC] Using GCP metadata server credentials") # Try to get project ID, but don't fail if unavailable project_id = case MetadataServer.get_project_id() do {:ok, id} -> id {:error, _} -> nil end {:ok, {:metadata_server, %{source: :metadata_server, project_id: project_id}}} else Logger.warning("[ADC] Metadata server not available") {:error, "No credentials found via ADC. Please set GOOGLE_APPLICATION_CREDENTIALS or run 'gcloud auth application-default login'"} end end defp generate_service_account_token(creds, cache_key) do Logger.debug("[ADC] Generating service account access token") try do # Create JWT for token exchange now = System.system_time(:second) jwt_payload = %{ iss: creds.client_email, sub: creds.client_email, aud: creds.token_uri, iat: now, exp: now + 3600, scope: Enum.join(@vertex_ai_scopes, " ") } case JWT.sign_with_key(jwt_payload, creds) do {:ok, assertion} -> exchange_jwt_for_token(assertion, creds.token_uri, cache_key) {:error, reason} -> {:error, "Failed to sign JWT: #{inspect(reason)}"} end rescue error in KeyError -> {:error, "Invalid service account credentials: missing #{error.key}"} error -> {:error, "Failed to generate service account token: #{inspect(error)}"} end end defp exchange_jwt_for_token(assertion, token_uri, cache_key) 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 }) case Req.post(token_uri, headers: headers, body: body) do {:ok, %Req.Response{status: 200, body: response_body}} -> parse_and_cache_token(response_body, cache_key) {: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: #{inspect(reason)}"} end end defp refresh_user_token(creds, cache_key) do Logger.debug("[ADC] Refreshing user access token") headers = [{"Content-Type", "application/x-www-form-urlencoded"}] body = URI.encode_query(%{ "client_id" => creds.client_id, "client_secret" => creds.client_secret, "refresh_token" => creds.refresh_token, "grant_type" => "refresh_token" }) case Req.post(@oauth2_token_uri, headers: headers, body: body) do {:ok, %Req.Response{status: 200, body: response_body}} -> parse_and_cache_token(response_body, cache_key) {:ok, %Req.Response{status: status, body: body}} -> error_body = if is_binary(body), do: body, else: inspect(body) {:error, "Token refresh failed with HTTP #{status}: #{error_body}"} {:error, reason} -> {:error, "Token refresh request failed: #{inspect(reason)}"} end end defp fetch_metadata_server_token(cache_key) do Logger.debug("[ADC] Fetching token from metadata server") case MetadataServer.get_access_token() do {:ok, %{token: token, expires_in: ttl}} -> TokenCache.put(cache_key, token, ttl) {:ok, token} {:error, reason} -> {:error, "Failed to get token from metadata server: #{reason}"} end end defp parse_and_cache_token(response_body, cache_key) do case response_body do %{"access_token" => access_token, "expires_in" => expires_in} when is_binary(access_token) and is_integer(expires_in) -> TokenCache.put(cache_key, access_token, expires_in) Logger.debug("[ADC] Cached access token (TTL: #{expires_in}s)") {:ok, access_token} %{"access_token" => access_token} when is_binary(access_token) -> # Default to 1 hour if no expires_in TokenCache.put(cache_key, access_token, 3600) Logger.debug("[ADC] Cached access token (default TTL: 3600s)") {:ok, access_token} _ -> case Jason.decode(response_body) do {:ok, decoded} -> parse_and_cache_token(decoded, cache_key) {:error, reason} -> {:error, "Failed to parse token response: #{inspect(reason)}"} end end end defp cache_key_for_service_account(creds) do email = Map.get(creds, :client_email, "unknown") "adc_service_account_#{:erlang.phash2(email)}" end defp cache_key_for_user(creds) do client_id = Map.get(creds, :client_id, "unknown") "adc_user_#{:erlang.phash2(client_id)}" end end