jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

601 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package oidctest 提供測試專用的 fake OpenID Connect Identity Provider
// 模擬 Innovedus Member Center 的對外行為OIDC discovery / JWKS / token exchange /
// authorize redirect讓 visionA-backend 的 OIDC client 與 BFF 流程可以在純
// in-process 環境完成 end-to-end 整合測試 — 不需要 docker、不需要 docker-compose、
// 不需要真的 Member Center。
//
// # 設計理由
//
// OB1internal/oidc已經為自己的 unit test 寫過一份 fake OIDC server
// internal/oidc/provider_test.go 裡的 fakeOIDC。OT1 把它「再寫一次成可重用的
// 公開 package」而不是直接 export 那個 fake理由有二
//
// 1. 邊界乾淨OB1 的 fakeOIDC 是 unit test 用的(檔名 *_test.go 不會出現在
// production binary 也不會出現在其他 package 的 import path刻意不拿來當
// 公開 fixture 是為了讓 unit test 自成一格、不被外部依賴牽動。
//
// 2. API 形狀不同unit test 的 fake 暴露很多 hooksnapshot/withState/skipIDToken
// …)給「驗測 OIDC client 的錯誤分類」這種白箱測試用e2e 整合測試需要的是
// 「黑箱模擬完整 BFF flow」— issuer URL、ExchangeCode / authorize-redirect 的
// 整體行為。兩邊的 API 形狀一旦混用反而綁手綁腳。
//
// 實作仍刻意對齊 OB1 的 fakeOIDC同樣 RS256 / 同樣 endpoint paths /
// 同樣 PKCE 與 nonce 處理),如果未來雙方有差異要對齊,更新本 package 即可。
//
// # 對齊文件
//
// - oidc-tdd.md §3 BFF Flow 詳細時序圖
// - oidc-tdd.md §6 PKCE 實作細節
// - oidc-tdd.md §7 id_token 驗證
// - adr-010-oidc-bff.md
//
// # 使用範例
//
// srv := oidctest.NewServer(t,
// oidctest.WithClientCredentials("visiona-backend-test", "test-secret"),
// )
// defer srv.Close()
//
// // 把 srv.URL 當作 IssuerURL 給 visionA-backend 的 OIDC provider。
// provider, _ := oidc.NewProvider(ctx, oidc.ProviderConfig{
// IssuerURL: srv.URL,
// ClientID: srv.ClientID,
// ClientSecret: srv.ClientSecret,
// RedirectURL: "http://localhost:8080/api/auth/callback",
// })
//
// // 預先告知 fake server下一個 ExchangeCode 之後要簽發的 id_token claims
// srv.SetNextIDTokenClaims(map[string]any{
// "sub": "user-from-mc",
// "email": "alice@innovedus.com",
// "name": "Alice",
// })
package oidctest
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// Server 是用 httptest.Server 包起來的 fake OIDC IdP。
//
// 提供以下端點:
// - GET /.well-known/openid-configuration → discovery doc
// - GET /jwks → JWKS含 1 把 RSA public key
// - POST /oauth/token → form-encoded code exchange回 id_token + access_token
// - GET /authorize → 自動「同意登入」並 redirect 回 client redirect_uri 帶 code
//
// Server 是 goroutine-safe內部 RWMutex 保護所有 mutable state
// 但 caller 仍應在「設定下一輪 token 行為 → 觸發 ExchangeCode」之間以同步方式進行
// 否則 race 行為可預期但難以推理。
type Server struct {
// URL 是 httptest.Server 的 URL同時也是 OIDC discovery 的 issuer。
// caller 直接拿這個當 ProviderConfig.IssuerURL。
URL string
// Issuer 等同 URL保留欄位是為了測試「issuer mismatch」場景時能暫時 override。
Issuer string
// ClientID 與 ClientSecret 是 fake server 認可的 confidential client 憑證。
// /oauth/token 會驗 client_secret不符直接 401。
ClientID string
ClientSecret string
// PrivateKey 是用來簽 id_token 的 RSA keyJWKS endpoint 公開對應的 public key。
// caller 一般不需要直接用,但 IssueIDToken 暴露給「自定 token claims」場景。
PrivateKey *rsa.PrivateKey
KeyID string
httpServer *httptest.Server
// ─── mutable statetest 之間用 SetXXX 改寫rwmu 保護) ───
rwmu sync.RWMutex
// nextIDTokenClaims若非 nil下一個 /oauth/token response 的 id_token 用這份 claims 簽發。
// caller 通常在每個 test 開頭 Set 一次handleToken 用完不會自動清空,方便同個
// fake server 在多次 ExchangeCode 中重複簽發同一個使用者的 token。
nextIDTokenClaims map[string]any
// nextAccessToken若非空字串下一個 /oauth/token response 的 access_token 用這個值。
// 預設為 "fake-access-token"。
nextAccessToken string
// 觀測欄位:記錄最後一次 /authorize 與 /oauth/token 收到的關鍵參數,
// e2e test 可用來驗 BFF 是否把 PKCE / state / nonce 正確帶過來。
lastAuthorizeQuery url.Values
lastTokenForm url.Values
// codeStore 是 authorization code → 該 code 對應的 PKCE challenge / nonce 的暫存。
// 模擬真 IdP 會把 code 與當時的 PKCE challenge 綁定token endpoint 才能驗 PKCE proof。
codeStore map[string]issuedCode
}
// issuedCode 是 SimulateAuthorizationFlow / IssueAuthCode 簽發 code 時記下的元資料。
// 之後 /oauth/token 用 code 反查出當時的 challenge 比對 PKCE。
type issuedCode struct {
CodeChallenge string
CodeChallengeMethod string
Nonce string
RedirectURI string
ClientID string
IssuedAt time.Time
}
// Option 是 NewServer 的功能選項。
type Option func(*Server)
// WithClientCredentials 設定 fake server 認可的 OAuth client_id / client_secret。
// 不呼叫此 option 則使用預設值visiona-backend-test / test-secret
func WithClientCredentials(clientID, clientSecret string) Option {
return func(s *Server) {
s.ClientID = clientID
s.ClientSecret = clientSecret
}
}
// WithIssuer 強制 override discovery doc 的 issuer claim。
// 一般場景請勿使用;此選項只給「測 issuer mismatch」這種對抗性測試用。
func WithIssuer(issuer string) Option {
return func(s *Server) {
s.Issuer = issuer
}
}
// NewServer 啟動一個 fake OIDC server。
//
// 會立即在 t.Cleanup 註冊關閉動作caller 不必自己呼叫 Close
// (但保留 Close 公開方法供「同個 test 內提早關閉以驗錯誤情境」使用)。
func NewServer(t *testing.T, opts ...Option) *Server {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("oidctest: rsa key gen failed: %v", err)
}
s := &Server{
PrivateKey: priv,
KeyID: "oidctest-key-1",
ClientID: "visiona-backend-test",
ClientSecret: "test-secret",
nextAccessToken: "fake-access-token",
codeStore: make(map[string]issuedCode),
}
for _, opt := range opts {
opt(s)
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
mux.HandleFunc("/jwks", s.handleJWKS)
mux.HandleFunc("/oauth/token", s.handleToken)
mux.HandleFunc("/authorize", s.handleAuthorize)
s.httpServer = httptest.NewServer(mux)
s.URL = s.httpServer.URL
if s.Issuer == "" {
s.Issuer = s.URL
}
t.Cleanup(s.Close)
return s
}
// Close 停掉內部 httptest.Server。重複呼叫安全。
func (s *Server) Close() {
if s.httpServer != nil {
s.httpServer.Close()
s.httpServer = nil
}
}
// SetNextIDTokenClaims 設定下一次 /oauth/token 回應裡 id_token 的 claims。
//
// 注意:傳入的 map 不會被 merge而是「整份覆蓋」預設值除了 iss/aud/exp 由 server 補足)。
// caller 可以放 sub / email / name / nonce 等自定 claim若漏傳 noncehandleToken
// 會用 lastAuthorizeQuery 收到的 nonce 補上(模擬真 IdP 行為authorize 收到的 nonce 會回灌到 id_token
//
// 若傳 nil 等同呼叫 ResetIDTokenClaims回到預設 sub。
func (s *Server) SetNextIDTokenClaims(claims map[string]any) {
s.rwmu.Lock()
defer s.rwmu.Unlock()
if claims == nil {
s.nextIDTokenClaims = nil
return
}
cp := make(map[string]any, len(claims))
for k, v := range claims {
cp[k] = v
}
s.nextIDTokenClaims = cp
}
// ResetIDTokenClaims 把「下一輪 id_token claims」回到預設值。
func (s *Server) ResetIDTokenClaims() { s.SetNextIDTokenClaims(nil) }
// SetNextAccessToken 改下一次 /oauth/token response 的 access_token 字串。
// 主要供「驗 backend 是否正確存了 access_token」這種測試。
func (s *Server) SetNextAccessToken(tok string) {
s.rwmu.Lock()
defer s.rwmu.Unlock()
s.nextAccessToken = tok
}
// LastAuthorizeQuery 回傳上一次 /authorize 收到的 query string複製可安全修改
// e2e test 通常用來驗 BFF 是否正確產 PKCE / state / nonce。
func (s *Server) LastAuthorizeQuery() url.Values {
s.rwmu.RLock()
defer s.rwmu.RUnlock()
return cloneValues(s.lastAuthorizeQuery)
}
// LastTokenForm 回傳上一次 /oauth/token 收到的 form value複製
// 用來驗 ExchangeCode 是否正確帶 client_secret / code_verifier。
func (s *Server) LastTokenForm() url.Values {
s.rwmu.RLock()
defer s.rwmu.RUnlock()
return cloneValues(s.lastTokenForm)
}
// IssueIDToken 直接用 fake server 的 RSA private key 簽一個 id_token回傳 raw JWT 字串。
//
// 用途:少數場景需要「跳過 token endpoint直接拿 id_token 餵給 VerifyIDToken」測試
// (例如測 backend 對「不正確 issuer 的 id_token」的拒絕行為
func (s *Server) IssueIDToken(claims map[string]any) (string, error) {
return signJWT(s.PrivateKey, s.KeyID, jose.RS256, claims)
}
// IssueAuthCode 預先簽發一個 authorization code並把對應的 PKCE challenge / nonce
// 記在 codeStore 中。後續 /oauth/token 收到此 code + 正確 code_verifier 才會放行。
//
// 主要供「不走完整 redirect 流程、直接構造 callback」的測試用。
// 如果你只是要 e2e 跑完整 flow呼叫 SimulateAuthorizationFlow 即可(會自動 issue code
func (s *Server) IssueAuthCode(challenge, challengeMethod, nonce, redirectURI string) (string, error) {
code, err := randomURLToken(24)
if err != nil {
return "", err
}
s.rwmu.Lock()
defer s.rwmu.Unlock()
s.codeStore[code] = issuedCode{
CodeChallenge: challenge,
CodeChallengeMethod: challengeMethod,
Nonce: nonce,
RedirectURI: redirectURI,
ClientID: s.ClientID,
IssuedAt: time.Now(),
}
return code, nil
}
// ───────────────────────── HTTP handlers ─────────────────────────
func (s *Server) handleDiscovery(w http.ResponseWriter, r *http.Request) {
s.rwmu.RLock()
issuer := s.Issuer
s.rwmu.RUnlock()
doc := map[string]any{
"issuer": issuer,
"authorization_endpoint": s.URL + "/authorize",
"token_endpoint": s.URL + "/oauth/token",
"jwks_uri": s.URL + "/jwks",
"response_types_supported": []string{"code"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"subject_types_supported": []string{"public"},
"scopes_supported": []string{"openid", "email", "profile"},
"code_challenge_methods_supported": []string{"S256"},
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleJWKS(w http.ResponseWriter, r *http.Request) {
jwks := map[string]any{
"keys": []map[string]any{
rsaPublicKeyToJWK(&s.PrivateKey.PublicKey, s.KeyID),
},
}
writeJSON(w, http.StatusOK, jwks)
}
// handleAuthorize 模擬「使用者打開 /authorize → 同意登入 → IdP redirect 回 client redirect_uri 帶 code」。
//
// 為了讓測試可以「不開瀏覽器」就跑通整段,我們不顯示登入頁,
// 而是直接把 code 帶上 redirect_uri 立刻 302 回去(等同「使用者已存在 SSO 並自動同意」)。
//
// 我們同時把 code 與當時的 PKCE challenge / nonce 綁起來,
// 後續 /oauth/token 才能驗 PKCE proof符合真 IdP 行為。
func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
s.rwmu.Lock()
s.lastAuthorizeQuery = cloneValues(q)
s.rwmu.Unlock()
redirectURI := q.Get("redirect_uri")
state := q.Get("state")
challenge := q.Get("code_challenge")
challengeMethod := q.Get("code_challenge_method")
nonce := q.Get("nonce")
clientID := q.Get("client_id")
// 基本驗redirect_uri / client_id 缺則 400。
// 真 IdP 還會驗 redirect_uri 是否在註冊白名單fake server 簡化掉,反正測試 caller 一定會
// 帶正確的 redirect_uri。
if redirectURI == "" || clientID == "" {
http.Error(w, "missing redirect_uri or client_id", http.StatusBadRequest)
return
}
// 簽發 code記 challenge / nonce 進 codeStore
code, err := s.IssueAuthCode(challenge, challengeMethod, nonce, redirectURI)
if err != nil {
http.Error(w, "issue code failed: "+err.Error(), http.StatusInternalServerError)
return
}
// 組 callback URLredirect_uri?code=<code>&state=<state>
cbURL, err := url.Parse(redirectURI)
if err != nil {
http.Error(w, "invalid redirect_uri: "+err.Error(), http.StatusBadRequest)
return
}
rq := cbURL.Query()
rq.Set("code", code)
if state != "" {
rq.Set("state", state)
}
cbURL.RawQuery = rq.Encode()
http.Redirect(w, r, cbURL.String(), http.StatusFound)
}
// handleToken 處理 POST /oauth/tokenauthorization_code grant
//
// 流程:
// 1. 驗 client_id / client_secret
// 2. 驗 grant_type == authorization_code
// 3. 從 codeStore 取出 code 對應的 challenge / nonce
// 4. 驗 PKCEsha256(code_verifier) == challengebase64url 比對)
// 5. 簽 id_token用 nextIDTokenClaims 或預設 claimsnonce 自動補入)
// 6. 回 token response
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "parse form: "+err.Error())
return
}
// HTTP Basic auth or form fields — 兩種都支援,跟真 OIDC IdP 一致
clientID, clientSecret := extractClientCredentials(r)
s.rwmu.Lock()
s.lastTokenForm = cloneValues(r.Form)
s.rwmu.Unlock()
// ─── 1. client credentials ───
if clientID != s.ClientID || clientSecret != s.ClientSecret {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client credentials mismatch")
return
}
// ─── 2. grant type ───
if gt := r.Form.Get("grant_type"); gt != "authorization_code" {
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "got "+gt)
return
}
// ─── 3. code 取對應 metadata ───
code := r.Form.Get("code")
if code == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "missing code")
return
}
s.rwmu.Lock()
meta, ok := s.codeStore[code]
if ok {
// 真 IdP 的 code 是「一次性」— 用過就刪,避免 replay。
delete(s.codeStore, code)
}
s.rwmu.Unlock()
if !ok {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "unknown or already-used code")
return
}
// ─── 4. PKCE ───
verifier := r.Form.Get("code_verifier")
if meta.CodeChallenge != "" {
if verifier == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code_verifier required (PKCE was used)")
return
}
if !verifyPKCE(verifier, meta.CodeChallenge, meta.CodeChallengeMethod) {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "PKCE verifier mismatch")
return
}
}
// ─── 5. 簽 id_token ───
s.rwmu.RLock()
customClaims := cloneClaims(s.nextIDTokenClaims)
accessToken := s.nextAccessToken
issuer := s.Issuer
s.rwmu.RUnlock()
now := time.Now()
claims := map[string]any{
"iss": issuer,
"aud": s.ClientID,
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"nbf": now.Unix(),
"sub": "sub-fake-user-001",
"email": "fake-user@example.com",
"name": "Fake User",
}
// 把 caller 指定的 claims 蓋過預設sub/email/name 等)
for k, v := range customClaims {
claims[k] = v
}
// 永遠把 authorize 收到的 nonce 灌回去(除非 caller 已經自行指定)
if _, has := claims["nonce"]; !has && meta.Nonce != "" {
claims["nonce"] = meta.Nonce
}
idToken, err := signJWT(s.PrivateKey, s.KeyID, jose.RS256, claims)
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "sign id_token: "+err.Error())
return
}
resp := map[string]any{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
"id_token": idToken,
}
writeJSON(w, http.StatusOK, resp)
}
// ───────────────────────── helpers ─────────────────────────
// extractClientCredentials 從 HTTP Basic auth 或 form field 取出 client_id / client_secret。
// 真 IdP 通常兩種都接RFC 6749 §2.3.1fake server 也比照辦理。
func extractClientCredentials(r *http.Request) (string, string) {
if cid, csec, ok := r.BasicAuth(); ok && cid != "" {
return cid, csec
}
return r.Form.Get("client_id"), r.Form.Get("client_secret")
}
// verifyPKCE 對照 RFC 7636 §4.6 規定驗 code_verifier 對 challenge
//
// S256: BASE64URL(SHA256(verifier)) == challenge
//
// 不支援 plainOAuth 2.1 已 deprecatedfake server 也只認 S256
func verifyPKCE(verifier, challenge, method string) bool {
if method == "" {
method = "S256" // 真 IdP 的預設可能不同,但 visionA 一律用 S256
}
if method != "S256" {
return false
}
expected := pkceS256(verifier)
return expected == challenge
}
// pkceS256BASE64URL(SHA256(verifier))。重新實作而不 import internal/oidc
// 保持 oidctest 不依賴 production package避免循環依賴 + 確保 oidctest 可被
// internal/oidc 自己未來想用而不打死)。
func pkceS256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeOAuthError 寫 RFC 6749 §5.2 規範的 token error response。
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": code,
"error_description": desc,
})
}
// cloneValues 複製 url.Values避免 caller 改到我們存的觀測值。
func cloneValues(in url.Values) url.Values {
if in == nil {
return nil
}
out := make(url.Values, len(in))
for k, vs := range in {
cp := make([]string, len(vs))
copy(cp, vs)
out[k] = cp
}
return out
}
// cloneClaims 複製 claims map避免並發 race。
func cloneClaims(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// rsaPublicKeyToJWK 把 RSA public key 編成 JWKS spec 的 key 物件。
// 與 OB1 的 fakeOIDC 寫法一致base64url 無 paddingexponent 手動轉 byte slice
func rsaPublicKeyToJWK(pub *rsa.PublicKey, kid string) map[string]any {
return map[string]any{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
}
}
func bigIntBytes(e int) []byte {
out := []byte{}
for e > 0 {
out = append([]byte{byte(e & 0xff)}, out...)
e >>= 8
}
if len(out) == 0 {
out = []byte{0}
}
return out
}
// signJWT 用 RSA private key 簽出 RS256 JWT。
//
// 接受 map[string]any 而非 jwt.Claims struct方便 caller 灌任意 claim
// (包含 OIDC 的 sub/email/name 與測試用的非標準欄位)。
func signJWT(priv *rsa.PrivateKey, kid string, alg jose.SignatureAlgorithm, claims map[string]any) (string, error) {
signerOpts := (&jose.SignerOptions{}).WithType("JWT")
signerOpts.WithHeader("kid", kid)
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: priv}, signerOpts)
if err != nil {
return "", fmt.Errorf("oidctest: new signer: %w", err)
}
tok, err := jwt.Signed(signer).Claims(claims).Serialize()
if err != nil {
return "", fmt.Errorf("oidctest: sign jwt: %w", err)
}
return tok, nil
}
// randomURLToken 產生 base64url 編碼的隨機 token給 authorization code 用。
func randomURLToken(nBytes int) (string, error) {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}