466 lines
15 KiB
Go
466 lines
15 KiB
Go
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)
|
|
}
|
|
}
|