// oidc_auth.go — Phase 0.6 BFF OIDC handler 實作。 // // 對齊文件: // - oidc-tdd.md §3.1(首次登入流程) // - oidc-tdd.md §3.3(登出) // - oidc-tdd.md §4.5(handler 程式碼範例) // - oidc-tdd.md §6(PKCE) // - oidc-tdd.md §7(id_token 驗證) // - ADR-010(BFF 模式) // // 與既有 auth.go(Static 路徑)並存,由 NewRouter 依 Deps.OIDCEnabled() 決定是否註冊。 // // 設計選擇: // - 把 OIDC pending state(state / nonce / code_verifier / return_to)合在 // usersession.Session 同一個 cookie 裡。雛形階段 pending 與已登入 session // 共用同一個 store;callback 完成後 pending 欄位清空、寫入 UserID/Email/Name。 // 簡化實作、減少 cookie 數量;symmetrically pending 持續時間短(≤ 10 分鐘)。 // - 不另外發 visiona_pending_sid cookie(與 oidc-tdd.md §4.5 範例不同 — TDD 是文件示意, // 雛形採取「合一 session」策略;這個權衡記錄於 OB4 任務說明)。 package api import ( "context" "crypto/subtle" "errors" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "visiona-backend/internal/oidc" "visiona-backend/internal/user" ) // oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。 // 這兩步都有網路 I/O(IdP token endpoint、JWKS 抓取);30s 足以涵蓋 IdP 緩慢回應, // 又不會讓 caller 端等到 default HTTP server timeout。 const oidcCallbackTimeout = 30 * time.Second // fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。 // // 背景:Member Center(MC)的 id_token 目前不發 email claim(ASP.NET Identity 預設只發 // sub/name)。A7 之後 OIDC callback 會把 claims provision 進 users 表,而 users.email 是 // NOT NULL(防呆檢查保留在 user store 層)。若 email 為空 → Upsert 失敗 → 登入 500。 // // 修法:email claim 缺時,用 "@" 當 placeholder: // - 用 sub 保證唯一(不撞 users 表 lower(email) unique index) // - 用明顯假的 .local TLD(RFC 6762 保留、不可能是真 email)標記「這不是真 email」 // // MC 端根治(讓 id_token 發真 email)交接給 MC 團隊,見 // docs/autoflow/04-architecture/mc-email-claim-handoff.md。MC 修好後,同一個 sub 再次登入, // Upsert 的 ON CONFLICT(id) DO UPDATE 會把 placeholder 覆寫成真 email(無需手動清資料)。 const fallbackEmailDomain = "noemail.visiona.local" // isFallbackEmail 回報 email 是否為本系統產生的 fallback placeholder(而非真 email)。 // // 供 log / 後續資料盤點判斷「哪些 user 還是 fallback、待 MC 修好後自動覆寫」。 func isFallbackEmail(email string) bool { return strings.HasSuffix(email, "@"+fallbackEmailDomain) } // resolveProvisionEmail 決定 provision 進 users 表時要寫入的 email。 // // - claimEmail 非空(MC 有發 / 未來修好)→ 直接用真 email、不套 fallback。 // - claimEmail 為空(MC 現況)→ 用 "@noemail.visiona.local" placeholder。 // // 回傳 (email, isFallback);isFallback=true 時 caller 應 log 標記。 // sub 理論上不會為空(VerifyIDToken 已驗 sub),但防禦性地仍會組出合法 email 字串。 func resolveProvisionEmail(sub, claimEmail string) (email string, isFallback bool) { if claimEmail != "" { return claimEmail, false } return sub + "@" + fallbackEmailDomain, true } // MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。 // // 故意與 Legacy MeResponse 區分:OIDC 沒有 Roles 概念(雛形),但有 Name。 type MeResponseOIDC struct { UserID string `json:"user_id"` Email string `json:"email,omitempty"` Name string `json:"name,omitempty"` } // LogoutResponse 是 POST /api/auth/logout 的 data payload。 // // IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端 // 「除了清掉 visionA session,還要連帶登出 IdP(Member Center)session」。 // 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。 type LogoutResponse struct { Success bool `json:"success"` // IDPLogout 帶 IdP(MC)登出資訊,供前端觸發 MC 登出(讓使用者能換帳號)。 // nil 時代表未啟用 MC 連動登出。 IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"` } // IDPLogoutInfo 描述「如何觸發 IdP(Member Center)登出」。 // // 為什麼需要 Method:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是 // MemberCenter.**Web**(:7880)的 /account/logout。實測 GET 即回 302(清 session + redirect), // 所以前端可直接 window.location = url 觸發,不必搞 form POST。故 Method="GET"。 // // 注意(host):必須指向 MC **Web**(:7880),不是 MC **Api**(:7850)。:7850 沒有 logout 頁 // (AccountController.Logout 在 Web 端),打 :7850 會 404。MC Web/Api 共享 DataProtection // (SetApplicationName("MemberCenter")),清 :7880 session 後 :7850 authorize 也視為未登入, // 下次登入會問帳密、能換帳號。 // // 流程交接(前端):拿到 IDPLogout 後 → // 1. 先(或同時)打 visionA logout(本 response 已清本地 session) // 2. window.location = URL(GET)觸發 MC :7880 登出 // 3. ⚠️ stage MC 跑 master 舊版(2026-04-30 image),logout **不支援 returnUrl**, // 清完 session 會 RedirectToAction("Index","Home") 停在 MC 首頁、不會自動回 visionA。 // 前端需自行把使用者帶回 visionA 登入(GET /api/auth/login)。 // // 注意(跨網域):MC /account/logout 在另一個 origin。GET 導向不受 antiforgery/CORS 限制 // (瀏覽器直接 navigate),比舊的跨站 form POST 單純。 type IDPLogoutInfo struct { // URL 是 IdP(MC Web,:7880)的瀏覽器登出入口。例: // https://stage-9527.innovedus.com:7880/account/logout URL string `json:"url"` // Method 是觸發登出要用的 HTTP method。MC Web(:7880) GET 即可觸發、固定為 "GET"。 Method string `json:"method"` } // registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。 // // 這兩個 endpoint 必須在 AuthMiddleware 之前註冊,否則 user 沒登入根本進不來。 // // 路徑刻意與 Legacy /api/auth/* 保持一致 — 因為 OIDC 啟用時 Legacy 的 /api/auth/login // (在 apiGroup 下)會變成「已登入才能呼叫的端點」、且仍會回 501 因為 deps.AuthProvider 通常為 nil。 // 實際生效的是這裡註冊的 OIDC 版本。 func registerOIDCPublicRoutes(r *gin.Engine, deps Deps) { r.GET("/api/auth/login", oidcLoginHandler(deps)) r.GET("/api/auth/callback", oidcCallbackHandler(deps)) } // registerOIDCAuthedRoutes 是被 OB4 規劃但實際整合在 registerAuthRoutes(auth.go)裡: // /api/auth/me 和 /api/auth/logout 在 OIDC 模式下需要不同的 handler, // 由 registerAuthRoutes 依 deps.OIDCEnabled() 動態選擇。 // oidcLoginHandler 實作 GET /api/auth/login(OIDC 模式)。 // // 流程(對齊 oidc-tdd.md §3.1 步驟 3): // 1. 解析 return_to query param(白名單檢查避免 open redirect) // 2. 產 PKCE code_verifier / state / nonce(皆 32 byte 隨機) // 3. 透過 SessionManager.StartSession 建立 pending session(含 cookie) // 4. 把 OIDC state 寫入 session 並 Update(讓 callback 能讀到) // 5. 算出 IdP authorize URL(含 state / nonce / code_challenge) // 6. 302 redirect user 到 IdP // // 任何步驟失敗 → 500(沒 session 可清 → 不需 fallback handling)。 // 不直接回 JSON 錯誤;redirect 才是這個 endpoint 的合約。失敗時用 WriteError 較直觀。 func oidcLoginHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { log := logOrDefault(deps.Logger) returnTo := sanitizeReturnTo(c.Query("return_to")) verifier, err := oidc.GenerateCodeVerifier() if err != nil { log.Error("oidc.login: generate code verifier failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil) return } state, err := oidc.GenerateState() if err != nil { log.Error("oidc.login: generate state failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil) return } nonce, err := oidc.GenerateNonce() if err != nil { log.Error("oidc.login: generate nonce failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil) return } // 開新 session(含 cookie)。先 Start 再 Update — Update 會把 OIDC state 寫進 store。 sess, err := deps.SessionManager.StartSession(c.Request.Context(), c.Writer) if err != nil { log.Error("oidc.login: start session failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start session", nil) return } sess.OIDCState = state sess.OIDCNonce = nonce sess.OIDCCodeVerifier = verifier if returnTo != "" { if sess.Extra == nil { sess.Extra = make(map[string]any, 1) } sess.Extra["return_to"] = returnTo } if err := deps.SessionManager.UpdateSession(c.Request.Context(), sess); err != nil { // 清 cookie 避免 user 拿到沒對應 store record 的 zombie cookie _ = deps.SessionManager.EndSession(c.Request.Context(), c.Writer, c.Request) log.Error("oidc.login: update pending session failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to persist pending session", nil) return } challenge := oidc.CodeChallenge(verifier) authURL := deps.OIDCProvider.AuthorizationURL(state, nonce, challenge) log.Info("oidc.login: redirecting to IdP", "request_id", RequestIDFrom(c), "action", "oidc.login.redirect", "return_to", returnTo, ) c.Redirect(http.StatusFound, authURL) } } // oidcCallbackHandler 實作 GET /api/auth/callback(OIDC 模式)。 // // 對齊 oidc-tdd.md §3.1 步驟 9-12 / §4.5: // 1. 處理 IdP error response(user 取消、IdP 錯誤) // 2. 從 cookie 拿 pending session // 3. 比對 state(CSRF 防護) // 4. ExchangeCode(PKCE) // 5. VerifyIDToken(驗簽 + nonce) // 6. RotateSessionID(Fix-A1:session fixation 防護,OWASP ASVS V3.2.1) // 7. 把 claims 寫入新 session(UserID / Email / Name),清 OIDC pending state,清 return_to // 8. UpdateSession(LastSeenAt 自動刷新) // 9. 302 回 frontend 的 PostLoginURL + return_to // // 失敗一律回 JSON 錯誤(4xx / 5xx);callback 是「夾在中間」的 endpoint, // 直接 redirect user 到 frontend 的 error 頁也是選項,但雛形先回 JSON 便於測試。 func oidcCallbackHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { log := logOrDefault(deps.Logger) ctx, cancel := context.WithTimeout(c.Request.Context(), oidcCallbackTimeout) defer cancel() // IdP 錯誤回應(OAuth 2.0 §4.1.2.1):user 拒絕授權、IdP 內部錯誤等 if errCode := c.Query("error"); errCode != "" { errDesc := c.Query("error_description") log.Warn("oidc.callback: IdP returned error", "request_id", RequestIDFrom(c), "error_code", errCode, "error_description", errDesc, ) // 清掉 pending session(即使存在),確保 cookie 不會殘留 _ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request) WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized, "identity provider returned error: "+errCode, nil) return } code := c.Query("code") state := c.Query("state") if code == "" || state == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "missing code or state query parameter", nil) return } // 從 cookie 取 pending session sess, err := deps.SessionManager.GetSession(ctx, c.Request) if err != nil { log.Warn("oidc.callback: pending session not found", "request_id", RequestIDFrom(c), "error", err) WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized, "no pending session", nil) return } // 驗 state(CSRF 防護)— 用常數時間比對避免 timing attack if subtle.ConstantTimeCompare([]byte(sess.OIDCState), []byte(state)) != 1 { log.Warn("oidc.callback: state mismatch", "request_id", RequestIDFrom(c)) // state 不對 → 視為攻擊嘗試或過期 session,刪掉重來 _ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request) WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized, "state mismatch", nil) return } // 換 token tok, err := deps.OIDCProvider.ExchangeCode(ctx, code, sess.OIDCCodeVerifier) if err != nil { log.Warn("oidc.callback: token exchange failed", "request_id", RequestIDFrom(c), "error", err) status := http.StatusBadGateway if errors.Is(err, oidc.ErrInvalidGrant) { status = http.StatusBadRequest } WriteError(c, status, ErrCodeUnauthorized, "token exchange failed", nil) return } // 驗 id_token(含 nonce 比對) claims, err := deps.OIDCProvider.VerifyIDToken(ctx, tok.IDToken, sess.OIDCNonce) if err != nil { log.Warn("oidc.callback: id_token verification failed", "request_id", RequestIDFrom(c), "error", err) WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "id_token verification failed", nil) return } // Provision users 列(DB-on FK 收尾,問題 #1)。 // // D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。upsert 後 users 表才有 // 這筆使用者,後續帶 owner_user_id FK 的寫入(model 上傳 / 配對 / pairing token)才不會 // FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK,僅維持對稱。 // // 在「驗 id_token 成功後、寫 session 之前」provision:fail-closed —— provision 失敗就不發 // session(否則使用者拿到能登入的 cookie 但 DB 沒對應 user,下一個寫入照樣爆,且更難診斷)。 // UserStore 為 nil(最小骨架 / 純 OIDC unit test)→ 略過 upsert。 // // email fallback(放寬 email 必填):MC 現況不發 email claim,claims.Email 為空。 // users.email NOT NULL 的防呆檢查保留在 user store 層;這裡在 provision 前先把空 email // 補成 "@noemail.visiona.local" placeholder,讓登入能成功。MC 修好後同 sub 再登入, // Upsert ON CONFLICT(id) DO UPDATE 會自動把 placeholder 覆寫成真 email。 provisionEmail, emailIsFallback := resolveProvisionEmail(claims.Subject, claims.Email) if emailIsFallback { // log 標記哪些 user 是 fallback,方便盤點「待 MC 修好後覆寫」的數量。 // 不 log email 內容(雖是 placeholder 無敏感性,仍保守只記 sub)。 log.Warn("oidc.callback: email claim missing, using fallback placeholder", "request_id", RequestIDFrom(c), "action", "oidc.callback.email_fallback", "user_id", claims.Subject, ) } if deps.UserStore != nil { if upErr := deps.UserStore.Upsert(ctx, &user.User{ ID: claims.Subject, // = users.id(D1-B) Email: provisionEmail, Name: claims.Name, }); upErr != nil { // 不洩漏 raw error 給 user;log 留診斷(不含 token / secret)。 log.Error("oidc.callback: provision user failed", "request_id", RequestIDFrom(c), "user_id", claims.Subject, "error", upErr) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to provision user", nil) return } } // Session fixation 防護(OWASP ASVS V3.2.1)— Fix-A1 / Major-1。 // // 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。 // 這樣攻擊者預先誘騙受害者使用的 pending cookie 在這一刻失效, // 即使攻擊者持有舊 cookie 也無法接續成「已登入」狀態。 // // rotate 失敗 → 不能讓登入完成(fail-closed)。清掉舊 cookie,回 500。 newSess, err := deps.SessionManager.RotateSessionID(ctx, c.Writer, c.Request) if err != nil { log.Error("oidc.callback: session rotation failed", "request_id", RequestIDFrom(c), "error", err) // 把舊 session 也清掉,避免 stale pending session 留著。 _ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to rotate session", nil) return } // 後續所有 session 操作都用 newSess(舊的已不可達)。 sess = newSess // 寫 session(清 pending state,填 user info) sess.UserID = claims.Subject sess.Email = claims.Email sess.Name = claims.Name // 雛形 access_token / id_token raw 仍保留在 session(未來 RP-initiated logout 用)。 // 注意:絕對不可進入 log(oidc-tdd.md §14.5)。 sess.AccessToken = tok.AccessToken sess.IDTokenRaw = tok.IDToken // 清掉 OIDC pending state sess.OIDCState = "" sess.OIDCNonce = "" sess.OIDCCodeVerifier = "" // 取 return_to(在 login handler 寫入 sess.Extra;經 rotation 後仍保留) returnTo := "/" if v, ok := sess.Extra["return_to"]; ok { if s, ok := v.(string); ok && s != "" { returnTo = s } } // 把 return_to 清理併入同一次 UpdateSession(Major-4 修復:避免吞錯誤的二次 Update)。 // 之前是先 UpdateSession 寫 user info、再 UpdateSession 清 return_to 並 _ = err 吞錯誤; // 現在合一:清 Extra → 一次 UpdateSession 把 user info + return_to 清理同時 commit。 if sess.Extra != nil { delete(sess.Extra, "return_to") } if err := deps.SessionManager.UpdateSession(ctx, sess); err != nil { log.Error("oidc.callback: update session failed", "request_id", RequestIDFrom(c), "error", err) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to persist session", nil) return } // 算 redirect URL:PostLoginURL + return_to。 // // 用 url.Parse + ResolveReference 而非字串拼接: // - 字串拼接會在 PostLoginURL 帶 trailing slash + returnTo 帶 leading slash // 時產生 "//",被瀏覽器當 protocol-relative URL 跳到外部站。 // - ResolveReference 正確處理 trailing slash、保留 query / fragment、 // 且若 returnTo 不慎含 scheme/host(理論上 sanitizeReturnTo 已擋)會 // 被當成絕對 URL 取代 base — 我們再用 SameHost 檢查防禦性兜底。 // // returnTo 已經 sanitizeReturnTo("/" 開頭、無 "//"、無 "://"),這裡是雙重防護。 redirectURL := returnTo if deps.OIDCPostLoginURL != "" { base, baseErr := url.Parse(deps.OIDCPostLoginURL) ref, refErr := url.Parse(returnTo) if baseErr != nil || refErr != nil || base.Host == "" { // PostLoginURL / returnTo 不是合法 URL — 退回 same-origin。 log.Warn("oidc.callback: parse redirect base/ref failed, falling back to same-origin", "request_id", RequestIDFrom(c), "base_err", baseErr, "ref_err", refErr) redirectURL = returnTo } else { resolved := base.ResolveReference(ref) // 防禦性檢查:resolve 後 host 必須仍等於 base.Host(避免 returnTo 偷渡 host)。 if resolved.Host != base.Host || resolved.Scheme != base.Scheme { log.Warn("oidc.callback: resolved redirect host/scheme mismatch, falling back", "request_id", RequestIDFrom(c), "base_host", base.Host, "resolved_host", resolved.Host) redirectURL = returnTo } else { redirectURL = resolved.String() } } } log.Info("oidc.callback: login success", "request_id", RequestIDFrom(c), "action", "oidc.callback.success", "user_id", claims.Subject, ) c.Redirect(http.StatusFound, redirectURL) } } // oidcLogoutHandler 實作 POST /api/auth/logout(OIDC 模式)。 // // 行為:一律清掉 visionA 自己的 session + cookie(原有行為,不變)。 // Idempotent:cookie 不存在或 session 已清也回 200。 // // MC 連動登出(2026-06 新增):MC **不支援標準 OIDC RP-initiated logout** // (discovery 列的 end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client // 未註冊 post_logout_redirect_uri),所以後端無法乾淨地 302 導向 MC end_session。 // 改採權宜方案:若 deps.OIDCLogoutURL 有設定,response 多回 idp_logout 欄位 // (MC Web :7880 /account/logout 的 GET 入口),由前端 navigate 觸發 MC 登出, // 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。 // // TODO:MC 補上標準 RP-initiated logout(GET end_session_endpoint + id_token_hint + // post_logout_redirect_uri)後,改回後端 302 導向標準流程、移除這個 idp_logout 權宜欄位。 // // 對齊 oidc-tdd.md §3.3。 func oidcLogoutHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { log := logOrDefault(deps.Logger) var userID string if uc, ok := UserContextFrom(c); ok { userID = uc.UserID } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() if err := deps.SessionManager.EndSession(ctx, c.Writer, c.Request); err != nil { // EndSession 內部已清 cookie;只 log 不 fail(保持 idempotent) log.Warn("oidc.logout: end session reported error", "request_id", RequestIDFrom(c), "error", err) } resp := LogoutResponse{Success: true} // 只有設定了 MC logout URL 才回 idp_logout(可關設計、向下相容)。 if deps.OIDCLogoutURL != "" { resp.IDPLogout = &IDPLogoutInfo{ URL: deps.OIDCLogoutURL, Method: http.MethodGet, // MC Web(:7880) /account/logout GET 即 302 觸發登出(實測) } } log.Info("oidc.logout", "request_id", RequestIDFrom(c), "action", "oidc.logout", "user_id", userID, "idp_logout", deps.OIDCLogoutURL != "", // 不記 URL 本身(非機密但精簡);只記是否連動 ) WriteSuccess(c, http.StatusOK, resp) } } // oidcMeHandler 實作 GET /api/auth/me(OIDC 模式)。 // // 主要從 AuthMiddleware 注入的 UserContext / Session 取資料 — 不再呼叫 store。 // 對齊 oidc-tdd.md §4.5 Me 範例。 func oidcMeHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc == nil { WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "not authenticated", nil) return } // Session 含 Name;UserContext 沒有,所以從 session 拿 var name string if sess, ok := UserSessionFrom(c); ok && sess != nil { name = sess.Name } WriteSuccess(c, http.StatusOK, MeResponseOIDC{ UserID: uc.UserID, Email: uc.Email, Name: name, }) } } // sanitizeReturnTo 防止 open redirect 攻擊。 // // 規則: // - 必須以 "/" 開頭(同 origin path) // - 不能以 "//" 開頭(protocol-relative URL,會跳到攻擊者站) // - 不能含 "://" 或 "\"(避免各種 URL parsing trick) // // 不合規回空字串(caller 視為「沒指定」,會走預設 "/")。 func sanitizeReturnTo(raw string) string { if raw == "" { return "" } if !strings.HasPrefix(raw, "/") { return "" } if strings.HasPrefix(raw, "//") { return "" } if strings.Contains(raw, "://") || strings.Contains(raw, "\\") { return "" } return raw }