Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Redirect safety --------------------------------------------------------
|
||||
|
||||
// The post-login destination comes from a query parameter, so it is
|
||||
// attacker-controlled. Anything that could leave the site must become "/".
|
||||
func TestSafeRedirectPathRefusesAnythingThatLeavesTheSite(t *testing.T) {
|
||||
hostile := []string{
|
||||
"//evil.test",
|
||||
"//evil.test/path",
|
||||
"/\\evil.test",
|
||||
"https://evil.test",
|
||||
"http://evil.test",
|
||||
"javascript:alert(1)",
|
||||
"/redirect?to=https://evil.test\r\nSet-Cookie: a=b",
|
||||
"evil.test",
|
||||
"",
|
||||
" ",
|
||||
"\\\\evil.test",
|
||||
}
|
||||
for _, in := range hostile {
|
||||
if got := SafeRedirectPath(in); got != "/" {
|
||||
t.Errorf("SafeRedirectPath(%q) = %q; anything that could leave the site must become \"/\"", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinary local paths survive, or the feature would be pointless.
|
||||
for _, in := range []string{"/", "/items", "/search?q=cost", "/items/abc-123"} {
|
||||
if got := SafeRedirectPath(in); got != in {
|
||||
t.Errorf("SafeRedirectPath(%q) = %q; a local path should survive", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PKCE -------------------------------------------------------------------
|
||||
|
||||
func TestAuthorizeURLAlwaysUsesPKCEAndAFreshState(t *testing.T) {
|
||||
s := NewOAuthService(OAuthConfig{
|
||||
Enabled: true, ProviderName: "Blackwall",
|
||||
AuthorizeURL: "https://id.example/authorize",
|
||||
TokenURL: "https://id.example/token",
|
||||
UserInfoURL: "https://id.example/userinfo",
|
||||
ClientID: "client-1", ClientSecret: "secret",
|
||||
RedirectURI: "https://relata.example/auth/oauth/callback",
|
||||
Scopes: []string{"openid", "email"},
|
||||
})
|
||||
|
||||
u1, fs1, err := s.AuthorizeURL(context.Background(), "/items")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u2, fs2, err := s.AuthorizeURL(context.Background(), "/items")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(u1, "code_challenge_method=S256") {
|
||||
t.Fatalf("authorize URL does not request S256 PKCE:\n%s", u1)
|
||||
}
|
||||
if strings.Contains(u1, fs1.Verifier) {
|
||||
t.Fatal("the PKCE verifier appears in the authorize URL; only its hash may be sent")
|
||||
}
|
||||
if fs1.State == fs2.State || fs1.Verifier == fs2.Verifier {
|
||||
t.Fatal("two sign-in attempts reused state or verifier; each must be fresh")
|
||||
}
|
||||
if len(fs1.State) < 32 {
|
||||
t.Fatalf("state is only %d characters; it must be unguessable", len(fs1.State))
|
||||
}
|
||||
if fs1.RedirectPath != "/items" {
|
||||
t.Fatalf("redirect path = %q, want /items", fs1.RedirectPath)
|
||||
}
|
||||
_ = u2
|
||||
}
|
||||
|
||||
func TestAuthorizeURLSanitisesTheReturnPath(t *testing.T) {
|
||||
s := NewOAuthService(OAuthConfig{
|
||||
Enabled: true, AuthorizeURL: "https://id.example/authorize",
|
||||
TokenURL: "https://id.example/token", UserInfoURL: "https://id.example/userinfo",
|
||||
ClientID: "c", ClientSecret: "s",
|
||||
})
|
||||
_, fs, err := s.AuthorizeURL(context.Background(), "//evil.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fs.RedirectPath != "/" {
|
||||
t.Fatalf("an off-site return path survived into the flow state: %q", fs.RedirectPath)
|
||||
}
|
||||
}
|
||||
|
||||
// --- State ------------------------------------------------------------------
|
||||
|
||||
func TestExchangeRefusesAMismatchedOrStaleState(t *testing.T) {
|
||||
s := NewOAuthService(OAuthConfig{
|
||||
Enabled: true, AuthorizeURL: "https://id.example/authorize",
|
||||
TokenURL: "https://id.example/token", UserInfoURL: "https://id.example/userinfo",
|
||||
ClientID: "c", ClientSecret: "s",
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
fresh := &FlowState{State: "expected", Verifier: "v", IssuedAt: time.Now().Unix()}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fs *FlowState
|
||||
state string
|
||||
}{
|
||||
{"no flow state at all", nil, "expected"},
|
||||
{"empty submitted state", fresh, ""},
|
||||
{"different state", fresh, "attacker"},
|
||||
{"prefix of the real state", fresh, "expec"},
|
||||
{"stale flow", &FlowState{State: "expected", IssuedAt: time.Now().Add(-30 * time.Minute).Unix()}, "expected"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := s.Exchange(ctx, c.fs, c.state, "some-code")
|
||||
if !errors.Is(err, ErrOAuthState) {
|
||||
t.Fatalf("err = %v, want ErrOAuthState — a callback that fails the state check "+
|
||||
"must be refused before anything is sent to the provider", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identity ---------------------------------------------------------------
|
||||
|
||||
// The subject is the account key. Without one there is nothing stable to key on,
|
||||
// and falling back to the email is precisely the bug this avoids.
|
||||
func TestIdentityRequiresASubject(t *testing.T) {
|
||||
if _, err := identityFromClaims(map[string]any{"email": "someone@example.com"}); err == nil {
|
||||
t.Fatal("an identity with an email but no subject was accepted")
|
||||
}
|
||||
id, err := identityFromClaims(map[string]any{"sub": "abc", "email": "A@Example.COM", "name": "A Person"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id.Subject != "abc" {
|
||||
t.Fatalf("subject = %q", id.Subject)
|
||||
}
|
||||
if id.Email != "a@example.com" {
|
||||
t.Fatalf("email = %q; it should be lowercased for comparison", id.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailDomainAllowlist(t *testing.T) {
|
||||
s := NewOAuthService(OAuthConfig{
|
||||
Enabled: true, AllowedEmailDomains: []string{"example.com", "@other.test"},
|
||||
})
|
||||
for _, c := range []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"someone@example.com", true},
|
||||
{"someone@EXAMPLE.com", true},
|
||||
{"someone@other.test", true},
|
||||
{"someone@evil.test", false},
|
||||
{"someone@sub.example.com", false}, // a subdomain is a different domain
|
||||
{"", false},
|
||||
{"notanemail", false},
|
||||
{"someone@example.com.evil.test", false},
|
||||
} {
|
||||
err := s.permitted(&Identity{Email: c.email})
|
||||
if (err == nil) != c.want {
|
||||
t.Errorf("permitted(%q): err=%v, want allowed=%v", c.email, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ID token validation ----------------------------------------------------
|
||||
|
||||
type testSigner struct {
|
||||
key *rsa.PrivateKey
|
||||
kid string
|
||||
}
|
||||
|
||||
func newTestSigner(t *testing.T) *testSigner {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &testSigner{key: k, kid: "test-key-1"}
|
||||
}
|
||||
|
||||
// jwksHandler serves the signer's public key in JWKS form.
|
||||
func (s *testSigner) jwksHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := base64.RawURLEncoding.EncodeToString(s.key.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.key.E)).Bytes())
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []map[string]string{
|
||||
{"kty": "RSA", "use": "sig", "kid": s.kid, "n": n, "e": e},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *testSigner) sign(t *testing.T, alg string, claims map[string]any) string {
|
||||
t.Helper()
|
||||
header := map[string]string{"alg": alg, "typ": "JWT", "kid": s.kid}
|
||||
hb, _ := json.Marshal(header)
|
||||
cb, _ := json.Marshal(claims)
|
||||
signing := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(cb)
|
||||
|
||||
h := crypto.SHA256.New()
|
||||
h.Write([]byte(signing))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, h.Sum(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func oidcService(t *testing.T, jwksURL, issuer, clientID string) *OAuthService {
|
||||
t.Helper()
|
||||
return NewOAuthService(OAuthConfig{
|
||||
Enabled: true, Issuer: issuer, JWKSURL: jwksURL,
|
||||
AuthorizeURL: issuer + "/authorize", TokenURL: issuer + "/token",
|
||||
ClientID: clientID, ClientSecret: "secret",
|
||||
})
|
||||
}
|
||||
|
||||
func TestIDTokenValidationAcceptsAGenuineToken(t *testing.T) {
|
||||
signer := newTestSigner(t)
|
||||
srv := httptest.NewServer(signer.jwksHandler())
|
||||
defer srv.Close()
|
||||
|
||||
s := oidcService(t, srv.URL, "https://id.example", "client-1")
|
||||
raw := signer.sign(t, "RS256", map[string]any{
|
||||
"iss": "https://id.example", "aud": "client-1", "sub": "user-42",
|
||||
"email": "person@example.com", "name": "A Person",
|
||||
"nonce": "n-1", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
id, err := s.identityFromIDToken(context.Background(), raw, "n-1")
|
||||
if err != nil {
|
||||
t.Fatalf("a correctly signed token was rejected: %v", err)
|
||||
}
|
||||
if id.Subject != "user-42" {
|
||||
t.Fatalf("subject = %q", id.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// Each of these is a real-world JWT forgery or a replay. None may pass.
|
||||
func TestIDTokenValidationRejectsForgeriesAndReplays(t *testing.T) {
|
||||
signer := newTestSigner(t)
|
||||
other := newTestSigner(t)
|
||||
other.kid = signer.kid // same key ID, different key: a substitution attack
|
||||
|
||||
srv := httptest.NewServer(signer.jwksHandler())
|
||||
defer srv.Close()
|
||||
|
||||
base := func() map[string]any {
|
||||
return map[string]any{
|
||||
"iss": "https://id.example", "aud": "client-1", "sub": "user-42",
|
||||
"nonce": "n-1", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(t *testing.T) string
|
||||
nonce string
|
||||
}{
|
||||
{"signed by the wrong key", func(t *testing.T) string {
|
||||
return other.sign(t, "RS256", base())
|
||||
}, "n-1"},
|
||||
|
||||
{"alg none", func(t *testing.T) string {
|
||||
hb, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT", "kid": signer.kid})
|
||||
cb, _ := json.Marshal(base())
|
||||
return base64.RawURLEncoding.EncodeToString(hb) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(cb) + "."
|
||||
}, "n-1"},
|
||||
|
||||
{"HMAC using the public key as the secret", func(t *testing.T) string {
|
||||
hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": signer.kid})
|
||||
cb, _ := json.Marshal(base())
|
||||
return base64.RawURLEncoding.EncodeToString(hb) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(cb) + ".c2ln"
|
||||
}, "n-1"},
|
||||
|
||||
{"wrong audience", func(t *testing.T) string {
|
||||
c := base()
|
||||
c["aud"] = "someone-elses-client"
|
||||
return signer.sign(t, "RS256", c)
|
||||
}, "n-1"},
|
||||
|
||||
{"wrong issuer", func(t *testing.T) string {
|
||||
c := base()
|
||||
c["iss"] = "https://evil.test"
|
||||
return signer.sign(t, "RS256", c)
|
||||
}, "n-1"},
|
||||
|
||||
{"expired", func(t *testing.T) string {
|
||||
c := base()
|
||||
c["exp"] = time.Now().Add(-time.Hour).Unix()
|
||||
return signer.sign(t, "RS256", c)
|
||||
}, "n-1"},
|
||||
|
||||
{"no expiry at all", func(t *testing.T) string {
|
||||
c := base()
|
||||
delete(c, "exp")
|
||||
return signer.sign(t, "RS256", c)
|
||||
}, "n-1"},
|
||||
|
||||
{"replayed with a different nonce", func(t *testing.T) string {
|
||||
return signer.sign(t, "RS256", base())
|
||||
}, "a-different-nonce"},
|
||||
|
||||
{"no subject", func(t *testing.T) string {
|
||||
c := base()
|
||||
delete(c, "sub")
|
||||
return signer.sign(t, "RS256", c)
|
||||
}, "n-1"},
|
||||
|
||||
{"payload tampered after signing", func(t *testing.T) string {
|
||||
raw := signer.sign(t, "RS256", base())
|
||||
parts := strings.Split(raw, ".")
|
||||
c := base()
|
||||
c["sub"] = "someone-else"
|
||||
cb, _ := json.Marshal(c)
|
||||
return parts[0] + "." + base64.RawURLEncoding.EncodeToString(cb) + "." + parts[2]
|
||||
}, "n-1"},
|
||||
|
||||
{"not a JWT", func(t *testing.T) string { return "garbage" }, "n-1"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := oidcService(t, srv.URL, "https://id.example", "client-1")
|
||||
if _, err := s.identityFromIDToken(context.Background(), c.build(t), c.nonce); err == nil {
|
||||
t.Fatal("the token was accepted; it must be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A key too small to be safe must not be usable, however well-formed the JWKS is.
|
||||
func TestJWKSRejectsUndersizedKeys(t *testing.T) {
|
||||
small, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := base64.RawURLEncoding.EncodeToString(small.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.E)).Bytes())
|
||||
if _, err := rsaKeyFromJWK(n, e); err == nil {
|
||||
t.Fatal("a 1024-bit RSA key was accepted as a signing key")
|
||||
}
|
||||
}
|
||||
|
||||
// The discovery document must not be able to point the flow at another issuer.
|
||||
func TestDiscoveryRefusesAnIssuerMismatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": "https://evil.test",
|
||||
"authorization_endpoint": "https://evil.test/authorize",
|
||||
"token_endpoint": "https://evil.test/token",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := NewOAuthService(OAuthConfig{
|
||||
Enabled: true, Issuer: srv.URL, ClientID: "c", ClientSecret: "s",
|
||||
})
|
||||
err := s.discover(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("a discovery document declaring a different issuer was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "issuer") {
|
||||
t.Fatalf("error does not name the problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider that replaces a signing key while keeping its key ID looks, from
|
||||
// the cache's point of view, entirely fresh. Without a refetch on a failed
|
||||
// signature, every sign-in would fail until the cache expired — up to an hour
|
||||
// of nobody being able to get in.
|
||||
func TestKeyRotationUnderTheSameKeyIDRecovers(t *testing.T) {
|
||||
first := newTestSigner(t)
|
||||
second := newTestSigner(t)
|
||||
second.kid = first.kid // same ID, different key
|
||||
|
||||
serving := first
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serving.jwksHandler().ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := oidcService(t, srv.URL, "https://id.example", "client-1")
|
||||
claims := func() map[string]any {
|
||||
return map[string]any{
|
||||
"iss": "https://id.example", "aud": "client-1", "sub": "user-1",
|
||||
"nonce": "n", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// Warm the cache with the first key.
|
||||
if _, err := s.identityFromIDToken(context.Background(), first.sign(t, "RS256", claims()), "n"); err != nil {
|
||||
t.Fatalf("the first key did not work: %v", err)
|
||||
}
|
||||
|
||||
// The provider rotates, reusing the key ID.
|
||||
serving = second
|
||||
|
||||
id, err := s.identityFromIDToken(context.Background(), second.sign(t, "RS256", claims()), "n")
|
||||
if err != nil {
|
||||
t.Fatalf("a token signed with the rotated key was rejected: %v", err)
|
||||
}
|
||||
if id.Subject != "user-1" {
|
||||
t.Fatalf("subject = %q", id.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// The refetch must not turn a forged token into a way to hammer the provider,
|
||||
// and must not make a genuinely bad signature acceptable.
|
||||
func TestRotationRefetchDoesNotAcceptForgeries(t *testing.T) {
|
||||
signer := newTestSigner(t)
|
||||
forger := newTestSigner(t)
|
||||
forger.kid = signer.kid
|
||||
|
||||
var fetches int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fetches++
|
||||
signer.jwksHandler().ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := oidcService(t, srv.URL, "https://id.example", "client-1")
|
||||
claims := map[string]any{
|
||||
"iss": "https://id.example", "aud": "client-1", "sub": "user-1",
|
||||
"nonce": "n", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Warm the cache.
|
||||
if _, err := s.identityFromIDToken(context.Background(), signer.sign(t, "RS256", claims), "n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterWarm := fetches
|
||||
|
||||
// Twenty forged tokens must all be refused, and must not cause twenty fetches.
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := s.identityFromIDToken(context.Background(), forger.sign(t, "RS256", claims), "n"); err == nil {
|
||||
t.Fatal("a token signed by the wrong key was accepted")
|
||||
}
|
||||
}
|
||||
if extra := fetches - afterWarm; extra > 2 {
|
||||
t.Fatalf("20 forged tokens caused %d key-set fetches; the refetch must be throttled", extra)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user