Powered by AppSignal & Oban Pro

Authentication with AzureSDK

livebooks/authentication.livemd

Authentication with AzureSDK

Mix.install([
  # {:azure_sdk, path: Path.expand("..", __DIR__)},
  {:azure_sdk, "~> 0.4.1"},
  {:plug, "~> 1.6"}
])

Introduction

Request credentials (Shared Key, SAS) run against Azurite. Token credentials (Entra ID) acquire tokens from Microsoft's identity platform, not from Azurite.

Azurite vs Entra:

  • Azurite emulates Storage (Blob/Queue/Table). Shared Key and SAS work locally.
  • Azurite is not an identity provider. It does not host login.microsoftonline.com or simulate ClientSecretCredential token exchange.
  • Azurite can accept Bearer JWTs on storage requests when started with --oauth basic and HTTPS, but the token still comes from real Entra (or the Req plug: mock in the Client secret cell below).

Azurite check

Start the emulator before the Shared Key section:

docker compose up -d
case :gen_tcp.connect(~c"127.0.0.1", 10_000, [:binary, active: false], 500) do
  {:ok, socket} ->
    :gen_tcp.close(socket)
    :azurite_ok

  {:error, _} ->
    "Start Azurite: docker compose up -d"
end

Shared Key (Azurite)

Works locally with the well-known devstore account key.

shared_key =
  AzureSDK.Identity.SharedKeyCredential.new(
    "devstoreaccount1",
    "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
  )

client =
  AzureSDK.Storage.Client.new(
    account: "devstoreaccount1",
    credential: shared_key,
    endpoint: "http://127.0.0.1:10000/devstoreaccount1"
  )

case :gen_tcp.connect(~c"127.0.0.1", 10_000, [:binary, active: false], 500) do
  {:ok, socket} ->
    :gen_tcp.close(socket)

    {:ok, containers} = AzureSDK.Storage.Container.list(client)
    {:shared_key, length(containers)}

  {:error, _} ->
    {:shared_key, :azurite_not_running}
end

SAS credential

Parses a SAS token into query parameters (no network call).

sas =
  AzureSDK.Identity.SASCredential.new(
    "sv=2021-06-08&ss=b&srt=sco&sp=rwdlacup&se=2030-01-01T00:00:00Z&sig=example"
  )

{:sas, sas.params["sv"]}

Client secret (Entra ID)

ClientSecretCredential POSTs to {authority}/{tenant}/oauth2/v2.0/token, which is Microsoft Entra, not Azurite.

When AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET are set, this cell uses your real tenant. Otherwise it uses Req's in-memory plug: mock (Bypass only works inside ExUnit, not Livebook).

present? = fn
  nil -> false
  "" -> false
  value -> String.trim(value) != ""
end

tenant = System.get_env("AZURE_TENANT_ID")
client_id = System.get_env("AZURE_CLIENT_ID")
client_secret = System.get_env("AZURE_CLIENT_SECRET")

use_live? = present?.(tenant) and present?.(client_id) and present?.(client_secret)

fetch_token = fn credential, req_options ->
  AzureSDK.Identity.TokenCredential.get_token(
    credential,
    ["https://storage.azure.com/.default"],
    req_options: req_options
  )
end

if use_live? do
  credential =
    AzureSDK.Identity.ClientSecretCredential.new(
      tenant_id: tenant,
      client_id: client_id,
      client_secret: client_secret
    )

  case fetch_token.(credential, []) do
    {:ok, token} ->
      {:client_secret, :live, byte_size(token.token), token.expires_at}

    {:error, error} ->
      {:client_secret, :live_error, error.code, error.message}
  end
else
  mock_plug = fn conn ->
    conn
    |> Plug.Conn.put_resp_content_type("application/json")
    |> Plug.Conn.resp(
      200,
      Jason.encode!(%{
        "access_token" => "mock-token-from-plug",
        "expires_in" => 3600,
        "token_type" => "Bearer"
      })
    )
  end

  credential =
    AzureSDK.Identity.ClientSecretCredential.new(
      tenant_id: "demo-tenant",
      client_id: "demo-client",
      client_secret: "demo-secret",
      authority_host: "http://login.example.com"
    )

  case fetch_token.(credential, plug: mock_plug) do
    {:ok, token} ->
      {:client_secret, :mocked_entra, byte_size(token.token), token.expires_at}

    {:error, error} ->
      {:client_secret, :mock_error, error.code, error.message}
  end
end

DefaultAzureCredential

Builds a chain: Environment → Managed Identity. Environment already covers client-secret and workload-identity when the matching env vars are set.

On a typical laptop with no Azure env vars and no IMDS endpoint, get_token/2 returns CredentialUnavailable after a short managed-identity probe.

credential = AzureSDK.Identity.DefaultAzureCredential.new()

case AzureSDK.Identity.TokenCredential.get_token(
       credential,
       ["https://storage.azure.com/.default"]
     ) do
  {:ok, token} ->
    {:default_azure_credential, :ok, byte_size(token.token)}

  {:error, %{code: code, message: message}} ->
    {:default_azure_credential, code, message}
end

Using a Bearer token against storage

After get_token/3, the pipeline applies Authorization: Bearer …. Against a real Azure Blob Storage account, pass a token credential to Storage.Client.new/1.

Azurite accepts Bearer tokens only when started with OAuth + HTTPS (azurite --oauth basic --cert … --key …). This repo's docker-compose.yml uses plain HTTP + Shared Key, so Bearer against local Azurite is out of scope here.

For local development, prefer Shared Key against Azurite (sections above). Use Entra credentials when targeting a real storage account in Azure.