OIDC SSO

企業級身分識別架構實作

主題:Keycloak 26 + Python Flask + PostgreSQL / Google

建構 OIDC SSO
與 PostgreSQL + Google 整合

示範如何整合最新版 Keycloak 26 作為認證中心,對接 Google 社群登入
並完全使用 Python FastAPI 當作bridge,用Python程式完美驗證會員系統 PostgreSQL 資料庫中的帳號密碼。

跨系統架構關聯圖

滑鼠移至節點上,可深入理解在 OIDC 授權流程中的通訊細節。

瀏覽器 Browser Flask 主程式 Port 5000 Keycloak 26 Port 8080 Google OAuth 外部 API FastAPI OIDC 代理 Port 8001 PostgreSQL DB 加密用戶資料表

交互式架構解析

請點擊左側架構圖上的任何一個元件

點擊圖示可解密各端點在 OIDC 單一登入架構中如何協同作業、並釐清 Port 與安全參數(Nonce、JWT Signature)的通訊流向。

HTTPS/HTTP 傳輸機制 系統主機: horacenas.local.com

最新版 Keycloak 26+ 管理後台設定步驟

手把手教您如何將 Google 社交登入與 Python FastAPI 雙管道設定在同一個 Realm 中。

第一階段

1 建立 Realm 與 Client

  • 左上角下拉選單點擊 Create Realm ➔ 輸入 demo-realm
  • 進入 Clients ➔ 建立新 Client ➔ 命名為 python-app
  • Capability config 頁面:將 Client authentication 設為 ON(此步將開啟秘密驗證以取得 Client Secret)。
  • Login settings 頁面: Valid Redirect URIs 填入 http://horacenas.local.com:5000/auth
第二階段

2 串接 Google 登入

  • 左側選單 Identity Providers ➔ 點擊 Add provider ➔ 選擇 Google
  • 在 Google Cloud Console 中建立憑證,已授權的重新導向 URI 填入: http://horacenas.local.com:8080/realms/demo-realm/broker/google/endpoint
  • 將 Google 給予的 Client ID 與 Client Secret 貼回 Keycloak 後儲存。
第三階段

3 串接 PostgreSQL 自訂驗證源

  • 左側選單 Identity Providers ➔ Add provider ➔ 選擇 OpenID Connect 1.0
  • Alias 輸入 postgres-oidcp;顯示名稱輸入「會員系統資料庫登入」。
  • Discovery endpoint 輸入 http://horacenas.local.com:8001/.well-known/openid-configuration ➔ 點擊 Fetch metadata,它會自動抓取所有 Python OIDC 端點。
  • Client ID 輸入 keycloak-bridgeClient Secret 填入共享金鑰(例如:MY_STATIC_SHARED_SECRET_123456_ABCDE)。
第四階段(最新版隱藏步驟)

4 切換演算法與停用 UserInfo

  • 最重要的一步: 點擊進入剛建立的 postgres-oidcp 設定頁面。
  • 切換至上方的 Provider details 頁籤。
  • 向下拉找到 Advanced settings ➔ 找到 Signature algorithm
  • 將預設的 RS256 變更為 HS256 ➔ 此時 Keycloak 就會直接採用 Client Secret 當作解密金鑰!
  • 切換回 Settings 頁籤,往下拉找到 OpenID Connect settings ➔ 將 Disable User Info 切換為 ON

OIDC 橋接代理代碼: idp_server.py

完全相容於 Python 3.14。移除了 passlib 依賴,採用原生 hashlib 以及 Nonce、Issuer 比對安全校正。

from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
import psycopg2
from psycopg2.extras import RealDictCursor
import jwt
import time
import hashlib

app = FastAPI()

# 用來暫存 Keycloak 每次登入帶過來的 nonce 安全檢查隨機字串
nonce_cache = {}

# PostgreSQL 資料庫連接設定
def get_db_connection():
    return psycopg2.connect(
        host="localhost",
        database="your_existing_db",
        user="postgres",
        password="your_password",
        cursor_factory=RealDictCursor
    )

# 1. OIDC Discovery 自動配置端點:讓 Keycloak 自動爬取此代理的所有 OIDC 端點
@app.get("/.well-known/openid-configuration")
def openid_configuration():
    # 這裡的 Issuer 必須對齊 horacenas.local.com
    base_url = "http://horacenas.local.com:8001"
    return {
        "issuer": base_url,
        "authorization_endpoint": f"{base_url}/authorize",
        "token_endpoint": f"{base_url}/token",
        "userinfo_endpoint": f"{base_url}/userinfo",
        "response_types_supported": ["code"],
        "subject_types_supported": ["public"],
        "id_token_signing_alg_values_supported": ["HS256"]
    }

# 2. 登入表單頁面:接收 Keycloak 帶來的 nonce 安全字串並在表單中隱藏傳遞
@app.get("/authorize")
def authorize(redirect_uri: str, state: str, nonce: str = None):
    return HTMLResponse(content=f"""
        

會員系統資料庫登入驗證

帳號:

密碼:

""") # 3. 驗證資料庫帳密:採用原生 hashlib 排除 python 3.14 passlib 相容衝突 @app.post("/login_check") def login_check(username: str = Form(...), password: str = Form(...), redirect_uri: str = Form(...), state: str = Form(...), nonce: str = Form(None)): conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT username, pw FROM users WHERE username = %s", (username,)) user = cursor.fetchone() cursor.close() conn.close() if not user: raise HTTPException(status_code=401, detail="找不到該使用者") # 計算 SHA-256 十六進位值 (對齊資料庫內存格式) calculated_sha256 = hashlib.sha256(password.encode('utf-8')).hexdigest() if calculated_sha256 != user["pw"]: raise HTTPException(status_code=401, detail="PostgreSQL 驗證失敗:密碼錯誤") # 驗證成功,暫存該用戶這次登入專屬的 nonce if nonce: nonce_cache[username] = nonce # 轉導回 Keycloak callback return RedirectResponse(url=f"{redirect_uri}?code={username}&state={state}", status_code=303) # 4. Token 交換端點:簽發真正合規、帶有 nonce 且 issuer 與金鑰長度安全的 JWT @app.post("/token") def token_endpoint(code: str = Form(...)): now = int(time.time()) # 消除 HS256 長度警告,密鑰長度補齊到 32 欄位以上 JWT_SECRET = "MY_STATIC_SHARED_SECRET_123456_ABCDE" user_nonce = nonce_cache.pop(code, None) id_token_payload = { "iss": "http://horacenas.local.com:8001", # 👈 與 Discovery Issuer 完全一致 "sub": str(code), "aud": "keycloak-bridge", # 與 Keycloak 中自訂 IdP 的 Client ID 一致 "exp": now + 3600, "iat": now, "auth_time": now, "name": str(code), "preferred_username": str(code), "email": f"{code}@example.com", "email_verified": True } if user_nonce: id_token_payload["nonce"] = user_nonce # 👈 核心安全修正:帶回 Nonce 避免 Replay 驗證錯誤 real_jwt_id_token = jwt.encode(id_token_payload, JWT_SECRET, algorithm="HS256") return { "access_token": str(code), "id_token": real_jwt_id_token, "token_type": "Bearer", "expires_in": 3600 } @app.get("/userinfo") def userinfo(authorization: str = None): # 此處保留防止 Keycloak 仍然呼叫,我們從 Bearer 解析用戶並動態回傳 username = "test01" if authorization and "Bearer " in authorization: username = authorization.split("Bearer ")[1] return { "sub": str(username), "name": str(username), "preferred_username": str(username), "email": f"{username}@example.com", "email_verified": True }

Flask SSO 主程式入口: app.py

連接 Keycloak 進行單一登入的 Python 網頁應用程式 (Port 5000)。

from flask import Flask, redirect, url_for, session, jsonify
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = 'FLASK_MAIN_APP_SECRET_KEY_Demo'

oauth = OAuth(app)

# OIDC Discovery 設定網址:讓 Flask 自動向 Keycloak 爬取認證端點
CONF_URL = "http://horacenas.local.com:8080/realms/demo-realm/.well-known/openid-configuration"

oauth.register(
    name='keycloak',
    client_id='python-app',
    # ⚠️ 這裡的 client_secret 必須跟 Keycloak -> Clients -> python-app -> Credentials 中的 Secret 一模一樣
    client_secret='請貼上在步驟三-2複製的_python-app_Client_Secret',
    server_metadata_url=CONF_URL,
    client_kwargs={'scope': 'openid profile email'}
)

@app.route('/')
def homepage():
    user = session.get('user')
    if user:
        return f'''
            

🎉 OIDC SSO 登入成功!

目前登入帳號: {user.get("preferred_username") or user.get("name")}

登入信箱: {user.get("email")}

JWT 解析詳細資訊:

{jsonify(user).data.decode('utf-8')}
登出此 Session
''' return '''

Python OIDC 整合環境

這個 Demo 應用串接了 Keycloak 單一登入體系

使用 SSO 整合驗證
''' @app.route('/login') def login(): # 轉導到 Keycloak 的認證管道 redirect_uri = url_for('auth', _external=True) return oauth.keycloak.authorize_redirect(redirect_uri) @app.route('/auth') def auth(): # 接收 Keycloak 回傳的 Code,並用 Client Secret 換取完整的 ID Token token = oauth.keycloak.authorize_access_token() userinfo = token.get('userinfo') if userinfo: session['user'] = userinfo return redirect('/') @app.route('/logout') def logout(): session.pop('user', None) return redirect('/') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)

PostgreSQL 資料庫密碼 SHA-256 轉換步驟

使用 PostgreSQL 內建模組,快速將原有的密碼轉換成安全、對齊 Python 的 SHA-256 雜湊格式。

1 步驟 1:啟用 PostgreSQL 內建加密擴充功能 (pgcrypto)

請在與你 Python 橋接器連接的 PostgreSQL 資料庫下執行此 SQL 指令,此指令只需執行一次:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

2 步驟 2:將資料表中的密碼轉成 SHA-256 十六進位格式

使用 digest()encode() 函數,你可以將使用者「明文密碼」大量更新為與 Python 對齊的 64 位元安全雜湊。以您的 `test01` 為例:

-- 更新特定用戶 (例如 test01 密碼為 test01)
UPDATE users
SET pw = encode(digest('test01', 'sha256'), 'hex')
WHERE id = 'test01';

3 步驟 3:往後新增使用者 (Insert) 時自動加密

以後系統在後台寫入新用戶時,直接在 SQL 階段套用此機制,確保資料庫絕不留存明文密碼:

INSERT INTO users (id, email, pw)
VALUES ('test02', 'test02@example.com', encode(digest('your_password', 'sha256'), 'hex'));

OIDC 實戰排錯黃金法則 (解決所有 We are sorry...)

總結在對接過程中遇到的高頻經典地雷,以及背後的解決方案。

Mismatch Subject Error

原因: `id_token` 內部的 `sub` 欄位與從 `/userinfo` 回傳的 `sub` 不一致。

解決方案: 兩邊強制轉型對齊(例如本實作中全部統一為 dynamic username),或直接在 Keycloak 開啟 "Disable User Info"

OpenID Provider did not return a nonce

原因: 為了防止 Replay 攻擊,OIDC 規定 /token 端點生成的 JWT 必須包含當初 Keycloak 發過來的 nonce 值。

解決方案: 在 `/authorize` 階段將 nonce 埋入 Form 表單,並透過記憶體 dict (`nonce_cache`) 帶入 `/token` 中的 JWT。

Wrong issuer from token

原因: Keycloak 用戶端使用的 host 與 JWT 內部的 `"iss"` 值字串不匹配。

解決方案: 在 Python 的 Discovery 設定及 `/token` 載荷中,將 `localhost` 改為完全一致的網域 `horacenas.local.com`。

invalid_client_credentials (Code to Token)

原因: Flask 主程式在拿 code 換 Token 時帶過去的 Client Secret 不對。

解決方案: 至 Keycloak -> Clients -> python-app 重新複製正確的金鑰填入 Flask 的 `client_secret` 設定。