initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
360
api/internal/auth/auth_test.go
Normal file
360
api/internal/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue