jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過,
讓登入/換帳號可用;另補真轉檔服務的整合 e2e。

OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過):
- email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發
  sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local
  placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫
- prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關)
- logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用
  隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。
  config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地)

真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集):
- real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result)
- real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS)

交接檔(給對應團隊根治):
- mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory)
- converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth

全程 Reviewer 審查 + 對 stage 真環境驗證。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:18:59 +08:00

782 lines
26 KiB
Go
Raw Permalink 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 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-josecoreos/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 code0 = 200
respondBody string // 非空時直接回此 body 取代正常 token response
idTokenClaims jwt.Claims // 自訂簽 token 的 standard claimszero = 預設)
idTokenExtra map[string]any
idTokenAlg jose.SignatureAlgorithm // 預設 RS256
skipIDToken bool // true 時 token response 不含 id_token
// 觀測:最後一次 /token 收到的 form / Authorization headerA1 加:驗 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_tokenJWS / 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 通常是 655370x010001= 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) {
// A12026-05-01ClientSecret 為選填,因此 "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)
// 預設 PromptLogin=false → authorize URL **不**帶 prompt 參數(沿用 SSO session
assert.Empty(t, q.Get("prompt"), "預設不應帶 prompt 參數")
}
// TestAuthorizationURL_PromptLogin 驗 PromptLogin config 開關對 authorize URL 的影響。
func TestAuthorizationURL_PromptLogin(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
newProviderWithPrompt := func(t *testing.T, promptLogin bool) 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,
PromptLogin: promptLogin,
})
require.NoError(t, err)
return p
}
state, _ := GenerateState()
nonce, _ := GenerateNonce()
verifier, _ := GenerateCodeVerifier()
challenge := CodeChallenge(verifier)
t.Run("enabled adds prompt=login", func(t *testing.T) {
p := newProviderWithPrompt(t, true)
u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge))
require.NoError(t, err)
assert.Equal(t, "login", u.Query().Get("prompt"),
"PromptLogin=true 時 authorize URL 應帶 prompt=login")
})
t.Run("disabled omits prompt", func(t *testing.T) {
p := newProviderWithPrompt(t, false)
u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge))
require.NoError(t, err)
assert.Empty(t, u.Query().Get("prompt"),
"PromptLogin=false 時 authorize URL 不應帶 prompt 參數")
})
}
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 套用預設 Scopescaller 沒填時)
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)
}
}
// =====================================================================
// A1Public 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 故意留空 — A1public PKCE-only client mode
RedirectURL: testRedirect,
})
require.NoError(t, err)
require.NotNil(t, p)
// AuthorizationURL 應含 PKCE 參數但**不含** client_secretOAuth 規格上 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 headerAuthorization 應為空字串)
// - 帶 code_verifierPKCE 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 帶 secretform=%v auth=%q",
form, auth)
}
// sanity checkfakeOIDC 自身沒寫錯(簽出來的 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)
}