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 }