從 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>
429 lines
14 KiB
Go
429 lines
14 KiB
Go
// Package oidctest 的自我驗證測試。
|
||
//
|
||
// 這個檔案只測 oidctest package「自己」的行為,不涉及 visionA-backend 任何 production code。
|
||
// 目的:在 oidctest 被 e2e test 大量依賴之前,先單獨確保它每個 endpoint 都符合預期 —
|
||
// 否則 e2e test 失敗時很難區分「OIDC client 寫錯」還是「fake server 寫錯」。
|
||
//
|
||
// 不重複測 OB1 的 internal/oidc/provider_test.go 已涵蓋的「provider 串接 fake server」場景,
|
||
// 這邊純粹從 HTTP wire 層驗 fake server 的對外 contract。
|
||
|
||
package oidctest
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rsa"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"io"
|
||
"math/big"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/go-jose/go-jose/v4"
|
||
"github.com/go-jose/go-jose/v4/jwt"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
const (
|
||
testRedirectURI = "http://localhost:8080/api/auth/callback"
|
||
)
|
||
|
||
// ───────────────────────── Discovery ─────────────────────────
|
||
|
||
func TestServer_Discovery_Endpoints(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
resp, err := http.Get(srv.URL + "/.well-known/openid-configuration")
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var doc map[string]any
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&doc))
|
||
|
||
// issuer 必須等於 server URL(除非 caller 用 WithIssuer 覆蓋)
|
||
assert.Equal(t, srv.URL, doc["issuer"])
|
||
|
||
// 各 endpoint 都應指向 server URL
|
||
assert.Equal(t, srv.URL+"/authorize", doc["authorization_endpoint"])
|
||
assert.Equal(t, srv.URL+"/oauth/token", doc["token_endpoint"])
|
||
assert.Equal(t, srv.URL+"/jwks", doc["jwks_uri"])
|
||
|
||
// 必要支援列表
|
||
assert.Contains(t, doc["response_types_supported"], "code")
|
||
assert.Contains(t, doc["id_token_signing_alg_values_supported"], "RS256")
|
||
assert.Contains(t, doc["code_challenge_methods_supported"], "S256")
|
||
}
|
||
|
||
func TestServer_Discovery_RespectsWithIssuer(t *testing.T) {
|
||
const customIssuer = "https://example.com/custom-issuer"
|
||
srv := NewServer(t, WithIssuer(customIssuer))
|
||
|
||
resp, err := http.Get(srv.URL + "/.well-known/openid-configuration")
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
var doc map[string]any
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&doc))
|
||
assert.Equal(t, customIssuer, doc["issuer"])
|
||
}
|
||
|
||
// ───────────────────────── JWKS ─────────────────────────
|
||
|
||
// TestServer_JWKS_CanVerifyServerSignedToken 確認 JWKS endpoint 公佈的 public key
|
||
// 真的能驗 fake server 用 IssueIDToken 簽出來的 token。這是 fake server 整體 contract
|
||
// 中「最關鍵」的一環 — 一旦這條路斷掉,所有 e2e test 都會失敗。
|
||
func TestServer_JWKS_CanVerifyServerSignedToken(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
// 1. 從 JWKS 取 public key
|
||
resp, err := http.Get(srv.URL + "/jwks")
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var jwks struct {
|
||
Keys []map[string]any `json:"keys"`
|
||
}
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&jwks))
|
||
require.Len(t, jwks.Keys, 1)
|
||
|
||
jwk := jwks.Keys[0]
|
||
assert.Equal(t, "RSA", jwk["kty"])
|
||
assert.Equal(t, "RS256", jwk["alg"])
|
||
assert.Equal(t, srv.KeyID, jwk["kid"])
|
||
|
||
// 2. 把 JWK reconstruct 成 *rsa.PublicKey
|
||
pub := jwkToRSAPublicKey(t, jwk)
|
||
|
||
// 3. 請 server 簽一個 token
|
||
tok, err := srv.IssueIDToken(map[string]any{
|
||
"sub": "verify-test",
|
||
"iss": srv.URL,
|
||
"aud": srv.ClientID,
|
||
"exp": time.Now().Add(time.Minute).Unix(),
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// 4. 用 jose 驗簽
|
||
parsed, err := jwt.ParseSigned(tok, []jose.SignatureAlgorithm{jose.RS256})
|
||
require.NoError(t, err, "簽出的 token 應為合法 JWS")
|
||
|
||
var out map[string]any
|
||
require.NoError(t, parsed.Claims(pub, &out), "JWKS 公開的 public key 應能驗 server 自己簽的 token")
|
||
assert.Equal(t, "verify-test", out["sub"])
|
||
}
|
||
|
||
// ───────────────────────── /oauth/token ─────────────────────────
|
||
|
||
func TestServer_Token_RejectsWrongClientSecret(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
// 先模擬 authorize 拿一個 code(避免 invalid_grant 先擋掉)
|
||
verifier := "verifier-xyz-1234567890123456789012345"
|
||
challenge := pkceS256(verifier)
|
||
code, err := srv.IssueAuthCode(challenge, "S256", "n-1", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
// 用「錯的 client_secret」打 token endpoint
|
||
resp := postToken(t, srv, url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
"client_id": {srv.ClientID},
|
||
"client_secret": {"wrong-secret"},
|
||
})
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
|
||
var errBody map[string]string
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
||
assert.Equal(t, "invalid_client", errBody["error"])
|
||
}
|
||
|
||
func TestServer_Token_AcceptsBasicAuth(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
verifier := "verifier-basicauth-12345678901234567890123"
|
||
challenge := pkceS256(verifier)
|
||
code, err := srv.IssueAuthCode(challenge, "S256", "n-2", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
form := url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
}
|
||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token",
|
||
strings.NewReader(form.Encode()))
|
||
require.NoError(t, err)
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.SetBasicAuth(srv.ClientID, srv.ClientSecret)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
require.Equal(t, http.StatusOK, resp.StatusCode, "Basic auth 應被接受")
|
||
|
||
var tokResp map[string]any
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp))
|
||
assert.NotEmpty(t, tokResp["id_token"])
|
||
assert.NotEmpty(t, tokResp["access_token"])
|
||
}
|
||
|
||
func TestServer_Token_PKCEMatch(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
verifier := "verifier-good-1234567890abcdefghij1234567"
|
||
challenge := pkceS256(verifier)
|
||
code, err := srv.IssueAuthCode(challenge, "S256", "nonce-good", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
resp := postToken(t, srv, url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
"client_id": {srv.ClientID},
|
||
"client_secret": {srv.ClientSecret},
|
||
})
|
||
defer resp.Body.Close()
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var tokResp map[string]any
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp))
|
||
assert.NotEmpty(t, tokResp["id_token"])
|
||
|
||
// nonce 應被灌到 id_token claims 裡
|
||
idToken := tokResp["id_token"].(string)
|
||
claims := decodeJWTPayload(t, idToken)
|
||
assert.Equal(t, "nonce-good", claims["nonce"])
|
||
}
|
||
|
||
func TestServer_Token_PKCEMismatch(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
correct := "verifier-A-1234567890abcdefghij12345678"
|
||
wrong := "verifier-B-1234567890abcdefghij12345678"
|
||
|
||
code, err := srv.IssueAuthCode(pkceS256(correct), "S256", "n-3", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
resp := postToken(t, srv, url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {wrong}, // 故意錯
|
||
"client_id": {srv.ClientID},
|
||
"client_secret": {srv.ClientSecret},
|
||
})
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||
|
||
var errBody map[string]string
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
||
assert.Equal(t, "invalid_grant", errBody["error"])
|
||
}
|
||
|
||
func TestServer_Token_CodeIsOneTimeUse(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
verifier := "verifier-once-1234567890abcdefghij12345"
|
||
code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "n-4", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
form := url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
"client_id": {srv.ClientID},
|
||
"client_secret": {srv.ClientSecret},
|
||
}
|
||
|
||
// 第一次:成功
|
||
r1 := postToken(t, srv, form)
|
||
require.Equal(t, http.StatusOK, r1.StatusCode)
|
||
r1.Body.Close()
|
||
|
||
// 第二次:同個 code,invalid_grant
|
||
r2 := postToken(t, srv, form)
|
||
defer r2.Body.Close()
|
||
assert.Equal(t, http.StatusBadRequest, r2.StatusCode)
|
||
|
||
var errBody map[string]string
|
||
require.NoError(t, json.NewDecoder(r2.Body).Decode(&errBody))
|
||
assert.Equal(t, "invalid_grant", errBody["error"])
|
||
}
|
||
|
||
func TestServer_Token_AppliesNextIDTokenClaims(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
srv.SetNextIDTokenClaims(map[string]any{
|
||
"sub": "user-overridden",
|
||
"email": "override@example.com",
|
||
"name": "Override User",
|
||
})
|
||
|
||
verifier := "verifier-claims-1234567890abcdefghij1234"
|
||
code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "nonce-claims", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
resp := postToken(t, srv, url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
"client_id": {srv.ClientID},
|
||
"client_secret": {srv.ClientSecret},
|
||
})
|
||
defer resp.Body.Close()
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var tokResp map[string]any
|
||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp))
|
||
|
||
claims := decodeJWTPayload(t, tokResp["id_token"].(string))
|
||
assert.Equal(t, "user-overridden", claims["sub"])
|
||
assert.Equal(t, "override@example.com", claims["email"])
|
||
assert.Equal(t, "Override User", claims["name"])
|
||
assert.Equal(t, "nonce-claims", claims["nonce"], "nonce 仍會自動補入")
|
||
}
|
||
|
||
// ───────────────────────── /authorize ─────────────────────────
|
||
|
||
func TestServer_Authorize_RedirectsWithCodeAndState(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
authorizeURL := srv.URL + "/authorize?" + url.Values{
|
||
"response_type": {"code"},
|
||
"client_id": {srv.ClientID},
|
||
"redirect_uri": {testRedirectURI},
|
||
"scope": {"openid email profile"},
|
||
"state": {"state-abc"},
|
||
"code_challenge": {pkceS256("any-verifier")},
|
||
"code_challenge_method": {"S256"},
|
||
"nonce": {"nonce-abc"},
|
||
}.Encode()
|
||
|
||
cb := srv.SimulateAuthorizationFlow(t, authorizeURL)
|
||
|
||
u, err := url.Parse(cb)
|
||
require.NoError(t, err)
|
||
// callback 應為 redirect_uri,帶 code & state
|
||
assert.Equal(t, "http", u.Scheme)
|
||
assert.Equal(t, "localhost:8080", u.Host)
|
||
assert.Equal(t, "/api/auth/callback", u.Path)
|
||
assert.NotEmpty(t, u.Query().Get("code"))
|
||
assert.Equal(t, "state-abc", u.Query().Get("state"))
|
||
|
||
// LastAuthorizeQuery 應記下 caller 帶的 PKCE / nonce
|
||
q := srv.LastAuthorizeQuery()
|
||
assert.Equal(t, "state-abc", q.Get("state"))
|
||
assert.Equal(t, "nonce-abc", q.Get("nonce"))
|
||
assert.Equal(t, "S256", q.Get("code_challenge_method"))
|
||
}
|
||
|
||
func TestServer_Authorize_MissingRedirectURIReturns400(t *testing.T) {
|
||
srv := NewServer(t)
|
||
|
||
// 沒帶 redirect_uri
|
||
resp, err := http.Get(srv.URL + "/authorize?client_id=" + srv.ClientID)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||
}
|
||
|
||
// ───────────────────────── WithClientCredentials ─────────────────────────
|
||
|
||
func TestServer_WithClientCredentials(t *testing.T) {
|
||
srv := NewServer(t,
|
||
WithClientCredentials("custom-client", "custom-secret"),
|
||
)
|
||
assert.Equal(t, "custom-client", srv.ClientID)
|
||
assert.Equal(t, "custom-secret", srv.ClientSecret)
|
||
|
||
// 用預設 secret 應失敗
|
||
verifier := "verifier-cc-1234567890abcdefghij12345678"
|
||
code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "n", testRedirectURI)
|
||
require.NoError(t, err)
|
||
|
||
resp := postToken(t, srv, url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {testRedirectURI},
|
||
"code_verifier": {verifier},
|
||
"client_id": {"custom-client"},
|
||
"client_secret": {"test-secret"}, // 預設值,不是 custom-secret
|
||
})
|
||
defer resp.Body.Close()
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
}
|
||
|
||
// ───────────────────────── helpers ─────────────────────────
|
||
|
||
func postToken(t *testing.T, srv *Server, form url.Values) *http.Response {
|
||
t.Helper()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
t.Cleanup(cancel)
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
srv.URL+"/oauth/token", strings.NewReader(form.Encode()))
|
||
require.NoError(t, err)
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
return resp
|
||
}
|
||
|
||
// jwkToRSAPublicKey 把 JWK map reconstruct 成 *rsa.PublicKey。
|
||
// 這是 OB1 fakeOIDC 沒做的(OB1 直接用 coreos lib 內部把 JWKS 解出來),
|
||
// 我們用最少程式碼自己 decode 一次驗證 server 的 JWKS contract。
|
||
func jwkToRSAPublicKey(t *testing.T, jwk map[string]any) *rsa.PublicKey {
|
||
t.Helper()
|
||
|
||
nB64, _ := jwk["n"].(string)
|
||
eB64, _ := jwk["e"].(string)
|
||
require.NotEmpty(t, nB64)
|
||
require.NotEmpty(t, eB64)
|
||
|
||
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
|
||
require.NoError(t, err)
|
||
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
|
||
require.NoError(t, err)
|
||
|
||
// e 是大端 byte slice → int
|
||
e := 0
|
||
for _, b := range eBytes {
|
||
e = e<<8 | int(b)
|
||
}
|
||
n := new(big.Int).SetBytes(nBytes)
|
||
|
||
return &rsa.PublicKey{N: n, E: e}
|
||
}
|
||
|
||
// decodeJWTPayload 取 JWT 中間段的 payload 解 JSON 出 claims。
|
||
// 不驗簽(呼叫者已在別處驗過簽章)。
|
||
func decodeJWTPayload(t *testing.T, tok string) map[string]any {
|
||
t.Helper()
|
||
parts := strings.Split(tok, ".")
|
||
require.Len(t, parts, 3, "JWT 應為 3 段 (header.payload.signature)")
|
||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||
require.NoError(t, err)
|
||
var out map[string]any
|
||
require.NoError(t, json.Unmarshal(raw, &out))
|
||
return out
|
||
}
|
||
|
||
// 確保 io 沒被當成 unused(某些版本 lint 嚴苛)— 故意使用一次。
|
||
var _ = io.EOF
|