從 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>
738 lines
24 KiB
Go
738 lines
24 KiB
Go
package oidc
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"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"
|
||
)
|
||
|
||
// fakeOIDC 是一個用 httptest 起來的最小化 OIDC 模擬器:
|
||
// - GET /.well-known/openid-configuration → discovery doc
|
||
// - GET /jwks → JWKS(含 1 把 RSA public key)
|
||
// - POST /token → 接 authorization_code,回 token set
|
||
//
|
||
// 簽 id_token 用 go-jose(coreos/go-oidc 的內部依賴,已在 go.sum 中),不需引額外 lib。
|
||
type fakeOIDC struct {
|
||
server *httptest.Server
|
||
signingKey *rsa.PrivateKey
|
||
keyID string
|
||
clientID string
|
||
|
||
// 可由各測試修改的「下一個 token 行為」控制旗標
|
||
mu chan struct{} // 簡單以 buffered chan 當 mutex(避免 import sync)
|
||
expectVerifier string // POST /token 時驗 code_verifier 是否相符;空字串=不驗
|
||
respondCode int // POST /token 回應 status code(0 = 200)
|
||
respondBody string // 非空時直接回此 body 取代正常 token response
|
||
idTokenClaims jwt.Claims // 自訂簽 token 的 standard claims(zero = 預設)
|
||
idTokenExtra map[string]any
|
||
idTokenAlg jose.SignatureAlgorithm // 預設 RS256
|
||
skipIDToken bool // true 時 token response 不含 id_token
|
||
|
||
// 觀測:最後一次 /token 收到的 form / Authorization header(A1 加:驗 public client mode)
|
||
lastTokenForm url.Values
|
||
lastTokenAuthHdr string
|
||
}
|
||
|
||
func newFakeOIDC(t *testing.T, clientID string) *fakeOIDC {
|
||
t.Helper()
|
||
|
||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
require.NoError(t, err, "產 RSA key 失敗")
|
||
|
||
f := &fakeOIDC{
|
||
signingKey: priv,
|
||
keyID: "test-key-1",
|
||
clientID: clientID,
|
||
mu: make(chan struct{}, 1),
|
||
idTokenAlg: jose.RS256,
|
||
}
|
||
f.mu <- struct{}{} // 初始化「鎖可用」
|
||
|
||
mux := http.NewServeMux()
|
||
// discovery doc 必須在 server 起來後才知道 issuer URL,所以用 closure 延遲組
|
||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||
base := "http://" + r.Host
|
||
doc := map[string]any{
|
||
"issuer": base,
|
||
"authorization_endpoint": base + "/authorize",
|
||
"token_endpoint": base + "/token",
|
||
"jwks_uri": base + "/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"},
|
||
}
|
||
writeJSON(w, doc)
|
||
})
|
||
|
||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||
jwks := map[string]any{
|
||
"keys": []map[string]any{
|
||
rsaPublicKeyToJWK(&priv.PublicKey, f.keyID),
|
||
},
|
||
}
|
||
writeJSON(w, jwks)
|
||
})
|
||
|
||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||
f.handleToken(t, w, r)
|
||
})
|
||
|
||
f.server = httptest.NewServer(mux)
|
||
t.Cleanup(f.server.Close)
|
||
return f
|
||
}
|
||
|
||
func (f *fakeOIDC) issuer() string { return f.server.URL }
|
||
|
||
// withState 是 helper:在 chan-as-mutex 保護下安全修改控制旗標。
|
||
func (f *fakeOIDC) withState(fn func()) {
|
||
<-f.mu
|
||
defer func() { f.mu <- struct{}{} }()
|
||
fn()
|
||
}
|
||
|
||
// snapshot 是 helper:原子讀取所有控制旗標。
|
||
func (f *fakeOIDC) snapshot() (verifier string, code int, body string, claims jwt.Claims, extra map[string]any, alg jose.SignatureAlgorithm, skipID bool) {
|
||
<-f.mu
|
||
defer func() { f.mu <- struct{}{} }()
|
||
return f.expectVerifier, f.respondCode, f.respondBody, f.idTokenClaims, f.idTokenExtra, f.idTokenAlg, f.skipIDToken
|
||
}
|
||
|
||
func (f *fakeOIDC) handleToken(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||
t.Helper()
|
||
|
||
if err := r.ParseForm(); err != nil {
|
||
http.Error(w, "bad form", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// 觀測:抄一份 form / Authorization header 給測試驗 public vs confidential mode
|
||
f.withState(func() {
|
||
f.lastTokenForm = make(url.Values, len(r.PostForm))
|
||
for k, vv := range r.PostForm {
|
||
f.lastTokenForm[k] = append([]string(nil), vv...)
|
||
}
|
||
f.lastTokenAuthHdr = r.Header.Get("Authorization")
|
||
})
|
||
|
||
expectVerifier, code, body, claims, extra, alg, skipID := f.snapshot()
|
||
|
||
// caller 可指定強制錯誤 / 自訂 body
|
||
if body != "" {
|
||
if code == 0 {
|
||
code = http.StatusBadRequest
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
_, _ = w.Write([]byte(body))
|
||
return
|
||
}
|
||
|
||
if expectVerifier != "" && r.Form.Get("code_verifier") != expectVerifier {
|
||
// PKCE proof 失敗:回 RFC 6749 §5.2 規範的 invalid_grant
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"PKCE verifier mismatch"}`))
|
||
return
|
||
}
|
||
|
||
// 簽 id_token
|
||
now := time.Now()
|
||
if claims.Issuer == "" {
|
||
claims = jwt.Claims{
|
||
Issuer: f.issuer(),
|
||
Subject: "sub-fake-user-001",
|
||
Audience: jwt.Audience{f.clientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
NotBefore: jwt.NewNumericDate(now),
|
||
}
|
||
}
|
||
if extra == nil {
|
||
extra = map[string]any{
|
||
"email": "fake-user@example.com",
|
||
"name": "Fake User",
|
||
"nonce": r.Form.Get("__test_nonce_passthrough"), // 不會由真 OIDC server 帶;測試以另路徑注入
|
||
}
|
||
}
|
||
|
||
rawIDToken, err := signIDToken(f.signingKey, f.keyID, alg, claims, extra)
|
||
if err != nil {
|
||
http.Error(w, "sign error: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
resp := map[string]any{
|
||
"access_token": "fake-access-token",
|
||
"token_type": "Bearer",
|
||
"expires_in": 3600,
|
||
}
|
||
if !skipID {
|
||
resp["id_token"] = rawIDToken
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// signIDToken 用 RSA private key 簽一個 OIDC id_token(JWS / RS256)。
|
||
func signIDToken(priv *rsa.PrivateKey, kid string, alg jose.SignatureAlgorithm, std jwt.Claims, extra 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 "", err
|
||
}
|
||
|
||
builder := jwt.Signed(signer).Claims(std)
|
||
if len(extra) > 0 {
|
||
builder = builder.Claims(extra)
|
||
}
|
||
return builder.Serialize()
|
||
}
|
||
|
||
// rsaPublicKeyToJWK 把 RSA public key 編成 JWKS spec 的 key 物件。
|
||
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 {
|
||
// RSA exponent 通常是 65537(0x010001)= 3 bytes。手動編碼。
|
||
out := []byte{}
|
||
for e > 0 {
|
||
out = append([]byte{byte(e & 0xff)}, out...)
|
||
e >>= 8
|
||
}
|
||
if len(out) == 0 {
|
||
out = []byte{0}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// =====================================================================
|
||
// 測試
|
||
// =====================================================================
|
||
|
||
const (
|
||
testClientID = "visiona-backend-test"
|
||
testClientSecret = "test-secret"
|
||
testRedirect = "http://localhost:8080/api/auth/callback"
|
||
)
|
||
|
||
func newProviderForTest(t *testing.T, fake *fakeOIDC) Provider {
|
||
t.Helper()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
p, err := NewProvider(ctx, ProviderConfig{
|
||
IssuerURL: fake.issuer(),
|
||
ClientID: testClientID,
|
||
ClientSecret: testClientSecret,
|
||
RedirectURL: testRedirect,
|
||
})
|
||
require.NoError(t, err)
|
||
return p
|
||
}
|
||
|
||
func TestNewProvider_Discovery(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
require.NotNil(t, p)
|
||
}
|
||
|
||
func TestNewProvider_RejectInvalidConfig(t *testing.T) {
|
||
// A1(2026-05-01):ClientSecret 為選填,因此 "missing secret" case 已移除。
|
||
cases := []struct {
|
||
name string
|
||
cfg ProviderConfig
|
||
}{
|
||
{"missing issuer", ProviderConfig{ClientID: "x", ClientSecret: "y", RedirectURL: "http://z"}},
|
||
{"missing client id", ProviderConfig{IssuerURL: "http://x", ClientSecret: "y", RedirectURL: "http://z"}},
|
||
{"missing redirect", ProviderConfig{IssuerURL: "http://x", ClientID: "y", ClientSecret: "z"}},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
_, err := NewProvider(context.Background(), c.cfg)
|
||
assert.ErrorIs(t, err, ErrInvalidConfig)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNewProvider_DiscoveryFailure(t *testing.T) {
|
||
// 用一個立刻關掉的 server 模擬 IdP 不可達
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
http.NotFound(w, r)
|
||
}))
|
||
defer srv.Close()
|
||
_, err := NewProvider(context.Background(), ProviderConfig{
|
||
IssuerURL: srv.URL,
|
||
ClientID: "c",
|
||
ClientSecret: "s",
|
||
RedirectURL: "http://r",
|
||
})
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrDiscoveryFetch)
|
||
}
|
||
|
||
func TestAuthorizationURL_Format(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
state, _ := GenerateState()
|
||
nonce, _ := GenerateNonce()
|
||
verifier, _ := GenerateCodeVerifier()
|
||
challenge := CodeChallenge(verifier)
|
||
|
||
raw := p.AuthorizationURL(state, nonce, challenge)
|
||
u, err := url.Parse(raw)
|
||
require.NoError(t, err)
|
||
|
||
q := u.Query()
|
||
assert.Equal(t, "code", q.Get("response_type"))
|
||
assert.Equal(t, testClientID, q.Get("client_id"))
|
||
assert.Equal(t, testRedirect, q.Get("redirect_uri"))
|
||
assert.Equal(t, state, q.Get("state"))
|
||
assert.Equal(t, nonce, q.Get("nonce"))
|
||
assert.Equal(t, challenge, q.Get("code_challenge"))
|
||
assert.Equal(t, "S256", q.Get("code_challenge_method"))
|
||
|
||
// scope 應含 openid email profile(順序由 oauth2 lib 決定,用 contains 驗)
|
||
scope := q.Get("scope")
|
||
for _, s := range []string{"openid", "email", "profile"} {
|
||
assert.Truef(t, strings.Contains(scope, s), "scope 應含 %q,得 %q", s, scope)
|
||
}
|
||
|
||
// authorization_endpoint 應指向 fake server 的 /authorize
|
||
assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path)
|
||
}
|
||
|
||
func TestExchangeCode_Success(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
verifier, _ := GenerateCodeVerifier()
|
||
fake.withState(func() { fake.expectVerifier = verifier })
|
||
|
||
tok, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "fake-access-token", tok.AccessToken)
|
||
assert.NotEmpty(t, tok.IDToken, "id_token 應有值")
|
||
assert.Equal(t, "Bearer", tok.TokenType)
|
||
assert.Greater(t, tok.ExpiresIn, 0)
|
||
}
|
||
|
||
func TestExchangeCode_PKCEMismatch(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
fake.withState(func() { fake.expectVerifier = "the-real-verifier" })
|
||
|
||
_, err := p.ExchangeCode(context.Background(), "fake-code", "wrong-verifier")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrInvalidGrant, "PKCE 不符應對應到 ErrInvalidGrant")
|
||
}
|
||
|
||
func TestExchangeCode_ServerError(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
fake.withState(func() {
|
||
fake.respondCode = http.StatusInternalServerError
|
||
fake.respondBody = `{"error":"server_error"}`
|
||
})
|
||
|
||
_, err := p.ExchangeCode(context.Background(), "fake-code", "any")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrTokenExchange)
|
||
assert.NotErrorIs(t, err, ErrInvalidGrant, "5xx 不應被分類為 invalid_grant")
|
||
}
|
||
|
||
func TestExchangeCode_MissingIDToken(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
fake.withState(func() { fake.skipIDToken = true })
|
||
|
||
_, err := p.ExchangeCode(context.Background(), "fake-code", "any")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrTokenExchange, "缺 id_token 應視為 token_exchange 失敗")
|
||
}
|
||
|
||
func TestVerifyIDToken_HappyPath(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
const expectedNonce = "nonce-happy-path"
|
||
now := time.Now()
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "user-123",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
}
|
||
fake.idTokenExtra = map[string]any{
|
||
"email": "alice@example.com",
|
||
"name": "Alice",
|
||
"nonce": expectedNonce,
|
||
}
|
||
})
|
||
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
claims, err := p.VerifyIDToken(context.Background(), tok.IDToken, expectedNonce)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "user-123", claims.Subject)
|
||
assert.Equal(t, "alice@example.com", claims.Email)
|
||
assert.Equal(t, "Alice", claims.Name)
|
||
assert.Equal(t, fake.issuer(), claims.Issuer)
|
||
assert.Equal(t, testClientID, claims.Audience)
|
||
assert.Equal(t, expectedNonce, claims.Nonce)
|
||
assert.False(t, claims.ExpiresAt.IsZero())
|
||
assert.NotEmpty(t, claims.Raw)
|
||
}
|
||
|
||
func TestVerifyIDToken_WrongNonce(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
now := time.Now()
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "user-x",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
}
|
||
fake.idTokenExtra = map[string]any{"nonce": "actual-nonce"}
|
||
})
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "expected-different-nonce")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrInvalidNonce)
|
||
}
|
||
|
||
func TestVerifyIDToken_WrongAudience(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
now := time.Now()
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "user-x",
|
||
Audience: jwt.Audience{"some-other-client"}, // 故意錯
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
}
|
||
fake.idTokenExtra = map[string]any{"nonce": "n1"}
|
||
})
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "n1")
|
||
require.Error(t, err)
|
||
// 涵蓋 audience 失敗 → 應映射到 ErrInvalidAudience 或至少 ErrInvalidIDToken
|
||
assert.True(t, errors.Is(err, ErrInvalidAudience) || errors.Is(err, ErrInvalidIDToken),
|
||
"audience 錯誤應對應到 ErrInvalidAudience(或 fallback ErrInvalidIDToken),得 %v", err)
|
||
}
|
||
|
||
func TestVerifyIDToken_Expired(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
past := time.Now().Add(-1 * time.Hour)
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "user-x",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(past.Add(-5 * time.Minute)),
|
||
Expiry: jwt.NewNumericDate(past), // 已過期
|
||
}
|
||
fake.idTokenExtra = map[string]any{"nonce": "n1"}
|
||
})
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "n1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrTokenExpired) || errors.Is(err, ErrInvalidIDToken),
|
||
"過期應對應到 ErrTokenExpired(或 fallback),得 %v", err)
|
||
}
|
||
|
||
func TestVerifyIDToken_BadSignature(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
now := time.Now()
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "u",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
}
|
||
fake.idTokenExtra = map[string]any{"nonce": "n1"}
|
||
})
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
// 把簽章部分整段替換成「保證無效」的 base64url 字串。
|
||
//
|
||
// 為什麼不用「翻最後一個字元」:base64url 末字元只承載部分原始 bit(取決於整段
|
||
// 長度對 3 取餘的結果),翻轉某些字元時 padding bit 不變,仍然會解碼出相同的
|
||
// 原始 bytes → 簽章值不變 → test 偶發 fail。改翻「中間字元」雖大幅降低風險
|
||
// 仍非 0;最穩定的作法是直接替換成完全不同的合法 base64url 字串,確保解碼出
|
||
// 來的 bytes 與原簽章不同。
|
||
parts := strings.Split(tok.IDToken, ".")
|
||
require.Len(t, parts, 3)
|
||
// 用相同長度的 'A' 串覆寫,base64url('A' * n) 解碼結果與原簽章 bytes 不同的機率近乎 100%
|
||
// (唯有原簽章本身就剛好全 0 才會碰撞,RSA 簽章機率為 0)。
|
||
parts[2] = strings.Repeat("A", len(parts[2]))
|
||
tampered := strings.Join(parts, ".")
|
||
|
||
_, err = p.VerifyIDToken(context.Background(), tampered, "n1")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrInvalidIDToken)
|
||
}
|
||
|
||
func TestVerifyIDToken_EmptyInputs(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake)
|
||
|
||
_, err := p.VerifyIDToken(context.Background(), "", "n1")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrInvalidIDToken)
|
||
|
||
// 空 nonce 也應拒絕(caller 必須提供)
|
||
now := time.Now()
|
||
fake.withState(func() {
|
||
fake.idTokenClaims = jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "u",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||
}
|
||
fake.idTokenExtra = map[string]any{"nonce": "actual"}
|
||
})
|
||
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
|
||
require.NoError(t, err)
|
||
|
||
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "")
|
||
require.Error(t, err)
|
||
assert.ErrorIs(t, err, ErrInvalidNonce)
|
||
}
|
||
|
||
// 確保 NewProvider 套用預設 Scopes(caller 沒填時)
|
||
func TestProviderConfig_DefaultScopes(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
ctx := context.Background()
|
||
cfg := ProviderConfig{
|
||
IssuerURL: fake.issuer(),
|
||
ClientID: testClientID,
|
||
ClientSecret: testClientSecret,
|
||
RedirectURL: testRedirect,
|
||
// 不填 Scopes
|
||
}
|
||
p, err := NewProvider(ctx, cfg)
|
||
require.NoError(t, err)
|
||
|
||
state, _ := GenerateState()
|
||
nonce, _ := GenerateNonce()
|
||
verifier, _ := GenerateCodeVerifier()
|
||
raw := p.AuthorizationURL(state, nonce, CodeChallenge(verifier))
|
||
u, _ := url.Parse(raw)
|
||
scope := u.Query().Get("scope")
|
||
for _, s := range DefaultScopes {
|
||
assert.Truef(t, strings.Contains(scope, s), "預設 scope 應含 %q,得 %q", s, scope)
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// A1:Public PKCE-only client mode 測試
|
||
// =====================================================================
|
||
|
||
// TestNewProvider_PublicClient 驗 ClientSecret 留空時能成功初始化 Provider,
|
||
// 且行為與 confidential client 等價(除了 token request 的 auth method 不同)。
|
||
func TestNewProvider_PublicClient(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
p, err := NewProvider(ctx, ProviderConfig{
|
||
IssuerURL: fake.issuer(),
|
||
ClientID: testClientID,
|
||
// ClientSecret 故意留空 — A1:public PKCE-only client mode
|
||
RedirectURL: testRedirect,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, p)
|
||
|
||
// AuthorizationURL 應含 PKCE 參數但**不含** client_secret(OAuth 規格上 authorize 端點本來就不該帶)
|
||
state, _ := GenerateState()
|
||
nonce, _ := GenerateNonce()
|
||
verifier, _ := GenerateCodeVerifier()
|
||
authURL := p.AuthorizationURL(state, nonce, CodeChallenge(verifier))
|
||
u, err := url.Parse(authURL)
|
||
require.NoError(t, err)
|
||
assert.Empty(t, u.Query().Get("client_secret"), "authorize URL 不應出現 client_secret")
|
||
assert.Equal(t, "S256", u.Query().Get("code_challenge_method"))
|
||
}
|
||
|
||
// TestNewProvider_ConfidentialClient 是 baseline:確認既有 confidential mode 仍能初始化。
|
||
// 既有 TestNewProvider_Discovery 其實已涵蓋此情境,這個測試明示「兩種 mode 共存」。
|
||
func TestNewProvider_ConfidentialClient(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
p, err := NewProvider(ctx, ProviderConfig{
|
||
IssuerURL: fake.issuer(),
|
||
ClientID: testClientID,
|
||
ClientSecret: testClientSecret, // 有值 → confidential
|
||
RedirectURL: testRedirect,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, p)
|
||
}
|
||
|
||
// TestExchangeCode_PublicClientNoSecretSent 驗 public client mode 下,
|
||
// /token request 的 form 不含 client_secret 欄位、且 Authorization header 不是 Basic auth。
|
||
//
|
||
// 這是 A1 改造的核心驗證:oauth2 lib 在 ClientSecret="" + AuthStyleInParams 時,
|
||
// 完全不送 client_secret(符合 RFC 6749 §2.3.1 對 public client 的規範)。
|
||
func TestExchangeCode_PublicClientNoSecretSent(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
p, err := NewProvider(ctx, ProviderConfig{
|
||
IssuerURL: fake.issuer(),
|
||
ClientID: testClientID,
|
||
// ClientSecret 留空 → public PKCE-only client
|
||
RedirectURL: testRedirect,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
verifier, _ := GenerateCodeVerifier()
|
||
fake.withState(func() { fake.expectVerifier = verifier })
|
||
|
||
tok, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, tok.IDToken)
|
||
|
||
// 取出 fake server 觀察到的 token request
|
||
var (
|
||
form url.Values
|
||
auth string
|
||
ok bool
|
||
)
|
||
fake.withState(func() {
|
||
form = fake.lastTokenForm
|
||
auth = fake.lastTokenAuthHdr
|
||
ok = form != nil
|
||
})
|
||
require.True(t, ok, "fake server 應該已收到一次 token request")
|
||
|
||
// public client 應該:
|
||
// - form 帶 client_id 但**不帶** client_secret
|
||
// - 不送 Authorization: Basic header(Authorization 應為空字串)
|
||
// - 帶 code_verifier(PKCE proof)
|
||
assert.Equal(t, testClientID, form.Get("client_id"), "public client 仍需在 form 帶 client_id")
|
||
assert.Empty(t, form.Get("client_secret"), "public client 不應送 client_secret form 欄位")
|
||
assert.Empty(t, auth, "public client 不應送 Authorization header(更不應是 Basic)")
|
||
assert.Equal(t, verifier, form.Get("code_verifier"), "PKCE verifier 必須帶")
|
||
}
|
||
|
||
// TestExchangeCode_ConfidentialClientSendsSecret 對照組:
|
||
// confidential client mode 下,/token request 必須帶 client_secret(或 Basic auth)。
|
||
func TestExchangeCode_ConfidentialClientSendsSecret(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
p := newProviderForTest(t, fake) // 用既有 helper:帶 testClientSecret
|
||
|
||
verifier, _ := GenerateCodeVerifier()
|
||
fake.withState(func() { fake.expectVerifier = verifier })
|
||
|
||
_, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
|
||
require.NoError(t, err)
|
||
|
||
var (
|
||
form url.Values
|
||
auth string
|
||
)
|
||
fake.withState(func() {
|
||
form = fake.lastTokenForm
|
||
auth = fake.lastTokenAuthHdr
|
||
})
|
||
|
||
// confidential 可以走兩種:Basic auth header,或 form 帶 client_secret。
|
||
// oauth2 lib 預設先試 InHeader → 第一輪通常是 Basic。
|
||
// 我們不挑路徑,只要「至少一邊」帶 secret 就算正確。
|
||
hasFormSecret := form.Get("client_secret") == testClientSecret
|
||
hasBasicAuth := strings.HasPrefix(auth, "Basic ")
|
||
assert.Truef(t, hasFormSecret || hasBasicAuth,
|
||
"confidential client 應透過 form 或 Basic auth 帶 secret;form=%v auth=%q",
|
||
form, auth)
|
||
}
|
||
|
||
// sanity check:fakeOIDC 自身沒寫錯(簽出來的 token coreos 能驗)
|
||
func TestFakeOIDC_SelfSignedTokenIsValid(t *testing.T) {
|
||
fake := newFakeOIDC(t, testClientID)
|
||
now := time.Now()
|
||
std := jwt.Claims{
|
||
Issuer: fake.issuer(),
|
||
Subject: "self-test",
|
||
Audience: jwt.Audience{testClientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Expiry: jwt.NewNumericDate(now.Add(time.Minute)),
|
||
}
|
||
tok, err := signIDToken(fake.signingKey, fake.keyID, jose.RS256, std,
|
||
map[string]any{"nonce": "test-nonce"})
|
||
require.NoError(t, err)
|
||
assert.True(t, strings.Count(tok, ".") == 2, "JWT 應有 3 段")
|
||
// 確保非空 payload
|
||
parts := strings.SplitN(tok, ".", 3)
|
||
require.Len(t, parts, 3)
|
||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||
require.NoError(t, err)
|
||
assert.Contains(t, string(payload), "self-test")
|
||
assert.Contains(t, string(payload), "test-nonce")
|
||
// 也驗證 fmt 可印(避免 unused import 'fmt' 在小幅 refactor 後消失)
|
||
_ = fmt.Sprintf("%s", tok)
|
||
}
|