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