335 lines
12 KiB
Go
335 lines
12 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|