Azure workloads authenticate to the Haijun API by presenting a JSON Web Token (JWT) issued by Microsoft Entra ID, then exchanging it for a short-lived Juglow access token. The setup follows the same shape on every Azure platform:
- Register the token audience: Create one app registration in your Microsoft Entra tenant to represent the Haijun API audience. Every workload in the tenant requests Entra tokens for it.
- Set up the identity for your platform: A managed identity on VMs, VM Scale Sets, App Service, Functions, and Container Apps, or Entra Workload Identity on AKS.
- Configure Juglow: Register your tenant's Entra issuer, create a service account, and write a federation rule that matches the token's claims.
- Exchange at runtime: Your workload exchanges its Entra-issued token at
POST /v1/oauth/tokenfor ansk-ant-oat01-...Juglow access token and calls Haijun with it.
On both paths the token you present to Juglow carries your tenant-specific Entra issuer and the managed identity's object ID in the sub and oid claims; only how the workload obtains that token differs. Pick the section for where your workload runs: Use a managed identity for VMs, VM Scale Sets, App Service, Functions, or Container Apps; Use Entra Workload Identity on AKS for AKS.
Prerequisites
- Familiarity with WIF concepts: service accounts, federation issuers, and federation rules.
- An Azure subscription with permission to assign managed identities (or configure Entra Workload Identity on AKS).
- Permission to create one app registration and service principal in your Microsoft Entra tenant (the shared Haijun API audience). Entra only issues tokens for an audience that exists in the tenant, so the Register the token audience step is required before any token request succeeds.
- Your Microsoft Entra tenant ID. Find it in the Azure portal under Microsoft Entra ID → Overview → Tenant ID.
- Permission to create service accounts, federation issuers, and federation rules in the Haijun Console for your Juglow organization.
Register the token audience
Microsoft Entra ID only issues a token when the requested audience exists in your tenant as an app registration with a service principal. Create one app registration to represent the Haijun API audience; every workload in the tenant can request tokens for it. Without this registration, token requests fail with a "resource not found in tenant" error (AADSTS50001 from the managed identity endpoints, AADSTS500011 from the Entra token endpoint).
# Create the app registration that represents the Haijun API audience.
APP_ID=$(az ad app create --display-name haijun-api-federation --query appId -o tsv)
# Request v2.0 access tokens and set the api://<APP_ID> identifier URI.
az ad app update --id "$APP_ID" \
--identifier-uris "api://$APP_ID" \
--set api.requestedAccessTokenVersion=2
# Create the service principal so the audience resolves in your tenant.
az ad sp create --id "$APP_ID"Note: Use the
api://identifier URI format. Entra restrictshttps://identifier URIs to verified domains of your own tenant, so a URI such ashttps://haijun.my.id/cannot be registered in most tenants;api://is accepted everywhere. WithrequestedAccessTokenVersion: 2, tokens for this audience are v2.0, which is what this guide assumes. If you reuse an existing registration that emits v1.0 tokens, see If your tokens are v1.0.
Use a managed identity
Use this path when your workload runs on a VM, a VM Scale Set, App Service, Functions, or Container Apps. The workload requests an Entra-issued JWT for its assigned managed identity from the platform's local token endpoint, then exchanges that JWT with Juglow.
Configure the managed identity
- Attach a managed identity
Enable a system-assigned or user-assigned managed identity on your Azure resource. In the Azure portal, open the resource, go to Identity, and turn on System assigned (or attach a user-assigned identity).
After the identity is created, note its Object (principal) ID. This GUID appears as both the sub and oid claims in the issued token, and your Juglow federation rule will match on it. You can find it on the resource's Identity page; for a user-assigned identity, it is the Object (principal) ID on the managed identity resource's Overview page. (A managed identity has only a service principal in Microsoft Entra ID, not an app registration.)
- Find the platform's token endpoint
The platform exposes a local token endpoint once the identity is attached:
- VMs and VM Scale Sets: IMDS at
http://169.254.169.254/metadata/identity/oauth2/tokenwith the headerMetadata: trueandapi-version=2018-02-01.
- App Service, Functions, and Container Apps: The URL in the
IDENTITY_ENDPOINTenvironment variable with the headerX-IDENTITY-HEADERset to the value ofIDENTITY_HEADER, andapi-version=2019-08-01. IMDS is not reachable on these platforms.
If the resource has more than one user-assigned managed identity, add client_id= to the token request to select one. Azure recommends always specifying it. Without it, the outcome depends on whether the resource also has a system-assigned identity enabled: if it does, the request silently falls back to that identity and then fails your federation rule's oid match; if it does not, the request fails outright as soon as a second user-assigned identity is attached.
- Decode a sample token
Request a token from the endpoint and decode its payload to confirm the claims your federation rule needs to match. (For the decode command, see Troubleshoot a failed exchange.) A v2.0 token for a managed identity carries these claims:
{
"iss": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
"sub": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"aud": "<APP_ID>",
"oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"tid": "<TENANT_ID>",
"azp": "<IDENTITY_CLIENT_ID>",
"ver": "2.0",
"exp": 1775527120
}| Claim | Value | Match this when |
|---|---|---|
oid | The managed identity's object ID, identical to sub | You want to authorize one specific managed identity. This is the default; the rule in Configure Juglow matches it. |
azp | The calling identity's client ID | You want to authorize every workload that shares one app registration. For a managed identity, azp is unique to that identity, so it is equivalent to oid. |
aud | The audience app registration's client ID (the GUID from Register the token audience) | Always. The rule's audience field must equal the token's aud value exactly. |
tid | Your tenant ID | You want defense in depth. The issuer URL already pins the tenant. |
If the decoded token's ver claim is 1.0, the claim names and values differ. See If your tokens are v1.0 before continuing.
Configure Juglow
In the Haijun Console, open Settings → Workload identity, click Connect workload, and select the Microsoft Entra tile. The wizard walks you through registering the issuer, creating a service account, and creating a federation rule.
The wizard creates these resources for you. Use the following values whether you enter them in the wizard or send them to the Admin API:
Federation issuer: Choose v2.0 (login.microsoftonline.com) in the wizard's Token issuer selector. (The selector defaults to v1; that default exists for tenants reusing older registrations that still emit v1.0 tokens.) Entra publishes an OIDC discovery document at the per-tenant issuer URL, so use discovery mode. Each Microsoft Entra tenant you federate needs its own issuer record.
{
"name": "azure-prod-tenant",
"issuer_url": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
"jwks": { "type": "discovery" },
"max_jwt_lifetime_seconds": 86400
}Warning: Managed identity workloads need
max_jwt_lifetime_seconds: 86400. Azure issues managed identity tokens with up to 24 hours betweeniatandexpbecause it caches each resource's token for that window and offers no way to force an early refresh, and the issuer's 1-hour default rejects those tokens, so the exchange fails with the opaque401authentication_errorresponse (messageAuthentication failed). The Connect workload wizard's Microsoft Entra tile creates the issuer withmax_jwt_lifetime_secondsset to7500and provides no field to change it during creation, so finish the wizard, then open Settings → Workload identity → Issuers, edit the issuer, and raise the value to86400. You can also update the issuer through the Admin API.
A longer accepted lifetime means a leaked Entra token stays exchangeable for longer. If a token leaks, the lever is disabling the federation rule; a tight oid match limits which identities can exchange a token in the first place, as described in Scope your rule.
Federation rule: Match on the managed identity's object ID and your tenant ID. For the v2.0 tokens this guide configures, the audience value is the audience app registration's client ID (the GUID from Register the token audience). Use the exact aud value from your decoded token.
{
"name": "azure-inference-worker",
"issuer_id": "fdis_...",
"match": {
"audience": "<APP_ID>",
"claims": {
"oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"tid": "<TENANT_ID>"
}
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}token_lifetime_seconds is the lifetime of the Juglow access token the exchange returns, not of the Entra token; the SDK refreshes it for you.
Acquire and use the token
At runtime your workload fetches its Entra token, exchanges it at POST /v1/oauth/token, and uses the returned bearer token to call Haijun. Each Juglow SDK handles the exchange and refresh loop when you supply identity_token_provider (typescript, php: identityTokenProvider; csharp: IdentityTokenProvider; go: option.WithFederationTokenProvider; java: federationTokenProvider), as shown in the following examples. The cURL tab shows the raw flow.
The samples fetch the managed identity token from the platform's token endpoint: IMDS on VMs and VM Scale Sets, or the IDENTITY_ENDPOINT service on App Service, Functions, and Container Apps. Replace in the api:// resource value with the audience app registration's client ID from Register the token audience.
Tip: If your workload already uses the Azure Identity client library, pass its token acquisition (
DefaultAzureCredentialwith the scopeapi://) to/.default identity_token_provider(typescript, php:identityTokenProvider; csharp:IdentityTokenProvider; go:option.WithFederationTokenProvider; java:federationTokenProvider) instead of calling the token endpoints directly. The library selects the correct endpoint on every Azure platform, including AKS with Entra Workload Identity.
# 1. Fetch the Entra-issued token (managed identity).
# On a VM or VM Scale Set, use IMDS. With multiple user-assigned
# identities, append &client_id=<IDENTITY_CLIENT_ID>.
ENTRA_TOKEN=$(curl -sS -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://<APP_ID>" \
| jq -r .access_token)
# On App Service, Functions, or Container Apps, use the local token
# service instead (IMDS is not reachable there):
# ENTRA_TOKEN=$(curl -sS -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
# "$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=api://<APP_ID>" \
# | jq -r .access_token)
# For AKS with Entra Workload Identity, use the two-hop exchange in the
# "Use Entra Workload Identity on AKS" section instead.
# 2. Exchange it for an Juglow access token.
RESPONSE=$(curl -sS https://haijun.my.id/v1/oauth/token \
-H "content-type: application/json" \
-d @- <<JSON
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$ENTRA_TOKEN",
"federation_rule_id": "$JUGLOW_FEDERATION_RULE_ID",
"organization_id": "$JUGLOW_ORGANIZATION_ID",
"service_account_id": "$JUGLOW_SERVICE_ACCOUNT_ID",
"workspace_id": "$JUGLOW_WORKSPACE_ID"
}
JSON
)
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
# 3. Call the Haijun API with the bearer token.
curl https://haijun.my.id/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "juglow-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "haijun-opus-5-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello from Azure"}]
}' | jq -r '.content[] | select(.type == "text") | .text' import os
import juglow
import requests
from juglow import WorkloadIdentityCredentials
# The audience app registration's identifier URI (see Register the token audience).
AUDIENCE = "api://<APP_ID>"
def fetch_entra_token() -> str:
"""Fetch a managed identity token from the platform's token endpoint."""
# With multiple user-assigned identities, add client_id=<IDENTITY_CLIENT_ID>
# to the request params to select one.
if endpoint := os.environ.get("IDENTITY_ENDPOINT"):
# App Service, Functions, Container Apps
response = requests.get(
endpoint,
headers={"X-IDENTITY-HEADER": os.environ["IDENTITY_HEADER"]},
params={"api-version": "2019-08-01", "resource": AUDIENCE},
timeout=5,
)
else:
# VM or VM Scale Set: Azure Instance Metadata Service (IMDS)
response = requests.get(
"http://169.254.169.254/metadata/identity/oauth2/token",
headers={"Metadata": "true"},
params={"api-version": "2018-02-01", "resource": AUDIENCE},
timeout=5,
)
response.raise_for_status()
return response.json()["access_token"]
client = juglow.Juglow(
credentials=WorkloadIdentityCredentials(
identity_token_provider=fetch_entra_token,
federation_rule_id=os.environ["JUGLOW_FEDERATION_RULE_ID"],
organization_id=os.environ["JUGLOW_ORGANIZATION_ID"],
service_account_id=os.environ["JUGLOW_SERVICE_ACCOUNT_ID"],
workspace_id=os.environ.get("JUGLOW_WORKSPACE_ID"),
),
)
message = client.messages.create(
model="haijun-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello from Azure"}],
)
print(next(block.text for block in message.content if block.type == "text")) import Juglow from "@juglow-ai/sdk";
import { oidcFederationProvider } from "@juglow-ai/sdk/lib/credentials/oidc-federation";
// The audience app registration's identifier URI (see Register the token audience).
const AUDIENCE = "api://<APP_ID>";
async function fetchEntraToken(): Promise<string> {
// App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
// VMs and VM Scale Sets use IMDS.
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
const identityEndpoint = process.env.IDENTITY_ENDPOINT;
const url = identityEndpoint
? `${identityEndpoint}?api-version=2019-08-01&resource=${AUDIENCE}`
: `http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=${AUDIENCE}`;
const headers: Record<string, string> = identityEndpoint
? { "X-IDENTITY-HEADER": process.env.IDENTITY_HEADER! }
: { Metadata: "true" };
const response = await fetch(url, { headers });
const body = (await response.json()) as { access_token: string };
return body.access_token;
}
const client = new Juglow({
credentials: oidcFederationProvider({
identityTokenProvider: fetchEntraToken,
federationRuleId: process.env.JUGLOW_FEDERATION_RULE_ID!,
organizationId: process.env.JUGLOW_ORGANIZATION_ID!,
serviceAccountId: process.env.JUGLOW_SERVICE_ACCOUNT_ID,
workspaceId: process.env.JUGLOW_WORKSPACE_ID,
baseURL: "https://haijun.my.id/",
fetch
})
});
const message = await client.messages.create({
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello from Azure" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
} package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/juglows/juglow-sdk-go"
"github.com/juglows/juglow-sdk-go/option"
)
// The audience app registration's identifier URI (see Register the token audience).
const audience = "api://<APP_ID>"
// fetchEntraToken fetches a managed identity token from the platform's token
// endpoint: IMDS on VMs and VM Scale Sets, or the IDENTITY_ENDPOINT service
// on App Service, Functions, and Container Apps.
func fetchEntraToken(ctx context.Context) (string, error) {
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
tokenURL := "http://169.254.169.254/metadata/identity/oauth2/token" +
"?api-version=2018-02-01&resource=" + audience
header, value := "Metadata", "true"
if endpoint := os.Getenv("IDENTITY_ENDPOINT"); endpoint != "" {
tokenURL = endpoint + "?api-version=2019-08-01&resource=" + audience
header, value = "X-IDENTITY-HEADER", os.Getenv("IDENTITY_HEADER")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", err
}
req.Header.Set(header, value)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("call token endpoint: %w", err)
}
defer resp.Body.Close()
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
return body.AccessToken, nil
}
func main() {
client := juglow.NewClient(
option.WithFederationTokenProvider(fetchEntraToken, option.FederationOptions{
FederationRuleID: os.Getenv("JUGLOW_FEDERATION_RULE_ID"),
OrganizationID: os.Getenv("JUGLOW_ORGANIZATION_ID"),
ServiceAccountID: os.Getenv("JUGLOW_SERVICE_ACCOUNT_ID"),
WorkspaceID: os.Getenv("JUGLOW_WORKSPACE_ID"),
}),
)
message, err := client.Messages.New(context.TODO(), juglow.MessageNewParams{
Model: juglow.ModelHaijunOpus5_5,
MaxTokens: 1024,
Messages: []juglow.MessageParam{
juglow.NewUserMessage(juglow.NewTextBlock("Hello from Azure")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(juglow.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
}
} HttpClient http = HttpClient.newHttpClient();
// The audience app registration's identifier URI (see Register the token audience).
String audience = "api://<APP_ID>";
// App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
// VMs and VM Scale Sets use IMDS.
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
String identityEndpoint = System.getenv("IDENTITY_ENDPOINT");
HttpRequest tokenRequest = identityEndpoint != null
? HttpRequest.newBuilder(URI.create(identityEndpoint + "?api-version=2019-08-01&resource=" + audience))
.header("X-IDENTITY-HEADER", System.getenv("IDENTITY_HEADER"))
.build()
: HttpRequest.newBuilder(URI.create("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=" + audience))
.header("Metadata", "true")
.build();
IdentityTokenProvider fetchEntraToken = () -> {
try {
var response = http.send(tokenRequest, HttpResponse.BodyHandlers.ofString());
return new ObjectMapper().readTree(response.body()).get("access_token").asText();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
JuglowClient client = JuglowOkHttpClient.builder()
.federationTokenProvider(
fetchEntraToken,
System.getenv("JUGLOW_FEDERATION_RULE_ID"),
System.getenv("JUGLOW_ORGANIZATION_ID"),
System.getenv("JUGLOW_SERVICE_ACCOUNT_ID"),
System.getenv("JUGLOW_WORKSPACE_ID"))
.build();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.HAIJUN_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello from Azure")
.build());
IO.println(message.content()); using Juglow.Credentials;
// ...
var credentials = new WorkloadIdentityCredentials(new WorkloadIdentityOptions
{
FederationRuleId = Environment.GetEnvironmentVariable("JUGLOW_FEDERATION_RULE_ID")!,
OrganizationId = Environment.GetEnvironmentVariable("JUGLOW_ORGANIZATION_ID"),
ServiceAccountId = Environment.GetEnvironmentVariable("JUGLOW_SERVICE_ACCOUNT_ID"),
WorkspaceId = Environment.GetEnvironmentVariable("JUGLOW_WORKSPACE_ID"),
IdentityTokenProvider = new EntraTokenProvider(),
});
using var client = new JuglowClient(new ClientOptions { Credentials = credentials });
var message = await client.Messages.Create(new()
{
Model = Model.HaijunOpus5_5,
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Hello from Azure" }],
});
foreach (var block in message.Content)
{
if (block.Value is TextBlock textBlock)
{
Console.WriteLine(textBlock.Text);
}
}
class EntraTokenProvider : IIdentityTokenProvider
{
// The audience app registration's identifier URI (see Register the token audience).
private const string Audience = "api://<APP_ID>";
private static readonly HttpClient httpClient = new();
public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
{
// App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
// VMs and VM Scale Sets use IMDS.
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
var identityEndpoint = Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT");
using var request = identityEndpoint is not null
? new HttpRequestMessage(HttpMethod.Get,
$"{identityEndpoint}?api-version=2019-08-01&resource={Audience}")
{
Headers = { { "X-IDENTITY-HEADER", Environment.GetEnvironmentVariable("IDENTITY_HEADER") } },
}
: new HttpRequestMessage(HttpMethod.Get,
$"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={Audience}")
{
Headers = { { "Metadata", "true" } },
};
using var response = await httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var json = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(ct), default, ct);
return json.RootElement.GetProperty("access_token").GetString()!;
}
} use Juglow\Client;
use Juglow\Credentials\WorkloadIdentityCredentials;
// The audience app registration's identifier URI (see Register the token audience).
const AUDIENCE = 'api://<APP_ID>';
function fetchEntraToken(): string
{
// App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
// VMs and VM Scale Sets use IMDS.
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
$identityEndpoint = getenv('IDENTITY_ENDPOINT');
if ($identityEndpoint !== false) {
$url = $identityEndpoint . '?api-version=2019-08-01&resource=' . AUDIENCE;
$header = 'X-IDENTITY-HEADER: ' . getenv('IDENTITY_HEADER');
} else {
$url = 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=' . AUDIENCE;
$header = 'Metadata: true';
}
$context = stream_context_create([
'http' => ['header' => $header . "\r\n"],
]);
$body = json_decode(file_get_contents($url, false, $context), true);
return $body['access_token'];
}
$credentials = new WorkloadIdentityCredentials(
identityTokenProvider: fetchEntraToken(...),
federationRuleId: getenv('JUGLOW_FEDERATION_RULE_ID'),
organizationId: getenv('JUGLOW_ORGANIZATION_ID'),
serviceAccountId: getenv('JUGLOW_SERVICE_ACCOUNT_ID'),
workspaceId: getenv('JUGLOW_WORKSPACE_ID') ?: null,
);
$client = new Client(credentials: $credentials);
$message = $client->messages->create(
model: 'haijun-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello from Azure']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL; require "juglow"
require "json"
require "net/http"
# The audience app registration's identifier URI (see Register the token audience).
AUDIENCE = "api://<APP_ID>"
def fetch_entra_token
# App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
# VMs and VM Scale Sets use IMDS.
# With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
if (endpoint = ENV["IDENTITY_ENDPOINT"])
url = "#{endpoint}?api-version=2019-08-01&resource=#{AUDIENCE}"
headers = {"X-IDENTITY-HEADER" => ENV.fetch("IDENTITY_HEADER")}
else
url = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=#{AUDIENCE}"
headers = {"Metadata" => "true"}
end
response = Net::HTTP.get(URI(url), headers)
JSON.parse(response).fetch("access_token")
end
credentials = Juglow::WorkloadIdentityCredentials.new(
identity_token_provider: -> { fetch_entra_token },
federation_rule_id: ENV.fetch("JUGLOW_FEDERATION_RULE_ID"),
organization_id: ENV.fetch("JUGLOW_ORGANIZATION_ID"),
service_account_id: ENV.fetch("JUGLOW_SERVICE_ACCOUNT_ID"),
workspace_id: ENV["JUGLOW_WORKSPACE_ID"]
)
client = Juglow::Client.new(credentials: credentials)
message = client.messages.create(
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello from Azure"}]
)
puts message.content.find { it.type == :text }.text # Write the Entra-issued access token to a file the CLI can read.
# Shown for a VM or VM Scale Set (IMDS). On App Service, Functions, or
# Container Apps, fetch from "$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=api://<APP_ID>"
# with -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" instead.
# With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
JUGLOW_IDENTITY_TOKEN_FILE=$(mktemp)
trap 'rm -f "$JUGLOW_IDENTITY_TOKEN_FILE"' EXIT
curl -sS -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://<APP_ID>" \
| jq -r .access_token > "$JUGLOW_IDENTITY_TOKEN_FILE"
export JUGLOW_IDENTITY_TOKEN_FILE
# JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
# JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID are read from the environment.
ant messages create \
--model haijun-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello from Azure"}'Verify the setup
From your Azure resource, run the cURL exchange shown in Acquire and use the token and confirm that POST /v1/oauth/token returns a 200 with an access_token beginning with sk-ant-oat01- and an expires_in value in seconds. If the exchange fails with the opaque 401 authentication_error response (message Authentication failed), check the authentication history page for the deny reason, then decode the Entra token (see Troubleshoot a failed exchange for the command) and check the most common Azure-side causes:
- Issuer mismatch: The registered
issuer_urlmust match the token'sissclaim exactly. A v2.0 token carrieshttps://login.microsoftonline.com/; if the decoded/v2.0 verclaim is1.0, see If your tokens are v1.0.
- Token lifetime: Managed identity tokens carry up to 24 hours between
iatandexp. If the issuer still has the wizard's7500(or the 1-hour default), raisemax_jwt_lifetime_secondsto86400as described in Configure Juglow.
- Audience mismatch: The rule's
audiencemust equal the token'saudexactly: the audience app registration's client ID for the v2.0 tokens this guide configures.
- Claim name mismatch: A rule that matches on a claim the token does not carry never passes. v1.0 tokens carry the client ID in
appid, notazp; see If your tokens are v1.0.
Use Entra Workload Identity on AKS
Use this path when your workload runs in an AKS pod. Entra Workload Identity federates a Kubernetes service account with a user-assigned managed identity: Kubernetes projects a service account token (signed by the AKS cluster's OIDC issuer) into the pod at the path in AZURE_FEDERATED_TOKEN_FILE. That projected token is not an Entra-issued token, so to stay on the Entra-mediated path described on this page, the workload performs a two-hop exchange: it first redeems the projected token at https://login.microsoftonline.com/ (federated client_credentials grant) for an Entra-issued access token, then passes that Entra token to the Juglow SDK as the identity token.
Tip: AKS pods can alternatively skip the Entra exchange and present the Kubernetes-projected service account token to Juglow directly. That path registers your AKS cluster's OIDC issuer with Juglow instead of your Entra tenant. See Use WIF with Kubernetes for that flow.
Configure Entra Workload Identity
- Enable the OIDC issuer and workload identity on your cluster
Enabling workload identity installs the azure-workload-identity mutating webhook for you; deploy it manually only on non-AKS clusters. Capture the cluster's OIDC issuer URL for the federated credential you create in a later step.
az aks update \
--resource-group <RESOURCE_GROUP> \
--name <CLUSTER_NAME> \
--enable-oidc-issuer \
--enable-workload-identity
AKS_OIDC_ISSUER=$(az aks show \
--resource-group <RESOURCE_GROUP> \
--name <CLUSTER_NAME> \
--query oidcIssuerProfile.issuerUrl -o tsv)- Create a user-assigned managed identity
Capture two values from the identity: the Client ID goes into the service account annotation (and is injected into the pod as AZURE_CLIENT_ID), and the Object (principal) ID appears as the oid claim that your Juglow federation rule matches.
az identity create \
--resource-group <RESOURCE_GROUP> \
--name haijun-inference-identity \
--location <LOCATION>
# Goes in the service account annotation; injected into the pod as AZURE_CLIENT_ID.
IDENTITY_CLIENT_ID=$(az identity show \
--resource-group <RESOURCE_GROUP> \
--name haijun-inference-identity \
--query clientId -o tsv)
# Appears as the oid claim that your federation rule matches.
IDENTITY_OBJECT_ID=$(az identity show \
--resource-group <RESOURCE_GROUP> \
--name haijun-inference-identity \
--query principalId -o tsv)- Create the annotated Kubernetes service account
The azure-workload-identity webhook reads the azure.workload.identity/client-id annotation to inject AZURE_CLIENT_ID into the pod, which the samples in Acquire and use the token read from the environment.
apiVersion: v1
kind: ServiceAccount
metadata:
name: haijun-inference
namespace: inference
annotations:
azure.workload.identity/client-id: <IDENTITY_CLIENT_ID>- Create the federated credential on the managed identity
The federated credential trusts your cluster's OIDC issuer for that specific service account. The --audience api://AzureADTokenExchange value is Entra's fixed audience for incoming Kubernetes service account tokens; it is unrelated to the Haijun API audience you registered earlier.
az identity federated-credential create \
--resource-group <RESOURCE_GROUP> \
--identity-name haijun-inference-identity \
--name haijun-inference-aks \
--issuer "$AKS_OIDC_ISSUER" \
--subject system:serviceaccount:inference:haijun-inference \
--audience api://AzureADTokenExchange- Label the pod and set its service account
The pod must carry the azure.workload.identity/use: "true" label and run as the annotated service account. The webhook then injects AZURE_FEDERATED_TOKEN_FILE, AZURE_CLIENT_ID, and AZURE_TENANT_ID into the pod. The file at AZURE_FEDERATED_TOKEN_FILE contains the Kubernetes-projected service account token, signed by the AKS cluster's OIDC issuer.
apiVersion: v1
kind: Pod
metadata:
name: inference-worker
namespace: inference
labels:
azure.workload.identity/use: "true"
spec:
serviceAccountName: haijun-inference
containers:
- name: app
image: your-registry/inference-worker:latest- Decode a sample token
The token your Juglow federation rule sees is not the projected file; it is the Entra-issued token returned by the client_credentials exchange. From inside a labeled pod, run step 1 of the cURL sample in Acquire and use the token and decode the result. It carries the same claim shape as the managed identity path:
{
"iss": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
"sub": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"aud": "<APP_ID>",
"oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"tid": "<TENANT_ID>",
"azp": "<IDENTITY_CLIENT_ID>",
"ver": "2.0",
"exp": 1775527120
}sub and oid are the managed identity's object ID, aud is the audience app registration's client ID, and azp is the managed identity's client ID (the value of AZURE_CLIENT_ID). The lifetime differs from the managed identity path: client_credentials tokens default to a random 60 to 90 minute window between iat and exp, not 24 hours.
Configure Juglow
In the Haijun Console, open Settings → Workload identity, click Connect workload, and select the Microsoft Entra tile. The wizard walks you through registering the issuer, creating a service account, and creating a federation rule.
The wizard creates these resources for you. Use the following values whether you enter them in the wizard or send them to the Admin API:
Federation issuer: Choose v2.0 (login.microsoftonline.com) in the wizard's Token issuer selector. (The selector defaults to v1; that default exists for tenants reusing older registrations that still emit v1.0 tokens.) Entra publishes an OIDC discovery document at the per-tenant issuer URL, so use discovery mode. Each Microsoft Entra tenant you federate needs its own issuer record.
{
"name": "azure-prod-tenant",
"issuer_url": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
"jwks": { "type": "discovery" },
"max_jwt_lifetime_seconds": 7500
}Warning: The Connect workload wizard's Microsoft Entra tile creates the issuer with
max_jwt_lifetime_secondsset to7500(just over 2 hours), which covers the default 60 to 90 minute lifetime ofclient_credentialstokens. A tenant token-lifetime policy or Continuous Access Evaluation (CAE) can extend that lifetime. If your decoded token'sexpminusiatexceeds 7500 seconds, edit the issuer in Settings → Workload identity → Issuers and raisemax_jwt_lifetime_secondsto match, or exchanges fail with the opaque401authentication_errorresponse (messageAuthentication failed). If your tenant also runs managed-identity workloads from Use a managed identity, use that section's86400value, which covers both paths.
A longer accepted lifetime means a leaked Entra token stays exchangeable for longer. If a token leaks, the lever is disabling the federation rule; a tight oid match limits which identities can exchange a token in the first place, as described in Scope your rule.
Federation rule: Match on the managed identity's object ID and your tenant ID. For the v2.0 tokens this guide configures, the audience value is the audience app registration's client ID (the GUID from Register the token audience). Use the exact aud value from your decoded token.
{
"name": "azure-inference-worker",
"issuer_id": "fdis_...",
"match": {
"audience": "<APP_ID>",
"claims": {
"oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
"tid": "<TENANT_ID>"
}
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}token_lifetime_seconds is the lifetime of the Juglow access token the exchange returns, not of the Entra token; the SDK refreshes it for you.
Acquire and use the token
At runtime the pod performs the two-hop exchange: it sends the Kubernetes-projected token (the file at AZURE_FEDERATED_TOKEN_FILE) to Entra's token endpoint as a federated client_credentials assertion, then exchanges the resulting Entra access token at POST /v1/oauth/token. Each Juglow SDK handles the second exchange and the refresh loop when you pass the Entra fetch to identity_token_provider (typescript, php: identityTokenProvider; csharp: IdentityTokenProvider; go: option.WithFederationTokenProvider; java: federationTokenProvider), as shown in the following examples. The cURL tab shows the raw flow.
Two different client IDs appear in the samples. is the audience app registration's client ID from Register the token audience; the scope api:// asks Entra for a token addressed to that audience. $AZURE_CLIENT_ID is the managed identity's client ID, injected by the webhook, and identifies the caller. Do not substitute one for the other.
Tip: If your workload already uses the Azure Identity client library, pass its token acquisition (
DefaultAzureCredentialwith the scopeapi://) to/.default identity_token_provider(typescript, php:identityTokenProvider; csharp:IdentityTokenProvider; go:option.WithFederationTokenProvider; java:federationTokenProvider) instead of performing the two-hop exchange yourself. The library reads the sameAZURE_FEDERATED_TOKEN_FILE,AZURE_CLIENT_ID, andAZURE_TENANT_IDenvironment variables and handles the Entra exchange.
# 1. Exchange the Kubernetes-projected token (at $AZURE_FEDERATED_TOKEN_FILE)
# for an Entra-issued JWT.
ENTRA_JWT=$(curl -sS "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
-d grant_type=client_credentials \
-d "client_id=$AZURE_CLIENT_ID" \
--data-urlencode "scope=api://<APP_ID>/.default" \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode "client_assertion@$AZURE_FEDERATED_TOKEN_FILE" \
| jq -r .access_token)
# 2. Exchange the Entra JWT for an Juglow access token.
ACCESS_TOKEN=$(curl -sS https://haijun.my.id/v1/oauth/token \
-H "content-type: application/json" \
-d @- <<JSON | jq -r .access_token
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$ENTRA_JWT",
"federation_rule_id": "$JUGLOW_FEDERATION_RULE_ID",
"organization_id": "$JUGLOW_ORGANIZATION_ID",
"service_account_id": "$JUGLOW_SERVICE_ACCOUNT_ID",
"workspace_id": "$JUGLOW_WORKSPACE_ID"
}
JSON
)
# 3. Call the Haijun API.
curl -sS https://haijun.my.id/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "juglow-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "haijun-opus-5-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello from Azure"}]
}' | jq -r '.content[] | select(.type == "text") | .text' import os
from pathlib import Path
import juglow
import requests
from juglow import WorkloadIdentityCredentials
def fetch_entra_token_via_federation() -> str:
federated_token = Path(os.environ["AZURE_FEDERATED_TOKEN_FILE"]).read_text()
response = requests.post(
f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}/oauth2/v2.0/token",
data={
"client_id": os.environ["AZURE_CLIENT_ID"],
"grant_type": "client_credentials",
"scope": "api://<APP_ID>/.default",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": federated_token,
},
timeout=5,
)
response.raise_for_status()
return response.json()["access_token"]
client = juglow.Juglow(
credentials=WorkloadIdentityCredentials(
identity_token_provider=fetch_entra_token_via_federation,
federation_rule_id=os.environ["JUGLOW_FEDERATION_RULE_ID"],
organization_id=os.environ["JUGLOW_ORGANIZATION_ID"],
service_account_id=os.environ["JUGLOW_SERVICE_ACCOUNT_ID"],
workspace_id=os.environ.get("JUGLOW_WORKSPACE_ID"),
),
)
message = client.messages.create(
model="haijun-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello from Azure"}],
)
print(next(block.text for block in message.content if block.type == "text")) import Juglow from "@juglow-ai/sdk";
import { oidcFederationProvider } from "@juglow-ai/sdk/lib/credentials/oidc-federation";
import { readFile } from "node:fs/promises";
async function fetchEntraTokenViaFederation(): Promise<string> {
const federatedToken = await readFile(process.env.AZURE_FEDERATED_TOKEN_FILE!, "utf8");
const response = await fetch(
`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AZURE_CLIENT_ID!,
grant_type: "client_credentials",
scope: "api://<APP_ID>/.default",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: federatedToken
})
}
);
const body = (await response.json()) as { access_token: string };
return body.access_token;
}
const client = new Juglow({
credentials: oidcFederationProvider({
identityTokenProvider: fetchEntraTokenViaFederation,
federationRuleId: process.env.JUGLOW_FEDERATION_RULE_ID!,
organizationId: process.env.JUGLOW_ORGANIZATION_ID!,
serviceAccountId: process.env.JUGLOW_SERVICE_ACCOUNT_ID,
workspaceId: process.env.JUGLOW_WORKSPACE_ID,
baseURL: "https://haijun.my.id/",
fetch
})
});
const message = await client.messages.create({
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello from Azure" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
} package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/juglows/juglow-sdk-go"
"github.com/juglows/juglow-sdk-go/option"
)
func fetchEntraTokenViaFederation(ctx context.Context) (string, error) {
federatedToken, err := os.ReadFile(os.Getenv("AZURE_FEDERATED_TOKEN_FILE"))
if err != nil {
return "", err
}
form := url.Values{
"client_id": {os.Getenv("AZURE_CLIENT_ID")},
"grant_type": {"client_credentials"},
"scope": {"api://<APP_ID>/.default"},
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
"client_assertion": {strings.TrimSpace(string(federatedToken))},
}
tokenURL := "https://login.microsoftonline.com/" + os.Getenv("AZURE_TENANT_ID") + "/oauth2/v2.0/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("content-type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", err
}
return body.AccessToken, nil
}
func main() {
client := juglow.NewClient(
option.WithFederationTokenProvider(option.IdentityTokenFunc(fetchEntraTokenViaFederation), option.FederationOptions{
FederationRuleID: os.Getenv("JUGLOW_FEDERATION_RULE_ID"),
OrganizationID: os.Getenv("JUGLOW_ORGANIZATION_ID"),
ServiceAccountID: os.Getenv("JUGLOW_SERVICE_ACCOUNT_ID"),
WorkspaceID: os.Getenv("JUGLOW_WORKSPACE_ID"),
}),
)
message, err := client.Messages.New(context.TODO(), juglow.MessageNewParams{
Model: juglow.ModelHaijunOpus5_5,
MaxTokens: 1024,
Messages: []juglow.MessageParam{
juglow.NewUserMessage(juglow.NewTextBlock("Hello from Azure")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(juglow.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
}
} IdentityTokenProvider fetchEntraTokenViaFederation = () -> {
try {
var form = Map.of(
"client_id", System.getenv("AZURE_CLIENT_ID"),
"grant_type", "client_credentials",
"scope", "api://<APP_ID>/.default",
"client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion", Files.readString(Path.of(System.getenv("AZURE_FEDERATED_TOKEN_FILE"))))
.entrySet().stream()
.map(entry -> entry.getKey() + "=" + URLEncoder.encode(entry.getValue(), UTF_8))
.collect(Collectors.joining("&"));
var request = HttpRequest.newBuilder(URI.create(
"https://login.microsoftonline.com/" + System.getenv("AZURE_TENANT_ID") + "/oauth2/v2.0/token"))
.header("content-type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return new ObjectMapper().readTree(response.body()).get("access_token").asText();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
JuglowClient client = JuglowOkHttpClient.builder()
.federationTokenProvider(
fetchEntraTokenViaFederation,
System.getenv("JUGLOW_FEDERATION_RULE_ID"),
System.getenv("JUGLOW_ORGANIZATION_ID"),
System.getenv("JUGLOW_SERVICE_ACCOUNT_ID"),
System.getenv("JUGLOW_WORKSPACE_ID"))
.build();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.HAIJUN_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello from Azure")
.build());
IO.println(message.content()); using Juglow.Credentials;
// ...
var credentials = new WorkloadIdentityCredentials(new WorkloadIdentityOptions
{
FederationRuleId = Environment.GetEnvironmentVariable("JUGLOW_FEDERATION_RULE_ID")!,
OrganizationId = Environment.GetEnvironmentVariable("JUGLOW_ORGANIZATION_ID"),
ServiceAccountId = Environment.GetEnvironmentVariable("JUGLOW_SERVICE_ACCOUNT_ID"),
WorkspaceId = Environment.GetEnvironmentVariable("JUGLOW_WORKSPACE_ID"),
IdentityTokenProvider = new EntraFederationTokenProvider(),
});
using var client = new JuglowClient(new ClientOptions { Credentials = credentials });
var message = await client.Messages.Create(new()
{
Model = Model.HaijunOpus5_5,
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Hello from Azure" }],
});
foreach (var block in message.Content)
{
if (block.Value is TextBlock textBlock)
{
Console.WriteLine(textBlock.Text);
}
}
class EntraFederationTokenProvider : IIdentityTokenProvider
{
private static readonly HttpClient Http = new();
public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
{
var federatedToken = await File.ReadAllTextAsync(
Environment.GetEnvironmentVariable("AZURE_FEDERATED_TOKEN_FILE")!, ct);
var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID");
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!,
["grant_type"] = "client_credentials",
["scope"] = "api://<APP_ID>/.default",
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
["client_assertion"] = federatedToken,
});
var response = await Http.PostAsync(
$"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token", form, ct);
response.EnsureSuccessStatusCode();
using var json = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(ct), default, ct);
return json.RootElement.GetProperty("access_token").GetString()!;
}
} use Juglow\Client;
use Juglow\Credentials\WorkloadIdentityCredentials;
function fetchEntraTokenViaFederation(): string
{
$ch = curl_init('https://login.microsoftonline.com/' . getenv('AZURE_TENANT_ID') . '/oauth2/v2.0/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'client_id' => getenv('AZURE_CLIENT_ID'),
'grant_type' => 'client_credentials',
'scope' => 'api://<APP_ID>/.default',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => file_get_contents(getenv('AZURE_FEDERATED_TOKEN_FILE')),
]),
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body['access_token'];
}
$client = new Client(
credentials: new WorkloadIdentityCredentials(
identityTokenProvider: fetchEntraTokenViaFederation(...),
federationRuleId: getenv('JUGLOW_FEDERATION_RULE_ID'),
organizationId: getenv('JUGLOW_ORGANIZATION_ID'),
serviceAccountId: getenv('JUGLOW_SERVICE_ACCOUNT_ID'),
workspaceId: getenv('JUGLOW_WORKSPACE_ID') ?: null,
),
);
$message = $client->messages->create(
model: 'haijun-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello from Azure']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL; require "juglow"
require "json"
require "net/http"
def fetch_entra_token_via_federation
tenant_id = ENV.fetch("AZURE_TENANT_ID")
federated_token = File.read(ENV.fetch("AZURE_FEDERATED_TOKEN_FILE"))
response = Net::HTTP.post_form(
URI("https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"),
"client_id" => ENV.fetch("AZURE_CLIENT_ID"),
"grant_type" => "client_credentials",
"scope" => "api://<APP_ID>/.default",
"client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion" => federated_token
)
JSON.parse(response.body).fetch("access_token")
end
client = Juglow::Client.new(
credentials: Juglow::WorkloadIdentityCredentials.new(
identity_token_provider: -> { fetch_entra_token_via_federation },
federation_rule_id: ENV.fetch("JUGLOW_FEDERATION_RULE_ID"),
organization_id: ENV.fetch("JUGLOW_ORGANIZATION_ID"),
service_account_id: ENV.fetch("JUGLOW_SERVICE_ACCOUNT_ID"),
workspace_id: ENV["JUGLOW_WORKSPACE_ID"]
)
)
message = client.messages.create(
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello from Azure"}]
)
puts message.content.find { it.type == :text }.text # 1. Exchange the Kubernetes-projected token for an Entra-issued access
# token and write it to a temp file the CLI can read.
JUGLOW_IDENTITY_TOKEN_FILE=$(mktemp)
trap 'rm -f "$JUGLOW_IDENTITY_TOKEN_FILE"' EXIT
curl -sS "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
-d client_id="$AZURE_CLIENT_ID" \
-d grant_type=client_credentials \
--data-urlencode "scope=api://<APP_ID>/.default" \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion@"$AZURE_FEDERATED_TOKEN_FILE" \
| jq -r .access_token > "$JUGLOW_IDENTITY_TOKEN_FILE"
export JUGLOW_IDENTITY_TOKEN_FILE
# 2. Call the Haijun API. JUGLOW_FEDERATION_RULE_ID,
# JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID are read
# from the environment.
ant messages create \
--model haijun-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello from Azure"}'Verify the setup
From inside a labeled pod, run the cURL exchange shown in Acquire and use the token and confirm that POST /v1/oauth/token returns a 200 with an access_token beginning with sk-ant-oat01- and an expires_in value in seconds. If the exchange fails with the opaque 401 authentication_error response (message Authentication failed), check the authentication history page for the deny reason, then decode the Entra-issued token from step 1 (see Troubleshoot a failed exchange for the command) and check the most common Azure-side causes:
- Issuer mismatch: The registered
issuer_urlmust match the token'sissclaim exactly. A v2.0 token carrieshttps://login.microsoftonline.com/; if the decoded/v2.0 verclaim is1.0, see If your tokens are v1.0.
- Token lifetime: If a tenant token-lifetime policy or CAE extends the
client_credentialstoken past 7500 seconds, raise the issuer'smax_jwt_lifetime_secondsas described in Configure Juglow.
- Audience mismatch: The rule's
audiencemust equal the token'saudexactly: the audience app registration's client ID for the v2.0 tokens this guide configures.
- Claim name mismatch: A rule that matches on a claim the token does not carry never passes. v1.0 tokens carry the client ID in
appid, notazp; see If your tokens are v1.0.
If your tokens are v1.0
This guide configures the audience app registration with api.requestedAccessTokenVersion: 2, so every token it shows is v2.0. If you reuse an existing registration that leaves requestedAccessTokenVersion unset, Entra issues v1.0 tokens instead. Decode a sample token and check its ver claim; if it is 1.0, four things change:
- Issuer: The
issclaim ishttps://sts.windows.net/instead of/ https://login.microsoftonline.com/. Register the issuer URL exactly as your token's/v2.0 issclaim carries it. The two URLs share the same JWKS, so discovery mode works for either.
- Wizard selector: Pick v1 (sts.windows.net) in the Connect workload wizard's Token issuer selector instead of v2.0 (login.microsoftonline.com).
- Audience: The
audclaim is the identifier URI you passed asresource(for example,api://), not the registration's client ID. Set the federation rule'saudienceto the exactaudvalue from your decoded token.
- Client ID claim: The calling identity's client ID appears in
appid, notazp. The two claims never appear in the same token, so a rule that matches onazpnever passes against a v1.0 token.
The oid, sub, and tid claims carry the same values in both versions, so the rest of this guide applies unchanged.
Scope your rule
A federation rule can match the token's subject with subject_prefix in addition to (or instead of) the claims map; see Rule matching semantics for how the fields combine. Entra sub values for these identities are fixed-length canonical GUIDs, so a subject_prefix containing the full 36-character object ID matches only that subject; this is a property of Entra's subject format, not of subject_prefix in general.
Warning: Every identity in your tenant can request a token for the registered audience, so
audienceandtidalone do not identify a specific workload. A rule that omits anoid(orazp/appid) match, or that uses a wildcard or partial-GUIDsubject_prefix, authorizes every managed identity and service principal in the tenant.
Lock the rule's match block to the narrowest scope that fits your use case:
- Match
oidas an exact value: Setclaims.oidto the managed identity's full object ID. Asubject_prefixset to that full object ID is equivalent (the Console wizard sets both); never use a wildcard or partial-GUIDsubject_prefix, which matches more identities than you intend.
- Pin
tidas defense in depth: The issuer URL already pins your tenant, but addingclaims.tidguards against configuration drift if the issuer record is later edited.
- Pin the audience: Set
audienceto the exactaudvalue from your decoded token so tokens minted for other applications are rejected.
- Use a separate rule for each managed identity: Create one rule for each identity rather than one rule that authorizes several, so you can revoke a single workload's access without affecting others.
Next steps
- Review the full configuration model in Workload Identity Federation.
- See the provider guides for AWS, Google Cloud, GitHub Actions, and Kubernetes.
- For environment variables, profile files, and credential precedence, see the WIF reference.