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。
完整流程
- Frontend 為每次登入產生 43..128 字元高熵
code_verifier,計算 S256code_challenge。 - Frontend 呼叫
GET /api/v1/auth/oidc/{registrationId},不傳 tenant。 - Server 將 app、challenge 保存為短效 Spring Session context,302 到自己的
/oauth2/authorization/{registrationId}。 - Spring Security 產生獨立 OAuth state/nonce/PKCE 並轉往 IdP。每個 state 使用自己的 session attribute,因此多分頁登入不互相覆蓋。
- Callback 驗證成功後,reference mapping 以 exact issuer + subject 全域尋找
ExternalIdentity,取得 Account 並由其推導 tenant。callback 只建立 subject/app/challenge 的最小 exchange grant, 不預先簽 JWT。 - Server redirect 到固定
success-uri?code=oidc...。Frontend 以原始 verifier 呼叫POST /api/v1/auth/oidc/exchange。 - 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,所以不受 Hibernateddl-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 正確。