Skip to main content

Microsoft Entra ID Integration

Integrate Microsoft Entra ID (formerly Azure Active Directory) as the identity provider for your marketplace application. This guide uses the Microsoft Graph API for user provisioning and the OAuth2 v2.0 token endpoint for authentication.

Prerequisites

  • Azure Tenant: An Azure AD / Entra ID tenant
  • App Registration with:
    • Application (client) ID
    • Client secret
    • API permissions: User.ReadWrite.All and Directory.ReadWrite.All (Application type, admin consent granted)
    • "Allow public client flows" enabled (for ROPC grant)
  • App Roles defined in the app registration:
    • gw-user -- Assigned to marketplace users
    • org-admin -- Assigned to organization administrators
  • Custom Claims: Map onPremisesExtensionAttributes (1-15) to token claims via Token Configuration

Azure Portal Setup

  1. Register an Application (Azure Portal > App registrations > New registration):

    Name: GW Marketplace Integration
    Supported accounts: Single tenant
    Redirect URI: (leave blank for backend-only)
  2. Add API Permissions (API permissions > Add > Microsoft Graph > Application):

    • User.ReadWrite.All
    • Directory.ReadWrite.All
    • Click "Grant admin consent"
  3. Create a Client Secret (Certificates & secrets > New client secret):

    • Save the value immediately (shown only once)
  4. Create App Roles (App roles > Create app role):

    • Display name: gw-user, Value: gw-user, Allowed: Users/Groups
    • Display name: org-admin, Value: org-admin, Allowed: Users/Groups
  5. Enable Public Client Flows (Authentication > Advanced > Allow public client flows: Yes)

  6. Configure Token Claims (Token configuration > Add optional claim):

    • Map extensionAttribute1 as gwUserId
    • Map extensionAttribute2 as orgId
    • Map extensionAttribute3 as gwApplicationId

:::caution ROPC Limitations The Resource Owner Password Credentials (ROPC) grant does not work with:

  • Accounts that have MFA enabled
  • Federated users (users authenticated through external IdPs)
  • Accounts with conditional access policies requiring interactive login

For production deployments, consider using the client credentials flow with application permissions or the on-behalf-of flow instead. :::

Environment Variables

# Azure Tenant
AZURE_TENANT_ID=your-directory-tenant-id

# Application Credentials
AZURE_CLIENT_ID=your-application-client-id
AZURE_CLIENT_SECRET=your-client-secret-value

# Marketplace Configuration
MARKETPLACE_APP_ID=your-registered-app-id

Exchange Flow

sequenceDiagram
participant SDK as Provider SDK
participant Backend as Your Backend
participant Entra as Microsoft Entra ID
participant Graph as Microsoft Graph

SDK->>Backend: POST /auth/exchange (Bearer marketplace-jwt)
Backend->>Backend: Validate JWT (signature, iss, applicationId, exp)
Backend->>Entra: POST /{tenant}/oauth2/v2.0/token (client_credentials)
Entra-->>Backend: App-only access token
Backend->>Graph: GET /users?$filter=extensionAttribute1 eq '{userId}'
alt User exists
Graph-->>Backend: User record
Backend->>Graph: PATCH /users/{id} (update extensionAttributes)
else User not found
Backend->>Graph: POST /users (create with password + attributes)
Graph-->>Backend: Created user
end
Backend->>Entra: POST /{tenant}/oauth2/v2.0/token (ROPC grant)
Entra-->>Backend: access_token + refresh_token
Backend-->>SDK: Tokens + user info

Backend Implementation

import { createHash } from 'crypto';

const TENANT_ID = process.env.AZURE_TENANT_ID!;
const CLIENT_ID = process.env.AZURE_CLIENT_ID!;
const CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET!;
const GRAPH_URL = 'https://graph.microsoft.com/v1.0';
const TOKEN_URL = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`;

// Deterministic password derivation
// Azure AD requires: 8+ chars, 3 of 4 categories (upper, lower, digit, special)
function derivedPassword(userId: string): string {
const hash = createHash('sha256')
.update(`${userId}${CLIENT_SECRET}`)
.digest('hex');
return `Gw!1${hash.slice(0, 28)}`;
}

// Obtain app-only token for Microsoft Graph API
let appToken: string | null = null;
let appTokenExpiry = 0;

async function getAppToken(): Promise<string> {
if (appToken && Date.now() < appTokenExpiry - 10_000) {
return appToken;
}

const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'https://graph.microsoft.com/.default',
});

const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});

if (!res.ok) throw new Error(`App token failed: ${res.status}`);
const data = await res.json();
appToken = data.access_token;
appTokenExpiry = Date.now() + data.expires_in * 1000;
return data.access_token;
}

// Find user by extensionAttribute1 (gwUserId)
async function findUser(gwUserId: string) {
const token = await getAppToken();
const filter = encodeURIComponent(
`onPremisesExtensionAttributes/extensionAttribute1 eq '${gwUserId}'`
);

const res = await fetch(
`${GRAPH_URL}/users?$filter=${filter}&$select=id,displayName,mail,userPrincipalName,onPremisesExtensionAttributes`,
{ headers: { Authorization: `Bearer ${token}` } },
);

if (!res.ok) {
// Fallback: search by UPN
const upn = encodeURIComponent(`gw-${gwUserId}@gw-marketplace.onmicrosoft.com`);
const fallback = await fetch(`${GRAPH_URL}/users/${upn}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (fallback.ok) return fallback.json();
return null;
}

const data = await res.json();
return data.value?.length > 0 ? data.value[0] : null;
}

// Create user with password and extension attributes
async function createUser(claims: any) {
const token = await getAppToken();
const upn = `gw-${claims.userId}@gw-marketplace.onmicrosoft.com`;
const password = derivedPassword(claims.userId);

const res = await fetch(`${GRAPH_URL}/users`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
accountEnabled: true,
displayName: `GW User ${claims.userId}`,
mailNickname: `gw-${claims.userId}`,
userPrincipalName: upn,
passwordProfile: { password, forceChangePasswordNextSignIn: false },
onPremisesExtensionAttributes: {
extensionAttribute1: claims.userId, // gwUserId
extensionAttribute2: claims.orgId, // orgId
extensionAttribute3: claims.applicationId, // gwApplicationId
},
}),
});

if (!res.ok) throw new Error(`Create user failed: ${res.status}`);
return res.json();
}

// Update extension attributes
async function updateUser(userId: string, claims: any) {
const token = await getAppToken();

const res = await fetch(`${GRAPH_URL}/users/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
onPremisesExtensionAttributes: {
extensionAttribute2: claims.orgId,
extensionAttribute3: claims.applicationId,
},
}),
});

if (!res.ok) throw new Error(`Update user failed: ${res.status}`);
}

// Obtain user tokens via ROPC grant
async function getUserTokens(upn: string, password: string) {
const params = new URLSearchParams({
grant_type: 'password',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
username: upn,
password,
scope: `${CLIENT_ID}/.default openid profile email offline_access`,
});

const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});

if (!res.ok) throw new Error(`Token grant failed: ${res.status}`);
return res.json();
}

Token Structure

Entra ID access tokens contain app roles and extension attributes:

{
"iss": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"exp": 1714000000,
"sub": "user-object-id",
"email": "gw-user@gw-marketplace.onmicrosoft.com",
"preferred_username": "gw-user-abc123@gw-marketplace.onmicrosoft.com",
"roles": ["gw-user", "org-admin"],
"onPremisesExtensionAttributes": {
"extensionAttribute1": "user-abc123",
"extensionAttribute2": "org-456",
"extensionAttribute3": "app-789"
}
}

Decode token claims:

function decodeAccessToken(accessToken: string) {
const payload = JSON.parse(
Buffer.from(accessToken.split('.')[1], 'base64url').toString()
);

return {
email: payload.email ?? payload.upn ?? null,
username: payload.preferred_username ?? payload.upn ?? null,
roles: payload.roles ?? [],
orgId: payload.onPremisesExtensionAttributes?.extensionAttribute2 ?? null,
gwUserId: payload.onPremisesExtensionAttributes?.extensionAttribute1 ?? null,
gwApplicationId: payload.onPremisesExtensionAttributes?.extensionAttribute3 ?? null,
};
}

Extension Attributes

Entra ID provides 15 onPremisesExtensionAttributes (extensionAttribute1 through extensionAttribute15) that can store custom data without creating extension properties:

AttributePurpose
extensionAttribute1gwUserId -- General Wisdom user identifier
extensionAttribute2orgId -- Organization identifier
extensionAttribute3gwApplicationId -- Application identifier

These attributes are available on all user objects without additional schema configuration. Map them as optional claims in Token Configuration to include them in access tokens.

Session Revocation

Azure AD does not have a standard OAuth2 token revocation endpoint. Tokens expire naturally (default 1 hour). For immediate revocation, use the revokeSignInSessions Graph API:

async function revokeSession(userId: string) {
const token = await getAppToken();

// Revoke all sign-in sessions for the user
await fetch(`${GRAPH_URL}/users/${userId}/revokeSignInSessions`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
}
note

This revokes all sessions for the user, not just the marketplace session. Use with caution in shared-tenant scenarios.

Microsoft Graph API Reference

Base URL: https://graph.microsoft.com/v1.0

OperationMethodEndpoint
List/Search UsersGET/users?$filter={odata-filter}
Create UserPOST/users
Get UserGET/users/{id}
Update UserPATCH/users/{id}
Revoke SessionsPOST/users/{id}/revokeSignInSessions
List App RolesGET/applications/{appId}/appRoles

OData Filter Examples

# Filter by extension attribute
$filter=onPremisesExtensionAttributes/extensionAttribute1 eq 'user-abc123'

# Filter by UPN
$filter=userPrincipalName eq 'gw-user@gw-marketplace.onmicrosoft.com'

# Filter by display name (starts with)
$filter=startswith(displayName, 'GW User')

Common Gotchas

  1. ROPC does not work with MFA: If the user's account has MFA enabled or conditional access policies that require interactive authentication, the ROPC grant will fail. Use application permissions for production.

  2. Federated users cannot use ROPC: Users authenticated through external identity providers (ADFS, other Azure AD tenants) cannot authenticate via the password grant.

  3. UPN domain must match tenant: The userPrincipalName must use a domain verified in your tenant. For programmatic users, use the .onmicrosoft.com domain.

  4. Admin consent required: The User.ReadWrite.All and Directory.ReadWrite.All permissions require admin consent before they function. Without consent, Graph API calls return 403.

  5. No standard token revocation: Unlike other providers, Azure AD does not have an OAuth2 revocation endpoint. Tokens live until expiry (default 1 hour) unless you call revokeSignInSessions.

  6. Extension attribute filtering: The OData filter for onPremisesExtensionAttributes may return 400 if the attribute has not been synced. Implement a UPN-based fallback.

  7. Password policy enforcement: Azure AD enforces tenant-level password policies. The Gw!1 prefix satisfies the "3 of 4 categories" requirement. If your tenant has a custom policy with banned passwords, adjust the prefix.

Additional Resources