Server 對外 URL
概述
io.leandev.appfuse.web.url 提供對外基底 URL 的驗證與組合能力。反向代理可以把外部
/gateway/ 映射到內部 /app-server/;寄出的簽章連結、OIDC callback 與 Cookie Path
必須使用瀏覽器看到的路徑。內部 Servlet context 仍負責 Server 路由。
採用前請確認依賴版本已包含 PublicServerUrl,並 pin 已發布的框架版本。
快速開始
import io.leandev.appfuse.web.url.PublicServerUrl;
import io.leandev.appfuse.web.url.PublicServerUrlContext;
import io.leandev.appfuse.web.url.PublicServerUrlResolver;
import java.util.Map;
var configured = PublicServerUrl.of("https://api.example.com/gateway/");
PublicServerUrlResolver resolver = context -> configured;
var url = resolver.resolve(PublicServerUrlContext.unspecified());
url.cookiePath(); // /gateway
url.path("/api/v1/auth"); // /gateway/api/v1/auth
url.uri("/api/v1/auth/link/consume", Map.of("token", "a+b"));
// https://api.example.com/gateway/api/v1/auth/link/consume?token=a%2Bb
resolver 由應用組裝成 bean;框架不讀取設定、不取得 request,也不判斷租戶或授權。 背景工作和 HTTP 流程使用同一個 resolver。
功能說明
基底必須是完整 HTTP(S) URL,不能包含 userinfo、query、fragment、dot segment、重複 斜線、Cookie 分隔符分號或編碼分隔符。scheme/host 大小寫、預設 port、尾端單一斜線及 unreserved percent encoding 會正規化;Unicode 路徑輸出為 ASCII percent encoding。
endpoint 與動態 segment 傳未編碼值,query 由 API 編碼一次。動態 segment 拒絕斜線、 反斜線、百分號與 dot segment;不能傳入另一個完整 URL。此能力不保證目的地可信, 受信任來源仍由應用控制。
業務目標如 /orders/123、API 的 /api/v1/... 圖片/下載路徑保留原契約。
前端 httpClient 對 API 相對路徑套用一次 API base;既有 LinkBuilder 也維持相對 href。
S3 等儲存供應者簽出的 URL 使用儲存端的位址與簽章機制。
配置選項
參考實作的固定 resolver 在非 DEV 環境必須明示:
app.public-base-url=https://api.example.com/gateway
server.servlet.context-path=/app-server
例如代理 /gateway/ → /app-server/,Session Path 為 /gateway,refresh Path 為
/gateway/api/v1/auth,OIDC callback 為
https://api.example.com/gateway/login/oauth2/code/{registrationId}。
root 對外入口設為 https://api.example.com,Session Path 才是 /。
僅 app.env=DEV 且 URL 未設定或空白時,使用本機預設:
http://localhost:{server.port}{server.servlet.context-path}(port 缺省 8080、context 缺省空字串;
server.ssl.enabled=true 時改為 HTTPS)。參考實作為 http://localhost:8080/app-server。
./gradlew bootRun 已預設提供 app.env=DEV;IDE/直接啟動須自行明示,僅啟用 Spring dev profile 不算。
明示 URL 優先且仍驗證格式;使用代理、tunnel、非 localhost 入口或 server.port=0/-1 時須自行設定。
其他環境(含未設定 app.env)沒有 localhost 部署預設;缺值於啟動時失敗。
| 消費端 | 設定/來源 |
|---|---|
| Refresh Cookie、OIDC Session Cookie、OIDC callback、SignedLink consume、Actuator discovery | app.public-base-url |
| SignedLink 消費後的前端落點 | app.security.signed-link.redirect-base-url,獨立前端 URL |
| OIDC 成功後的前端頁面 | app.security.oidc.success-uri,獨立前端 URL |
| 前端 API base | 部署的前端 app.baseURL,須對齊對外 API 入口 |
| 內部 Controller/security matcher | Server 內部路由,不加入 public prefix |
Spring OIDC 的標準 redirect-uri 模板由 canonical URL 展開;與 canonical callback 不符
即拒絕啟動。自訂 ClientRegistrationRepository 須同時實作 Iterable,讓啟動驗證列舉
所有註冊;不能只依 runtime callback 覆寫略過檢查。設定 IdP 的 allowlist 時使用最終展開值。
參考接線保留原 state/nonce/PKCE。
Boot 4 使用 spring-boot-starter-session-jdbc 與 DefaultCookieSerializerCustomizer;
Session 接線由 optional oidc-login 擁有,位於
feature/oidclogin/SessionInfrastructureConfig.java。OAuth2 Client 與 Session JDBC starter
一同宣告在 gradle/features/oidc-login.gradle.kts,由 feature catalog 登記為 defaultable seam;
主建置檔載入 gradle/features/*.gradle.kts。provision OIDC 時帶入,未安裝時不帶入。
auth 的 JPA refresh token 紀錄與 mail 的 OAuth2 郵件驗證不依賴 HTTP Session。
已安裝 OIDC 後,營運期 app.security.oidc.enabled=false 不移除套件或 Session 接線;
安裝範圍與營運開關分開管理。
@SpringSessionDataSource 的 DelegatingDataSource 使用同一連線池、獨立 transaction resource key;
Session repository 與具名 JDBC transaction operations 皆使用該 wrapper,避免沿用並提交
JPA 交易中的連線。只另建交易管理器並指定 REQUIRES_NEW 並不足以隔離。
OIDC feature 預設 spring.session.jdbc.initialize-schema=always:應用啟動時,Spring Boot
對支援的資料庫執行套件內的標準 SQL,建立 SPRING_SESSION、SPRING_SESSION_ATTRIBUTES
及相關索引/外鍵;資料庫帳號須有建表權限。這是 runtime 初始化,不是 provision 工具連線建表。
每次啟動都會嘗試,沿用 Spring 預設 continue-on-error=true,既有表不會因此清空;但其他
DDL 錯誤也可能被忽略,不能把啟動成功當成 schema 已完備。此機制不升級既有表格結構。
若這兩張表已由 Flyway/DBA 管理,在外部 {serviceName}.properties 設定:
spring.session.jdbc.initialize-schema=never
此時由原有 migration 流程建表與升級,避免兩套建表機制並行;保留明示的 operator override。 JPA 交易持有連線時首次存取 Session,同一請求會同時占用池內兩條連線;請依並行交易量 預留 Session 所需容量,避免所有連線被外層交易占滿後互相等待。
| 設定情況 | 啟動診斷與處理 |
|---|---|
| 非 DEV 缺少新 URL(即使有舊 link-base-url) | app.public-base-url is required unless app.env=DEV.;明示新值。有效舊值另提示 Suggested app.public-base-url from legacy setting:,不自動採用 |
| 新 URL 非法 | app.public-base-url must be a valid absolute HTTP(S) server base URL;修正完整 URL |
| cookie-path-prefix 非空 | Remove app.security.refresh-session.cookie-path-prefix; configure app.public-base-url with the complete public path;移除舊值,否則拒絕啟動 |
| cookie-path-prefix 空值 | 忽略 |
| 舊 link-base-url 與新 URL 正規化後相同 | 警告 app.security.signed-link.link-base-url is deprecated; remove it and retain app.public-base-url;移除舊 key |
| 舊 link-base-url 不同 | app.security.signed-link.link-base-url conflicts with app.public-base-url; migrate the legacy setting;先統一位址 |
| 舊 link-base-url 非法 | Invalid legacy app.security.signed-link.link-base-url; migrate to app.public-base-url;移除或修正舊值 |
升級既有專案時,先複製並調整 package:config/PublicServerUrlConfig.java、
config/PublicActuatorLinksConfig.java;這些是 skeleton 接線,不會由 /feature upgrade 自動補齊。
已安裝 OIDC 者,將舊 config/SessionInfrastructureConfig.java 搬至 feature,並採用專屬
Gradle script、移除主建置檔的重複相依;不得同時留下兩份 Session bean。
未安裝 OIDC 且應用沒有 HTTP Session 用途者,移除舊 Session 接線、專屬測試、兩個 starter
及直接宣告的 spring-session-jdbc,保留 SignedLink/refresh/Actuator 測試。
既有 SQL migration 與資料表保留,不因移除 runtime dependency 改寫歷史 checksum 或刪表。
新安裝 OIDC 預設自動初始化 Session schema;若選用 Flyway/DBA 管理,覆寫為 never,
並依應用自己的 migration 序列備妥標準 schema,不套用來源參考實作的 migration 版號。
既有 OIDC 下游若仍保留片段中的 embedded,採納新預設;外部設定若有明示值則保留並確認
其建表責任。未安裝 OIDC 的下游不新增片段、套件或資料表。
同步 consumer 與主設定檔的 app.public-base-url/@conf-env type(url) 宣告,再重跑各已使用 tier 的
/env-config dev、/env-config test、/env-config prod。dev 可留空使用上述本機預設;其他必要參數仍須準備。
在已發布的新依賴與接線下執行 ADR-034 驗收矩陣
的 T1–T6:根路徑、相同 context、增加/替換/移除 prefix 與多段 prefix。檢查 Cookie 實際
送回、OIDC callback、SignedLink 背景連結及原有 401/403;資源路徑只套一次 API base。
參考矩陣使用 server.forward-headers-strategy=none;其他策略須在採用環境另行驗證。
重用瀏覽器 fixture 時,可用 -Dpublic-url.playwright-module=/absolute/path/node_modules/playwright
指定 Playwright 安裝位置;執行方式及證據界線見 ADR 的本機測試證據。
進階用法:部署切換與回復
以下步驟供採用專案在規劃部署時執行。
- 保存舊 artifact、外部設定、代理路由及 IdP callback 設定;記錄前端 API base、 Cookie 名稱/Path/Domain、Session 撤銷策略與仍有效連結的最晚到期時間。
- 以新 canonical URL 更新所有 Server consumer 與代理/IdP/前端設定。 不以 Forwarded header 推導 canonical URL,也不讓代理再重寫相同的 Location/Cookie Path。
- Path 改變時安排重新登入。舊 Path
/等仍可能匹配新路徑;有重疊時設定新的app.security.refresh-session.cookie-name與server.servlet.session.cookie.name。 未知舊 Path 的 Cookie 不保證可清除,重新登入也不代表舊 credential 已撤銷。 - 若部署使用 mock auth,同步名稱契約:前端 mock adapter、Vite mock-auth plugin 與
Host 的 MockAuthSessionController 接線。預設名稱為
APP_SERVER_REFRESH;若啟用 mock auth,須核對 Host 的實際掛載路徑與開關,不能沿用另一環境的 Cookie 契約。 - 保存已寄送通知與 Outbox 的完整 URL 快照。舊入口保留至既有 token 到期,轉送時 保留 query 且避免記錄 token;不改寫快照、不把已過期 token 當成重新簽發。
- 回復時一起回復 artifact、設定、代理與 IdP;依演練結果保留新入口相容路由,處理 切換後新 Cookie/新發連結。以有效與過期連結、舊/新名稱 Cookie 分別驗證。
應用正式切換前須在實際部署環境演練上述流程,記錄 artifact 雜湊、設定版本、測試結果 與回復後的 credential 狀態。本機代理測試不能取代 WAR/Tomcat、正式 IdP、外部資料庫、 多節點及真實通知的切換/回復演練。
PublicServerUrlContext 保留顯式 scope 擴充點。固定 resolver 接線使用單一 URL;同一 Server
多租戶各自網域仍需額外設計受信任網域目錄、登入前租戶辨識及背景租戶傳遞。
API 參考
| API | 用途 |
|---|---|
PublicServerUrl.of(String)/PublicServerUrl(URI) | 驗證並建立不可變基底 |
cookiePath() | Session 的對外 Path,root 為 / |
path(endpoint, segments...) | 對外絕對路徑,不含 origin |
uri(endpoint, segments...) | 完整 Server URI |
uri(endpoint, query, segments...) | 完整 URI 與編碼後 query |
PublicServerUrlResolver.resolve(context) | 由應用明示選擇基底 |
PublicServerUrlContext.unspecified() | 單一部署不指定 scope |
參見 LinkBuilder、SignedLink、 Notification 與 ADR-034。