Advantech

People & Groups API

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.

1. When to use this API

SSO sign-in (/userinfo)People & Groups API
Who callsYour site, on behalf of the signed-in userYour server itself, nobody signed in
Whose dataOnly the signed-in personAnyone and any group in the company (as permitted)
AuthenticationClient ID + secret exchanged for an access tokenA JWT signed with the device's private key
ExamplesShow "Hello, John", get the signed-in user's departmentNightly 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 →

2. Get started in 3 steps

  1. Create a key on the server that will call the API. The private key device-private.pem stays on that server: never mail it, never commit it.
    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).

  2. Give the public key to the SSO administrator. Fill in the text below and send it to an SSO super administrator. They register the device, tick the permissions and give you the device Client ID (dev_…).
    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.

  3. Download a client library and run the demo. Set PEOPLE_CLIENT_ID and PEOPLE_KEY_FILE; the demo prints your permissions and a person's data, manager, reports and groups. ↓

3. Client libraries (6 languages)

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.

Python

Download .zip (Python)

Run the demo (replace dev_xxxx with the device Client ID you received)

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.

All files (click to view)

people_client.py
"""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)
requirements.txt
pyjwt[crypto]>=2.8
requests>=2.31

Node.js

Download .zip (Node.js)

Run the demo (replace dev_xxxx with the device Client ID you received)

# 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.

All files (click to view)

people_client.mjs
// 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;
    });
  }
}
package.json
{
  "name": "people-api-client",
  "private": true,
  "type": "module",
  "engines": { "node": ">=18" },
  "scripts": { "demo": "node people_client.mjs" }
}

C# / .NET

Download .zip (C# / .NET)

Run the demo (replace dev_xxxx with the device Client ID you received)

# .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.

All files (click to view)

PeopleClient.cs
// 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(); }
}
Program.cs
// 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;
    });
}
PeopleDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Go

Download .zip (Go)

Run the demo (replace dev_xxxx with the device Client ID you received)

# 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.

All files (click to view)

people_client.go
// 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)
		}
	}
}
go.mod
module people-api-client

go 1.21

Java

Download .zip (Java)

Run the demo (replace dev_xxxx with the device Client ID you received)

# 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.

All files (click to view)

PeopleClient.java
// 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

Download .zip (PHP)

Run the demo (replace dev_xxxx with the device Client ID you received)

# 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.

All files (click to view)

people_client.php
<?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;
        });
    }
}

4. API reference

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>
APIPermissionWhat 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.readOne 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}/photousers.readThe user's photo (96×96 JPEG). The service also needs the photo field. 404 when there is no photo.
GET /users?q={text}users.searchFind users whose name, email or UPN starts with q. Good for "type a name" autocomplete.
GET /usersusers.listEvery user in the company, or only one department. Large: fetch page by page with cursor.
GET /users/{key}/managerusers.orgThe person's manager; manager is null when there is none.
GET /users/{key}/direct-reportsusers.orgThe person's direct reports.
GET /users/{key}/groupsusers.groupsWhich groups the person is in. transitive=1 also lists groups joined through other groups.
GET /users/{key}/groups/{groupId}users.groupsWhether the person is in a group, nested groups included. Good for permission checks ("only the approvers group may approve").
GET /groups?q={keyword}groups.readSearch 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.readOne group.
GET /groups/{groupId}/membersgroups.membersWho is in the group. transitive=1 expands member groups (returns people only, not the sub-groups).
GET /groups/{groupId}/ownersgroups.membersThe group's owners.

Check permissions

GET/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.

Request

GET https://ae-sso.advantech.com/api/directory/scopes

With the client library

Pythonc.scopes()
Node.jsawait c.scopes()
C# / .NETawait c.Scopes()
Goc.Scopes()
Javac.scopes()
PHP$c->scopes()

Response example

{
  "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
}

Users

GET/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.

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com

With the client library

Pythonc.user("john.chen@advantech.com")
Node.jsawait c.user("john.chen@advantech.com")
C# / .NETawait c.User("john.chen@advantech.com")
Goc.User("john.chen@advantech.com")
Javac.user("john.chen@advantech.com")
PHP$c->user('john.chen@advantech.com')

Response example

{
  "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"
}
GET/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.

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/photo

With the client library

Pythonc.photo("john.chen@advantech.com") # bytes
Node.jsawait c.photo("john.chen@advantech.com") // Buffer
C# / .NETawait c.Photo("john.chen@advantech.com") // byte[]
Goc.Photo("john.chen@advantech.com")
Javac.photo("john.chen@advantech.com") // byte[]
PHP$c->photo('john.chen@advantech.com') // string

Response example

HTTP 200  Content-Type: image/jpeg   (96 × 96)

…JPEG bytes…
GET/api/directory/users users.list

Every user in the company, or only one department. Large: fetch page by page with cursor.

ParameterNeedWhat it does
departmentOptionalOnly this department (exact match)
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/users?department=Sales&top=100

With the client library

Pythonfor u in c.list_users(department="Sales"): …
Node.jsfor await (const u of c.listUsers({ department: "Sales" })) …
C# / .NETawait foreach (var u in c.ListUsers("Sales")) …
Goc.ListUsers("Sales")
Javac.listUsers("Sales")
PHPforeach ($c->listUsers('Sales') as $u) …

Response example

{ "users": [ { "sub": "…", "email": "…", "name": "…", "department": "Sales" }, … ],
  "count": 342, "next": "RFNwdAIAAQAAAB…" }

Organization (manager and reports)

GET/api/directory/users/{key}/manager users.org

The person's manager; manager is null when there is none.

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/manager

With the client library

Pythonc.manager("john.chen@advantech.com")
Node.jsawait c.manager("john.chen@advantech.com")
C# / .NETawait c.Manager("john.chen@advantech.com")
Goc.Manager("john.chen@advantech.com")
Javac.manager("john.chen@advantech.com")
PHP$c->manager('john.chen@advantech.com')

Response example

{ "manager": { "sub": "…", "email": "mary.lin@advantech.com", "name": "Mary Lin", "job_title": "Director" } }
// no manager:  { "manager": null }
GET/api/directory/users/{key}/direct-reports users.org

The person's direct reports.

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/direct-reports

With the client library

Pythonlist(c.direct_reports("john.chen@advantech.com"))
Node.jsc.directReports("john.chen@advantech.com") // async iterator
C# / .NETc.DirectReports("john.chen@advantech.com") // IAsyncEnumerable
Goc.DirectReports("john.chen@advantech.com")
Javac.directReports("john.chen@advantech.com")
PHP$c->directReports('john.chen@advantech.com')

Response example

{ "users": [ { "sub": "…", "email": "amy.wu@advantech.com", "name": "Amy Wu", "job_title": "Engineer" } ], "count": 6 }

A person's groups

GET/api/directory/users/{key}/groups users.groups

Which groups the person is in. transitive=1 also lists groups joined through other groups.

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email
transitiveOptional1 = include nested groups
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/groups?transitive=1

With the client library

Pythonc.user_groups("john.chen@advantech.com", transitive=True)
Node.jsc.userGroups("john.chen@advantech.com", { transitive: true })
C# / .NETc.UserGroups("john.chen@advantech.com", transitive: true)
Goc.UserGroups("john.chen@advantech.com", true)
Javac.userGroups("john.chen@advantech.com", true)
PHP$c->userGroups('john.chen@advantech.com', true)

Response example

{ "groups": [ { "id": "3f2a9c1e-…", "name": "Sales TW", "type": "microsoft365" },
              { "id": "…", "name": "All Staff", "type": "security" } ] }
GET/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").

ParameterNeedWhat it does
{key}RequiredThe user's Azure object ID, UPN or email
{groupId}RequiredThe group's object ID (GUID). Unknown? Search with /groups?q= first

Request

GET https://ae-sso.advantech.com/api/directory/users/john.chen@advantech.com/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77

With the client library

Pythonc.is_member("john.chen@advantech.com", group_id)
Node.jsawait c.isMember("john.chen@advantech.com", groupId)
C# / .NETawait c.IsMember("john.chen@advantech.com", groupId)
Goc.IsMember("john.chen@advantech.com", groupID)
Javac.isMember("john.chen@advantech.com", groupId)
PHP$c->isMember('john.chen@advantech.com', $groupId)

Response example

{ "member": true, "user_id": "8d6e2f1a-…", "group_id": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77" }

Groups

GET/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).

ParameterNeedWhat it does
qOptionalKeyword, 2–100 characters; omit to list all
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/groups?q=sales

With the client library

Pythonlist(c.search_groups("sales"))
Node.jsc.searchGroups("sales") // async iterator
C# / .NETc.SearchGroups("sales")
Goc.SearchGroups("sales")
Javac.searchGroups("sales")
PHP$c->searchGroups('sales')

Response example

{ "groups": [ { "id": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77", "name": "Sales TW", "description": "Taiwan sales team",
                "mail": "sales-tw@advantech.com", "type": "microsoft365" } ],
  "count": 12, "next": "…" }
GET/api/directory/groups/{groupId} groups.read

One group.

ParameterNeedWhat it does
{groupId}RequiredThe group's object ID (GUID). Unknown? Search with /groups?q= first

Request

GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77

With the client library

Pythonc.group(group_id)
Node.jsawait c.group(groupId)
C# / .NETawait c.Group(groupId)
Goc.GetGroup(groupID)
Javac.group(groupId)
PHP$c->group($groupId)

Response example

{ "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" }
GET/api/directory/groups/{groupId}/members groups.members

Who is in the group. transitive=1 expands member groups (returns people only, not the sub-groups).

ParameterNeedWhat it does
{groupId}RequiredThe group's object ID (GUID). Unknown? Search with /groups?q= first
transitiveOptional1 = include nested groups
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77/members?transitive=1&top=100

With the client library

Pythonc.group_members(group_id, transitive=True)
Node.jsc.groupMembers(groupId, { transitive: true })
C# / .NETc.GroupMembers(groupId, transitive: true)
Goc.GroupMembers(groupID, true)
Javac.groupMembers(groupId, true)
PHP$c->groupMembers($groupId, true)

Response example

{
  "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": "…"
}
GET/api/directory/groups/{groupId}/owners groups.members

The group's owners.

ParameterNeedWhat it does
{groupId}RequiredThe group's object ID (GUID). Unknown? Search with /groups?q= first
topOptionalItems per page, 1–100, default 25 (the libraries use 100)
cursorOptionalThe "next" value of the previous page

Request

GET https://ae-sso.advantech.com/api/directory/groups/3f2a9c1e-7b4d-4e8a-9f61-2c5d8e0a1b77/owners

With the client library

Pythonc.group_owners(group_id)
Node.jsc.groupOwners(groupId)
C# / .NETc.GroupOwners(groupId)
Goc.GroupOwners(groupID)
Javac.groupOwners(groupId)
PHP$c->groupOwners($groupId)

Response example

{ "owners": [ { "sub": "…", "email": "mary.lin@advantech.com", "name": "Mary Lin" } ] }

5. Data formats and paging

User object

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.

FieldWhat it doesReturned by
sub, email, name, preferred_usernameAlwaysAll APIs
masked, masked_fieldsEvery 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_nameGiven nameAll APIs
family_nameFamily nameAll APIs
job_titleJob titleAll APIs
departmentDepartmentAll APIs
companyCompanyAll APIs
office_locationOfficeAll APIs
mobile_phoneMobile phoneAll APIs
business_phonesBusiness phonesAll APIs
employee_idEmployee IDAll APIs
countryCountryAll APIs
cityCityAll APIs
preferred_languagePreferred languageAll APIs
tenant_idAzure tenant IDAll APIs
picturePhoto URL + /userinfo/photoSingle-user lookup only (/users/{key})
managerDirect managerSingle-user lookup only (/users/{key})
manager_chainManager chain (all managers above)Single-user lookup only (/users/{key})
direct_reportsDirect reportsSingle-user lookup only (/users/{key})
team_membersPeople under the same managerSingle-user lookup only (/users/{key})
groupsGroup namesSingle-user lookup only (/users/{key})
azureAll Microsoft Graph data and the id_token (includes every field above)Single-user lookup only (/users/{key})

Group object

FieldWhat it does
id, nameAlways
descriptionGroup description
mailGroup email
typeGroup 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).
visibilityPublic / private
created_atCreated at

Paging

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.

6. Permissions

An SSO super administrator sets these per service under "People & Groups API permissions" on the service page. All devices of a service share them.

PermissionWhat it doesAPI
users.readLook up one user (by ID or email), including the photoGET /users/{id|email}
GET /users/{id}/photo
users.searchSearch users whose name / email starts with a textGET /users?q=
users.listList every user in the company (optionally by department) — a lot of data, grant with careGET /users
GET /users?department=
users.orgA person's manager and direct reportsGET /users/{id}/manager
GET /users/{id}/direct-reports
users.groupsWhich 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.readSearch groups by keyword and read a groupGET /groups?q=
GET /groups/{group}
groups.membersA group's members (optionally including nested groups' members) and ownersGET /groups/{group}/members
GET /groups/{group}/owners

7. Authentication (JWT) details

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>

Test with curl

# 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"

8. Errors and limits

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 / errorMeaningFix
400 invalid_requestBad parameterA 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_tokenInvalid or expired JWT, or the device is disabledBad signature, iss, aud or exp, or the device is disabled. The message says which.
403 insufficient_scopeThe service lacks this API's permissionThe service lacks this API's permission; ask the SSO administrator to tick it under "People & Groups API permissions".
403 ip_not_allowedThe source IP is not in the device's allowlistThe calling IP is not on the device's allowlist.
403 service_disabledThe service is disabledAsk the SSO administrator to check the service.
404 not_foundNot 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_limitedToo many callsAt most 120 calls per minute per device; wait or cache results.
502 graph_forbiddenThe SSO lacks a Graph permission in AzureThe SSO's directory app lacks a Graph permission in Azure (group APIs need GroupMember.Read.All); contact the SSO administrator.
502 graph_errorMicrosoft Graph errorRetry later; if it persists contact the SSO administrator.

Limits

9. Common tasks

Shown with the Python library; the other languages' function names are in "Client libraries" and under each API.

List every member of a group (nested groups included)

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"))

Check a permission (is the person in a group?)

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

Export a department's employees (CSV)

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")])

Find someone's manager and team

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)

Handle masked data (never store * in your database)

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=.

10. FAQ

The private key leaked — what now?
Ask the SSO administrator to disable or delete the device at once, then create a new key and register again. The old key stops working immediately.
Several servers need to call it?
Give each its own key and register each as a device of the same service, so each can be disabled separately and the log tells them apart.
Why are some fields missing?
The field is not granted to your service, or the person has no value in Azure. /scopes shows which fields you receive.
How fresh is the data?
Every call asks Microsoft Graph live, so it is the current Azure AD data. If you call a lot, cache for a few minutes.
Can a browser (JavaScript) call it directly?
No — the private key must not be in a browser. Call it from your back end and pass what the front end needs.
Java says the key format is wrong?
Java needs PKCS#8 (BEGIN PRIVATE KEY), which is what the openssl genpkey command on this page creates. Convert an old EC PRIVATE KEY with openssl pkcs8 -topk8 -nocrypt -in device-private.pem -out device-private-pk8.pem.
Can it change Azure data?
No. The API is read-only: nothing can be created, changed or deleted.

Advantech Single Sign-On