Advantech

People & Groups API

人員與群組 API

讓你的後端伺服器、排程、批次程式,不需要任何人登入,就能查詢公司 Azure AD 裡的人員、主管與部屬、群組與成員。資料即時來自 Microsoft Graph,只回傳你的服務被允許看的部分。

1. 什麼時候用這個 API

SSO 登入 (/userinfo)人員與群組 API
誰呼叫使用者登入後,你的網站代表他呼叫你的伺服器自己呼叫,沒有人登入
能拿到誰的資料只有登入的那個人公司裡任何人、任何群組 (依權限)
驗證方式Client ID + Client Secret 換 access token設備私密金鑰簽的 JWT
例子顯示「你好,John」、取得登入者的部門每晚同步員工清單、寄信給群組成員、查某人主管簽核、判斷某人是否屬於某群組

只需要知道「目前登入的人」是誰,用 SSO 就夠了,不用申請這個 API。 SSO 登入整合指南 →

2. 3 步驟開始

  1. 在要呼叫 API 的伺服器上產生金鑰。 私密金鑰 device-private.pem 只留在這台伺服器,不要寄給任何人、不要放進 Git。
    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

    Windows 可用 Git Bash 或 WSL 執行 openssl。每台伺服器建議各自產生一組金鑰 (各自註冊成一個設備)。

  2. 把公開金鑰交給 SSO 管理員。 複製下面的內容填好寄給 SSO 最高管理者,他會註冊設備、勾選權限,然後給你設備的 Client ID (dev_…)。
    申請「人員與群組 API」
    
    服務名稱: ____________
    呼叫 API 的伺服器 IP: ____________
    需要的 API 權限: users.read, groups.read, groups.members …
    需要的使用者欄位: department, job_title, manager …
    只會用到的群組: (選填,群組的物件識別碼)
    公開金鑰 (device-public.pem 的內容):
    -----BEGIN PUBLIC KEY-----
    …
    -----END PUBLIC KEY-----

    只申請真正需要的權限,管理員比較容易核准;之後需要更多再申請即可,不用換金鑰。

  3. 下載用戶端函式庫並執行範例。 設定 PEOPLE_CLIENT_ID 與 PEOPLE_KEY_FILE 兩個環境變數,執行範例就會印出你的權限、某個人的資料、主管、部屬、群組。 ↓

3. 用戶端函式庫 (6 種語言)

每種語言都是單一檔案、幾乎不需要套件:包含簽 JWT、自動換新權杖、自動翻頁、429 自動重試、錯誤處理。可以直接複製到你的專案。SSO 的網址已經填好。

Python

下載 .zip (Python)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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

下載 .zip (Node.js)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

# 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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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

下載 .zip (C# / .NET)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

# .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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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

下載 .zip (Go)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

# 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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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

下載 .zip (Java)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

# 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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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

下載 .zip (PHP)

執行範例 (把 dev_xxxx 換成管理員給你的設備 Client ID)

# 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:把 export X=Y 改成 $env:X="Y"。

所有檔案 (點開看程式碼)

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 參考

所有 API 都是 GET、回傳 JSON (大頭照除外),網址前面都是同一個位址,並帶 Authorization 標頭。

GET https://ae-sso.advantech.com/api/directory/…
Authorization: Bearer <JWT>
API需要的權限說明
GET /scopes—這台設備目前有哪些權限、會拿到哪些使用者欄位與群組欄位、群組是否有限制。第一次串接時先呼叫它確認設定。
GET /users/{key}users.read查一位使用者的完整資料 (格式和 SSO 的 /userinfo 相同)。可用 Azure 物件 ID、UPN 或 Email。主管、主管鏈、部屬、群組名稱等欄位,要服務有開放才會出現。
GET /users/{key}/photousers.read使用者大頭照 (96×96 JPEG)。服務還需要開放「大頭照」欄位。沒有照片時回 404。
GET /users?q={text}users.search用文字搜尋使用者:姓名、Email 或 UPN 的開頭符合 q。適合做「輸入名字選人」的自動完成。
GET /usersusers.list列出公司所有使用者,可用 department 只列某個部門。資料量大,用 cursor 一頁一頁取。
GET /users/{key}/managerusers.org這個人的直屬主管。沒有主管時 manager 是 null。
GET /users/{key}/direct-reportsusers.org這個人的直屬部屬。
GET /users/{key}/groupsusers.groups這個人屬於哪些群組。transitive=1 會連「透過其他群組間接加入」的也列出來。
GET /users/{key}/groups/{groupId}users.groups這個人是否屬於某個群組,巢狀群組也算。適合做權限判斷 (例如「只有簽核群組的人可以按核准」)。
GET /groups?q={keyword}groups.read用關鍵字搜尋群組:比對群組名稱與說明中「字詞的開頭」,不分大小寫。不帶 q 會列出所有群組 (有群組限制時只列允許的)。
GET /groups/{groupId}groups.read一個群組的資料。
GET /groups/{groupId}/membersgroups.members群組裡有哪些人。transitive=1 會把成員群組裡的人也展開 (只回傳人,不回傳子群組本身)。
GET /groups/{groupId}/ownersgroups.members群組的擁有者。

權限確認

GET/api/directory/scopes 不需權限

這台設備目前有哪些權限、會拿到哪些使用者欄位與群組欄位、群組是否有限制。第一次串接時先呼叫它確認設定。

請求

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

用函式庫呼叫

Pythonc.scopes()
Node.jsawait c.scopes()
C# / .NETawait c.Scopes()
Goc.Scopes()
Javac.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
}

使用者

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

查一位使用者的完整資料 (格式和 SSO 的 /userinfo 相同)。可用 Azure 物件 ID、UPN 或 Email。主管、主管鏈、部屬、群組名稱等欄位,要服務有開放才會出現。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email

請求

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

用函式庫呼叫

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

回應範例

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

使用者大頭照 (96×96 JPEG)。服務還需要開放「大頭照」欄位。沒有照片時回 404。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email

請求

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

用函式庫呼叫

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

回應範例

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

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

列出公司所有使用者,可用 department 只列某個部門。資料量大,用 cursor 一頁一頁取。

參數必要性說明
department選用只列這個部門 (完全相同)
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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

回應範例

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

組織 (主管與部屬)

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

這個人的直屬主管。沒有主管時 manager 是 null。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email

請求

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

用函式庫呼叫

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

回應範例

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

這個人的直屬部屬。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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

回應範例

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

某人的群組

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

這個人屬於哪些群組。transitive=1 會連「透過其他群組間接加入」的也列出來。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email
transitive選用1 = 包含巢狀群組
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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)

回應範例

{ "groups": [ { "id": "3f2a9c1e-…", "name": "Sales TW", "type": "microsoft365" },
              { "id": "…", "name": "All Staff", "type": "security" } ] }
GET/api/directory/users/{key}/groups/{groupId} users.groups

這個人是否屬於某個群組,巢狀群組也算。適合做權限判斷 (例如「只有簽核群組的人可以按核准」)。

參數必要性說明
{key}必要使用者的 Azure 物件 ID、UPN 或 Email
{groupId}必要群組的物件識別碼 (GUID)。不知道的話先用 /groups?q= 搜尋

請求

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

用函式庫呼叫

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)

回應範例

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

群組

GET/api/directory/groups?q={keyword} groups.read

用關鍵字搜尋群組:比對群組名稱與說明中「字詞的開頭」,不分大小寫。不帶 q 會列出所有群組 (有群組限制時只列允許的)。

參數必要性說明
q選用關鍵字,2–100 字;不帶則列出全部
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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

回應範例

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

一個群組的資料。

參數必要性說明
{groupId}必要群組的物件識別碼 (GUID)。不知道的話先用 /groups?q= 搜尋

請求

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

用函式庫呼叫

Pythonc.group(group_id)
Node.jsawait c.group(groupId)
C# / .NETawait c.Group(groupId)
Goc.GetGroup(groupID)
Javac.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" }
GET/api/directory/groups/{groupId}/members groups.members

群組裡有哪些人。transitive=1 會把成員群組裡的人也展開 (只回傳人,不回傳子群組本身)。

參數必要性說明
{groupId}必要群組的物件識別碼 (GUID)。不知道的話先用 /groups?q= 搜尋
transitive選用1 = 包含巢狀群組
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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)

回應範例

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

群組的擁有者。

參數必要性說明
{groupId}必要群組的物件識別碼 (GUID)。不知道的話先用 /groups?q= 搜尋
top選用每頁筆數 1–100,預設 25 (函式庫預設 100)
cursor選用上一頁回應中的 next,用來取下一頁

請求

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

用函式庫呼叫

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

回應範例

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

5. 資料格式與分頁

使用者物件

所有回傳使用者的 API 都用同一種格式。只有服務被允許的欄位會出現;沒有值的欄位可能省略。

注意:管理員可以對某些人的某些欄位設定「隱碼」。被隱碼的欄位仍然存在,但值會變成部分或全部是 * 的文字 (例如 j*******n@advantech.com、******)。每一位使用者都附帶 masked (true/false) 與 masked_fields (被遮的欄位清單),程式可以直接判斷。登入的本人看自己的資料不會被遮,但資料裡的其他人 (主管、部屬等) 可能被遮。識別使用者請用 sub。

欄位說明出現在
sub, email, name, preferred_username一定會有所有 API
masked, masked_fields每一位使用者 (包括 manager、部屬、群組成員等裡面的每個人) 都有這兩個欄位:masked = 這個人是否有資料被隱碼 (true/false);masked_fields = 這次回應中被隱碼的欄位名稱清單 (沒有時是空陣列 [])。程式可以用它判斷要不要顯示「資料已隱藏」,或避免把 * 當成真實資料存起來。所有 API
given_name名所有 API
family_name姓所有 API
job_title職稱所有 API
department部門所有 API
company公司所有 API
office_location辦公室所有 API
mobile_phone手機所有 API
business_phones公司電話所有 API
employee_id員工編號所有 API
country國家所有 API
city城市所有 API
preferred_language慣用語言所有 API
tenant_idAzure 租用戶 ID所有 API
picture大頭照網址 + /userinfo/photo只在查單一使用者 (/users/{key})
manager直屬主管只在查單一使用者 (/users/{key})
manager_chain主管鏈 (往上所有主管)只在查單一使用者 (/users/{key})
direct_reports直屬部屬只在查單一使用者 (/users/{key})
team_members同主管的成員只在查單一使用者 (/users/{key})
groups所屬群組名稱只在查單一使用者 (/users/{key})
azureMicrosoft Graph 全部資料與 id_token (包含上面所有欄位)只在查單一使用者 (/users/{key})

群組物件

欄位說明
id, name一定會有
description群組說明
mail群組 Email
type群組 type:security 安全性群組、microsoft365 Microsoft 365 群組 (Teams)、distribution 通訊群組、mail_security 擁有郵件的安全性群組;動態成員資格會加上 _dynamic。若管理員設定了「只允許這些群組」,其他群組對這個服務來說就像不存在 (404)。
visibility公開 / 私人
created_at建立時間

分頁

清單類 API 可帶 top (1–100,預設 25)。回應裡有 next 就表示還有下一頁:把它放進 cursor 參數再呼叫一次,直到沒有 next。count 是總筆數 (有時無法提供)。

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

用戶端函式庫已經自動翻頁:list_users、group_members 等函式會一直取到最後一頁。

6. 權限

SSO 最高管理者在「服務」頁的「人員與群組 API 權限」為每個服務設定。同一個服務的所有設備共用這些權限。

需要的權限說明API
users.read查單一使用者 (用 ID 或 Email),含大頭照GET /users/{id|email}
GET /users/{id}/photo
users.search用姓名 / Email 開頭搜尋使用者GET /users?q=
users.list列出全公司所有使用者 (可依部門篩選) — 資料量大,請謹慎開放GET /users
GET /users?department=
users.org查某人的主管、直屬部屬GET /users/{id}/manager
GET /users/{id}/direct-reports
users.groups查某人屬於哪些群組、是否屬於某個群組 (含巢狀群組)GET /users/{id}/groups
GET /users/{id}/groups/{group}
groups.read用關鍵字搜尋群組、查群組資料GET /groups?q=
GET /groups/{group}
groups.members查群組的成員 (可含巢狀群組的成員) 與擁有者GET /groups/{group}/members
GET /groups/{group}/owners

7. 驗證 (JWT) 細節

每次呼叫都帶一個用設備私密金鑰簽的 JWT。支援 ES256 (EC P-256,建議) 與 RS256 (RSA 2048 以上)。

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>

用 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. 錯誤與限制

錯誤時回傳 HTTP 狀態碼與 JSON:error 是固定的代碼 (程式判斷用),error_description 是說明 (給人看)。

HTTP 403
{ "error": "insufficient_scope", "error_description": "This service is not allowed to use groups.members (ask the SSO administrator)" }
HTTP / error意思怎麼解決
400 invalid_request參數不正確參數不正確,例如 groupId 不是 GUID (請先用 /groups?q= 找到 id)、q 少於 2 個字、cursor 不正確。
401 invalid_tokenJWT 無效、過期,或設備被停用簽章、iss、aud 或 exp 不正確,或設備已停用。錯誤訊息會說明原因。
403 insufficient_scope服務沒有這個 API 的權限服務沒有這個 API 的權限,請 SSO 管理員在服務頁「人員與群組 API 權限」勾選。
403 ip_not_allowed來源 IP 不在設備的允許清單呼叫來源 IP 不在設備的允許清單。
403 service_disabled服務已停用請 SSO 管理員確認服務狀態。
404 not_found找不到 (或功能未開啟、群組不在允許範圍)功能未開啟,或找不到這位使用者 / 群組。
429 rate_limited呼叫太頻繁每台設備每分鐘最多 120 次,請稍候或快取結果。
502 graph_forbiddenSSO 在 Azure 缺少 Graph 權限SSO 的目錄應用程式在 Azure 少了 Graph 權限 (群組 API 需要 GroupMember.Read.All),請聯絡 SSO 管理員。
502 graph_errorMicrosoft Graph 暫時錯誤稍後重試;持續發生請聯絡 SSO 管理員。

限制

9. 常見用法

以下用 Python 函式庫示範,其他語言的函式名稱見「用戶端函式庫」與每個 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

匯出某部門的員工清單 (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")])

查某人的主管與團隊

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

群組的物件識別碼可在 Azure 入口網站 → 群組 → 概觀找到,或用 /groups?q= 搜尋。

10. 常見問題

私密金鑰外洩了怎麼辦?
立刻請 SSO 管理員在服務頁停用或刪除這個設備,再產生新金鑰重新註冊。舊金鑰在停用後立即失效。
有好幾台伺服器要呼叫?
每台各自產生金鑰、各自註冊成一個設備 (同一個服務)。這樣可以個別停用,紀錄也分得清楚。
為什麼某些欄位沒有出現?
欄位沒有開放給你的服務,或 Azure 裡這個人沒有填這個資料。呼叫 /scopes 可以看到你的服務能拿到哪些欄位。
資料多久更新?
每次呼叫都即時向 Microsoft Graph 查詢,所以是 Azure AD 目前的資料。如果你大量使用,請自己快取幾分鐘。
可以從瀏覽器 (JavaScript) 直接呼叫嗎?
不行。私密金鑰不能放在瀏覽器。請由你的後端呼叫,再把需要的資料給前端。
Java 說金鑰格式不對?
Java 需要 PKCS#8 格式 (BEGIN PRIVATE KEY)。用本頁的 openssl genpkey 指令產生就是這個格式;舊的 EC PRIVATE KEY 可用 openssl pkcs8 -topk8 -nocrypt -in device-private.pem -out device-private-pk8.pem 轉換。
會不會改到 Azure 的資料?
不會。這個 API 只能讀取,沒有任何新增、修改、刪除的功能。

Advantech Single Sign-On