303 lines
8.4 KiB
Go
303 lines
8.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- OIDC ---
|
|
|
|
// fakeIssuer hosts a discovery document and a JWKS for a generated RSA key,
|
|
// and mints RS256 tokens signed by it.
|
|
type fakeIssuer struct {
|
|
srv *httptest.Server
|
|
key *rsa.PrivateKey
|
|
}
|
|
|
|
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeIssuer{key: key}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"jwks_uri": f.srv.URL + "/jwks"})
|
|
})
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
pub := &key.PublicKey
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"keys": []map[string]string{{
|
|
"kty": "RSA",
|
|
"kid": "test-key",
|
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
|
"e": base64.RawURLEncoding.EncodeToString(mustBigIntBytes(pub.E)),
|
|
}},
|
|
})
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
// mint signs an RS256 JWT with the given claims (kid "test-key").
|
|
func (f *fakeIssuer) mint(t *testing.T, claims map[string]any) string {
|
|
t.Helper()
|
|
enc := func(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
signing := enc(map[string]string{"alg": "RS256", "kid": "test-key"}) + "." + enc(claims)
|
|
digest := sha256.Sum256([]byte(signing))
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
// mustBigIntBytes turns an RSA exponent (typically 65537) into its
|
|
// big-endian byte encoding for the JWK "e" field.
|
|
func mustBigIntBytes(e int) []byte {
|
|
// smallest big-endian encoding of e.
|
|
n := e
|
|
if n == 0 {
|
|
return []byte{0}
|
|
}
|
|
var buf []byte
|
|
for n > 0 {
|
|
buf = append([]byte{byte(n & 0xff)}, buf...)
|
|
n >>= 8
|
|
}
|
|
return buf
|
|
}
|
|
|
|
func bearerReq(token string) *http.Request {
|
|
r := httptest.NewRequest("GET", "/api/nodes", nil)
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
return r
|
|
}
|
|
|
|
func TestOIDCAuthenticate(t *testing.T) {
|
|
iss := newFakeIssuer(t)
|
|
o := NewOIDC(iss.srv.URL, "my-client")
|
|
|
|
valid := map[string]any{
|
|
"iss": iss.srv.URL,
|
|
"sub": "user-1",
|
|
"email": "user-1@example.com",
|
|
"name": "User One",
|
|
"aud": "my-client",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"scope": "openid profile",
|
|
}
|
|
|
|
p, err := o.Authenticate(bearerReq(iss.mint(t, valid)))
|
|
if err != nil {
|
|
t.Fatalf("valid token rejected: %v", err)
|
|
}
|
|
if p.Subject != "user-1" || p.Kind != KindOIDC || !p.HasScope("profile") {
|
|
t.Fatalf("unexpected principal: %+v", p)
|
|
}
|
|
|
|
// VerifyClaims returns the rich claim set incl. email/name.
|
|
rawTok := iss.mint(t, valid)
|
|
c, err := o.VerifyClaims(rawTok)
|
|
if err != nil {
|
|
t.Fatalf("VerifyClaims rejected valid token: %v", err)
|
|
}
|
|
if c.Subject != "user-1" || c.Email != "user-1@example.com" || c.Name != "User One" || len(c.Scopes) != 2 {
|
|
t.Fatalf("unexpected claims: %+v", c)
|
|
}
|
|
|
|
// aud as array form also accepted
|
|
arr := map[string]any{
|
|
"iss": iss.srv.URL, "sub": "user-1", "aud": []string{"other", "my-client"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
}
|
|
if _, err := o.Authenticate(bearerReq(iss.mint(t, arr))); err != nil {
|
|
t.Fatalf("array aud rejected: %v", err)
|
|
}
|
|
|
|
cases := map[string]map[string]any{
|
|
"expired": {"iss": iss.srv.URL, "sub": "u", "aud": "my-client", "exp": time.Now().Add(-time.Hour).Unix()},
|
|
"wrong issuer": {"iss": "https://evil.example", "sub": "u", "aud": "my-client", "exp": time.Now().Add(time.Hour).Unix()},
|
|
"wrong audience": {"iss": iss.srv.URL, "sub": "u", "aud": "not-me", "exp": time.Now().Add(time.Hour).Unix()},
|
|
}
|
|
for name, c := range cases {
|
|
if _, err := o.Authenticate(bearerReq(iss.mint(t, c))); err == nil {
|
|
t.Errorf("%s token accepted", name)
|
|
}
|
|
if _, err := o.VerifyClaims(iss.mint(t, c)); err == nil {
|
|
t.Errorf("VerifyClaims accepted %s token", name)
|
|
}
|
|
}
|
|
|
|
// tampered payload must fail signature verification
|
|
tok := iss.mint(t, valid)
|
|
tampered := fmt.Sprintf("%s.%s.%s",
|
|
tok[:len(tok)/3],
|
|
base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"x","sub":"hacker"}`)),
|
|
"AAAA")
|
|
if _, err := o.Authenticate(bearerReq(tampered)); err == nil {
|
|
t.Error("tampered token accepted")
|
|
}
|
|
if _, err := o.VerifyClaims(tampered); err == nil {
|
|
t.Error("VerifyClaims accepted tampered token")
|
|
}
|
|
|
|
// no credentials → ErrNoCredentials so Chain falls through
|
|
if _, err := o.Authenticate(httptest.NewRequest("GET", "/", nil)); err == nil {
|
|
t.Error("missing bearer accepted")
|
|
}
|
|
if _, err := o.VerifyClaims(""); err == nil {
|
|
t.Error("VerifyClaims accepted empty token")
|
|
}
|
|
}
|
|
|
|
// --- API keys ---
|
|
|
|
func TestKeyStoreMintAuthenticateRevoke(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "apikeys.json")
|
|
ks, err := OpenKeyStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, rec, err := ks.Mint("ci", []string{"nodes:read"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
a := APIKeyAuth{Keys: ks}
|
|
r := httptest.NewRequest("GET", "/api/nodes", nil)
|
|
r.Header.Set("X-API-Key", key)
|
|
p, err := a.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("minted key rejected: %v", err)
|
|
}
|
|
if p.Subject != rec.ID || p.Kind != KindAPIKey || !p.HasScope("nodes:read") {
|
|
t.Fatalf("unexpected principal: %+v", p)
|
|
}
|
|
|
|
// persists across reopen
|
|
ks2, err := OpenKeyStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := (APIKeyAuth{Keys: ks2}).Authenticate(r); err != nil {
|
|
t.Fatalf("key lost after reopen: %v", err)
|
|
}
|
|
|
|
// invalid key rejected
|
|
bad := httptest.NewRequest("GET", "/", nil)
|
|
bad.Header.Set("X-API-Key", "gck_not_a_real_key")
|
|
if _, err := a.Authenticate(bad); err == nil {
|
|
t.Error("invalid key accepted")
|
|
}
|
|
|
|
// revoked key rejected
|
|
if err := ks.Revoke(rec.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.Authenticate(r); err == nil {
|
|
t.Error("revoked key accepted")
|
|
}
|
|
if err := ks.Revoke(rec.ID); err == nil {
|
|
t.Error("revoking twice should fail")
|
|
}
|
|
}
|
|
|
|
func TestChainBearerThenAPIKey(t *testing.T) {
|
|
iss := newFakeIssuer(t)
|
|
ks, err := OpenKeyStore(filepath.Join(t.TempDir(), "k.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, _, err := ks.Mint("integration", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := Chain{NewOIDC(iss.srv.URL, ""), APIKeyAuth{Keys: ks}}
|
|
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-API-Key", key)
|
|
p, err := chain.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("api key via chain rejected: %v", err)
|
|
}
|
|
if p.Kind != KindAPIKey {
|
|
t.Fatalf("expected apikey principal, got %+v", p)
|
|
}
|
|
|
|
tok := iss.mint(t, map[string]any{"iss": iss.srv.URL, "sub": "u1", "exp": time.Now().Add(time.Hour).Unix()})
|
|
p, err = chain.Authenticate(bearerReq(tok))
|
|
if err != nil {
|
|
t.Fatalf("bearer via chain rejected: %v", err)
|
|
}
|
|
if p.Kind != KindOIDC {
|
|
t.Fatalf("expected oidc principal, got %+v", p)
|
|
}
|
|
|
|
if _, err := chain.Authenticate(httptest.NewRequest("GET", "/", nil)); err == nil {
|
|
t.Error("credential-less request accepted by chain")
|
|
}
|
|
}
|
|
|
|
// --- middleware ---
|
|
|
|
func TestBearerMiddleware(t *testing.T) {
|
|
iss := newFakeIssuer(t)
|
|
tok := iss.mint(t, map[string]any{
|
|
"iss": iss.srv.URL, "sub": "u", "aud": "client",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
a := NewOIDC(iss.srv.URL, "client")
|
|
hits := 0
|
|
handler := BearerMiddleware(a, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hits++
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// valid token → passes through
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, bearerReq(tok))
|
|
if w.Code != http.StatusOK || hits != 1 {
|
|
t.Fatalf("valid token: code=%d hits=%d", w.Code, hits)
|
|
}
|
|
|
|
// missing token → 401
|
|
w = httptest.NewRecorder()
|
|
handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
|
if w.Code != http.StatusUnauthorized || hits != 1 {
|
|
t.Fatalf("missing token: code=%d hits=%d", w.Code, hits)
|
|
}
|
|
}
|
|
|
|
func TestWriteMiddlewarePassesReads(t *testing.T) {
|
|
a := NewOIDC("https://issuer.example", "")
|
|
hits := 0
|
|
handler := WriteMiddleware(a, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits++ }))
|
|
|
|
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, httptest.NewRequest(m, "/", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("%s: code=%d, want 200", m, w.Code)
|
|
}
|
|
}
|
|
}
|