Advantech

People & Groups API

ユーザー・グループ API

バックエンド サーバー、定期ジョブ、バッチ処理から、誰もサインインせずに、社内 Azure AD のユーザー、上司と部下、グループとメンバーを参照できます。データは Microsoft Graph からリアルタイムに取得し、サービスに許可された部分だけを返します。

1. この API を使う場面

SSO サインイン (/userinfo)ユーザー・グループ API
呼び出す側サインインしたユーザーの代わりにサイトが呼ぶサーバー自身が呼ぶ (サインイン不要)
取得できるデータサインインした本人のみ社内の誰でも、どのグループでも (権限の範囲で)
認証方式Client ID とシークレットでアクセス トークンを取得デバイスの秘密鍵で署名した 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
    
    サービス名: ____________
    呼び出し元サーバーの IP: ____________
    必要な API 権限: users.read, groups.read, groups.members …
    必要なユーザー項目: department, job_title, manager …
    使うグループだけ: (任意、グループのオブジェクト ID)
    公開鍵 (device-public.pem の内容):
    -----BEGIN PUBLIC KEY-----
    …
    -----END PUBLIC KEY-----

    必要な権限だけを申請すると承認が早くなります。後から追加しても鍵を作り直す必要はありません。

  3. クライアント ライブラリをダウンロードしてデモを実行します。 PEOPLE_CLIENT_ID と PEOPLE_KEY_FILE を設定すると、デモが権限、ある人のデータ、上司、部下、グループを表示します。 ↓

3. クライアント ライブラリ (6 言語)

どの言語も依存関係がほぼない 1 ファイルです。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—このデバイスが呼べる API、受け取るユーザー項目とグループ項目、グループの制限の有無。連携時はまずこれを呼んで確認してください。
GET /users/{key}users.readユーザー 1 人の全データ (SSO の /userinfo と同じ形式)。Azure オブジェクト ID、UPN またはメールで指定。上司、上司の系列、部下、グループ名は許可されている場合だけ含まれます。
GET /users/{key}/photousers.readユーザーの写真 (96×96 JPEG)。サービスに写真項目の許可も必要です。写真がない場合は 404。
GET /users?q={text}users.search名前、メール、UPN が q で始まるユーザーを検索。「名前を入力して選ぶ」入力補完に。
GET /usersusers.list全社のユーザー、または特定の部署だけ。件数が多いので cursor で 1 ページずつ取得します。
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グループ 1 件。
GET /groups/{groupId}/membersgroups.membersグループのメンバー。transitive=1 でメンバー グループを展開します (人だけを返し、サブグループ自体は返しません)。
GET /groups/{groupId}/ownersgroups.membersグループの所有者。

権限の確認

GET/api/directory/scopes 権限不要

このデバイスが呼べる API、受け取るユーザー項目とグループ項目、グループの制限の有無。連携時はまずこれを呼んで確認してください。

リクエスト

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

ユーザー 1 人の全データ (SSO の /userinfo と同じ形式)。Azure オブジェクト ID、UPN またはメールで指定。上司、上司の系列、部下、グループ名は許可されている場合だけ含まれます。

パラメーター必要性説明
{key}必須ユーザーの Azure オブジェクト ID、UPN またはメール

リクエスト

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 またはメール

リクエスト

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

全社のユーザー、または特定の部署だけ。件数が多いので cursor で 1 ページずつ取得します。

パラメーター必要性説明
department任意この部署だけ (完全一致)
top任意1 ページの件数 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 またはメール

リクエスト

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 またはメール
top任意1 ページの件数 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 またはメール
transitive任意1 = 入れ子のグループを含む
top任意1 ページの件数 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 またはメール
{groupId}必須グループのオブジェクト ID (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 ページの件数 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

グループ 1 件。

パラメーター必要性説明
{groupId}必須グループのオブジェクト ID (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}必須グループのオブジェクト ID (GUID)。不明なら先に /groups?q= で検索
transitive任意1 = 入れ子のグループを含む
top任意1 ページの件数 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}必須グループのオブジェクト ID (GUID)。不明なら先に /groups?q= で検索
top任意1 ページの件数 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 を使ってください。

項目説明返す API
sub, email, name, preferred_username常に含まれるすべての API
masked, masked_fieldsすべての人 (manager、部下、グループ メンバーなどの中の各人を含む) にこの 2 項目があります: masked = この人のデータが隠されているか (true/false)、masked_fields = この応答でマスキングされた項目名の一覧 (ない場合は [])。UI で「非表示」を示したり、* を本物のデータとして保存しないために使えます。すべての 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写真の URL + /userinfo/photoユーザー 1 件の参照のみ (/users/{key})
manager直属の上司ユーザー 1 件の参照のみ (/users/{key})
manager_chain上司のチェーン (上位の上司すべて)ユーザー 1 件の参照のみ (/users/{key})
direct_reports直属の部下ユーザー 1 件の参照のみ (/users/{key})
team_members同じ上司のメンバーユーザー 1 件の参照のみ (/users/{key})
groupsグループ名ユーザー 1 件の参照のみ (/users/{key})
azureMicrosoft Graph の全データと id_token (上の全項目を含む)ユーザー 1 件の参照のみ (/users/{key})

グループ オブジェクト

項目説明
id, name常に含まれる
descriptionグループの説明
mailグループのメール
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ユーザー 1 件の参照 (ID またはメール)、写真を含むGET /users/{id|email}
GET /users/{id}/photo
users.search名前 / メールの前方一致でユーザーを検索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呼び出しが多すぎるデバイスごとに 1 分あたり最大 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

グループのオブジェクト ID は 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