跳至主要内容

OIDC browser login 參考實作

oidc-login 是應用層可客製的 browser login reference implementation。Spring Security 負責 Authorization Code、IdP PKCE、state、nonce、issuer 與 audience 驗證;應用端組合 app context、全域 external-identity mapping、frontend PKCE 與 app JWT。

它與 OAuth2 Client Credentials 不同:前者辨識互動式使用者,後者讓服務或 郵件系統取得機器 token。

完整流程​

  1. Frontend 為每次登入產生 43..128 字元高熵 code_verifier,計算 S256 code_challenge。
  2. Frontend 呼叫 GET /api/v1/auth/oidc/{registrationId},不傳 tenant。
  3. Server 將 app、challenge 保存為短效 Spring Session context,302 到自己的 /oauth2/authorization/{registrationId}。
  4. Spring Security 產生獨立 OAuth state/nonce/PKCE 並轉往 IdP。每個 state 使用自己的 session attribute,因此多分頁登入不互相覆蓋。
  5. Callback 驗證成功後,reference mapping 以 exact issuer + subject 全域尋找 ExternalIdentity,取得 Account 並由其推導 tenant。callback 只建立 subject/app/challenge 的最小 exchange grant, 不預先簽 JWT。
  6. Server redirect 到固定 success-uri?code=oidc...。Frontend 以原始 verifier 呼叫 POST /api/v1/auth/oidc/exchange。
  7. JPA store 在 transaction 中鎖定並原子消耗 code;成功後重新載入 Account 的最新狀態, 才簽發 access/refresh JWT。

錯誤 verifier 採 constant-time challenge 比對,而且不消耗 code。正確 code 在多節點間 也只能成功一次。

設定​

app:
security:
oidc:
enabled: true
success-uri: "https://office.example.com/login"
exchange-ttl: 2m
protocol-session-ttl: 5m

spring:
security:
oauth2:
client:
registration:
google:
client-id: ${OIDC_GOOGLE_CLIENT_ID}
client-secret: ${OIDC_GOOGLE_CLIENT_SECRET}
scope: openid,profile,email
provider:
google:
issuer-uri: https://accounts.google.com
session:
jdbc:
initialize-schema: always

server:
servlet:
session:
timeout: 5m
cookie:
http-only: true
same-site: lax
secure: true

success-uri 必須是 absolute HTTPS;只有 localhost、127/8、::1 可在開發時使用 HTTP。 URI 不得含 userinfo、fragment,或預先放入 code/error query parameter。

Feature 啟用卻沒有任何 ClientRegistration 時,應用會以清楚訊息停止啟動,避免顯示一個 永遠無法完成的登入入口。

Schema 與 ddl-auto​

這裡有兩套不同性質的 table:

  • oidc_exchange_grant 是 reference implementation 的一般 JPA Entity,和其他 Entity 一樣完全由專案既有 spring.jpa.hibernate.ddl-auto 決定建立/更新/驗證。正式環境若 使用 migration,照既有 Entity migration 流程建表。
  • SPRING_SESSION、SPRING_SESSION_ATTRIBUTES 是 Spring Session JDBC 自己的 protocol schema,不是 JPA Entity,所以不受 Hibernate ddl-auto 控制。Reference config 使用 initialize-schema: always:應用啟動時對支援的內嵌與外部資料庫執行 Spring 提供的 dialect schema script,資料庫帳號須有建立表格與索引的 DDL 權限。

這個預設隨 config/feature-oidc-login.yml provision;OAuth2 Client 與 Session JDBC 相依由 gradle/features/oidc-login.gradle.kts 帶入,Session 接線也屬 OIDC feature。 未安裝 OIDC 的專案不需要這些相依或表格。

always 每次啟動都會嘗試初始化,沿用 Spring 的 continue-on-error=true;既有 Session 資料保留,但它不負責升級舊 schema。其他 DDL 錯誤也可能被忽略,因此仍須驗證兩張表可讀寫; 多節點首次啟動時應協調初始化順序。

若 Session 表格已由 Flyway/DBA 管理,在外部 {serviceName}.properties 覆寫:

spring.session.jdbc.initialize-schema=never

由該應用自己的 migration 序列負責建表/升級,保留已套用 migration,不複製來源的版本號。 既有下游採納新 feature 預設時,保留有意設定的外部 override。

Servlet context path 與 reverse proxy​

Reference controller 以 HttpServletRequest.getContextPath() 建立內部 authorization redirect,因此 /app-server 不會被吃掉。IdP 註冊的 callback 必須等於實際對外位址:

https://api.example.com/app-server/login/oauth2/code/google

若 TLS 在 reverse proxy 終止,請:

  • 讓 proxy 設定正確的 Forwarded 或 X-Forwarded-Proto/Host/Port/Prefix。
  • 在 Spring Boot 設定適合部署環境的 server.forward-headers-strategy。
  • 只信任受控 proxy 傳入的 forwarded headers,不直接信任公網 client 自填值。
  • 確認 proxy 沒有錯誤 strip 或重複加入 /app-server。

SameSite=Lax 適合一般 top-level OIDC redirect;production HTTPS 必須把 session cookie secure 設為 true。若 provider 或嵌入式登入流程需要不同 cookie policy,應另行做瀏覽器 相容性與 CSRF 評估。

Store 與 Session 可替換面​

Reference OidcExchangeStore 使用 JPA row lock。專案可提供自己的 bean 取代,例如 Redis Lua script/transaction 實作;替代品仍必須同時保證:

  • store 只接收 code hash,不保存明文 code;
  • verifier mismatch 不刪除 grant;
  • 成功 consume 為跨節點原子操作;
  • expiry 以 server-side timestamp 判定;
  • grant 不含 access/refresh JWT。

Protocol context 使用 Spring Session JDBC。要改用 Redis,替換 Spring Session repository 與依賴即可;OidcProtocolContextRepository 仍只使用標準 HttpSession API。

Frontend 範例​

const bytes = crypto.getRandomValues(new Uint8Array(64))
const verifier = base64Url(bytes)
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
)
const challenge = base64Url(new Uint8Array(digest))

sessionStorage.setItem("oidc-verifier", verifier)
location.assign(
`${apiBase}/api/v1/auth/oidc/google`
+ `?app=office`
+ `&code_challenge=${challenge}`
+ `&code_challenge_method=S256`,
)

Callback landing page 從 URL 取出 code,讀取同一 flow 的 verifier,呼叫 exchange 後立即 刪除 verifier。不要把 verifier 寫入 localStorage、URL、log 或 analytics event。

驗證清單​

  • Provider 上的 callback URI 與對外 context path 完全一致。
  • 未連結 external identity 時 fail closed;需要自動 mapping 時依 External Identity 的 policy seam 實作。
  • 錯誤 verifier 後,正確 verifier 仍能成功一次;成功後 replay 回 401。
  • 同一 browser 兩個登入分頁的 state/context 各自完成。
  • Spring Session 兩張表已由自動初始化或 Flyway/DBA 建立且可讀寫;oidc_exchange_grant 符合專案 ddl-auto/migration 政策。
  • production session cookie 為 Secure、HttpOnly,且 proxy forwarded-header trust 正確。