361 lines
10 KiB
Go
361 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
|
||
|
|
}
|