跳至主要内容

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: embedded

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,或預先放入 codeerror 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_SESSIONSPRING_SESSION_ATTRIBUTES 是 Spring Session JDBC 自己的 protocol schema,不是 JPA Entity,所以不受 Hibernate ddl-auto 控制。Reference config 使用 initialize-schema: embedded:H2 等 embedded DB 自動建立;外部 DB 預設要求 migration 或 DBA 套用 Spring Session 對應 dialect 的官方 schema script。

如果專案明確決定讓應用在外部 DB 自動建立 Spring Session tables,可把 spring.session.jdbc.initialize-schema 設為 always;這是獨立於 ddl-auto 的部署政策, 需確保 DB 帳號具有 DDL 權限且多節點同時啟動不會造成 schema race。

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 設定正確的 ForwardedX-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 各自完成。
  • 外部 DB 已套用 Spring Session schema;oidc_exchange_grant 符合專案 ddl-auto/migration 政策。
  • production session cookie 為 Secure、HttpOnly,且 proxy forwarded-header trust 正確。