package oidc_test import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "keycape/internal/domain" "keycape/internal/server/oidc" ) // KEY-WP-0016 — negative coverage for the authorization-code protocol bindings // and UserInfo token verification. Each case asserts the specific rejection, so // a binding that stops being enforced fails here rather than passing silently. func codeExchange(t *testing.T, params url.Values) *http.Request { t.Helper() req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(params.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } func TestCodeExchangeRejectsRedirectURIMismatchAndOmission(t *testing.T) { for name, redirect := range map[string]string{ "omitted": "", "different": "https://attacker.example.com/callback", "prefix": seededRedirectURI + "/../callback", "trailing": seededRedirectURI + "/", } { t.Run(name, func(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) verifier := "test-verifier" code := seededSession(sessions, verifier) params := url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}} if redirect != "" { params.Set("redirect_uri", redirect) } w := httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, params)) if w.Code != http.StatusBadRequest { t.Fatalf("status %d, want 400", w.Code) } if !strings.Contains(w.Body.String(), "redirect_uri") { t.Fatalf("wrong rejection: %s", w.Body.String()) } }) } } func TestCodeExchangeRejectsServiceOnlyClient(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{}) h.ClientConfig["test-client"].GrantTypes = []string{"client_credentials"} code := seededSession(sessions, "verifier") w := httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"verifier"}, "redirect_uri": {seededRedirectURI}})) if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "grant_type") { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } } // An empty grantTypes stays an implicit authorization-code client, matching // what config validation already assumes. func TestCodeExchangeAllowsImplicitAuthorizationCodeClient(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) h.ClientConfig["test-client"].GrantTypes = nil verifier := "test-verifier" code := seededSession(sessions, verifier) w := httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {seededRedirectURI}})) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } } func TestConfidentialCodeClientRequiresItsSecret(t *testing.T) { makeHandler := func(t *testing.T) (string, http.Handler) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) h.ClientConfig["test-client"].ClientType = "confidential" h.ClientConfig["test-client"].ClientSecret = "human-client-secret" return seededSession(sessions, "test-verifier"), h } base := func(code string) url.Values { return url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"test-verifier"}, "redirect_uri": {seededRedirectURI}} } t.Run("no credentials", func(t *testing.T) { code, h := makeHandler(t) w := httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, base(code))) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) t.Run("wrong secret", func(t *testing.T) { code, h := makeHandler(t) req := codeExchange(t, base(code)) req.SetBasicAuth("test-client", "not-the-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) t.Run("other client identity", func(t *testing.T) { code, h := makeHandler(t) req := codeExchange(t, base(code)) req.SetBasicAuth("someone-else", "human-client-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) t.Run("correct secret", func(t *testing.T) { code, h := makeHandler(t) req := codeExchange(t, base(code)) req.SetBasicAuth("test-client", "human-client-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } }) } // A public client must not become "authenticated" by presenting a secret, and a // confidential registration with no configured secret must not accept an empty // one. func TestPublicClientSecretIsIgnoredAndEmptyConfidentialSecretIsRejected(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) code := seededSession(sessions, "test-verifier") req := codeExchange(t, url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"test-verifier"}, "redirect_uri": {seededRedirectURI}}) req.SetBasicAuth("test-client", "irrelevant") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("public client exchange: status %d", w.Code) } sessions2 := oidc.NewSessionStore() h2, _ := newTokenHandler(t, sessions2, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) h2.ClientConfig["test-client"].ClientType = "confidential" code2 := seededSession(sessions2, "test-verifier") req2 := codeExchange(t, url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code2}, "code_verifier": {"test-verifier"}, "redirect_uri": {seededRedirectURI}}) req2.SetBasicAuth("test-client", "") w2 := httptest.NewRecorder() h2.ServeHTTP(w2, req2) if w2.Code != http.StatusUnauthorized { t.Fatalf("empty configured secret accepted: status %d", w2.Code) } } // Concurrent exchanges of one code must yield exactly one token: the store // consumes the session atomically rather than reading and deleting separately. func TestConcurrentCodeExchangeSucceedsExactlyOnce(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) verifier := "test-verifier" code := seededSession(sessions, verifier) const attempts = 16 var wg sync.WaitGroup codes := make([]int, attempts) start := make(chan struct{}) for i := 0; i < attempts; i++ { wg.Add(1) go func(i int) { defer wg.Done() req := codeExchange(t, url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {seededRedirectURI}}) <-start w := httptest.NewRecorder() h.ServeHTTP(w, req) codes[i] = w.Code }(i) } close(start) wg.Wait() succeeded := 0 for _, c := range codes { if c == http.StatusOK { succeeded++ } } if succeeded != 1 { t.Fatalf("%d concurrent exchanges succeeded, want exactly 1", succeeded) } } // A failed exchange must not leave the code replayable. func TestFailedExchangeConsumesTheCode(t *testing.T) { sessions := oidc.NewSessionStore() h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) code := seededSession(sessions, "test-verifier") params := url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"wrong-verifier"}, "redirect_uri": {seededRedirectURI}} w := httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, params)) if w.Code != http.StatusBadRequest { t.Fatalf("first attempt: status %d", w.Code) } params.Set("code_verifier", "test-verifier") w = httptest.NewRecorder() h.ServeHTTP(w, codeExchange(t, params)) if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "authorization code") { t.Fatalf("code survived a failed exchange: %d %s", w.Code, w.Body.String()) } } // -------------------------------------------------------------------------- // KEY-WP-0016-T03 — UserInfo token verification bindings. // -------------------------------------------------------------------------- func TestUserinfoRejectsUnverifiedTokenShapes(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{ "user-alice": aliceUser(), "alice": aliceUser(), }} valid := func() map[string]interface{} { now := time.Now() return map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "aud": "test-client", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), "scope": "openid profile", } } t.Run("accepts a well-formed access token", func(t *testing.T) { h, key := newUserinfoHandler(t, users) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(buildToken(t, valid(), key))) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } }) t.Run("foreign issuer", func(t *testing.T) { h, key := newUserinfoHandler(t, users) claims := valid() claims["iss"] = "https://auth.other.example" w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(buildToken(t, claims, key))) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) t.Run("missing issuer", func(t *testing.T) { h, key := newUserinfoHandler(t, users) claims := valid() delete(claims, "iss") w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(buildToken(t, claims, key))) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) // An ID token is correctly signed by this issuer but is not an access // token: it carries no scope claim. t.Run("id token presented as access token", func(t *testing.T) { h, key := newUserinfoHandler(t, users) claims := valid() delete(claims, "scope") claims["nonce"] = "nonce1" w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(buildToken(t, claims, key))) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } }) } // The JOSE header algorithm is checked before the signature, so a token // claiming "none" or a symmetric algorithm can never reach the RSA check. func TestUserinfoRejectsNonRS256Algorithms(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{ "user-alice": aliceUser(), "alice": aliceUser(), }} h, key := newUserinfoHandler(t, users) now := time.Now() payload, err := json.Marshal(map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "scope": "openid", }) if err != nil { t.Fatal(err) } encodedPayload := base64.RawURLEncoding.EncodeToString(payload) // Keep a genuine RS256 signature and only restate the algorithm, so the // test isolates the header check rather than relying on a broken signature. signed := buildToken(t, map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "scope": "openid", }, key) genuineSignature := strings.Split(signed, ".")[2] for _, alg := range []string{"none", "HS256", "RS512", "rs256", ""} { header, err := json.Marshal(map[string]interface{}{"alg": alg, "typ": "JWT", "kid": "key-1"}) if err != nil { t.Fatal(err) } token := base64.RawURLEncoding.EncodeToString(header) + "." + encodedPayload + "." + genuineSignature w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusUnauthorized { t.Fatalf("alg %q accepted: status %d", alg, w.Code) } } }