照著做、複製貼上就完成。不需要懂 OAuth,也不需要碰 Azure。
把下面這段複製給 SSO 管理員,他會回你兩個值:Client ID 和 Client Secret。
請幫我在 Advantech SSO 新增服務 服務名稱:(填你的網站名稱) Redirect URI: https://myapp.advantech.world/auth/callback
PHP 版的 Redirect URI 不同,請看步驟 2 的 PHP 頁籤。Client Secret 是密碼,只放在後端環境變數,不要放進網頁或 Git。
範本是一個可以直接跑的小專案:登入頁、首頁、後端都做好了。下載解壓縮 → 把步驟 1 拿到的兩個值填進指令 → 執行。
myapp/ flask_app.py requirements.txt public/login.html public/index.html README.txt
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
# 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)
flask>=3.0 requests>=2.31
<!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>
<!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>
myapp/ express_app.mjs package.json public/login.html public/index.html README.txt
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
// 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"));
{
"name": "myapp",
"private": true,
"type": "module",
"scripts": { "start": "node express_app.mjs" },
"dependencies": {
"express": "^4.21.2",
"express-session": "^1.18.1"
}
}
<!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>
<!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>
myapp/ sso.php login.html index.html README.txt
# 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
<?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);
}
<!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>
<!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>
myapp/ MyApp.csproj Program.cs wwwroot/login.html wwwroot/index.html README.txt
# .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
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
// 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();
<!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>
<!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>
下載範本 .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
# 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
<?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>
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);
}
}
// 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));
}
}
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
<!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>
<!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>
myapp/ main.go go.mod public/login.html public/index.html README.txt
# 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
// 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))
}
module myapp go 1.22
<!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>
<!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>
https://myapp.advantech.world/沒有成功?看 出錯了?
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>
按鈕文字預設跟著瀏覽器語言 (繁中 / 簡中 / 英 / 日 / 韓)。 語言 · 按鈕的選項 (語言、樣式、分隔線)
不需要任何框架。在頁面加上這兩行,就有登入按鈕、使用者名片 (大頭照 + 姓名 + 登出)、欄位顯示與未登入自動導向。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
存成 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>
存成 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>
| 屬性 | 說明 |
|---|---|
| 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= 時才顯示 (登入頁的錯誤訊息)。 |
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);
});
| 我想要… | 怎麼做 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 在後端知道「現在是誰」 |
| ||||||||||||
| 讓某個頁面或 API 必須登入 |
| ||||||||||||
| 在網頁上顯示姓名、部門 | <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=token | Client 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 細節。照上面步驟做的話,不需要看這裡。
所有網站都使用同一套流程 (OAuth 2.0 Authorization Code)。Client Secret 只存在你的後端,瀏覽器永遠拿不到。
/auth/login?silent=1 試著直接登入 (見下方「跨服務單一登入」);若使用者還沒在任何服務登入過,才導到 /login.html。/auth/login 產生隨機 state,把使用者導到 https://ae-sso.advantech.com/authorize。/auth/callback?code=…&state=…;後端比對 state,再以 POST https://ae-sso.advantech.com/token 用 code + Client Secret 換到使用者資料。/auth/me 取得目前使用者,顯示姓名、部門與大頭照。每個範例都是可以直接執行的單一檔案,提供同樣的五個路徑,前端套件可以通用:
| 路徑 | 說明 |
|---|---|
| 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。 |
使用者在任何一個服務登入後,SSO 會保留 8 小時的登入狀態 (SSO 網域上的 cookie)。之後進入其他服務時,只要那個服務把使用者導到 /authorize,SSO 會直接發 code 導回去,不會再出現 Microsoft 登入畫面。
要讓使用者「完全不需要點按鈕」,各服務在發現使用者沒有自己的 session 時,不要先顯示登入頁,而是:
https://ae-sso.advantech.com/authorize?…&prompt=none (範例中的 /auth/login?silent=1)。code 回來,照一般流程換取使用者資料,使用者只會看到網址閃一下。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
<a href="https://b.advantech.world/reports">),不需要傳遞任何權杖;B 會自動完成登入並停在 /reports。/logout 會結束 SSO 登入狀態,但不會清除其他服務各自的 session;各服務的 session 會在自己的有效期限後失效。登入完成後,判斷依據是你網站自己的 session,不需要每個請求都去問 SSO。範例中對應的寫法:
| 語言 | 取得目前使用者 | 保護頁面 / API |
|---|---|---|
| Python / Flask | current_user() | @login_required |
| Node.js / Express | req.session.user | requireLogin |
| PHP | sso_user() | sso_require_login() |
| C# / ASP.NET Core | User.Identity.IsAuthenticated | .RequireAuthorization() |
| Java / Spring Boot | session.getAttribute("user") | RequireLogin filter |
| Go | currentUser(w, r) | requireLogin(h) |
/token 同時回傳 access_token (1 小時有效)。若要確認使用者仍有權限或取得最新資料,可在有效期間呼叫 GET https://ae-sso.advantech.com/userinfo;權杖過期後請讓使用者重新登入 (若 SSO 登入仍有效 (8 小時),使用者不需輸入密碼,會自動通過)。sub 當作使用者在你網站的唯一識別 (Azure AD 物件 ID,永遠不變);Email 可能因改名而變動。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 時這兩個欄位可能是空的。資料在使用者登入時更新,管理員也可以在後台「使用者」頁用應用程式權限手動更新。
使用者登入時,SSO 會從 Microsoft 取得 96×96 的大頭照。有兩種用法:
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="">
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()));
img-src 加入 https://ae-sso.advantech.com。picture 時,前端套件會自動改顯示姓名縮寫。任何能發 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 參考與 6 種語言的用戶端函式庫。
state 並在 callback 比對,避免 CSRF。code 只能用一次,60 秒內有效;redirect_uri 必須與服務登錄的完全一致。https://ae-sso.advantech.com/logout?redirect_uri=…;redirect_uri 必須是已登錄網站的網址。Advantech Single Sign-On