Advantech

3 個步驟,5 分鐘接上 Advantech 登入

照著做、複製貼上就完成。不需要懂 OAuth,也不需要碰 Azure。

1跟 SSO 管理員要 Client ID 和 Client Secret

把下面這段複製給 SSO 管理員,他會回你兩個值:Client ID 和 Client Secret。

請幫我在 Advantech SSO 新增服務
服務名稱:(填你的網站名稱)
Redirect URI: https://myapp.advantech.world/auth/callback

PHP 版的 Redirect URI 不同,請看步驟 2 的 PHP 頁籤。Client Secret 是密碼,只放在後端環境變數,不要放進網頁或 Git。

2選你的語言,下載範本並執行

範本是一個可以直接跑的小專案:登入頁、首頁、後端都做好了。下載解壓縮 → 把步驟 1 拿到的兩個值填進指令 → 執行。

Python / Flask

下載範本 .zip (Python / Flask)

範本內容

myapp/
flask_app.py
requirements.txt
public/login.html
public/index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

pip install -r requirements.txt

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world
export FLASK_SECRET_KEY=change-me-to-a-long-random-string

python flask_app.py

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:5000

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

flask_app.py
# Advantech SSO + Flask  —  pip install flask requests
#
#   export SSO_CLIENT_ID=svc_xxxxxxxx
#   export SSO_CLIENT_SECRET=xxxxxxxx
#   export APP_URL=https://myapp.advantech.world      # this site; register APP_URL/auth/callback as Redirect URI
#   export FLASK_SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))")
#   python flask_app.py           (put login.html / index.html in ./public)
import os, secrets
from functools import wraps
from urllib.parse import urlencode

import requests
from flask import Flask, abort, jsonify, redirect, request, send_from_directory, session

SSO = "https://ae-sso.advantech.com"
CLIENT_ID = os.environ["SSO_CLIENT_ID"]
CLIENT_SECRET = os.environ["SSO_CLIENT_SECRET"]
APP_URL = os.environ["APP_URL"].rstrip("/")
REDIRECT_URI = APP_URL + "/auth/callback"

# the session is a signed cookie (max ~4 KB), so keep only the fields you use
KEEP = ("sub", "email", "name", "given_name", "family_name", "job_title", "department",
        "company", "office_location", "employee_id", "manager", "picture")

app = Flask(__name__, static_folder="public", static_url_path="")
app.secret_key = os.environ["FLASK_SECRET_KEY"]
app.config.update(SESSION_COOKIE_SECURE=APP_URL.startswith("https://"),
                  SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")


def safe_next(v):
    return v if v and v.startswith("/") and not v.startswith("//") else "/"


# ---------- 1. sign in ----------
# /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY
# service, the SSO returns straight away (no page, no click); otherwise it comes
# back with error=login_required and we show login.html.
@app.get("/auth/login")
def login():
    state = secrets.token_urlsafe(16)
    # remember several pending sign-ins (two tabs, a double click ...), newest 5
    pending = dict(list(session.get("pending", {}).items())[-4:])
    pending[state] = safe_next(request.args.get("next"))
    session["pending"] = pending
    q = {"client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
         "response_type": "code", "state": state}
    if request.args.get("silent") == "1":
        q["prompt"] = "none"
    return redirect(f"{SSO}/authorize?{urlencode(q)}")


@app.get("/auth/callback")
def callback():
    pending = session.get("pending", {})
    next_url = pending.pop(request.args.get("state", ""), None)
    session["pending"] = pending
    if next_url is None:                 # unknown / reused state
        return redirect("/login.html?error=state")
    if request.args.get("error"):      # login_required (silent try) / access_denied
        q = {"next": next_url}
        if request.args["error"] != "login_required":
            q["error"] = request.args["error"]
        return redirect("/login.html?" + urlencode(q))
    r = requests.post(f"{SSO}/token", data={
        "grant_type": "authorization_code",
        "code": request.args.get("code", ""),
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }, timeout=10)
    if r.status_code != 200:
        return redirect("/login.html?error=token")
    user = r.json()["user"]
    session["user"] = {k: user[k] for k in KEEP if k in user}
    return redirect(next_url)


@app.get("/auth/logout")
def logout():
    session.clear()
    return redirect(f"{SSO}/logout?" + urlencode({"redirect_uri": APP_URL + "/"}))


# ---------- 2. is the user signed in? ----------
def current_user():
    return session.get("user")          # None = not signed in


def login_required(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        if current_user() is None:
            if request.path.startswith("/api/"):
                abort(401)
            # try single sign-on first; login.html is shown only if that fails
            return redirect("/auth/login?" + urlencode({"silent": 1, "next": request.full_path.rstrip("?")}))
        return view(*args, **kwargs)
    return wrapper


# ---------- 3. user data (used by advantech-sso.js) ----------
@app.get("/auth/me")
def me():
    u = current_user()
    return (jsonify(u), 200) if u else (jsonify(error="not signed in"), 401)


# ---------- your app ----------
@app.get("/")
@login_required
def index():
    return send_from_directory("public", "index.html")


@app.get("/api/hello")
@login_required
def hello():
    u = current_user()
    return {"message": f"Hello {u['name']}", "department": u.get("department")}


if __name__ == "__main__":
    app.run(port=5000)
requirements.txt
flask>=3.0
requests>=2.31
public/login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
public/index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

Node.js / Express

下載範本 .zip (Node.js / Express)

範本內容

myapp/
express_app.mjs
package.json
public/login.html
public/index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

npm install

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world
export SESSION_SECRET=change-me-to-a-long-random-string

npm start

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:3000

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

express_app.mjs
// Advantech SSO + Node.js / Express  —  npm i express express-session   (Node 18+)
//
//   SSO_CLIENT_ID=svc_xxxxxxxx SSO_CLIENT_SECRET=xxxxxxxx \
//   APP_URL=https://myapp.advantech.world SESSION_SECRET=long-random-string \
//   node express_app.mjs            (put login.html / index.html in ./public)
//
// Register APP_URL/auth/callback as the Redirect URI of the service.
// express-session keeps sessions in memory: use a store (Redis, DB ...) in production.
import crypto from "node:crypto";
import express from "express";
import session from "express-session";

const SSO = "https://ae-sso.advantech.com";
const { SSO_CLIENT_ID: CLIENT_ID, SSO_CLIENT_SECRET: CLIENT_SECRET } = process.env;
const APP_URL = process.env.APP_URL.replace(/\/$/, "");
const REDIRECT_URI = `${APP_URL}/auth/callback`;

const app = express();
app.set("trust proxy", 1);
app.use(session({
  secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, secure: APP_URL.startsWith("https://"), sameSite: "lax", maxAge: 8 * 3600e3 },
}));

const safeNext = (v) => (typeof v === "string" && v.startsWith("/") && !v.startsWith("//") ? v : "/");

// ---------- 1. sign in ----------
// /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY
// service, the SSO returns straight away (no page, no click); otherwise it comes
// back with error=login_required and we show login.html.
app.get("/auth/login", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  // remember several pending sign-ins (two tabs, a double click ...), newest 5
  const pending = Object.entries(req.session.pending || {}).slice(-4);
  req.session.pending = Object.fromEntries([...pending, [state, safeNext(req.query.next)]]);
  const q = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
                                  response_type: "code", state });
  if (req.query.silent === "1") q.set("prompt", "none");
  res.redirect(`${SSO}/authorize?${q}`);
});

app.get("/auth/callback", async (req, res) => {
  const pending = req.session.pending || {};
  const next = Object.hasOwn(pending, String(req.query.state)) ? pending[String(req.query.state)] : null;
  delete pending[String(req.query.state)];
  if (next === null) return res.redirect("/login.html?error=state");   // unknown / reused state
  if (req.query.error) {                 // login_required (silent try) / access_denied
    const q = new URLSearchParams({ next });
    if (req.query.error !== "login_required") q.set("error", String(req.query.error));
    return res.redirect(`/login.html?${q}`);
  }
  const r = await fetch(`${SSO}/token`, {
    method: "POST",
    body: new URLSearchParams({ grant_type: "authorization_code", code: String(req.query.code || ""),
      redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!r.ok) return res.redirect("/login.html?error=token");
  const { user } = await r.json();
  req.session.regenerate(() => {          // new session id after sign-in
    req.session.user = user;              // sub, email, name, department, picture ...
    res.redirect(next);
  });
});

app.get("/auth/logout", (req, res) => {
  req.session.destroy(() => {
    res.redirect(`${SSO}/logout?` + new URLSearchParams({ redirect_uri: `${APP_URL}/` }));
  });
});

// ---------- 2. is the user signed in? ----------
function requireLogin(req, res, next) {
  if (req.session.user) return next();
  if (req.path.startsWith("/api/")) return res.status(401).json({ error: "not signed in" });
  // try single sign-on first; login.html is shown only if that fails
  res.redirect("/auth/login?" + new URLSearchParams({ silent: "1", next: req.originalUrl }));
}

// ---------- 3. user data (used by advantech-sso.js) ----------
app.get("/auth/me", (req, res) => {
  if (!req.session.user) return res.status(401).json({ error: "not signed in" });
  res.json(req.session.user);
});

// ---------- your app ----------
app.get("/", requireLogin, (req, res) => res.sendFile("index.html", { root: "public" }));
app.get("/api/hello", requireLogin, (req, res) => {
  res.json({ message: `Hello ${req.session.user.name}`, department: req.session.user.department });
});
app.use(express.static("public"));   // login.html etc.

app.listen(3000, () => console.log("http://localhost:3000"));
package.json
{
  "name": "myapp",
  "private": true,
  "type": "module",
  "scripts": { "start": "node express_app.mjs" },
  "dependencies": {
    "express": "^4.21.2",
    "express-session": "^1.18.1"
  }
}
public/login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
public/index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

PHP

下載範本 .zip (PHP)

範本內容

myapp/
sso.php
login.html
index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

# PHP 8+ with the curl extension

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world

php -S localhost:8000
# on Apache/nginx: copy the files to the web root and set the 3 variables
# (Apache: SetEnv SSO_CLIENT_ID ...   nginx: fastcgi_param SSO_CLIENT_ID ...;)

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:8000

PHP 版要登錄的 Redirect URI: https://myapp.advantech.world/sso.php?a=callback

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

sso.php
<?php
// Advantech SSO + PHP (8.0+, no framework)  —  save as sso.php next to login.html / index.html
//
//   Set SSO_CLIENT_ID, SSO_CLIENT_SECRET, APP_URL (e.g. https://myapp.advantech.world)
//   in the web server environment (or edit the constants below).
//   Register  APP_URL/sso.php?a=callback  as the Redirect URI of the service.
//   In the HTML, point advantech-sso.js at this file:
//     <script src=".../advantech-sso.js" data-me="/sso.php?a=me"
//             data-login="/sso.php?a=login" data-logout="/sso.php?a=logout"></script>
//
//   Protect any other PHP page with:
//     require __DIR__ . '/sso.php';
//     $user = sso_require_login();   // signs in silently, or shows login.html
//     echo htmlspecialchars($user['name']);

const SSO = 'https://ae-sso.advantech.com';
define('CLIENT_ID', getenv('SSO_CLIENT_ID'));
define('CLIENT_SECRET', getenv('SSO_CLIENT_SECRET'));
define('APP_URL', rtrim(getenv('APP_URL'), '/'));
define('REDIRECT_URI', APP_URL . '/sso.php?a=callback');

session_set_cookie_params(['httponly' => true, 'secure' => str_starts_with(APP_URL, 'https://'), 'samesite' => 'Lax']);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();

function sso_safe_next($v) {
    return is_string($v) && str_starts_with($v, '/') && !str_starts_with($v, '//') ? $v : '/';
}

// ---------- 2. is the user signed in? ----------
function sso_user() {
    return $_SESSION['user'] ?? null;    // null = not signed in
}

function sso_require_login() {
    $u = sso_user();
    if ($u === null) {
        // try single sign-on first; login.html is shown only if that fails
        header('Location: /sso.php?' . http_build_query(['a' => 'login', 'silent' => 1, 'next' => $_SERVER['REQUEST_URI']]));
        exit;
    }
    return $u;
}

// only route when sso.php itself is requested (not when it is require'd)
if (realpath($_SERVER['SCRIPT_FILENAME']) !== __FILE__) return;

switch ($_GET['a'] ?? '') {
    // ---------- 1. sign in ----------
    // ?silent=1 adds prompt=none: if the user already signed in to ANY service,
    // the SSO returns straight away (no page, no click); otherwise it comes back
    // with error=login_required and we show login.html.
    case 'login':
        $state = bin2hex(random_bytes(16));
        // remember several pending sign-ins (two tabs, a double click ...), newest 5
        $pending = array_slice($_SESSION['pending'] ?? [], -4, null, true);
        $pending[$state] = sso_safe_next($_GET['next'] ?? '/');
        $_SESSION['pending'] = $pending;
        $q = ['client_id' => CLIENT_ID, 'redirect_uri' => REDIRECT_URI,
              'response_type' => 'code', 'state' => $state];
        if (($_GET['silent'] ?? '') === '1') $q['prompt'] = 'none';
        header('Location: ' . SSO . '/authorize?' . http_build_query($q));
        exit;

    case 'callback':
        $state = (string)($_GET['state'] ?? '');
        $next = $_SESSION['pending'][$state] ?? null;
        unset($_SESSION['pending'][$state]);
        if ($state === '' || $next === null) {   // unknown / reused state
            header('Location: /login.html?error=state'); exit;
        }
        if (!empty($_GET['error'])) {      // login_required (silent try) / access_denied
            $q = ['next' => $next];
            if ($_GET['error'] !== 'login_required') $q['error'] = $_GET['error'];
            header('Location: /login.html?' . http_build_query($q)); exit;
        }
        $ch = curl_init(SSO . '/token');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_POSTFIELDS => http_build_query([
                'grant_type' => 'authorization_code', 'code' => $_GET['code'] ?? '',
                'redirect_uri' => REDIRECT_URI, 'client_id' => CLIENT_ID, 'client_secret' => CLIENT_SECRET,
            ]),
        ]);
        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($status !== 200) { header('Location: /login.html?error=token'); exit; }
        session_regenerate_id(true);
        $_SESSION['user'] = json_decode($body, true)['user'];   // sub, email, name, department, picture ...
        header('Location: ' . $next);
        exit;

    case 'logout':
        $_SESSION = [];
        session_destroy();
        header('Location: ' . SSO . '/logout?' . http_build_query(['redirect_uri' => APP_URL . '/']));
        exit;

    // ---------- 3. user data (used by advantech-sso.js) ----------
    case 'me':
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        $u = sso_user();
        if ($u === null) { http_response_code(401); echo '{"error":"not signed in"}'; exit; }
        echo json_encode($u, JSON_UNESCAPED_UNICODE);
        exit;

    default:
        http_response_code(404);
}
login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-me="/sso.php?a=me" data-login="/sso.php?a=login" data-logout="/sso.php?a=logout" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-me="/sso.php?a=me" data-login="/sso.php?a=login" data-logout="/sso.php?a=logout" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

C# / ASP.NET Core

下載範本 .zip (C# / ASP.NET Core)

範本內容

myapp/
MyApp.csproj
Program.cs
wwwroot/login.html
wwwroot/index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

# .NET 8 SDK

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world

dotnet run --urls http://localhost:5000

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:5000

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

MyApp.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
// Advantech SSO + ASP.NET Core (.NET 8, minimal API)
//
//   Environment variables: SSO_CLIENT_ID, SSO_CLIENT_SECRET, APP_URL (e.g. https://myapp.advantech.world)
//   dotnet run          (login.html / index.html in wwwroot/)
//   Register  APP_URL/auth/callback  as the Redirect URI of the service.
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;

const string SSO = "https://ae-sso.advantech.com";

var builder = WebApplication.CreateBuilder(args);
var clientId = builder.Configuration["SSO_CLIENT_ID"]!;
var clientSecret = builder.Configuration["SSO_CLIENT_SECRET"]!;
var appUrl = builder.Configuration["APP_URL"]!.TrimEnd('/');
var redirectUri = appUrl + "/auth/callback";

builder.Services.AddHttpClient();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        o.LoginPath = "/login.html";                 // ?ReturnUrl=... is appended
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.Cookie.SameSite = SameSiteMode.Lax;
        o.Events.OnRedirectToLogin = ctx =>          // APIs get 401 instead of a redirect
        {
            if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/auth/me"))
                ctx.Response.StatusCode = 401;
            else   // try single sign-on first; login.html is shown only if that fails
                ctx.Response.Redirect("/auth/login?silent=1&next=" + Uri.EscapeDataString(ctx.Request.Path + ctx.Request.QueryString));
            return Task.CompletedTask;
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();

static string SafeNext(string? v) => v is { Length: > 0 } && v.StartsWith('/') && !v.StartsWith("//") ? v : "/";

// ---------- 1. sign in ----------
// /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY service,
// the SSO returns straight away (no page, no click); otherwise it comes back with
// error=login_required and we show login.html.
app.MapGet("/auth/login", (HttpContext ctx, string? next, string? silent) =>
{
    var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
    // one short-lived cookie per pending sign-in, so two tabs don't overwrite each other
    var opts = new CookieOptions { HttpOnly = true, Secure = ctx.Request.IsHttps, SameSite = SameSiteMode.Lax, MaxAge = TimeSpan.FromMinutes(10) };
    ctx.Response.Cookies.Append("sso_st_" + state, SafeNext(next), opts);
    var p = new Dictionary<string, string?> {
        ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["response_type"] = "code", ["state"] = state };
    if (silent == "1") p["prompt"] = "none";
    var q = QueryString.Create(p);
    return Results.Redirect($"{SSO}/authorize{q}");
});

app.MapGet("/auth/callback", async (HttpContext ctx, IHttpClientFactory http, string? code, string? state, string? error) =>
{
    var key = "sso_st_" + (state ?? "");
    var saved = string.IsNullOrEmpty(state) || state.Length != 32 ? null : ctx.Request.Cookies[key];
    if (saved is null) return Results.Redirect("/login.html?error=state");   // unknown / reused state
    ctx.Response.Cookies.Delete(key);
    var next = SafeNext(saved);
    if (!string.IsNullOrEmpty(error))      // login_required (silent try) / access_denied
        return Results.Redirect("/login.html?next=" + Uri.EscapeDataString(next)
            + (error == "login_required" ? "" : "&error=" + Uri.EscapeDataString(error)));

    var res = await http.CreateClient().PostAsync($"{SSO}/token", new FormUrlEncodedContent(new Dictionary<string, string> {
        ["grant_type"] = "authorization_code", ["code"] = code ?? "", ["redirect_uri"] = redirectUri,
        ["client_id"] = clientId, ["client_secret"] = clientSecret }));
    if (!res.IsSuccessStatusCode) return Results.Redirect("/login.html?error=token");

    var user = JsonNode.Parse(await res.Content.ReadAsStringAsync())!["user"]!;
    var claims = new List<Claim> {
        new("sub", (string)user["sub"]!),
        new(ClaimTypes.Name, (string?)user["name"] ?? ""),
        new(ClaimTypes.Email, (string?)user["email"] ?? ""),
        new("sso_user", user.ToJsonString()),       // the whole user JSON (department, picture ...)
    };
    await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)));
    return Results.Redirect(next);
});

app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
    await ctx.SignOutAsync();
    return Results.Redirect($"{SSO}/logout?redirect_uri={Uri.EscapeDataString(appUrl + "/")}");
});

// ---------- 2. is the user signed in?  ctx.User.Identity.IsAuthenticated / .RequireAuthorization() ----------
static JsonNode? SsoUser(ClaimsPrincipal p) =>
    p.Identity?.IsAuthenticated == true ? JsonNode.Parse(p.FindFirst("sso_user")?.Value ?? "{}") : null;

// ---------- 3. user data (used by advantech-sso.js) ----------
app.MapGet("/auth/me", (ClaimsPrincipal p) => Results.Content(SsoUser(p)!.ToJsonString(), "application/json"))
   .RequireAuthorization();

// ---------- your app ----------
app.MapGet("/", () => Results.File(Path.Combine(app.Environment.WebRootPath, "index.html"), "text/html"))
   .RequireAuthorization();
app.MapGet("/api/hello", (ClaimsPrincipal p) =>
{
    var u = SsoUser(p)!;
    return Results.Json(new { message = $"Hello {u["name"]}", department = (string?)u["department"] });
}).RequireAuthorization();

app.Run();
wwwroot/login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
wwwroot/index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

Java / Spring Boot

下載範本 .zip (Java / Spring Boot)

範本內容

myapp/
pom.xml
src/main/java/com/example/myapp/MyAppApplication.java
src/main/java/com/example/myapp/SsoController.java
src/main/resources/application.properties
src/main/resources/static/login.html
src/main/resources/static/index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

# Java 17+ and Maven

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world

mvn spring-boot:run

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:8080

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
    <relativePath/>
  </parent>
  <groupId>com.example</groupId>
  <artifactId>myapp</artifactId>
  <version>1.0.0</version>
  <properties>
    <java.version>17</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>
src/main/java/com/example/myapp/MyAppApplication.java
package com.example.myapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MyAppApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyAppApplication.class, args);
    }
}
src/main/java/com/example/myapp/SsoController.java
// Advantech SSO + Java / Spring Boot 3 (spring-boot-starter-web)
//
//   application.properties:
//     sso.client-id=${SSO_CLIENT_ID}
//     sso.client-secret=${SSO_CLIENT_SECRET}
//     sso.app-url=${APP_URL}
//     server.servlet.session.cookie.same-site=lax
//   Register  APP_URL/auth/callback  as the Redirect URI of the service.
//   Put login.html / index.html in src/main/resources/static/.
package com.example.myapp;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.filter.OncePerRequestFilter;

@RestController
public class SsoController {
    static final String SSO = "https://ae-sso.advantech.com";

    @Value("${sso.client-id}") String clientId;
    @Value("${sso.client-secret}") String clientSecret;
    @Value("${sso.app-url}") String appUrl;

    private final RestClient http = RestClient.create();

    String redirectUri() { return appUrl.replaceAll("/$", "") + "/auth/callback"; }
    static String enc(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8); }
    static String safeNext(String v) { return v != null && v.startsWith("/") && !v.startsWith("//") ? v : "/"; }

    // ---------- 1. sign in ----------
    // /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY service,
    // the SSO returns straight away (no page, no click); otherwise it comes back with
    // error=login_required and we show login.html.
    @GetMapping("/auth/login")
    public void login(@RequestParam(required = false) String next, @RequestParam(required = false) String silent,
                      HttpSession session, HttpServletResponse res) throws IOException {
        byte[] b = new byte[16];
        new SecureRandom().nextBytes(b);
        String state = HexFormat.of().formatHex(b);
        // remember several pending sign-ins (two tabs, a double click ...), newest 5
        @SuppressWarnings("unchecked")
        var pending = (java.util.LinkedHashMap<String, String>) session.getAttribute("pending");
        if (pending == null) pending = new java.util.LinkedHashMap<>();
        while (pending.size() >= 5) pending.remove(pending.keySet().iterator().next());
        pending.put(state, safeNext(next));
        session.setAttribute("pending", pending);
        res.sendRedirect(SSO + "/authorize?client_id=" + enc(clientId) + "&redirect_uri=" + enc(redirectUri())
                + "&response_type=code&state=" + state + ("1".equals(silent) ? "&prompt=none" : ""));
    }

    @GetMapping("/auth/callback")
    @SuppressWarnings("unchecked")
    public void callback(@RequestParam(defaultValue = "") String code, @RequestParam(defaultValue = "") String state,
                         @RequestParam(required = false) String error,
                         HttpServletRequest req, HttpServletResponse res) throws IOException {
        HttpSession session = req.getSession();
        var pending = (Map<String, String>) session.getAttribute("pending");
        String next = pending == null ? null : pending.remove(state);
        if (next == null) { res.sendRedirect("/login.html?error=state"); return; }   // unknown / reused state
        if (error != null) {                 // login_required (silent try) / access_denied
            res.sendRedirect("/login.html?next=" + enc(next) + ("login_required".equals(error) ? "" : "&error=" + enc(error)));
            return;
        }

        var form = new LinkedMultiValueMap<String, String>();
        form.add("grant_type", "authorization_code");
        form.add("code", code);
        form.add("redirect_uri", redirectUri());
        form.add("client_id", clientId);
        form.add("client_secret", clientSecret);
        Map<String, Object> body;
        try {
            body = http.post().uri(SSO + "/token").contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .body(form).retrieve().body(Map.class);
        } catch (Exception e) {
            res.sendRedirect("/login.html?error=token");
            return;
        }
        req.changeSessionId();                                   // new session id after sign-in
        req.getSession().setAttribute("user", body.get("user")); // sub, email, name, department, picture ...
        res.sendRedirect(next);
    }

    @GetMapping("/auth/logout")
    public void logout(HttpSession session, HttpServletResponse res) throws IOException {
        session.invalidate();
        res.sendRedirect(SSO + "/logout?redirect_uri=" + enc(appUrl.replaceAll("/$", "") + "/"));
    }

    // ---------- 3. user data (used by advantech-sso.js) ----------
    @GetMapping("/auth/me")
    public ResponseEntity<Object> me(HttpSession session) {
        Object user = session.getAttribute("user");
        return user == null ? ResponseEntity.status(401).body(Map.of("error", "not signed in")) : ResponseEntity.ok(user);
    }

    // ---------- your app ----------
    @GetMapping("/api/hello")
    public Map<String, Object> hello(@SessionAttribute("user") Map<String, Object> user) {
        return Map.of("message", "Hello " + user.get("name"));
    }
}

// ---------- 2. is the user signed in? (every request except the public ones) ----------
@Component
class RequireLogin extends OncePerRequestFilter {
    @Override
    protected boolean shouldNotFilter(HttpServletRequest req) {
        String p = req.getRequestURI();
        return p.startsWith("/auth/") || p.equals("/login.html") || p.equals("/favicon.ico");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        HttpSession s = req.getSession(false);
        if (s != null && s.getAttribute("user") != null) { chain.doFilter(req, res); return; }
        if (req.getRequestURI().startsWith("/api/")) { res.sendError(401); return; }
        String next = req.getRequestURI() + (req.getQueryString() != null ? "?" + req.getQueryString() : "");
        // try single sign-on first; login.html is shown only if that fails
        res.sendRedirect("/auth/login?silent=1&next=" + URLEncoder.encode(next, StandardCharsets.UTF_8));
    }
}
src/main/resources/application.properties
sso.client-id=${SSO_CLIENT_ID}
sso.client-secret=${SSO_CLIENT_SECRET}
sso.app-url=${APP_URL}
server.port=8080
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.http-only=true
src/main/resources/static/login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
src/main/resources/static/index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

Go

下載範本 .zip (Go)

範本內容

myapp/
main.go
go.mod
public/login.html
public/index.html
README.txt

執行指令 (把 svc_xxxxxxxx / xxxxxxxx 換成步驟 1 的值)

# Go 1.22+

export SSO_CLIENT_ID=svc_xxxxxxxx
export SSO_CLIENT_SECRET=xxxxxxxx
export APP_URL=https://myapp.advantech.world

go run .

Windows PowerShell:把 export X=Y 改成 $env:X="Y"。

本機測試時,網站網址請填: http://localhost:8080

範本裡的所有檔案 (後端 + 前端 HTML,點開看程式碼)

main.go
// Advantech SSO + Go (standard library only, Go 1.22+)
//
//	SSO_CLIENT_ID=svc_xxxxxxxx SSO_CLIENT_SECRET=xxxxxxxx \
//	APP_URL=https://myapp.advantech.world go run .      (login.html / index.html in ./public)
//
// Register APP_URL/auth/callback as the Redirect URI of the service.
// Sessions live in memory here; use a database or Redis when you run several instances.
package main

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"
)

const SSO = "https://ae-sso.advantech.com"

var (
	clientID     = os.Getenv("SSO_CLIENT_ID")
	clientSecret = os.Getenv("SSO_CLIENT_SECRET")
	appURL       = strings.TrimRight(os.Getenv("APP_URL"), "/")
	redirectURI  = appURL + "/auth/callback"
)

type User map[string]any // sub, email, name, department, job_title, manager, picture ...

type session struct {
	User    User
	Pending map[string]string // state -> page to return to (several tabs may sign in at once)
	Expires time.Time
}

var (
	mu       sync.Mutex
	sessions = map[string]*session{}
)

func randHex() string { b := make([]byte, 16); rand.Read(b); return hex.EncodeToString(b) }

func getSession(w http.ResponseWriter, r *http.Request) *session {
	mu.Lock()
	defer mu.Unlock()
	if c, err := r.Cookie("sid"); err == nil {
		if s := sessions[c.Value]; s != nil && time.Now().Before(s.Expires) {
			return s
		}
	}
	id, s := randHex(), &session{Expires: time.Now().Add(8 * time.Hour)}
	sessions[id] = s
	http.SetCookie(w, &http.Cookie{Name: "sid", Value: id, Path: "/", HttpOnly: true,
		Secure: strings.HasPrefix(appURL, "https://"), SameSite: http.SameSiteLaxMode})
	return s
}

func safeNext(v string) string {
	if strings.HasPrefix(v, "/") && !strings.HasPrefix(v, "//") {
		return v
	}
	return "/"
}

// ---------- 2. is the user signed in? ----------
func currentUser(w http.ResponseWriter, r *http.Request) User { return getSession(w, r).User } // nil = no

func requireLogin(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if currentUser(w, r) == nil {
			if strings.HasPrefix(r.URL.Path, "/api/") {
				http.Error(w, `{"error":"not signed in"}`, http.StatusUnauthorized)
				return
			}
			// try single sign-on first; login.html is shown only if that fails
			http.Redirect(w, r, "/auth/login?"+url.Values{"silent": {"1"}, "next": {r.URL.RequestURI()}}.Encode(), http.StatusFound)
			return
		}
		h(w, r)
	}
}

func main() {
	// ---------- 1. sign in ----------
	// /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY
	// service, the SSO returns straight away (no page, no click); otherwise it
	// comes back with error=login_required and we show login.html.
	http.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) {
		s := getSession(w, r)
		state := randHex()
		mu.Lock()
		if s.Pending == nil || len(s.Pending) >= 5 {
			s.Pending = map[string]string{}
		}
		s.Pending[state] = safeNext(r.URL.Query().Get("next"))
		mu.Unlock()
		q := url.Values{"client_id": {clientID}, "redirect_uri": {redirectURI},
			"response_type": {"code"}, "state": {state}}
		if r.URL.Query().Get("silent") == "1" {
			q.Set("prompt", "none")
		}
		http.Redirect(w, r, SSO+"/authorize?"+q.Encode(), http.StatusFound)
	})

	http.HandleFunc("GET /auth/callback", func(w http.ResponseWriter, r *http.Request) {
		s := getSession(w, r)
		state := r.URL.Query().Get("state")
		mu.Lock()
		next, ok := s.Pending[state]
		delete(s.Pending, state)
		mu.Unlock()
		if !ok { // unknown / reused state
			http.Redirect(w, r, "/login.html?error=state", http.StatusFound)
			return
		}
		if e := r.URL.Query().Get("error"); e != "" { // login_required (silent try) / access_denied
			q := url.Values{"next": {next}}
			if e != "login_required" {
				q.Set("error", e)
			}
			http.Redirect(w, r, "/login.html?"+q.Encode(), http.StatusFound)
			return
		}
		res, err := http.PostForm(SSO+"/token", url.Values{
			"grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")},
			"redirect_uri": {redirectURI}, "client_id": {clientID}, "client_secret": {clientSecret}})
		if err != nil || res.StatusCode != http.StatusOK {
			http.Redirect(w, r, "/login.html?error=token", http.StatusFound)
			return
		}
		defer res.Body.Close()
		var body struct{ User User }
		if json.NewDecoder(res.Body).Decode(&body) != nil {
			http.Redirect(w, r, "/login.html?error=token", http.StatusFound)
			return
		}
		mu.Lock()
		s.User = body.User
		mu.Unlock()
		http.Redirect(w, r, next, http.StatusFound)
	})

	http.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
		if c, err := r.Cookie("sid"); err == nil {
			mu.Lock()
			delete(sessions, c.Value)
			mu.Unlock()
		}
		http.Redirect(w, r, SSO+"/logout?"+url.Values{"redirect_uri": {appURL + "/"}}.Encode(), http.StatusFound)
	})

	// ---------- 3. user data (used by advantech-sso.js) ----------
	http.HandleFunc("GET /auth/me", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		u := currentUser(w, r)
		if u == nil {
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte(`{"error":"not signed in"}`))
			return
		}
		json.NewEncoder(w).Encode(u)
	})

	// ---------- your app ----------
	http.HandleFunc("GET /{$}", requireLogin(func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "public/index.html")
	}))
	http.HandleFunc("GET /api/hello", requireLogin(func(w http.ResponseWriter, r *http.Request) {
		u := currentUser(w, r)
		json.NewEncoder(w).Encode(map[string]any{"message": "Hello " + u["name"].(string), "department": u["department"]})
	}))
	http.Handle("GET /", http.FileServer(http.Dir("public")))

	log.Fatal(http.ListenAndServe(":8080", nil))
}
go.mod
module myapp

go 1.22
public/login.html
<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>
public/index.html
<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

3打開網站測試

  1. 打開你的網站首頁: https://myapp.advantech.world/
  2. 會自動跳到登入頁 (如果你已經登入過其他服務,會直接進去)。按「使用 Advantech 帳號登入」。
  3. 回到首頁,看到你的名字和大頭照,就完成了!

沒有成功?看 出錯了?

已經有網站和登入頁了?

1) 後端從步驟 2 的範本複製 /auth/login、/auth/callback、/auth/me、/auth/logout 四個路徑 (直接用範本檔案最快)。 2) 在你的登入頁貼這 3 行,就會出現「使用 Advantech 帳號登入」按鈕:

<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" defer></script>

<div data-sso="button"></div>

按鈕文字預設跟著瀏覽器語言 (繁中 / 簡中 / 英 / 日 / 韓)。 語言 · 按鈕的選項 (語言、樣式、分隔線)

前端套件 (HTML / CSS / JS)

不需要任何框架。在頁面加上這兩行,就有登入按鈕、使用者名片 (大頭照 + 姓名 + 登出)、欄位顯示與未登入自動導向。JS 只會呼叫你自己網站的 /auth/*,不會接觸 Client Secret。

<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" defer></script>

可以直接引用 SSO 上的檔案,也可以下載後放到自己的網站 (建議正式環境自行存放)。

advantech-sso.js advantech-sso.css login.html index.html

登入頁 login.html

存成 public/login.html。後端在未登入時導到 /login.html?next=原本的網址,登入失敗時導到 /login.html?error=…。已登入的人打開這頁會直接被帶到 next。

<!doctype html>
<!--
  Advantech SSO sign-in page. Save as public/login.html on your site.
  Your backend sends signed-out visitors here (login.html?next=/page), and
  failed sign-ins here with ?error=... . Change "My App" and the texts freely.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 · My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-login-page defer></script>
</head>
<body class="adv-sso-page">
<main class="adv-sso-card">
  <div class="adv-sso-card-logo">
    <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="992" height="202">
  </div>
  <div class="adv-sso-card-body">
    <h1 class="adv-sso-title">My App</h1>
    <p class="adv-sso-lead">請使用 Advantech 公司帳號 (Microsoft) 登入。</p>
    <p class="adv-sso-info" data-sso-error="state" hidden>登入逾時或被中斷了,請再按一次登入。</p>
    <p class="adv-sso-error" data-sso-error="access_denied" hidden>你的帳號沒有這個網站的使用權限,請聯絡網站管理員。</p>
    <p class="adv-sso-error" data-sso-error hidden>登入沒有成功,請再試一次。如果一直發生,請聯絡網站管理員。</p>
    <button type="button" class="adv-sso-btn adv-sso-block" data-sso="login">
      <svg class="adv-sso-ms" viewBox="0 0 21 21" aria-hidden="true"><path fill="#f25022" d="M1 1h9v9H1z"/><path fill="#7fba00" d="M11 1h9v9h-9z"/><path fill="#00a4ef" d="M1 11h9v9H1z"/><path fill="#ffb900" d="M11 11h9v9h-9z"/></svg>
      使用 Advantech 帳號登入
    </button>
    <p class="adv-sso-foot">Advantech Single Sign-On</p>
  </div>
</main>
</body>
</html>

受保護的頁面 index.html

存成 public/index.html。data-require-login 會讓未登入的人自動去登入;資料本身仍要由後端保護。

<!doctype html>
<!--
  A protected page. Save as public/index.html on your site.
  data-require-login sends signed-out visitors to sign in automatically.
  (Protect the data on the server too - see the backend examples.)
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My App</title>
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-require-login defer></script>
<style>
  body { margin: 0; font-family: var(--adv-sso-font); color: var(--adv-sso-ink); background: var(--adv-sso-paper); }
  main { max-width: 860px; margin: 32px auto; padding: 0 16px; }
  .card { background: #fff; border: 1px solid var(--adv-sso-line); border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; }
  .profile { display: flex; gap: 20px; align-items: center; }
  .profile img { width: 96px; height: 96px; }
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
  dt { color: var(--adv-sso-muted); }
  dd { margin: 0; }
  pre { background: #0f2236; color: #e6eef7; padding: 14px; border-radius: 4px; overflow: auto; font-size: 13px; }
</style>
</head>
<body>
<header class="adv-sso-header">
  <img src="https://ae-sso.advantech.com/static/logo.svg" alt="Advantech" width="157" height="32">
  <div data-sso="user" class="adv-sso-on-dark"></div>
</header>

<main data-sso-show="in">
  <section class="card profile">
    <img class="adv-sso-avatar" data-sso-photo alt="" hidden>
    <div>
      <h1 style="margin:0">你好,<span data-sso-field="name"></span></h1>
      <div data-sso-field="email"></div>
    </div>
  </section>

  <section class="card">
    <dl>
      <dt>部門</dt><dd data-sso-field="department"></dd>
      <dt>職稱</dt><dd data-sso-field="job_title"></dd>
      <dt>公司</dt><dd data-sso-field="company"></dd>
      <dt>辦公室</dt><dd data-sso-field="office_location"></dd>
      <dt>主管</dt><dd data-sso-field="manager.name"></dd>
    </dl>
  </section>

  <section class="card">
    <h2 style="margin-top:0">/auth/me</h2>
    <pre data-sso-json></pre>
  </section>
</main>
</body>
</html>

可用的 HTML 屬性

屬性說明
data-me / data-login / data-logout放在 <script> 上,改變後端路徑 (預設 /auth/me、/auth/login、/auth/logout)。
data-login-text / data-logout-text放在 <script> 上,改變使用者名片的登入 / 登出文字 (預設會依語言自動翻譯)。
data-require-login放在 <script> 上:未登入就自動嘗試單一登入,必要時才顯示登入頁。
data-login-page放在 <script> 上:這是登入頁;已登入就前往 ?next=。
data-sso="button"不需要改版面。在現有登入頁的 <head> 加上 CSS 與 JS,再在想放按鈕的地方加一個 <div data-sso="button"></div>,就會出現「使用 Advantech 帳號登入」按鈕。
data-sso="login"點擊即登入 (可加 data-next="/path" 指定登入後的頁面)。
data-sso="logout"點擊即登出 (網站與 SSO 一起登出)。
data-sso="user"自動產生使用者名片:大頭照、姓名、Email、登出連結;未登入時顯示登入按鈕。深色背景加 class="adv-sso-on-dark"。
data-sso-field="…"填入使用者欄位,例如 name、email、department、job_title、manager.name。
data-sso-photo放在 <img> 上,顯示使用者大頭照 (沒有照片時隱藏)。
data-sso-show="in|out"只在已登入 (in) 或未登入 (out) 時顯示。
data-sso-json顯示完整的使用者 JSON,開發時很方便。
data-sso-error網址有 ?error= 時才顯示 (登入頁的錯誤訊息)。

JavaScript API

const user = await AdvSSO.getUser();      // user JSON, or null when signed out
const me = await AdvSSO.requireLogin();   // redirects to sign-in when signed out
AdvSSO.login("/reports");                 // sign in, then go to /reports
AdvSSO.logout();

document.addEventListener("advsso:user", (e) => {
  if (e.detail) console.log(e.detail.name, e.detail.department, e.detail.picture);
});

我想要…

我想要…怎麼做
在後端知道「現在是誰」
Python / Flaskcurrent_user()
Node.js / Expressreq.session.user
PHPsso_user()
C# / ASP.NET CoreUser.Identity.IsAuthenticated
Java / Spring Bootsession.getAttribute("user")
GocurrentUser(w, r)
拿到的是使用者 JSON (sub、email、name、department…)。用 sub 當使用者 ID。
讓某個頁面或 API 必須登入
Python / Flask@login_required
Node.js / ExpressrequireLogin
PHPsso_require_login()
C# / ASP.NET Core.RequireAuthorization()
Java / Spring BootRequireLogin filter
GorequireLogin(h)
在網頁上顯示姓名、部門<span data-sso-field="name"></span> <span data-sso-field="department"></span>
顯示大頭照<img data-sso-photo alt=""> 或 <img src="${user.picture}">
右上角放「大頭照 + 姓名 + 登出」<div data-sso="user"></div>
登出按鈕<a href="/auth/logout">Logout</a>
取得主管、部屬user.manager.name, user.manager_chain, user.direct_reports — 組織資料:主管、部屬、同主管成員
需要更多欄位 (例如手機),但拿不到每個服務能拿哪些欄位由 SSO 管理員設定,請他到服務頁「可存取的資料」打勾。 使用者資料 (profile)
使用者從別的系統連過來,不想再登入一次什麼都不用做,範本已經處理好 (自動單一登入)。 跨服務單一登入 (A 服務登入後,B 服務不用再登入)

出錯了?

看到什麼怎麼解決
SSO 頁面說「redirect_uri 未登錄」你的網站網址和管理員登錄的 Redirect URI 不完全一樣。http / https、網域、連接埠、路徑都要相同 (例如 http://localhost:5000/auth/callback)。請檢查 APP_URL,或請管理員改登錄的網址。
回到登入頁,網址有 error=tokenClient ID 或 Client Secret 錯了。檢查環境變數有沒有設定、有沒有多空白,改完要重新啟動程式。
網址有 error=state登入前後的 session 不見了 (範本的登入頁會先自動重試一次)。仍然出現時最常見的原因:APP_URL 是 https 但你用 http 開網站;網址主機不同 (127.0.0.1 和 localhost 是不同的 cookie);每次重啟都換了 session 金鑰 (FLASK_SECRET_KEY / SESSION_SECRET);或同一台電腦 localhost 上的其他網站用了同名 cookie (cookie 不分連接埠)。請用和 APP_URL 一模一樣的網址開網站,並固定 session 金鑰。
SSO 說「沒有權限」,或網址有 error=access_denied你的帳號不在這個服務的白名單裡 (或在黑名單)。請找 SSO 管理員。
某個欄位 (例如 mobile_phone) 沒有出現服務沒有開放這個欄位,或這個人在 Azure 裡本來就沒填。請管理員到服務頁「可存取的資料」確認,那裡也能預覽會收到什麼。
大頭照不顯示這個人沒有設定照片、服務沒開放「大頭照」,或你的網站有設定 CSP 但 img-src 沒加入 https://ae-sso.advantech.com。
登入後又一直跳回登入頁後端沒有把使用者存進 session。用瀏覽器直接打開 /auth/me:應該要看到你的資料 (200);看到 401 就是 session 沒存到 (通常和 error=state 同樣原因)。
按了登出,再登入卻直接變回同一個人、不用輸入密碼網站和 SSO 都已登出,但 Microsoft 自己還記得你。登出後的下一次登入會顯示 Microsoft 的「選擇帳戶」畫面,可以選別的帳號。如果要每次都重新輸入密碼,請 SSO 管理員在「Azure AD 設定 → 登出行為」開啟「同時登出 Microsoft」,或在登出網址加上 microsoft=1。

進階參考

原理、所有網址、欄位說明、HTML 屬性、HTTP 細節。照上面步驟做的話,不需要看這裡。

這個 SSO 的網址

Authorize URL
https://ae-sso.advantech.com/authorize
Token URL
https://ae-sso.advantech.com/token
Userinfo URL
https://ae-sso.advantech.com/userinfo
Photo URL
https://ae-sso.advantech.com/userinfo/photo 大頭照 (Bearer access token)
Logout URL
https://ae-sso.advantech.com/logout
JS
https://ae-sso.advantech.com/assets/js/advantech-sso.js
CSS
https://ae-sso.advantech.com/assets/css/advantech-sso.css

登入流程

所有網站都使用同一套流程 (OAuth 2.0 Authorization Code)。Client Secret 只存在你的後端,瀏覽器永遠拿不到。

  1. 使用者打開你的網站,後端發現沒有登入 → 先以 /auth/login?silent=1 試著直接登入 (見下方「跨服務單一登入」);若使用者還沒在任何服務登入過,才導到 /login.html。
  2. 按下登入按鈕 → 你的後端 /auth/login 產生隨機 state,把使用者導到 https://ae-sso.advantech.com/authorize。
  3. 使用者在 Microsoft 登入 (已登入過其他服務則直接通過)。
  4. SSO 導回 /auth/callback?code=…&state=…;後端比對 state,再以 POST https://ae-sso.advantech.com/token 用 code + Client Secret 換到使用者資料。
  5. 後端把使用者資料存進自己的 session,之後每個請求只要看 session 就知道是否已登入。
  6. 前端用 /auth/me 取得目前使用者,顯示姓名、部門與大頭照。

已經有自己的登入頁?貼一行就有登入按鈕

不需要改版面。在現有登入頁的 <head> 加上 CSS 與 JS,再在想放按鈕的地方加一個 <div data-sso="button"></div>,就會出現「使用 Advantech 帳號登入」按鈕。

<!-- 1. in <head> -->
<link rel="stylesheet" href="https://ae-sso.advantech.com/assets/css/advantech-sso.css">
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" defer></script>

<!-- 2. where the button should appear -->
<div data-sso="button"></div>

<!-- e.g. under an existing username / password form -->
<form method="post" action="/login"> … </form>
<div data-sso="button" data-divider="top"></div>

實際效果 (預覽,按鈕不會真的登入):

按鈕會連到你網站後端的 /auth/login (Client Secret 必須在後端),所以後端仍需加上「後端範例」中的 /auth/login 與 /auth/callback 兩個路徑。路徑不同時,用 <script> 的 data-login 或按鈕的 data-href 指定。

屬性說明
data-next="/path"登入後要回到的頁面 (預設讀網址上的 ?next= 或 ?returnUrl=,都沒有就回首頁)。
data-text="…"自訂按鈕文字。
data-lang="ja"只改這一顆按鈕的語言 (zh-TW / zh-CN / en / ja / ko)。沒設定時依下方「語言」規則。
data-variant="outline"outline = 白底藍框的次要按鈕。
data-width="auto"auto = 依文字寬度;預設滿版。
data-divider="top|bottom"在按鈕上方 (top) 或下方 (bottom) 加一條「或」分隔線,適合放在帳號密碼表單旁。
data-href="…"直接指定按鈕連結 (例如 PHP 的 /sso.php?a=login)。

頁面上只有按鈕時,JS 不會呼叫 /auth/me,不會影響原本的登入頁。

語言

按鈕、「或」分隔線、使用者名片的登入 / 登出文字都會自動翻譯。預設跟著瀏覽器的語言 (支援繁中、簡中、英、日、韓,其他語言顯示英文)。要固定或切換語言,用下面任一種方式 (由上到下優先):

  1. 單一按鈕:data-lang
  2. 程式中切換 (例如你的網站有語言選單):AdvSSO.setLang("ja"),立即重繪;AdvSSO.setLang("auto") 回到瀏覽器語言。
  3. 整個網站固定:在 script 標籤加 data-lang,或在載入前設定 window.AdvSSOConfig。用 "page" 會改跟 <html lang>。
  4. 新增其他語言或改文字:AdvSSOConfig.texts 或 AdvSSO.addTexts()。
<!-- 1. one button -->
<div data-sso="button" data-lang="ja"></div>

<!-- 2. your own language menu -->
<select onchange="AdvSSO.setLang(this.value)">
  <option value="auto">Auto</option><option value="zh-TW">繁體中文</option>
  <option value="en">English</option><option value="ja">日本語</option>
</select>

<!-- 3. the whole site (either line) -->
<script src="https://ae-sso.advantech.com/assets/js/advantech-sso.js" data-lang="zh-TW" defer></script>
<script>window.AdvSSOConfig = { lang: "zh-TW" };</script>   <!-- before advantech-sso.js -->

<!-- 4. another language / your own wording -->
<script>
window.AdvSSOConfig = {
  texts: {
    th: { button: "เข้าสู่ระบบด้วยบัญชี Advantech", or: "หรือ", signIn: "เข้าสู่ระบบ", signOut: "ออกจากระบบ" },
    en: { button: "Continue with Advantech" }
  }
};
</script>

後端範例

每個範例都是可以直接執行的單一檔案,提供同樣的五個路徑,前端套件可以通用:

路徑說明
GET /auth/login產生 state,導到 SSO 登入。?next= 是登入後要回到的頁面;?silent=1 會加上 prompt=none,只在已登入 SSO 時直接通過。
GET /auth/callback比對 state;若有 error (例如 login_required) 就導到登入頁,否則以 code + Client Secret 呼叫 /token,把 user 存進 session。
GET /auth/me已登入回傳使用者 JSON,未登入回傳 401。
GET /auth/logout清除網站 session,並登出 SSO 後回到網站首頁。
/ , /api/*受保護的頁面與 API:未登入的網頁先導到 /auth/login?silent=1&next=…,API 回 401。

選你的語言,下載範本並執行

跨服務單一登入 (A 服務登入後,B 服務不用再登入)

使用者在任何一個服務登入後,SSO 會保留 8 小時的登入狀態 (SSO 網域上的 cookie)。之後進入其他服務時,只要那個服務把使用者導到 /authorize,SSO 會直接發 code 導回去,不會再出現 Microsoft 登入畫面。

要讓使用者「完全不需要點按鈕」,各服務在發現使用者沒有自己的 session 時,不要先顯示登入頁,而是:

  1. 導到 https://ae-sso.advantech.com/authorize?…&prompt=none (範例中的 /auth/login?silent=1)。
  2. 已登入 SSO → 立即帶著 code 回來,照一般流程換取使用者資料,使用者只會看到網址閃一下。
  3. 還沒登入 SSO → 帶著 error=login_required 回來 (不會顯示任何頁面),這時才顯示你的 login.html。沒有這個服務權限時是 error=access_denied。
# B: no session of its own → try silently
GET https://ae-sso.advantech.com/authorize?client_id=svc_B&redirect_uri=https%3A%2F%2Fb.advantech.world%2Fauth%2Fcallback&response_type=code&state=RANDOM&prompt=none

# already signed in (e.g. to A)   → https://b.advantech.world/auth/callback?code=…&state=RANDOM
# not signed in yet               → https://b.advantech.world/auth/callback?error=login_required&state=RANDOM
# no access to B                  → https://b.advantech.world/auth/callback?error=access_denied&state=RANDOM

後端怎麼知道使用者已登入?

登入完成後,判斷依據是你網站自己的 session,不需要每個請求都去問 SSO。範例中對應的寫法:

語言取得目前使用者保護頁面 / API
Python / Flaskcurrent_user()@login_required
Node.js / Expressreq.session.userrequireLogin
PHPsso_user()sso_require_login()
C# / ASP.NET CoreUser.Identity.IsAuthenticated.RequireAuthorization()
Java / Spring Bootsession.getAttribute("user")RequireLogin filter
GocurrentUser(w, r)requireLogin(h)

使用者資料 (profile)

POST /token 回應中的 user、以及 GET /userinfo 的回應,都是下面的格式。每個服務能取得哪些欄位由管理員在服務頁的「可存取的資料」設定;sub、email、name、preferred_username 一律提供。只有 Azure 有提供的欄位才會出現。

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

{
  "sub": "d3b9c1e2-…-azure-object-id",
  "email": "john.chen@advantech.com",
  "name": "John Chen",
  "preferred_username": "john.chen@advantech.com",
  "given_name": "John",
  "family_name": "Chen",
  "job_title": "Application Engineer",
  "department": "AE",
  "company": "Advantech",
  "office_location": "Taipei",
  "employee_id": "A12345",
  "manager": { "name": "Mary Lin", "email": "mary.lin@advantech.com", "job_title": "Manager",
               "masked": true, "masked_fields": ["mobile_phone"] },
  "groups": ["AE Team", "All Employees"],
  "picture": "https://ae-sso.advantech.com/photo/eyJ0Ijoi…",
  "masked": false,
  "masked_fields": []
}
欄位說明
sub使用者唯一 ID (Azure AD 物件 ID),建議當主鍵
masked, masked_fields每一位使用者 (包括 manager、部屬、群組成員等裡面的每個人) 都有這兩個欄位:masked = 這個人是否有資料被隱碼 (true/false);masked_fields = 這次回應中被隱碼的欄位名稱清單 (沒有時是空陣列 [])。程式可以用它判斷要不要顯示「資料已隱藏」,或避免把 * 當成真實資料存起來。
email, preferred_username公司 Email (小寫)
name顯示名稱
given_name, family_name名 / 姓
job_title職稱
department部門
company公司名稱
office_location辦公室位置
mobile_phone, business_phones手機 / 公司電話
employee_id員工編號
country, city國家 / 城市
manager主管的 sub / name / email / job_title (/me/manager 沒權限時,改從 beta /me/profile 取得)
manager_chain主管鏈:直屬主管在第一個,往上到最高主管 (來自 beta /me/profile);沒有 User.ReadBasic.All 時可能只有 sub
direct_reports直屬部屬的 sub / name / email / job_title / department
groups所屬群組名稱
picture大頭照網址,可直接放進 <img src> (使用者沒有照片時不會出現)
team_members, azure同主管成員 (team_members)、Microsoft Graph 原始資料 (azure);預設不提供,需管理員開啟

用 access_token 再次取得使用者資料:

curl -H "Authorization: Bearer $ACCESS_TOKEN" https://ae-sso.advantech.com/userinfo

組織資料:主管、部屬、同主管成員

使用者每次登入時,SSO 會從 Microsoft Graph 取得組織關係:manager、manager_chain、直屬部屬 (direct_reports) 與同主管成員 (team_members)。實際能取得哪些,由管理員在服務頁的「可存取的資料」設定。/me/manager 被拒絕 (403) 時,SSO 會改用 beta /me/profile 的職位資料取得主管與主管鏈,只需要 User.Read。

{
  "sub": "…", "name": "Mary Lin", "email": "mary.lin@advantech.com",
  "manager": { "sub": "…", "name": "David Wu", "email": "david.wu@advantech.com", "job_title": "Director" },
  "manager_chain": [
    { "sub": "…", "name": "David Wu", "email": "david.wu@advantech.com", "job_title": "Director" },
    { "sub": "…", "name": "", "email": "", "job_title": "", "department": "" }
  ],
  "direct_reports": [
    { "sub": "…", "name": "John Chen", "email": "john.chen@advantech.com", "job_title": "Engineer", "department": "AE" },
    { "sub": "…", "name": "Amy Lee",   "email": "amy.lee@advantech.com",   "job_title": "Engineer", "department": "AE" }
  ],
  "team_members": [
    { "sub": "…", "name": "Kevin Huang", "email": "kevin.huang@advantech.com", "job_title": "Manager", "department": "FAE" }
  ]
}

取得部屬與同主管成員需要 Azure 的 User.Read.All 權限;只有 User.Read 時這兩個欄位可能是空的。資料在使用者登入時更新,管理員也可以在後台「使用者」頁用應用程式權限手動更新。

大頭照 (photo)

使用者登入時,SSO 會從 Microsoft 取得 96×96 的大頭照。有兩種用法:

  1. 最簡單:使用 user.picture。這是帶簽章的網址,7 天內有效,瀏覽器可以直接載入,不需要權杖:
    <img src="${user.picture}" alt="" width="96" height="96" style="border-radius:50%">
    
    <!-- or let advantech-sso.js do it -->
    <img data-sso-photo alt="">
  2. 要存到自己的伺服器:後端用 access_token 呼叫 GET https://ae-sso.advantech.com/userinfo/photo,回應就是圖片本身 (image/jpeg 或 image/png);沒有照片時回 404。
    curl -H "Authorization: Bearer $ACCESS_TOKEN" -o photo.jpg https://ae-sso.advantech.com/userinfo/photo
    
    # Python
    r = requests.get("https://ae-sso.advantech.com/userinfo/photo", headers={"Authorization": f"Bearer {access_token}"}, timeout=10)
    if r.status_code == 200:
        open(f"photos/{user['sub']}.jpg", "wb").write(r.content)
    
    // Node.js
    const r = await fetch("https://ae-sso.advantech.com/userinfo/photo", { headers: { Authorization: `Bearer ${accessToken}` } });
    if (r.ok) await fs.promises.writeFile(`photos/${user.sub}.jpg`, Buffer.from(await r.arrayBuffer()));

其他語言:直接呼叫 HTTP

任何能發 HTTP 請求的語言都可以串接,以下是完整的請求內容:

# 1. browser → SSO
GET https://ae-sso.advantech.com/authorize?client_id=svc_xxxxxxxx&redirect_uri=https%3A%2F%2Fmyapp.advantech.world%2Fauth%2Fcallback&response_type=code&state=RANDOM

# 2. SSO → browser → your site
GET https://myapp.advantech.world/auth/callback?code=ONE_TIME_CODE&state=RANDOM

# 3. your backend → SSO  (server to server)
curl -X POST https://ae-sso.advantech.com/token \
  -d grant_type=authorization_code \
  -d code=ONE_TIME_CODE \
  -d redirect_uri=https://myapp.advantech.world/auth/callback \
  -d client_id=svc_xxxxxxxx \
  -d client_secret=$SSO_CLIENT_SECRET

# response
{ "access_token": "…", "token_type": "Bearer", "expires_in": 3600,
  "user": { "sub": "…", "email": "…", "name": "…", "picture": "…" } }

# 4. later (optional, while the access_token is valid)
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://ae-sso.advantech.com/userinfo
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://ae-sso.advantech.com/userinfo/photo -o photo.jpg

# silent sign-in (single sign-on across services): add &prompt=none to step 1
#   → ?code=… when already signed in, ?error=login_required otherwise
# other prompt values (select_account, login): /developers/advanced#prompt

# 5. sign out
GET https://ae-sso.advantech.com/logout?redirect_uri=https%3A%2F%2Fmyapp.advantech.world%2F

人員與群組 API

要讓你的伺服器在沒有人登入的情況下查詢員工、主管、部屬、群組與成員,請看「人員與群組 API」的完整說明、API 參考與 6 種語言的用戶端函式庫。

人員與群組 API 指南 →

注意事項

Advantech Single Sign-On