302 lines
9.1 KiB
Go
302 lines
9.1 KiB
Go
|
|
package attest
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// mockOIDCServer is an httptest server that serves /.well-known/openid-configuration
|
||
|
|
// and a client_credentials token endpoint. It records call counts so tests can
|
||
|
|
// assert on cache behaviour.
|
||
|
|
type mockOIDCServer struct {
|
||
|
|
srv *httptest.Server
|
||
|
|
discoveryCalls atomic.Int32
|
||
|
|
tokenCalls atomic.Int32
|
||
|
|
expiresIn int32 // seconds; swap atomically if the test wants to change it between calls
|
||
|
|
tokenPrefix string // tokens are "<prefix>-<N>" to allow identity assertions
|
||
|
|
expectedClientID string
|
||
|
|
expectedSecret string
|
||
|
|
expectedScope string
|
||
|
|
expectedAudience string
|
||
|
|
t *testing.T
|
||
|
|
}
|
||
|
|
|
||
|
|
func newMockOIDCServer(t *testing.T) *mockOIDCServer {
|
||
|
|
t.Helper()
|
||
|
|
m := &mockOIDCServer{
|
||
|
|
expiresIn: 60,
|
||
|
|
tokenPrefix: "tok",
|
||
|
|
expectedClientID: "admin-api",
|
||
|
|
expectedSecret: "s3cret",
|
||
|
|
t: t,
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
m.discoveryCalls.Add(1)
|
||
|
|
if r.Method != http.MethodGet {
|
||
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"issuer": m.srv.URL,
|
||
|
|
"token_endpoint": m.srv.URL + "/api/oidc/token",
|
||
|
|
})
|
||
|
|
})
|
||
|
|
mux.HandleFunc("/api/oidc/token", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
n := m.tokenCalls.Add(1)
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
id, secret, ok := r.BasicAuth()
|
||
|
|
if !ok || id != m.expectedClientID || secret != m.expectedSecret {
|
||
|
|
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if got := r.PostForm.Get("grant_type"); got != "client_credentials" {
|
||
|
|
http.Error(w, `{"error":"unsupported_grant_type"}`, http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if m.expectedScope != "" && r.PostForm.Get("scope") != m.expectedScope {
|
||
|
|
http.Error(w, `{"error":"invalid_scope"}`, http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if m.expectedAudience != "" && r.PostForm.Get("audience") != m.expectedAudience {
|
||
|
|
http.Error(w, `{"error":"invalid_audience"}`, http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
resp := pocketIDTokenResponse{
|
||
|
|
AccessToken: fmt.Sprintf("%s-%d", m.tokenPrefix, n),
|
||
|
|
TokenType: "Bearer",
|
||
|
|
ExpiresIn: int(atomic.LoadInt32(&m.expiresIn)),
|
||
|
|
}
|
||
|
|
_ = json.NewEncoder(w).Encode(resp)
|
||
|
|
})
|
||
|
|
m.srv = httptest.NewServer(mux)
|
||
|
|
t.Cleanup(m.srv.Close)
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_DiscoversAndFetches(t *testing.T) {
|
||
|
|
m := newMockOIDCServer(t)
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: m.srv.URL,
|
||
|
|
ClientID: m.expectedClientID,
|
||
|
|
ClientSecret: m.expectedSecret,
|
||
|
|
}
|
||
|
|
tok, err := src.Token(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Token: %v", err)
|
||
|
|
}
|
||
|
|
if tok != "tok-1" {
|
||
|
|
t.Fatalf("token: got %q, want %q", tok, "tok-1")
|
||
|
|
}
|
||
|
|
if got := m.discoveryCalls.Load(); got != 1 {
|
||
|
|
t.Errorf("discovery calls = %d, want 1", got)
|
||
|
|
}
|
||
|
|
if got := m.tokenCalls.Load(); got != 1 {
|
||
|
|
t.Errorf("token calls = %d, want 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_CachesWithinLeeway(t *testing.T) {
|
||
|
|
m := newMockOIDCServer(t)
|
||
|
|
now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: m.srv.URL,
|
||
|
|
ClientID: m.expectedClientID,
|
||
|
|
ClientSecret: m.expectedSecret,
|
||
|
|
RefreshLeeway: 30 * time.Second,
|
||
|
|
Now: func() time.Time { return now },
|
||
|
|
}
|
||
|
|
// expires_in=60s, leeway=30s → cache valid for ~30s wall time.
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
tok, err := src.Token(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Token call %d: %v", i, err)
|
||
|
|
}
|
||
|
|
if tok != "tok-1" {
|
||
|
|
t.Fatalf("Token call %d: got %q, want cached tok-1", i, tok)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := m.tokenCalls.Load(); got != 1 {
|
||
|
|
t.Errorf("token endpoint hit %d times, expected 1 cached fetch", got)
|
||
|
|
}
|
||
|
|
if got := m.discoveryCalls.Load(); got != 1 {
|
||
|
|
t.Errorf("discovery hit %d times, expected 1 (sticky)", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_RefreshesWithinExpiryLeeway(t *testing.T) {
|
||
|
|
m := newMockOIDCServer(t)
|
||
|
|
// Pretend the token server issues 60s tokens.
|
||
|
|
now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: m.srv.URL,
|
||
|
|
ClientID: m.expectedClientID,
|
||
|
|
ClientSecret: m.expectedSecret,
|
||
|
|
RefreshLeeway: 30 * time.Second,
|
||
|
|
Now: func() time.Time { return now },
|
||
|
|
}
|
||
|
|
first, err := src.Token(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Token (first): %v", err)
|
||
|
|
}
|
||
|
|
if first != "tok-1" {
|
||
|
|
t.Fatalf("first token = %q", first)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Advance to one second before the exp-leeway cutoff. Cache must hold.
|
||
|
|
// exp = 12:01:00, leeway = 30s, so cache is valid while now < 12:00:30.
|
||
|
|
now = now.Add(29 * time.Second)
|
||
|
|
same, err := src.Token(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Token (within leeway): %v", err)
|
||
|
|
}
|
||
|
|
if same != "tok-1" {
|
||
|
|
t.Fatalf("inside leeway expected cached tok-1, got %q", same)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Advance to exp-30s exactly: now+leeway == cachedExpiry → cache
|
||
|
|
// invalid (strict Before). This is the "verwirft bei exp-30s"
|
||
|
|
// behaviour from the task spec.
|
||
|
|
now = now.Add(1 * time.Second)
|
||
|
|
refreshed, err := src.Token(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Token (at exp-leeway): %v", err)
|
||
|
|
}
|
||
|
|
if refreshed != "tok-2" {
|
||
|
|
t.Fatalf("at exp-leeway cutoff expected refreshed tok-2, got %q", refreshed)
|
||
|
|
}
|
||
|
|
if got := m.tokenCalls.Load(); got != 2 {
|
||
|
|
t.Errorf("token endpoint hits = %d, want 2", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_PassesOptionalScopeAndAudience(t *testing.T) {
|
||
|
|
m := newMockOIDCServer(t)
|
||
|
|
m.expectedScope = "trust-api.sign"
|
||
|
|
m.expectedAudience = "https://trust.basicstack.de"
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: m.srv.URL,
|
||
|
|
ClientID: m.expectedClientID,
|
||
|
|
ClientSecret: m.expectedSecret,
|
||
|
|
Scope: m.expectedScope,
|
||
|
|
Audience: m.expectedAudience,
|
||
|
|
}
|
||
|
|
if _, err := src.Token(context.Background()); err != nil {
|
||
|
|
t.Fatalf("Token: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_RejectsBadCredentials(t *testing.T) {
|
||
|
|
m := newMockOIDCServer(t)
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: m.srv.URL,
|
||
|
|
ClientID: m.expectedClientID,
|
||
|
|
ClientSecret: "wrong",
|
||
|
|
}
|
||
|
|
_, err := src.Token(context.Background())
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected error for bad credentials")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "status 401") {
|
||
|
|
t.Errorf("expected 401 in error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_RejectsMissingConfig(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
src *PocketIDTokenSource
|
||
|
|
}{
|
||
|
|
{"no issuer", &PocketIDTokenSource{ClientID: "a", ClientSecret: "b"}},
|
||
|
|
{"no client id", &PocketIDTokenSource{Issuer: "https://auth", ClientSecret: "b"}},
|
||
|
|
{"no client secret", &PocketIDTokenSource{Issuer: "https://auth", ClientID: "a"}},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
if _, err := tc.src.Token(context.Background()); err == nil {
|
||
|
|
t.Fatal("expected error, got nil")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_DiscoveryError(t *testing.T) {
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
http.Error(w, "oops", http.StatusInternalServerError)
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: srv.URL,
|
||
|
|
ClientID: "a",
|
||
|
|
ClientSecret: "b",
|
||
|
|
}
|
||
|
|
_, err := src.Token(context.Background())
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected discovery error")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "discovery") {
|
||
|
|
t.Errorf("expected discovery in error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_MissingTokenEndpoint(t *testing.T) {
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(`{"issuer":"https://auth"}`))
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: srv.URL,
|
||
|
|
ClientID: "a",
|
||
|
|
ClientSecret: "b",
|
||
|
|
}
|
||
|
|
_, err := src.Token(context.Background())
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "token_endpoint") {
|
||
|
|
t.Fatalf("expected missing token_endpoint error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPocketIDTokenSource_RejectsNonPositiveExpiry(t *testing.T) {
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
switch r.URL.Path {
|
||
|
|
case "/.well-known/openid-configuration":
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"token_endpoint":%q}`, "http://"+r.Host+"/token")))
|
||
|
|
case "/token":
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(`{"access_token":"x","expires_in":0}`))
|
||
|
|
default:
|
||
|
|
http.NotFound(w, r)
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
defer srv.Close()
|
||
|
|
src := &PocketIDTokenSource{
|
||
|
|
Issuer: srv.URL,
|
||
|
|
ClientID: "a",
|
||
|
|
ClientSecret: "b",
|
||
|
|
}
|
||
|
|
_, err := src.Token(context.Background())
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "expires_in") {
|
||
|
|
t.Fatalf("expected expires_in error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compile-time check that PocketIDTokenSource satisfies the attest.TokenSource interface.
|
||
|
|
var _ TokenSource = (*PocketIDTokenSource)(nil)
|