People & Groups API
Lets your back-end servers, scheduled jobs and batch programs look up people, managers and reports, groups and members in the company's Azure AD — with nobody signed in. Data comes live from Microsoft Graph and only what your service is allowed to see is returned.
| SSO sign-in (/userinfo) | People & Groups API | |
|---|---|---|
| Who calls | Your site, on behalf of the signed-in user | Your server itself, nobody signed in |
| Whose data | Only the signed-in person | Anyone and any group in the company (as permitted) |
| Authentication | Client ID + secret exchanged for an access token | A JWT signed with the device's private key |
| Examples | Show "Hello, John", get the signed-in user's department | Nightly employee sync, mail a group's members, find someone's approver, check group membership |
If you only need to know who is signed in, SSO is enough — you do not need this API. SSO sign-in guide →
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out device-private.pem openssl pkey -in device-private.pem -pubout -out device-public.pem
On Windows run openssl in Git Bash or WSL. Give every server its own key (each is registered as a device).
Request: People & Groups API Service: ____________ IP address of the calling server: ____________ API permissions needed: users.read, groups.read, groups.members … User fields needed: department, job_title, manager … Only these groups: (optional, group object IDs) Public key (contents of device-public.pem): -----BEGIN PUBLIC KEY----- … -----END PUBLIC KEY-----
Ask only for what you need — it is approved faster. You can ask for more later without a new key.
Each language is a single file with (almost) no dependencies: JWT signing, token renewal, paging, retry on 429 and error handling. Copy it into your project. The SSO address is already filled in.
pip install -r requirements.txt export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem python people_client.py john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
"""Advantech People & Groups API client (Python 3.8+).
pip install "pyjwt[crypto]" requests
Environment:
PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
PEOPLE_KEY_FILE device private key (default device-private.pem)
Usage:
from people_client import PeopleClient
c = PeopleClient()
print(c.user("john.chen@advantech.com"))
for m in c.group_members(group_id, transitive=True):
print(m["name"], m["email"])
Masking: the SSO administrator can hide some fields of some people. The field
is still there but its value is * (e.g. "******"). Every person has
"masked" (bool) and "masked_fields" (list) — see PeopleClient.masked_fields().
"""
import os
import time
import jwt # pyjwt[crypto]
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
class PeopleAPIError(Exception):
def __init__(self, status, error, description):
super().__init__(f"{status} {error}: {description}")
self.status, self.error, self.description = status, error, description
class PeopleClient:
def __init__(self, base=None, client_id=None, key_file=None, timeout=15):
self.base = (base or os.environ.get("PEOPLE_API_URL", "https://ae-sso.advantech.com")).rstrip("/")
self.client_id = client_id or os.environ["PEOPLE_CLIENT_ID"]
with open(key_file or os.environ.get("PEOPLE_KEY_FILE", "device-private.pem"), "rb") as f:
self.key = serialization.load_pem_private_key(f.read(), password=None)
self.alg = "ES256" if isinstance(self.key, ec.EllipticCurvePrivateKey) else "RS256"
self.timeout = timeout
self.http = requests.Session()
self._jwt, self._jwt_exp = None, 0
# ---------- plumbing ----------
def _token(self):
"""A 5-minute JWT signed with the device key (reused until 1 minute before it expires)."""
now = int(time.time())
if self._jwt is None or now > self._jwt_exp - 60:
self._jwt_exp = now + 300
self._jwt = jwt.encode({"iss": self.client_id, "aud": self.base + "/api/directory",
"iat": now, "exp": self._jwt_exp}, self.key, algorithm=self.alg)
return self._jwt
def get(self, path, raw=False, **params):
params = {k: v for k, v in params.items() if v not in (None, "", False)}
r = self.http.get(self.base + "/api/directory" + path, params=params, timeout=self.timeout,
headers={"Authorization": "Bearer " + self._token()})
if r.status_code == 429: # rate limited: wait and retry once
time.sleep(int(r.headers.get("Retry-After", "5")))
return self.get(path, raw, **params)
if r.status_code != 200:
try:
e = r.json()
except ValueError:
e = {}
raise PeopleAPIError(r.status_code, e.get("error", "http_error"), e.get("error_description", r.text[:200]))
return r.content if raw else r.json()
def pages(self, path, key, **params):
"""Yield every item of a list, following "next" page by page."""
params.setdefault("top", 100)
while True:
page = self.get(path, **params)
yield from page[key]
if not page.get("next"):
return
params["cursor"] = page["next"]
@staticmethod
def masked_fields(person):
"""Fields of this person hidden by the administrator (their values are "*…*")."""
return person.get("masked_fields", [])
# ---------- users ----------
def scopes(self):
"""What this device may call and which fields it receives."""
return self.get("/scopes")
def user(self, key):
"""One user by object id, UPN or email (users.read)."""
return self.get(f"/users/{key}")
def photo(self, key):
"""JPEG/PNG bytes of the user's photo (users.read + photo field)."""
return self.get(f"/users/{key}/photo", raw=True)
def search_users(self, q, top=10):
"""Users whose name or email starts with q (users.search)."""
return self.get("/users", q=q, top=top)["users"]
def list_users(self, department=None):
"""Every user, optionally one department (users.list)."""
return self.pages("/users", "users", department=department)
def manager(self, key):
"""The user's manager, or None (users.org)."""
return self.get(f"/users/{key}/manager")["manager"]
def direct_reports(self, key):
"""The user's direct reports (users.org)."""
return self.pages(f"/users/{key}/direct-reports", "users")
# ---------- groups ----------
def user_groups(self, key, transitive=False):
"""Groups the user belongs to; transitive=True includes nested groups (users.groups)."""
return self.pages(f"/users/{key}/groups", "groups", transitive=1 if transitive else None)
def is_member(self, key, group_id):
"""True if the user is in the group, directly or through nested groups (users.groups)."""
return self.get(f"/users/{key}/groups/{group_id}")["member"]
def search_groups(self, q=None):
"""Groups whose name or description contains a word starting with q; all groups without q (groups.read)."""
return self.pages("/groups", "groups", q=q)
def group(self, group_id):
"""One group (groups.read)."""
return self.get(f"/groups/{group_id}")
def group_members(self, group_id, transitive=False):
"""People in the group; transitive=True expands nested groups (groups.members)."""
return self.pages(f"/groups/{group_id}/members", "members", transitive=1 if transitive else None)
def group_owners(self, group_id):
"""The group's owners (groups.members)."""
return self.pages(f"/groups/{group_id}/owners", "owners")
if __name__ == "__main__":
# python people_client.py [someone@advantech.com] [group keyword]
import json
import sys
c = PeopleClient()
show = lambda title, v: print(f"\n== {title}\n" + json.dumps(v, ensure_ascii=False, indent=2))
show("my permissions", c.scopes())
if len(sys.argv) > 1:
who = sys.argv[1]
try:
u = c.user(who)
print("\n== masked fields of", u["name"], ":", PeopleClient.masked_fields(u) or "none")
except PeopleAPIError as e:
print("\n== user:", e)
for title, call in [("user", lambda: c.user(who)), ("manager", lambda: c.manager(who)),
("direct reports", lambda: list(c.direct_reports(who))),
("groups", lambda: [g["name"] for g in c.user_groups(who, transitive=True)])]:
try:
show(title, call())
except PeopleAPIError as e:
print(f"\n== {title}: {e}")
if len(sys.argv) > 2:
try:
groups = list(c.search_groups(sys.argv[2]))
show("groups matching " + sys.argv[2], groups)
if groups:
show("members of " + groups[0]["name"], [m["email"] for m in c.group_members(groups[0]["id"], transitive=True)])
except PeopleAPIError as e:
print("\n== groups:", e)
pyjwt[crypto]>=2.8 requests>=2.31
# Node.js 18+, no packages to install export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem node people_client.mjs john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
// Advantech People & Groups API client (Node.js 18+, no packages).
//
// Environment:
// PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
// PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
// PEOPLE_KEY_FILE device private key (default device-private.pem)
//
// Usage:
// import { PeopleClient } from "./people_client.mjs";
// const c = new PeopleClient();
// console.log(await c.user("john.chen@advantech.com"));
// for await (const m of c.groupMembers(groupId, { transitive: true })) console.log(m.name, m.email);
//
// Masking: the SSO administrator can hide some fields of some people. The field
// is still there but its value is * (e.g. "******"). Every person has
// "masked" (boolean) and "masked_fields" (array) — see PeopleClient.maskedFields().
import crypto from "node:crypto";
import fs from "node:fs";
export class PeopleAPIError extends Error {
constructor(status, error, description) {
super(`${status} ${error}: ${description}`);
Object.assign(this, { status, error, description });
}
}
export class PeopleClient {
constructor({ base, clientId, keyFile } = {}) {
this.base = (base || process.env.PEOPLE_API_URL || "https://ae-sso.advantech.com").replace(/\/+$/, "");
this.clientId = clientId || process.env.PEOPLE_CLIENT_ID;
if (!this.clientId) throw new Error("set PEOPLE_CLIENT_ID");
this.key = crypto.createPrivateKey(fs.readFileSync(keyFile || process.env.PEOPLE_KEY_FILE || "device-private.pem"));
this.alg = this.key.asymmetricKeyType === "ec" ? "ES256" : "RS256";
this.jwt = null;
this.jwtExp = 0;
}
// ---------- plumbing ----------
// A 5-minute JWT signed with the device key (reused until 1 minute before it expires).
token() {
const now = Math.floor(Date.now() / 1000);
if (!this.jwt || now > this.jwtExp - 60) {
this.jwtExp = now + 300;
const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const data = enc({ alg: this.alg, typ: "JWT" }) + "." +
enc({ iss: this.clientId, aud: this.base + "/api/directory", iat: now, exp: this.jwtExp });
const sig = crypto.sign("sha256", Buffer.from(data), this.alg === "ES256" ? { key: this.key, dsaEncoding: "ieee-p1363" } : this.key);
this.jwt = data + "." + sig.toString("base64url");
}
return this.jwt;
}
async get(path, params = {}, { raw = false } = {}) {
const url = new URL(this.base + "/api/directory" + path);
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) url.searchParams.set(k, v);
const r = await fetch(url, { headers: { Authorization: `Bearer ${this.token()}` } });
if (r.status === 429) { // rate limited: wait and retry
await new Promise((ok) => setTimeout(ok, 1000 * Number(r.headers.get("Retry-After") || 5)));
return this.get(path, params, { raw });
}
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new PeopleAPIError(r.status, e.error || "http_error", e.error_description || r.statusText);
}
return raw ? Buffer.from(await r.arrayBuffer()) : r.json();
}
// Every item of a list, following "next" page by page.
async *pages(path, key, params = {}) {
let cursor;
do {
const page = await this.get(path, { top: 100, ...params, cursor });
yield* page[key];
cursor = page.next;
} while (cursor);
}
// Fields of this person hidden by the administrator (their values are "*…*").
static maskedFields(person) { return person?.masked_fields ?? []; }
// ---------- users ----------
scopes() { return this.get("/scopes"); } // what this device may do
user(key) { return this.get(`/users/${encodeURIComponent(key)}`); } // users.read
photo(key) { return this.get(`/users/${encodeURIComponent(key)}/photo`, {}, { raw: true }); } // users.read + photo
async searchUsers(q, top = 10) { return (await this.get("/users", { q, top })).users; } // users.search
listUsers({ department } = {}) { return this.pages("/users", "users", { department }); } // users.list
async manager(key) { return (await this.get(`/users/${encodeURIComponent(key)}/manager`)).manager; } // users.org
directReports(key) { return this.pages(`/users/${encodeURIComponent(key)}/direct-reports`, "users"); } // users.org
// ---------- groups ----------
userGroups(key, { transitive = false } = {}) { // users.groups
return this.pages(`/users/${encodeURIComponent(key)}/groups`, "groups", { transitive: transitive ? 1 : undefined });
}
async isMember(key, groupId) { // users.groups
return (await this.get(`/users/${encodeURIComponent(key)}/groups/${groupId}`)).member;
}
searchGroups(q) { return this.pages("/groups", "groups", { q }); } // groups.read
group(groupId) { return this.get(`/groups/${groupId}`); } // groups.read
groupMembers(groupId, { transitive = false } = {}) { // groups.members
return this.pages(`/groups/${groupId}/members`, "members", { transitive: transitive ? 1 : undefined });
}
groupOwners(groupId) { return this.pages(`/groups/${groupId}/owners`, "owners"); } // groups.members
}
// node people_client.mjs [someone@advantech.com] [group keyword]
if (import.meta.url === `file://${process.argv[1]}`) {
const c = new PeopleClient();
const show = (title, v) => console.log(`\n== ${title}\n` + JSON.stringify(v, null, 2));
const tryShow = async (title, fn) => { try { show(title, await fn()); } catch (e) { console.log(`\n== ${title}: ${e.message}`); } };
const all = async (it) => { const out = []; for await (const x of it) out.push(x); return out; };
const [who, keyword] = process.argv.slice(2);
await tryShow("my permissions", () => c.scopes());
if (who) {
await tryShow("user", async () => {
const u = await c.user(who);
console.log(`\n== masked fields of ${u.name}: ${PeopleClient.maskedFields(u).join(", ") || "none"}`);
return u;
});
await tryShow("manager", () => c.manager(who));
await tryShow("direct reports", () => all(c.directReports(who)));
await tryShow("groups", async () => (await all(c.userGroups(who, { transitive: true }))).map((g) => g.name));
}
if (keyword) {
await tryShow("groups matching " + keyword, async () => {
const groups = await all(c.searchGroups(keyword));
if (groups.length) show("members of " + groups[0].name, (await all(c.groupMembers(groups[0].id, { transitive: true }))).map((m) => m.email));
return groups;
});
}
}
{
"name": "people-api-client",
"private": true,
"type": "module",
"engines": { "node": ">=18" },
"scripts": { "demo": "node people_client.mjs" }
}
# .NET 8 SDK, no NuGet packages export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem dotnet run -- john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
// Advantech People & Groups API client (.NET 8, no packages).
//
// Environment:
// PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
// PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
// PEOPLE_KEY_FILE device private key (default device-private.pem)
//
// Usage:
// var c = PeopleClient.FromEnvironment();
// var user = await c.User("john.chen@advantech.com"); // JsonElement
// Console.WriteLine(user.GetProperty("name").GetString());
// await foreach (var m in c.GroupMembers(groupId, transitive: true))
// Console.WriteLine(m.GetProperty("email").GetString());
//
// Masking: the SSO administrator can hide some fields of some people. The field
// is still there but its value is * (e.g. "******"). Every person has
// "masked" (bool) and "masked_fields" (array) — see PeopleClient.MaskedFields().
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public class PeopleApiException(int status, string error, string description)
: Exception($"{status} {error}: {description}")
{
public int Status { get; } = status;
public string Error { get; } = error;
}
public sealed class PeopleClient : IDisposable
{
readonly string _base, _clientId;
readonly AsymmetricAlgorithm _key;
readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
string? _jwt;
long _jwtExp;
public PeopleClient(string baseUrl, string clientId, string privateKeyPem)
{
_base = baseUrl.TrimEnd('/');
_clientId = clientId;
var ec = ECDsa.Create();
try { ec.ImportFromPem(privateKeyPem); _key = ec; }
catch (Exception) // not an EC key: RSA
{
ec.Dispose();
var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
_key = rsa;
}
}
public static PeopleClient FromEnvironment() => new(
Environment.GetEnvironmentVariable("PEOPLE_API_URL") ?? "https://ae-sso.advantech.com",
Environment.GetEnvironmentVariable("PEOPLE_CLIENT_ID") ?? throw new InvalidOperationException("set PEOPLE_CLIENT_ID"),
File.ReadAllText(Environment.GetEnvironmentVariable("PEOPLE_KEY_FILE") ?? "device-private.pem"));
// ---------- plumbing ----------
static string B64(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
// A 5-minute JWT signed with the device key (reused until 1 minute before it expires).
string Token()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_jwt != null && now < _jwtExp - 60) return _jwt;
_jwtExp = now + 300;
var alg = _key is ECDsa ? "ES256" : "RS256";
var data = B64(JsonSerializer.SerializeToUtf8Bytes(new { alg, typ = "JWT" })) + "." +
B64(JsonSerializer.SerializeToUtf8Bytes(new { iss = _clientId, aud = _base + "/api/directory", iat = now, exp = _jwtExp }));
var bytes = Encoding.ASCII.GetBytes(data);
var sig = _key is ECDsa ec
? ec.SignData(bytes, HashAlgorithmName.SHA256) // already r||s, as JWT wants
: ((RSA)_key).SignData(bytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return _jwt = data + "." + B64(sig);
}
async Task<HttpResponseMessage> Send(string path, IDictionary<string, string?>? query)
{
var url = _base + "/api/directory" + path;
var qs = query?.Where(kv => !string.IsNullOrEmpty(kv.Value))
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value!));
if (qs != null && qs.Any()) url += "?" + string.Join("&", qs);
while (true)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token());
var res = await _http.SendAsync(req);
if (res.StatusCode == HttpStatusCode.TooManyRequests) // rate limited: wait and retry
{
await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
continue;
}
if (res.IsSuccessStatusCode) return res;
string error = "http_error", desc = res.ReasonPhrase ?? "";
try
{
var e = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
error = e.GetProperty("error").GetString() ?? error;
desc = e.GetProperty("error_description").GetString() ?? desc;
}
catch (Exception) { }
throw new PeopleApiException((int)res.StatusCode, error, desc);
}
}
public async Task<JsonElement> Get(string path, IDictionary<string, string?>? query = null)
{
using var res = await Send(path, query);
return JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement.Clone();
}
// Every item of a list, following "next" page by page.
public async IAsyncEnumerable<JsonElement> Pages(string path, string key, Dictionary<string, string?>? query = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
query ??= new();
query["top"] = "100";
while (!ct.IsCancellationRequested)
{
var page = await Get(path, query);
foreach (var item in page.GetProperty(key).EnumerateArray()) yield return item;
if (!page.TryGetProperty("next", out var next)) yield break;
query["cursor"] = next.GetString();
}
}
static string E(string s) => Uri.EscapeDataString(s);
static Dictionary<string, string?> Nested(bool on) => new() { ["transitive"] = on ? "1" : null };
// Fields of this person hidden by the administrator (their values are "*…*").
public static string[] MaskedFields(JsonElement person) =>
person.TryGetProperty("masked_fields", out var f) && f.ValueKind == JsonValueKind.Array
? f.EnumerateArray().Select(x => x.GetString() ?? "").ToArray()
: [];
// ---------- users ----------
public Task<JsonElement> Scopes() => Get("/scopes"); // what this device may do
public Task<JsonElement> User(string key) => Get($"/users/{E(key)}"); // users.read
public async Task<byte[]> Photo(string key) // users.read + photo
{
using var res = await Send($"/users/{E(key)}/photo", null);
return await res.Content.ReadAsByteArrayAsync();
}
public async Task<JsonElement> SearchUsers(string q, int top = 10) => // users.search
(await Get("/users", new Dictionary<string, string?> { ["q"] = q, ["top"] = top.ToString() })).GetProperty("users");
public IAsyncEnumerable<JsonElement> ListUsers(string? department = null) => // users.list
Pages("/users", "users", new() { ["department"] = department });
public async Task<JsonElement?> Manager(string key) // users.org
{
var m = (await Get($"/users/{E(key)}/manager")).GetProperty("manager");
return m.ValueKind == JsonValueKind.Null ? null : m;
}
public IAsyncEnumerable<JsonElement> DirectReports(string key) => // users.org
Pages($"/users/{E(key)}/direct-reports", "users");
// ---------- groups ----------
public IAsyncEnumerable<JsonElement> UserGroups(string key, bool transitive = false) => // users.groups
Pages($"/users/{E(key)}/groups", "groups", Nested(transitive));
public async Task<bool> IsMember(string key, string groupId) => // users.groups
(await Get($"/users/{E(key)}/groups/{E(groupId)}")).GetProperty("member").GetBoolean();
public IAsyncEnumerable<JsonElement> SearchGroups(string? q = null) => // groups.read
Pages("/groups", "groups", new() { ["q"] = q });
public Task<JsonElement> Group(string groupId) => Get($"/groups/{E(groupId)}"); // groups.read
public IAsyncEnumerable<JsonElement> GroupMembers(string groupId, bool transitive = false) => // groups.members
Pages($"/groups/{E(groupId)}/members", "members", Nested(transitive));
public IAsyncEnumerable<JsonElement> GroupOwners(string groupId) => // groups.members
Pages($"/groups/{E(groupId)}/owners", "owners");
public void Dispose() { _http.Dispose(); _key.Dispose(); }
}
// Demo: dotnet run -- [someone@advantech.com] [group keyword]
using System.Text.Json;
using var c = PeopleClient.FromEnvironment();
var opts = new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
async Task Show(string title, Func<Task<object?>> call)
{
try { Console.WriteLine($"\n== {title}\n" + JsonSerializer.Serialize(await call(), opts)); }
catch (PeopleApiException e) { Console.WriteLine($"\n== {title}: {e.Message}"); }
}
async Task<List<JsonElement>> All(IAsyncEnumerable<JsonElement> items)
{
var list = new List<JsonElement>();
await foreach (var x in items) list.Add(x);
return list;
}
await Show("my permissions", async () => await c.Scopes());
if (args.Length > 0)
{
var who = args[0];
await Show("user", async () =>
{
var u = await c.User(who);
var hidden = PeopleClient.MaskedFields(u);
Console.WriteLine($"\n== masked fields of {u.GetProperty("name").GetString()}: {(hidden.Length > 0 ? string.Join(", ", hidden) : "none")}");
return u;
});
await Show("manager", async () => await c.Manager(who));
await Show("direct reports", async () => await All(c.DirectReports(who)));
await Show("groups", async () => (await All(c.UserGroups(who, transitive: true))).Select(g => g.GetProperty("name").GetString()));
}
if (args.Length > 1)
{
await Show("groups matching " + args[1], async () =>
{
var groups = await All(c.SearchGroups(args[1]));
if (groups.Count > 0)
await Show("members of " + groups[0].GetProperty("name").GetString(), async () =>
(await All(c.GroupMembers(groups[0].GetProperty("id").GetString()!, transitive: true))).Select(m => m.GetProperty("email").GetString()));
return groups;
});
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
# Go 1.21+, standard library only export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem go run . john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
// Advantech People & Groups API client (Go 1.21+, standard library only).
//
// Environment:
//
// PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
// PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
// PEOPLE_KEY_FILE device private key (default device-private.pem)
//
// Masking: the SSO administrator can hide some fields of some people. The field
// is still there but its value is * (e.g. "******"). Every person has
// "masked" (bool) and "masked_fields" (list) — see MaskedFields().
//
// Copy the PeopleClient part into your project; main() at the end is a demo:
//
// go run people_client.go someone@advantech.com sales
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type PeopleClient struct {
Base, ClientID string
Key crypto.Signer
HTTP *http.Client
mu sync.Mutex
jwt string
jwtExp int64
}
// APIError is a non-200 answer: Status 403 + Code "insufficient_scope" etc.
type APIError struct {
Status int
Code, Description string
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Description) }
// NewPeopleClient reads the configuration from the environment.
func NewPeopleClient() (*PeopleClient, error) {
base := os.Getenv("PEOPLE_API_URL")
if base == "" {
base = "https://ae-sso.advantech.com"
}
keyFile := os.Getenv("PEOPLE_KEY_FILE")
if keyFile == "" {
keyFile = "device-private.pem"
}
pemBytes, err := os.ReadFile(keyFile)
if err != nil {
return nil, err
}
blk, _ := pem.Decode(pemBytes)
if blk == nil {
return nil, errors.New(keyFile + ": not a PEM file")
}
var key any
switch blk.Type {
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(blk.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(blk.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(blk.Bytes)
}
if err != nil {
return nil, err
}
id := os.Getenv("PEOPLE_CLIENT_ID")
if id == "" {
return nil, errors.New("set PEOPLE_CLIENT_ID")
}
return &PeopleClient{Base: strings.TrimRight(base, "/"), ClientID: id, Key: key.(crypto.Signer),
HTTP: &http.Client{Timeout: 15 * time.Second}}, nil
}
// token is a 5-minute JWT signed with the device key (reused until 1 minute before it expires).
func (c *PeopleClient) token() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now().Unix()
if c.jwt != "" && now < c.jwtExp-60 {
return c.jwt, nil
}
alg := "RS256"
if _, ok := c.Key.(*ecdsa.PrivateKey); ok {
alg = "ES256"
}
enc := func(v any) string { b, _ := json.Marshal(v); return base64.RawURLEncoding.EncodeToString(b) }
data := enc(map[string]string{"alg": alg, "typ": "JWT"}) + "." +
enc(map[string]any{"iss": c.ClientID, "aud": c.Base + "/api/directory", "iat": now, "exp": now + 300})
h := sha256.Sum256([]byte(data))
var sig []byte
switch k := c.Key.(type) {
case *ecdsa.PrivateKey:
r, s, err := ecdsa.Sign(rand.Reader, k, h[:])
if err != nil {
return "", err
}
sig = make([]byte, 64) // JWT wants r||s, 32 bytes each
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
case *rsa.PrivateKey:
var err error
if sig, err = rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, h[:]); err != nil {
return "", err
}
default:
return "", errors.New("the device key must be EC P-256 or RSA")
}
c.jwt, c.jwtExp = data+"."+base64.RawURLEncoding.EncodeToString(sig), now+300
return c.jwt, nil
}
// Raw calls GET /api/directory{path} and returns the body.
func (c *PeopleClient) Raw(path string, params url.Values) ([]byte, error) {
tok, err := c.token()
if err != nil {
return nil, err
}
u := c.Base + "/api/directory" + path
if len(params) > 0 {
u += "?" + params.Encode()
}
req, _ := http.NewRequest(http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if resp.StatusCode == http.StatusTooManyRequests { // rate limited: wait and retry
wait, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
time.Sleep(time.Duration(max(wait, 5)) * time.Second)
return c.Raw(path, params)
}
if resp.StatusCode != http.StatusOK {
var e struct {
Error string `json:"error"`
Description string `json:"error_description"`
}
_ = json.Unmarshal(body, &e)
return nil, &APIError{resp.StatusCode, e.Error, e.Description}
}
return body, nil
}
// Get decodes a JSON answer into out.
func (c *PeopleClient) Get(path string, params url.Values, out any) error {
b, err := c.Raw(path, params)
if err != nil {
return err
}
return json.Unmarshal(b, out)
}
// Person is a user as returned by the API (only the fields the service may see are filled).
type Person = map[string]any
// Group: id, name and the group fields the service may see.
type Group = map[string]any
// MaskedFields lists the fields of a person hidden by the administrator.
func MaskedFields(p Person) []string {
var out []string
if list, ok := p["masked_fields"].([]any); ok {
for _, f := range list {
if s, ok := f.(string); ok {
out = append(out, s)
}
}
}
return out
}
// All reads every page of a list ("next" → cursor).
func (c *PeopleClient) All(path, key string, params url.Values) ([]map[string]any, error) {
if params == nil {
params = url.Values{}
}
params.Set("top", "100")
var out []map[string]any
for {
var page map[string]json.RawMessage
if err := c.Get(path, params, &page); err != nil {
return out, err
}
var items []map[string]any
_ = json.Unmarshal(page[key], &items)
out = append(out, items...)
var next string
_ = json.Unmarshal(page["next"], &next)
if next == "" {
return out, nil
}
params.Set("cursor", next)
}
}
func esc(s string) string { return url.PathEscape(s) }
func transitive(on bool) url.Values {
if on {
return url.Values{"transitive": {"1"}}
}
return nil
}
// ---------- users ----------
func (c *PeopleClient) Scopes() (map[string]any, error) {
var v map[string]any
return v, c.Get("/scopes", nil, &v)
}
// User: one user by object id, UPN or email (users.read).
func (c *PeopleClient) User(key string) (Person, error) {
var v Person
return v, c.Get("/users/"+esc(key), nil, &v)
}
// Photo: JPEG/PNG bytes (users.read + photo field).
func (c *PeopleClient) Photo(key string) ([]byte, error) {
return c.Raw("/users/"+esc(key)+"/photo", nil)
}
// SearchUsers: name or email starts with q (users.search).
func (c *PeopleClient) SearchUsers(q string, top int) ([]Person, error) {
var v struct{ Users []Person }
return v.Users, c.Get("/users", url.Values{"q": {q}, "top": {strconv.Itoa(top)}}, &v)
}
// ListUsers: everyone, or one department when department != "" (users.list).
func (c *PeopleClient) ListUsers(department string) ([]Person, error) {
p := url.Values{}
if department != "" {
p.Set("department", department)
}
return c.All("/users", "users", p)
}
// Manager: nil when the user has none (users.org).
func (c *PeopleClient) Manager(key string) (Person, error) {
var v struct{ Manager Person }
return v.Manager, c.Get("/users/"+esc(key)+"/manager", nil, &v)
}
// DirectReports (users.org).
func (c *PeopleClient) DirectReports(key string) ([]Person, error) {
return c.All("/users/"+esc(key)+"/direct-reports", "users", nil)
}
// ---------- groups ----------
// UserGroups: groups of a user; nested = include groups joined through other groups (users.groups).
func (c *PeopleClient) UserGroups(key string, nested bool) ([]Group, error) {
return c.All("/users/"+esc(key)+"/groups", "groups", transitive(nested))
}
// IsMember: nested groups count (users.groups).
func (c *PeopleClient) IsMember(key, groupID string) (bool, error) {
var v struct{ Member bool }
return v.Member, c.Get("/users/"+esc(key)+"/groups/"+esc(groupID), nil, &v)
}
// SearchGroups: by keyword; every group when q == "" (groups.read).
func (c *PeopleClient) SearchGroups(q string) ([]Group, error) {
p := url.Values{}
if q != "" {
p.Set("q", q)
}
return c.All("/groups", "groups", p)
}
// GetGroup (groups.read).
func (c *PeopleClient) GetGroup(groupID string) (Group, error) {
var v Group
return v, c.Get("/groups/"+esc(groupID), nil, &v)
}
// GroupMembers: nested = expand member groups (groups.members).
func (c *PeopleClient) GroupMembers(groupID string, nested bool) ([]Person, error) {
return c.All("/groups/"+esc(groupID)+"/members", "members", transitive(nested))
}
// GroupOwners (groups.members).
func (c *PeopleClient) GroupOwners(groupID string) ([]Person, error) {
return c.All("/groups/"+esc(groupID)+"/owners", "owners", nil)
}
// ---------- demo: go run people_client.go [someone@advantech.com] [group keyword] ----------
func main() {
c, err := NewPeopleClient()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
show := func(title string, v any, err error) {
if err != nil {
fmt.Printf("\n== %s: %v\n", title, err)
return
}
b, _ := json.MarshalIndent(v, "", " ")
fmt.Printf("\n== %s\n%s\n", title, b)
}
v, err := c.Scopes()
show("my permissions", v, err)
if len(os.Args) > 1 {
who := os.Args[1]
u, err := c.User(who)
show("user", u, err)
if err == nil {
fmt.Printf("\n== masked fields of %v: %v\n", u["name"], MaskedFields(u))
}
m, err := c.Manager(who)
show("manager", m, err)
r, err := c.DirectReports(who)
show("direct reports", r, err)
g, err := c.UserGroups(who, true)
show("groups", g, err)
}
if len(os.Args) > 2 {
groups, err := c.SearchGroups(os.Args[2])
show("groups matching "+os.Args[2], groups, err)
if err == nil && len(groups) > 0 {
id, _ := groups[0]["id"].(string)
ms, err := c.GroupMembers(id, true)
show("members of the first group", ms, err)
}
}
}
module people-api-client go 1.21
# Java 17+, no libraries (the key must be PKCS#8: "BEGIN PRIVATE KEY") export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem java PeopleClient.java john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
// Advantech People & Groups API client (Java 17+, no libraries).
//
// Environment:
// PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
// PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
// PEOPLE_KEY_FILE device private key (default device-private.pem, PKCS#8 "BEGIN PRIVATE KEY")
//
// Methods return the JSON text of the answer; parse it with the JSON library you
// already use (Jackson: new ObjectMapper().readTree(json)). List methods return
// every page joined into one JSON array.
//
// Masking: the SSO administrator can hide some fields of some people. The field
// is still there but its value is * (e.g. "******"). Every person has
// "masked" (true/false) and "masked_fields" (array) — see maskedFields().
//
// Demo: java PeopleClient.java someone@advantech.com sales
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PeopleClient {
public static class PeopleApiException extends RuntimeException {
public final int status;
public final String body;
PeopleApiException(int status, String body) {
super(status + " " + body);
this.status = status;
this.body = body;
}
}
private final String base, clientId;
private final PrivateKey key;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
private String jwt;
private long jwtExp;
public PeopleClient(String base, String clientId, String privateKeyPem) throws Exception {
this.base = base.replaceAll("/+$", "");
this.clientId = clientId;
if (privateKeyPem.contains("BEGIN EC PRIVATE KEY") || privateKeyPem.contains("BEGIN RSA PRIVATE KEY"))
throw new IllegalArgumentException("convert the key to PKCS#8: openssl pkcs8 -topk8 -nocrypt -in device-private.pem -out device-private-pk8.pem");
byte[] der = Base64.getMimeDecoder().decode(privateKeyPem.replaceAll("-----[A-Z ]+-----", ""));
PrivateKey k;
try { k = KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(der)); }
catch (Exception e) { k = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); }
this.key = k;
}
public static PeopleClient fromEnvironment() throws Exception {
String id = System.getenv("PEOPLE_CLIENT_ID");
if (id == null) throw new IllegalStateException("set PEOPLE_CLIENT_ID");
return new PeopleClient(env("PEOPLE_API_URL", "https://ae-sso.advantech.com"), id,
Files.readString(Path.of(env("PEOPLE_KEY_FILE", "device-private.pem"))));
}
private static String env(String k, String def) {
String v = System.getenv(k);
return v == null || v.isEmpty() ? def : v;
}
// ---------- plumbing ----------
private static String b64(byte[] b) { return Base64.getUrlEncoder().withoutPadding().encodeToString(b); }
/** A 5-minute JWT signed with the device key (reused until 1 minute before it expires). */
private synchronized String token() throws Exception {
long now = System.currentTimeMillis() / 1000;
if (jwt != null && now < jwtExp - 60) return jwt;
jwtExp = now + 300;
boolean ec = key instanceof ECPrivateKey;
String data = b64(("{\"alg\":\"" + (ec ? "ES256" : "RS256") + "\",\"typ\":\"JWT\"}").getBytes(StandardCharsets.UTF_8)) + "." +
b64(("{\"iss\":\"" + clientId + "\",\"aud\":\"" + base + "/api/directory\",\"iat\":" + now + ",\"exp\":" + jwtExp + "}")
.getBytes(StandardCharsets.UTF_8));
Signature s = Signature.getInstance(ec ? "SHA256withECDSAinP1363Format" : "SHA256withRSA"); // JWT wants r||s
s.initSign(key);
s.update(data.getBytes(StandardCharsets.US_ASCII));
return jwt = data + "." + b64(s.sign());
}
private HttpResponse<byte[]> send(String path, Map<String, String> query) throws Exception {
StringBuilder url = new StringBuilder(base + "/api/directory" + path);
if (query != null) {
char sep = '?';
for (var e : query.entrySet()) {
if (e.getValue() == null || e.getValue().isEmpty()) continue;
url.append(sep).append(enc(e.getKey())).append('=').append(enc(e.getValue()));
sep = '&';
}
}
while (true) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url.toString())).timeout(Duration.ofSeconds(15))
.header("Authorization", "Bearer " + token()).GET().build();
HttpResponse<byte[]> res = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (res.statusCode() == 429) { // rate limited: wait and retry
Thread.sleep(1000L * Long.parseLong(res.headers().firstValue("Retry-After").orElse("5")));
continue;
}
if (res.statusCode() != 200) throw new PeopleApiException(res.statusCode(), new String(res.body(), StandardCharsets.UTF_8));
return res;
}
}
/** GET /api/directory{path} → JSON text. */
public String get(String path, Map<String, String> query) throws Exception {
return new String(send(path, query).body(), StandardCharsets.UTF_8);
}
private static final Pattern NEXT = Pattern.compile("\"next\"\\s*:\\s*\"([^\"]+)\"");
/** Every page of a list joined into one JSON array (follows "next"). */
public String all(String path, String key, Map<String, String> query) throws Exception {
Map<String, String> q = query == null ? new LinkedHashMap<>() : new LinkedHashMap<>(query);
q.put("top", "100");
List<String> parts = new ArrayList<>();
while (true) {
String page = get(path, q);
String items = arrayOf(page, key);
if (items.length() > 2) parts.add(items.substring(1, items.length() - 1));
Matcher m = NEXT.matcher(page);
if (!m.find()) return "[" + String.join(",", parts) + "]";
q.put("cursor", m.group(1));
}
}
// the JSON array stored under "key" (the answer's own structure, no JSON library needed)
private static String arrayOf(String json, String key) {
int i = json.indexOf("\"" + key + "\"");
int start = json.indexOf('[', i);
int depth = 0;
boolean str = false;
for (int j = start; j < json.length(); j++) {
char c = json.charAt(j);
if (str) { if (c == '\\') j++; else if (c == '"') str = false; continue; }
if (c == '"') str = true;
else if (c == '[' || c == '{') depth++;
else if ((c == ']' || c == '}') && --depth == 0) return json.substring(start, j + 1);
}
return "[]";
}
private static String enc(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"); }
private static Map<String, String> nested(boolean on) { return on ? Map.of("transitive", "1") : null; }
/** Fields hidden by the administrator in one person's JSON (from user(...) etc.). */
public static List<String> maskedFields(String personJson) {
List<String> out = new ArrayList<>();
String arr = arrayOf(personJson, "masked_fields");
Matcher m = Pattern.compile("\"([^\"]+)\"").matcher(arr);
while (m.find()) out.add(m.group(1));
return out;
}
// ---------- users ----------
public String scopes() throws Exception { return get("/scopes", null); } // what this device may do
public String user(String key) throws Exception { return get("/users/" + enc(key), null); } // users.read
public byte[] photo(String key) throws Exception { return send("/users/" + enc(key) + "/photo", null).body(); } // users.read + photo
public String searchUsers(String q, int top) throws Exception { // users.search
return arrayOf(get("/users", Map.of("q", q, "top", String.valueOf(top))), "users");
}
public String listUsers(String department) throws Exception { // users.list
return all("/users", "users", department == null ? null : Map.of("department", department));
}
public String manager(String key) throws Exception { return get("/users/" + enc(key) + "/manager", null); } // users.org → {"manager": …|null}
public String directReports(String key) throws Exception { return all("/users/" + enc(key) + "/direct-reports", "users", null); } // users.org
// ---------- groups ----------
public String userGroups(String key, boolean transitive) throws Exception { // users.groups
return all("/users/" + enc(key) + "/groups", "groups", nested(transitive));
}
public boolean isMember(String key, String groupId) throws Exception { // users.groups
return get("/users/" + enc(key) + "/groups/" + enc(groupId), null).contains("\"member\":true");
}
public String searchGroups(String q) throws Exception { // groups.read
return all("/groups", "groups", q == null ? null : Map.of("q", q));
}
public String group(String groupId) throws Exception { return get("/groups/" + enc(groupId), null); } // groups.read
public String groupMembers(String groupId, boolean transitive) throws Exception { // groups.members
return all("/groups/" + enc(groupId) + "/members", "members", nested(transitive));
}
public String groupOwners(String groupId) throws Exception { return all("/groups/" + enc(groupId) + "/owners", "owners", null); } // groups.members
// ---------- demo ----------
interface Call { String run() throws Exception; }
private static void show(String title, Call c) {
try { System.out.println("\n== " + title + "\n" + c.run()); }
catch (PeopleApiException e) { System.out.println("\n== " + title + ": " + e.getMessage()); }
catch (Exception e) { throw new RuntimeException(e); }
}
public static void main(String[] args) throws Exception {
PeopleClient c = fromEnvironment();
show("my permissions", c::scopes);
if (args.length > 0) {
String who = args[0];
show("user", () -> {
String u = c.user(who);
System.out.println("\n== masked fields: " + (maskedFields(u).isEmpty() ? "none" : maskedFields(u)));
return u;
});
show("manager", () -> c.manager(who));
show("direct reports", () -> c.directReports(who));
show("groups", () -> c.userGroups(who, true));
}
if (args.length > 1) {
show("groups matching " + args[1], () -> {
String groups = c.searchGroups(args[1]);
Matcher m = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"").matcher(groups);
if (m.find()) show("members of the first group", () -> c.groupMembers(m.group(1), true));
return groups;
});
}
}
}
# PHP 8+ with the curl and openssl extensions export PEOPLE_CLIENT_ID=dev_xxxxxxxxxxxxxxxxxxxx export PEOPLE_KEY_FILE=device-private.pem php people_client.php john.chen@advantech.com sales
Windows PowerShell: write $env:X="Y" instead of export X=Y.
<?php
// Advantech People & Groups API client (PHP 8+, extensions: curl, openssl).
//
// Environment:
// PEOPLE_API_URL SSO address (default https://ae-sso.advantech.com)
// PEOPLE_CLIENT_ID device Client ID (dev_..., from the SSO administrator)
// PEOPLE_KEY_FILE device private key (default device-private.pem)
//
// Usage:
// require 'people_client.php';
// $c = PeopleClient::fromEnvironment();
// $user = $c->user('john.chen@advantech.com'); // array
// foreach ($c->groupMembers($groupId, true) as $m) echo $m['name'], "\n";
//
// Masking: the SSO administrator can hide some fields of some people. The field
// is still there but its value is * (e.g. "******"). Every person has
// "masked" (bool) and "masked_fields" (array) — see PeopleClient::maskedFields().
//
// Demo: php people_client.php someone@advantech.com sales
class PeopleApiException extends RuntimeException {
public function __construct(public int $status, public string $error, string $description) {
parent::__construct("$status $error: $description");
}
}
class PeopleClient {
private $key;
private string $alg;
private ?string $jwt = null;
private int $jwtExp = 0;
public function __construct(private string $base, private string $clientId, string $privateKeyPem) {
$this->base = rtrim($base, '/');
$this->key = openssl_pkey_get_private($privateKeyPem);
if (!$this->key) throw new InvalidArgumentException('cannot read the private key');
$this->alg = openssl_pkey_get_details($this->key)['type'] === OPENSSL_KEYTYPE_EC ? 'ES256' : 'RS256';
}
public static function fromEnvironment(): self {
$id = getenv('PEOPLE_CLIENT_ID') ?: throw new RuntimeException('set PEOPLE_CLIENT_ID');
return new self(getenv('PEOPLE_API_URL') ?: 'https://ae-sso.advantech.com', $id,
file_get_contents(getenv('PEOPLE_KEY_FILE') ?: 'device-private.pem'));
}
// ---------- plumbing ----------
private static function b64(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); }
// openssl gives an ASN.1 DER ECDSA signature; JWT wants r||s (32 bytes each)
private static function derToRaw(string $der): string {
$pos = 2 + (ord($der[1]) & 0x80 ? (ord($der[1]) & 0x7f) : 0);
$out = '';
for ($i = 0; $i < 2; $i++) {
$len = ord($der[$pos + 1]);
$int = ltrim(substr($der, $pos + 2, $len), "\0");
$out .= str_pad($int, 32, "\0", STR_PAD_LEFT);
$pos += 2 + $len;
}
return $out;
}
// A 5-minute JWT signed with the device key (reused until 1 minute before it expires).
private function token(): string {
$now = time();
if ($this->jwt !== null && $now < $this->jwtExp - 60) return $this->jwt;
$this->jwtExp = $now + 300;
$data = self::b64(json_encode(['alg' => $this->alg, 'typ' => 'JWT'])) . '.' .
self::b64(json_encode(['iss' => $this->clientId, 'aud' => $this->base . '/api/directory',
'iat' => $now, 'exp' => $this->jwtExp], JSON_UNESCAPED_SLASHES));
openssl_sign($data, $sig, $this->key, OPENSSL_ALGO_SHA256);
if ($this->alg === 'ES256') $sig = self::derToRaw($sig);
return $this->jwt = $data . '.' . self::b64($sig);
}
/** GET /api/directory{path}; returns the decoded JSON (or the bytes when $raw). */
public function get(string $path, array $query = [], bool $raw = false) {
$query = array_filter($query, fn($v) => $v !== null && $v !== '' && $v !== false);
$url = $this->base . '/api/directory' . $path . ($query ? '?' . http_build_query($query) : '');
while (true) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token()]]);
$res = curl_exec($ch);
if ($res === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headers = substr($res, 0, curl_getinfo($ch, CURLINFO_HEADER_SIZE));
$body = substr($res, curl_getinfo($ch, CURLINFO_HEADER_SIZE));
if ($status === 429) { // rate limited: wait and retry
sleep(preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m) ? (int)$m[1] : 5);
continue;
}
if ($status !== 200) {
$e = json_decode($body, true) ?: [];
throw new PeopleApiException($status, $e['error'] ?? 'http_error', $e['error_description'] ?? substr($body, 0, 200));
}
return $raw ? $body : json_decode($body, true);
}
}
/** Every item of a list, following "next" page by page. */
public function pages(string $path, string $key, array $query = []): Generator {
$query['top'] = 100;
do {
$page = $this->get($path, $query);
yield from $page[$key];
$query['cursor'] = $page['next'] ?? null;
} while ($query['cursor']);
}
private static function e(string $s): string { return rawurlencode($s); }
/** Fields of this person hidden by the administrator (their values are "*…*"). */
public static function maskedFields(array $person): array { return $person['masked_fields'] ?? []; }
// ---------- users ----------
public function scopes(): array { return $this->get('/scopes'); } // what this device may do
public function user(string $key): array { return $this->get('/users/' . self::e($key)); } // users.read
public function photo(string $key): string { return $this->get('/users/' . self::e($key) . '/photo', [], true); } // users.read + photo
public function searchUsers(string $q, int $top = 10): array { return $this->get('/users', ['q' => $q, 'top' => $top])['users']; } // users.search
public function listUsers(?string $department = null): Generator { return $this->pages('/users', 'users', ['department' => $department]); } // users.list
public function manager(string $key): ?array { return $this->get('/users/' . self::e($key) . '/manager')['manager']; } // users.org
public function directReports(string $key): Generator { return $this->pages('/users/' . self::e($key) . '/direct-reports', 'users'); } // users.org
// ---------- groups ----------
public function userGroups(string $key, bool $transitive = false): Generator { // users.groups
return $this->pages('/users/' . self::e($key) . '/groups', 'groups', ['transitive' => $transitive ? 1 : null]);
}
public function isMember(string $key, string $groupId): bool { // users.groups
return $this->get('/users/' . self::e($key) . '/groups/' . self::e($groupId))['member'];
}
public function searchGroups(?string $q = null): Generator { return $this->pages('/groups', 'groups', ['q' => $q]); } // groups.read
public function group(string $groupId): array { return $this->get('/groups/' . self::e($groupId)); } // groups.read
public function groupMembers(string $groupId, bool $transitive = false): Generator { // groups.members
return $this->pages('/groups/' . self::e($groupId) . '/members', 'members', ['transitive' => $transitive ? 1 : null]);
}
public function groupOwners(string $groupId): Generator { return $this->pages('/groups/' . self::e($groupId) . '/owners', 'owners'); } // groups.members
}
// demo when run directly
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
$c = PeopleClient::fromEnvironment();
$show = function (string $title, callable $call) {
try {
$v = $call();
echo "\n== $title\n", json_encode($v instanceof Generator ? iterator_to_array($v, false) : $v,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
} catch (PeopleApiException $e) {
echo "\n== $title: ", $e->getMessage(), "\n";
}
};
$show('my permissions', fn() => $c->scopes());
if ($who = $argv[1] ?? null) {
$show('user', function () use ($c, $who) {
$u = $c->user($who);
echo "\n== masked fields of {$u['name']}: ", implode(', ', PeopleClient::maskedFields($u)) ?: 'none', "\n";
return $u;
});
$show('manager', fn() => $c->manager($who));
$show('direct reports', fn() => $c->directReports($who));
$show('groups', fn() => array_column(iterator_to_array($c->userGroups($who, true), false), 'name'));
}
if ($kw = $argv[2] ?? null) {
$show("groups matching $kw", function () use ($c, $kw, $show) {
$groups = iterator_to_array($c->searchGroups($kw), false);
if ($groups) $show('members of ' . $groups[0]['name'], fn() => $c->groupMembers($groups[0]['id'], true));
return $groups;
});
}
}
Every API is a GET returning JSON (except the photo), under the same address, with an Authorization header.
GET https://ae-sso.advantech.com/api/directory/… Authorization: Bearer <JWT>
| API | Permission | What it does |
|---|---|---|
| GET /scopes | — | What this device may call, which user and group fields it receives and whether groups are limited. Call it first when you integrate. |
| GET /users/{key} | users.read | One user's full data (same shape as SSO /userinfo), by Azure object ID, UPN or email. Manager, manager chain, reports and group names appear only if the service may see them. |
| GET /users/{key}/photo | users.read | The user's photo (96×96 JPEG). The service also needs the photo field. 404 when there is no photo. |
| GET /users?q={text} | users.search | Find users whose name, email or UPN starts with q. Good for "type a name" autocomplete. |
| GET /users | users.list | Every user in the company, or only one department. Large: fetch page by page with cursor. |
| GET /users/{key}/manager | users.org | The person's manager; manager is null when there is none. |
| GET /users/{key}/direct-reports | users.org | The person's direct reports. |
| GET /users/{key}/groups | users.groups | Which groups the person is in. transitive=1 also lists groups joined through other groups. |
| GET /users/{key}/groups/{groupId} | users.groups | Whether the person is in a group, nested groups included. Good for permission checks ("only the approvers group may approve"). |
| GET /groups?q={keyword} | groups.read | Search groups by keyword: word prefixes of the name and description, case-insensitive. Without q, lists every group (only the allowed ones when groups are limited). |
| GET /groups/{groupId} | groups.read | One group. |
| GET /groups/{groupId}/members | groups.members | Who is in the group. transitive=1 expands member groups (returns people only, not the sub-groups). |
| GET /groups/{groupId}/owners | groups.members | The group's owners. |
/api/directory/scopes
no permission needed
What this device may call, which user and group fields it receives and whether groups are limited. Call it first when you integrate.
GET https://ae-sso.advantech.com/api/directory/scopes
| Python | c.scopes() |
|---|---|
| Node.js | await c.scopes() |
| C# / .NET | await c.Scopes() |
| Go | c.Scopes() |
| Java | c.scopes() |
| PHP | $c->scopes() |
{
"service": "HR Portal", "device": "hr-batch-01",
"scopes": ["groups.members", "groups.read", "users.groups", "users.org", "users.read", "users.search"],
"user_fields": ["sub", "email", "name", "preferred_username", "department", "job_title", "manager", "picture"],
"group_fields": ["id", "name", "description", "mail", "type"],
"groups_limited": false, "allowed_group_count": 0
}
/api/directory/users/{key}
users.read
One user's full data (same shape as SSO /userinfo), by Azure object ID, UPN or email. Manager, manager chain, reports and group names appear only if the service may see them.
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com
| Python | c.user("john.chen@advantech.com") |
|---|---|
| Node.js | await c.user("john.chen@advantech.com") |
| C# / .NET | await c.User("john.chen@advantech.com") |
| Go | c.User("john.chen@advantech.com") |
| Java | c.user("john.chen@advantech.com") |
| PHP | $c->user('john.chen@advantech.com') |
{
"sub": "8d6e2f1a-…", "email": "john.chen@advantech.com", "name": "John Chen",
"preferred_username": "john.chen@advantech.com",
"given_name": "John", "family_name": "Chen",
"job_title": "Product Manager", "department": "Sales", "company": "Advantech",
"office_location": "Taipei", "mobile_phone": "+886 …", "employee_id": "A12345",
"manager": { "id": "…", "name": "Mary Lin", "email": "mary.lin@advantech.com", "job_title": "Director" },
"manager_chain": [ … ], "direct_reports": [ … ], "groups": ["Sales TW", "All Staff"],
"picture": "https://ae-sso.advantech.com/api/directory/users/8d6e2f1a-…/photo",
"masked": true,
"masked_fields": ["employee_id", "mobile_phone"] ← those values are "******" / "+88*****678"
}
/api/directory/users/{key}/photo
users.read
The user's photo (96×96 JPEG). The service also needs the photo field. 404 when there is no photo.
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/photo
| Python | c.photo("john.chen@advantech.com") # bytes |
|---|---|
| Node.js | await c.photo("john.chen@advantech.com") // Buffer |
| C# / .NET | await c.Photo("john.chen@advantech.com") // byte[] |
| Go | c.Photo("john.chen@advantech.com") |
| Java | c.photo("john.chen@advantech.com") // byte[] |
| PHP | $c->photo('john.chen@advantech.com') // string |
HTTP 200 Content-Type: image/jpeg (96 × 96) …JPEG bytes…
/api/directory/users?q={text}
users.search
Find users whose name, email or UPN starts with q. Good for "type a name" autocomplete.
| Parameter | Need | What it does |
|---|---|---|
| q | Required | Search text, 2–100 characters |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/users?q=chen&top=10
| Python | c.search_users("chen") |
|---|---|
| Node.js | await c.searchUsers("chen") |
| C# / .NET | await c.SearchUsers("chen") |
| Go | c.SearchUsers("chen", 10) |
| Java | c.searchUsers("chen", 10) |
| PHP | $c->searchUsers('chen') |
{
"users": [ {
"sub": "8d6e2f1a-…",
"email": "john.chen@advantech.com",
"name": "John Chen",
"preferred_username": "john.chen@advantech.com",
"department": "Sales",
"job_title": "Product Manager",
"masked": false,
"masked_fields": []
} ],
"next": "…"
}
/api/directory/users
users.list
Every user in the company, or only one department. Large: fetch page by page with cursor.
| Parameter | Need | What it does |
|---|---|---|
| department | Optional | Only this department (exact match) |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/users?department=Sales&top=100
| Python | for u in c.list_users(department="Sales"): … |
|---|---|
| Node.js | for await (const u of c.listUsers({ department: "Sales" })) … |
| C# / .NET | await foreach (var u in c.ListUsers("Sales")) … |
| Go | c.ListUsers("Sales") |
| Java | c.listUsers("Sales") |
| PHP | foreach ($c->listUsers('Sales') as $u) … |
{ "users": [ { "sub": "…", "email": "…", "name": "…", "department": "Sales" }, … ],
"count": 342, "next": "RFNwdAIAAQAAAB…" }
/api/directory/users/{key}/manager
users.org
The person's manager; manager is null when there is none.
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/manager
| Python | c.manager("john.chen@advantech.com") |
|---|---|
| Node.js | await c.manager("john.chen@advantech.com") |
| C# / .NET | await c.Manager("john.chen@advantech.com") |
| Go | c.Manager("john.chen@advantech.com") |
| Java | c.manager("john.chen@advantech.com") |
| PHP | $c->manager('john.chen@advantech.com') |
{ "manager": { "sub": "…", "email": "mary.lin@advantech.com", "name": "Mary Lin", "job_title": "Director" } }
// no manager: { "manager": null }
/api/directory/users/{key}/direct-reports
users.org
The person's direct reports.
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/direct-reports
| Python | list(c.direct_reports("john.chen@advantech.com")) |
|---|---|
| Node.js | c.directReports("john.chen@advantech.com") // async iterator |
| C# / .NET | c.DirectReports("john.chen@advantech.com") // IAsyncEnumerable |
| Go | c.DirectReports("john.chen@advantech.com") |
| Java | c.directReports("john.chen@advantech.com") |
| PHP | $c->directReports('john.chen@advantech.com') |
{ "users": [ { "sub": "…", "email": "amy.wu@advantech.com", "name": "Amy Wu", "job_title": "Engineer" } ], "count": 6 }
/api/directory/users/{key}/groups
users.groups
Which groups the person is in. transitive=1 also lists groups joined through other groups.
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
| transitive | Optional | 1 = include nested groups |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/groups?transitive=1
| Python | c.user_groups("john.chen@advantech.com", transitive=True) |
|---|---|
| Node.js | c.userGroups("john.chen@advantech.com", { transitive: true }) |
| C# / .NET | c.UserGroups("john.chen@advantech.com", transitive: true) |
| Go | c.UserGroups("john.chen@advantech.com", true) |
| Java | c.userGroups("john.chen@advantech.com", true) |
| PHP | $c->userGroups('john.chen@advantech.com', true) |
{ "groups": [ { "id": "3f2a9c1e-…", "name": "Sales TW", "type": "microsoft365" },
{ "id": "…", "name": "All Staff", "type": "security" } ] }
/api/directory/users/{key}/groups/{groupId}
users.groups
Whether the person is in a group, nested groups included. Good for permission checks ("only the approvers group may approve").
| Parameter | Need | What it does |
|---|---|---|
| {key} | Required | The user's Azure object ID, UPN or email |
| {groupId} | Required | The group's object ID (GUID). Unknown? Search with /groups?q= first |
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77
| Python | c.is_member("john.chen@advantech.com", group_id) |
|---|---|
| Node.js | await c.isMember("john.chen@advantech.com", groupId) |
| C# / .NET | await c.IsMember("john.chen@advantech.com", groupId) |
| Go | c.IsMember("john.chen@advantech.com", groupID) |
| Java | c.isMember("john.chen@advantech.com", groupId) |
| PHP | $c->isMember('john.chen@advantech.com', $groupId) |
{ "member": true, "user_id": "8d6e2f1a-…", "group_id": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77" }
/api/directory/groups?q={keyword}
groups.read
Search groups by keyword: word prefixes of the name and description, case-insensitive. Without q, lists every group (only the allowed ones when groups are limited).
| Parameter | Need | What it does |
|---|---|---|
| q | Optional | Keyword, 2–100 characters; omit to list all |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/groups?q=sales
| Python | list(c.search_groups("sales")) |
|---|---|
| Node.js | c.searchGroups("sales") // async iterator |
| C# / .NET | c.SearchGroups("sales") |
| Go | c.SearchGroups("sales") |
| Java | c.searchGroups("sales") |
| PHP | $c->searchGroups('sales') |
{ "groups": [ { "id": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77", "name": "Sales TW", "description": "Taiwan sales team",
"mail": "sales-tw@advantech.com", "type": "microsoft365" } ],
"count": 12, "next": "…" }
/api/directory/groups/{groupId}
groups.read
One group.
| Parameter | Need | What it does |
|---|---|---|
| {groupId} | Required | The group's object ID (GUID). Unknown? Search with /groups?q= first |
GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77
| Python | c.group(group_id) |
|---|---|
| Node.js | await c.group(groupId) |
| C# / .NET | await c.Group(groupId) |
| Go | c.GetGroup(groupID) |
| Java | c.group(groupId) |
| PHP | $c->group($groupId) |
{ "id": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77", "name": "Sales TW", "description": "Taiwan sales team",
"mail": "sales-tw@advantech.com", "type": "microsoft365", "visibility": "Private", "created_at": "2021-04-01T08:00:00Z" }
/api/directory/groups/{groupId}/members
groups.members
Who is in the group. transitive=1 expands member groups (returns people only, not the sub-groups).
| Parameter | Need | What it does |
|---|---|---|
| {groupId} | Required | The group's object ID (GUID). Unknown? Search with /groups?q= first |
| transitive | Optional | 1 = include nested groups |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77/members?transitive=1&top=100
| Python | c.group_members(group_id, transitive=True) |
|---|---|
| Node.js | c.groupMembers(groupId, { transitive: true }) |
| C# / .NET | c.GroupMembers(groupId, transitive: true) |
| Go | c.GroupMembers(groupID, true) |
| Java | c.groupMembers(groupId, true) |
| PHP | $c->groupMembers($groupId, true) |
{
"members": [ {
"sub": "8d6e2f1a-…",
"email": "john.chen@advantech.com",
"name": "John Chen",
"preferred_username": "john.chen@advantech.com",
"department": "Sales",
"job_title": "Product Manager",
"masked": false,
"masked_fields": []
} ],
"count": 87, "next": "…"
}
/api/directory/groups/{groupId}/owners
groups.members
The group's owners.
| Parameter | Need | What it does |
|---|---|---|
| {groupId} | Required | The group's object ID (GUID). Unknown? Search with /groups?q= first |
| top | Optional | Items per page, 1–100, default 25 (the libraries use 100) |
| cursor | Optional | The "next" value of the previous page |
GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77/owners
| Python | c.group_owners(group_id) |
|---|---|
| Node.js | c.groupOwners(groupId) |
| C# / .NET | c.GroupOwners(groupId) |
| Go | c.GroupOwners(groupID) |
| Java | c.groupOwners(groupId) |
| PHP | $c->groupOwners($groupId) |
{ "owners": [ { "sub": "…", "email": "mary.lin@advantech.com", "name": "Mary Lin" } ] }
Every API that returns people uses this shape. Only fields the service may see appear; empty fields may be left out.
Note: administrators can mask some fields of some people. A masked field is still present but its value is partly or fully * (e.g. j*******n@advantech.com, ******). Every person carries masked (true/false) and masked_fields (the hidden field names), so your code can tell directly. A signed-in person always sees their own data unmasked, but other people inside it (manager, reports …) may be masked. Identify people by sub.
| Field | What it does | Returned by |
|---|---|---|
| sub, email, name, preferred_username | Always | All APIs |
| masked, masked_fields | Every person (including each person inside manager, reports, group members …) carries these two fields: masked = whether any of this person's data is hidden (true/false); masked_fields = the names of the fields masked in this answer ([] when none). Use them to show "hidden" in your UI, or to avoid storing * as if it were real data. | All APIs |
| given_name | Given name | All APIs |
| family_name | Family name | All APIs |
| job_title | Job title | All APIs |
| department | Department | All APIs |
| company | Company | All APIs |
| office_location | Office | All APIs |
| mobile_phone | Mobile phone | All APIs |
| business_phones | Business phones | All APIs |
| employee_id | Employee ID | All APIs |
| country | Country | All APIs |
| city | City | All APIs |
| preferred_language | Preferred language | All APIs |
| tenant_id | Azure tenant ID | All APIs |
| picture | Photo URL + /userinfo/photo | Single-user lookup only (/users/{key}) |
| manager | Direct manager | Single-user lookup only (/users/{key}) |
| manager_chain | Manager chain (all managers above) | Single-user lookup only (/users/{key}) |
| direct_reports | Direct reports | Single-user lookup only (/users/{key}) |
| team_members | People under the same manager | Single-user lookup only (/users/{key}) |
| groups | Group names | Single-user lookup only (/users/{key}) |
| azure | All Microsoft Graph data and the id_token (includes every field above) | Single-user lookup only (/users/{key}) |
| Field | What it does |
|---|---|
| id, name | Always |
| description | Group description |
| Group email | |
| type | Group type: security, microsoft365 (Microsoft 365 group / Teams), distribution, mail_security (mail-enabled security group); _dynamic is appended for dynamic membership. If the administrator limited the service to certain groups, any other group behaves as if it did not exist (404). |
| visibility | Public / private |
| created_at | Created at |
List APIs take top (1–100, default 25). If the response has next there is another page: pass it as cursor and call again until there is no next. count is the total (not always available).
# page 1
GET /api/directory/groups/{groupId}/members?top=100
→ { "members": [ …100… ], "count": 342, "next": "RFNwdAIAAQAAAB…" }
# page 2: same request + cursor=next
GET /api/directory/groups/{groupId}/members?top=100&cursor=RFNwdAIAAQAAAB…
→ { "members": [ …100… ], "count": 342, "next": "…" }
# … until the answer has no "next"
The client libraries page for you: list_users, group_members etc. keep fetching until the last page.
An SSO super administrator sets these per service under "People & Groups API permissions" on the service page. All devices of a service share them.
| Permission | What it does | API |
|---|---|---|
| users.read | Look up one user (by ID or email), including the photo | GET /users/{id|email} GET /users/{id}/photo |
| users.search | Search users whose name / email starts with a text | GET /users?q= |
| users.list | List every user in the company (optionally by department) — a lot of data, grant with care | GET /users GET /users?department= |
| users.org | A person's manager and direct reports | GET /users/{id}/manager GET /users/{id}/direct-reports |
| users.groups | Which groups a person belongs to, and whether they belong to a given group (nested groups count) | GET /users/{id}/groups GET /users/{id}/groups/{group} |
| groups.read | Search groups by keyword and read a group | GET /groups?q= GET /groups/{group} |
| groups.members | A group's members (optionally including nested groups' members) and owners | GET /groups/{group}/members GET /groups/{group}/owners |
Every call carries a JWT signed with the device's private key. ES256 (EC P-256, recommended) and RS256 (RSA 2048+) are supported.
header {"alg": "ES256", "typ": "JWT"} ← RS256 for an RSA key
payload {"iss": "dev_xxxxxxxxxxxxxxxxxxxx", ← device Client ID
"aud": "https://ae-sso.advantech.com/api/directory",
"iat": 1760000000, ← now (seconds)
"exp": 1760000300} ← now + 300 (at most 10 minutes)
signature = ES256(base64url(header) + "." + base64url(payload), device-private.pem) ← r||s, 64 bytes
GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com
Authorization: Bearer <header>.<payload>.<signature>
# pip install "pyjwt[crypto]"
TOKEN=$(python3 -c 'import jwt,time,sys; n=int(time.time()); print(jwt.encode({"iss":sys.argv[1],"aud":"https://ae-sso.advantech.com/api/directory","iat":n,"exp":n+300}, open("device-private.pem").read(), algorithm="ES256"))' dev_xxxxxxxxxxxxxxxxxxxx)
curl -H "Authorization: Bearer $TOKEN" https://ae-sso.advantech.com/api/directory/scopes
curl -H "Authorization: Bearer $TOKEN" "https://ae-sso.advantech.com/api/directory/groups?q=sales"
Errors return an HTTP status and JSON: error is a fixed code (for your program), error_description explains it (for people).
HTTP 403
{ "error": "insufficient_scope", "error_description": "This service is not allowed to use groups.members (ask the SSO administrator)" }
| HTTP / error | Meaning | Fix |
|---|---|---|
| 400 invalid_request | Bad parameter | A bad parameter, e.g. groupId is not a GUID (find it with /groups?q= first), q shorter than 2 characters, or a bad cursor. |
| 401 invalid_token | Invalid or expired JWT, or the device is disabled | Bad signature, iss, aud or exp, or the device is disabled. The message says which. |
| 403 insufficient_scope | The service lacks this API's permission | The service lacks this API's permission; ask the SSO administrator to tick it under "People & Groups API permissions". |
| 403 ip_not_allowed | The source IP is not in the device's allowlist | The calling IP is not on the device's allowlist. |
| 403 service_disabled | The service is disabled | Ask the SSO administrator to check the service. |
| 404 not_found | Not found (or the feature is off, or the group is outside the allowed ones) | The feature is off, or the user / group was not found. |
| 429 rate_limited | Too many calls | At most 120 calls per minute per device; wait or cache results. |
| 502 graph_forbidden | The SSO lacks a Graph permission in Azure | The SSO's directory app lacks a Graph permission in Azure (group APIs need GroupMember.Read.All); contact the SSO administrator. |
| 502 graph_error | Microsoft Graph error | Retry later; if it persists contact the SSO administrator. |
Shown with the Python library; the other languages' function names are in "Client libraries" and under each API.
from people_client import PeopleClient
c = PeopleClient()
group = next(c.search_groups("Sales TW")) # the first group whose name matches
for m in c.group_members(group["id"], transitive=True):
print(m["name"], m["email"], m.get("department"))
APPROVERS = "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77" # group object id (Azure portal → Groups → Overview)
def can_approve(email):
return c.is_member(email, APPROVERS) # nested groups count
import csv, sys
w = csv.writer(sys.stdout)
w.writerow(["name", "email", "department", "job_title"])
for u in c.list_users(department="Sales"): # every page, 100 at a time
w.writerow([u["name"], u["email"], u.get("department"), u.get("job_title")])
who = c.user("john.chen@advantech.com")
boss = c.manager(who["email"]) # None when there is no manager
team = [p["name"] for p in c.direct_reports(who["email"])]
print(who["name"], "→ reports to", boss and boss["name"], "| team:", team)
for m in c.group_members(group_id):
hidden = PeopleClient.masked_fields(m) # e.g. ["mobile_phone"]; [] when nothing is hidden
phone = None if "mobile_phone" in hidden else m.get("mobile_phone")
save_to_my_db(m["sub"], m["name"], phone) # never store "******" over the real value
Find a group's object ID in the Azure portal → Groups → Overview, or search with /groups?q=.
Advantech Single Sign-On