All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Closes the local protocol surface of gap G01 from the scope assessment (KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left four bindings unenforced, and UserInfo verified less than the caller CLI does. Authorization-code path: bind the exchange to the redirect URI the code was issued for, refuse clients whose registration does not permit the grant, and authenticate confidential clients with a digest-based constant-time comparison over the same credential sources as the service grant. An empty grantTypes stays an implicit authorization-code client, matching config validation. Code consumption: SessionStore.Consume reads and deletes under one lock. The previous Get/Delete pair spanned JWT signing, and the added test reproduces the race against that version -- 9 of 16 concurrent exchanges succeeded, and a failed exchange left the code replayable. UserInfo: check the JOSE header algorithm before trusting the signature, require the configured issuer, and require an access token rather than accepting an ID token of the right shape. Purpose is decided on the scope claim so the issued token contract, which consumers pin exactly, does not change. SCOPE.md and the assessment record which bindings are now enforced and that the Authelia upstream-trust assumption remains open, so G01 is not fully closed and no profile-conformance claim is made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
334 lines
12 KiB
Go
334 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)
|
|
}
|
|
}
|
|
}
|