go-places/api/internal/auth/auth_test.go
2026-10-07 15:14:50 +02:00

360 lines
10 KiB
Go

package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const testSecret = "test-signing-secret"
// sign builds a token with the given claims and method.
func sign(t *testing.T, method jwt.SigningMethod, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testSecret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// validClaims returns an unexpired claim set for the verifier under test.
func validClaims() Claims {
return Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
Username: "ada",
Email: "ada@example.com",
}
}
func TestJWTVerifierAcceptsValidToken(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != "user-42" {
t.Errorf("ID = %q, want user-42", got.ID)
}
if got.Username != "ada" {
t.Errorf("Username = %q, want ada", got.Username)
}
if got.Email != "ada@example.com" {
t.Errorf("Email = %q", got.Email)
}
if v.Name() != "jwt" {
t.Errorf("Name = %q, want jwt", v.Name())
}
}
func TestJWTVerifierFallsBackToSubjectForUsername(t *testing.T) {
t.Parallel()
// A token that only asserts a subject still has to yield a usable identity,
// so the username defaults to the subject.
claims := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-7",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, claims))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Username != "user-7" {
t.Errorf("Username = %q, want the subject as fallback", got.Username)
}
}
func TestJWTVerifierRejections(t *testing.T) {
t.Parallel()
expired := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
}}
noExp := Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-42"}}
noSub := Claims{RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tests := []struct {
name string
token string
}{
{"empty", ""},
{"expired", sign(t, jwt.SigningMethodHS256, expired)},
{"missing expiration", sign(t, jwt.SigningMethodHS256, noExp)},
{"missing subject", sign(t, jwt.SigningMethodHS256, noSub)},
{"wrong secret", signWithSecret(t, jwt.SigningMethodHS256, validClaims(), "other-secret")},
// The algorithm is pinned, so a token cannot pick "none" and skip
// signature verification entirely.
{"alg none", unsignedToken(t, validClaims())},
{"garbage", "not-a-token"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), tc.token); err == nil {
t.Error("Verify accepted a token it should have rejected")
}
})
}
}
func TestJWTVerifierReportsMissingTokenDistinctly(t *testing.T) {
t.Parallel()
// The middleware needs to tell "you sent nothing" from "what you sent is
// bad" so it can word the challenge properly.
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
if _, err := v.Verify(context.Background(), "garbage"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierRequiresConfiguredSecret(t *testing.T) {
t.Parallel()
// An empty secret must reject everything rather than accept a token signed
// with an empty key.
v := NewJWTVerifier("", "", "")
_, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierEnforcesAudienceAndIssuer(t *testing.T) {
t.Parallel()
claims := validClaims()
claims.Audience = jwt.ClaimStrings{"go-places"}
claims.Issuer = "go-loose"
token := sign(t, jwt.SigningMethodHS256, claims)
matching := NewJWTVerifier(testSecret, "go-places", "go-loose")
if _, err := matching.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with matching aud/iss: %v", err)
}
// A token minted for a different client must not be accepted, or a token
// issued for another service in the same cluster would grant access.
wrongAudience := NewJWTVerifier(testSecret, "some-other-app", "go-loose")
if _, err := wrongAudience.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token with the wrong audience")
}
wrongIssuer := NewJWTVerifier(testSecret, "go-places", "some-other-issuer")
if _, err := wrongIssuer.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token from the wrong issuer")
}
// Skipping the checks is the documented behaviour of the empty arguments.
unpinned := NewJWTVerifier(testSecret, "", "")
if _, err := unpinned.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with no aud/iss configured: %v", err)
}
}
func TestJWTVerifierHonoursContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
v := NewJWTVerifier(testSecret, "", "")
_, err := v.Verify(ctx, sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDevVerifierAcceptsOnlyConfiguredTokens(t *testing.T) {
t.Parallel()
v := NewDevVerifier(nil)
got, err := v.Verify(context.Background(), "valid_token")
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != DefaultDevUser.ID {
t.Errorf("ID = %q, want %q", got.ID, DefaultDevUser.ID)
}
if v.Name() != "dev" {
t.Errorf("Name = %q, want dev", v.Name())
}
if _, err := v.Verify(context.Background(), "anything-else"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
}
func TestBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
header string
want string
wantErr error
}{
{name: "standard", header: "Bearer abc.def.ghi", want: "abc.def.ghi"},
{name: "lowercase scheme", header: "bearer abc", want: "abc"},
{name: "mixed case scheme", header: "BeArEr abc", want: "abc"},
{name: "extra spaces", header: "Bearer abc ", want: "abc"},
{name: "bare token", header: "abc.def.ghi", want: "abc.def.ghi"},
{name: "empty", header: "", wantErr: ErrMissingToken},
{name: "whitespace only", header: " ", wantErr: ErrMissingToken},
{name: "scheme without token", header: "Bearer ", wantErr: ErrMissingToken},
// A header naming some other scheme must not be silently read as a
// bearer token.
{name: "other scheme", header: "Basic dXNlcjpwYXNz", wantErr: ErrInvalidToken},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := BearerToken(tc.header)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want %v", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("token = %q, want %q", got, tc.want)
}
})
}
}
func TestMiddlewareAttachesUserToContext(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
var seen User
var ok bool
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
seen, ok = FromContext(r.Context())
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if !ok {
t.Fatal("the handler could not read the identity from the context")
}
if seen.ID != DefaultDevUser.ID {
t.Errorf("user = %q, want %q", seen.ID, DefaultDevUser.ID)
}
}
func TestMiddlewareRejectsUnusableCredentials(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
tests := []struct {
name string
header string
body string
}{
{name: "no header", body: "a bearer token is required"},
{name: "bad token", header: "Bearer nope", body: "the token was rejected"},
{name: "other scheme", header: "Basic dXNlcjpwYXNz", body: "the token was rejected"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Local to the subtest: the subtests run in parallel, so a shared
// flag would be a data race.
called := false
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if called {
t.Error("the protected handler ran without a valid credential")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") {
t.Errorf("WWW-Authenticate = %q, want a Bearer challenge", got)
}
if body := rec.Body.String(); !strings.Contains(body, tc.body) {
t.Errorf("body = %q, want it to mention %q", body, tc.body)
}
})
}
}
func TestFromContextReportsAbsentIdentity(t *testing.T) {
t.Parallel()
if _, ok := FromContext(context.Background()); ok {
t.Error("FromContext found an identity on a bare context")
}
}
// signWithSecret signs with a different key to model a forged token.
func signWithSecret(t *testing.T, method jwt.SigningMethod, claims jwt.Claims, secret string) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// unsignedToken builds an alg=none token, the classic JWT downgrade attack.
func unsignedToken(t *testing.T, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).
SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}