diff --git a/connector/ldap/kerberos.go b/connector/ldap/kerberos.go new file mode 100644 index 00000000..7030bfb6 --- /dev/null +++ b/connector/ldap/kerberos.go @@ -0,0 +1,332 @@ +// Package ldap implements strategies for authenticating using the LDAP protocol. +// This file contains Kerberos/SPNEGO authentication support for LDAP connector. +package ldap + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/jcmturner/gofork/encoding/asn1" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/gssapi" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/service" + "github.com/jcmturner/gokrb5/v8/spnego" + "github.com/jcmturner/gokrb5/v8/types" + + "github.com/dexidp/dex/connector" + "github.com/go-ldap/ldap/v3" +) + +// KerberosValidator abstracts SPNEGO validation for unit-testing. +type KerberosValidator interface { + // ValidateRequest returns (principal, realm, ok, err). ok=false means header missing/invalid. + ValidateRequest(r *http.Request) (string, string, bool, error) + // Challenge writes a 401 Negotiate challenge. + Challenge(w http.ResponseWriter) + // ContinueToken tries to advance SPNEGO handshake and returns a response token to include + // in WWW-Authenticate: Negotiate . Returns (nil, false) if no continuation is needed/possible. + ContinueToken(r *http.Request) ([]byte, bool) +} + +// writeNegotiateChallenge writes a standard 401 Negotiate challenge. +func writeNegotiateChallenge(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", "Negotiate") + w.WriteHeader(http.StatusUnauthorized) +} + +// mapPrincipal maps a Kerberos principal to LDAP username per configuration. +func mapPrincipal(principal, realm, mapping string) string { + p := principal + switch strings.ToLower(mapping) { + case "localpart", "samaccountname": + if i := strings.IndexByte(principal, '@'); i >= 0 { + p = principal[:i] + } + return strings.ToLower(p) + case "userprincipalname": + return strings.ToLower(principal) + default: + if i := strings.IndexByte(principal, '@'); i >= 0 { + p = principal[:i] + } + return strings.ToLower(p) + } +} + +// gokrb5 implementation of KerberosValidator + +// context key used by gokrb5 to store credentials in the context +var ctxCredentialsKey interface{} = "github.com/jcmturner/gokrb5/v8/ctxCredentials" + +// SPNEGO NegTokenResp (AcceptIncomplete + KRB5 mech) base64 payload used by gokrb5's HTTP server +// to prompt the client to continue the handshake. +const spnegoIncompleteKRB5B64 = "oRQwEqADCgEBoQsGCSqGSIb3EgECAg==" + +type gokrb5Validator struct { + kt *keytab.Keytab + logger *slog.Logger +} + +func newGokrb5ValidatorWithLogger(keytabPath string, logger *slog.Logger) (KerberosValidator, error) { + kt, err := keytab.Load(keytabPath) + if err != nil { + return nil, fmt.Errorf("failed to load keytab: %w", err) + } + if fi, err := os.Stat(keytabPath); err != nil || fi.IsDir() { + return nil, fmt.Errorf("invalid keytab path: %s", keytabPath) + } + if logger == nil { + logger = slog.Default() + } + return &gokrb5Validator{kt: kt, logger: logger}, nil +} + +func (v *gokrb5Validator) ValidateRequest(r *http.Request) (string, string, bool, error) { + h := r.Header.Get("Authorization") + if h == "" || !strings.HasPrefix(h, "Negotiate ") { + if v.logger != nil { + v.logger.Info("kerberos: missing or non-negotiate Authorization header", "path", r.URL.Path) + } + return "", "", false, nil + } + b64 := strings.TrimSpace(h[len("Negotiate "):]) + if b64 == "" { + if v.logger != nil { + v.logger.Info("kerberos: empty negotiate token", "path", r.URL.Path) + } + return "", "", false, nil + } + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + if v.logger != nil { + v.logger.Info("kerberos: invalid base64 in Authorization", "err", err) + } + return "", "", false, nil + } + var tok spnego.SPNEGOToken + if err := tok.Unmarshal(data); err != nil { + // Try raw KRB5 token and wrap + var k5 spnego.KRB5Token + if k5.Unmarshal(data) != nil { + if v.logger != nil { + v.logger.Info("kerberos: failed to unmarshal SPNEGO token and not raw KRB5", "err", err) + } + return "", "", false, nil + } + tok.Init = true + tok.NegTokenInit = spnego.NegTokenInit{ + MechTypes: []asn1.ObjectIdentifier{k5.OID}, + MechTokenBytes: data, + } + } + + // Pass client address when available (improves AP-REQ validation with address-bound tickets) + var sp *spnego.SPNEGO + if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { + sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) + } else { + if v.logger != nil { + v.logger.Info("kerberos: cannot parse client address", "remote", r.RemoteAddr, "err", err) + } + sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) + } + authed, ctx, status := sp.AcceptSecContext(&tok) + if status.Code != gssapi.StatusComplete { + if v.logger != nil { + v.logger.Info("kerberos: AcceptSecContext not complete", "code", status.Code, "message", status.Message) + } + return "", "", false, nil + } + if !authed || ctx == nil { + if v.logger != nil { + v.logger.Info("kerberos: not authenticated or no context") + } + return "", "", false, nil + } + id, _ := ctx.Value(ctxCredentialsKey).(*credentials.Credentials) + if id == nil { + if v.logger != nil { + v.logger.Info("kerberos: credentials missing in context") + } + return "", "", false, fmt.Errorf("no credentials in context") + } + return id.UserName(), id.Domain(), true, nil +} + +func (v *gokrb5Validator) Challenge(w http.ResponseWriter) { writeNegotiateChallenge(w) } + +// ContinueToken attempts to continue the SPNEGO handshake and returns a response token +// (to be placed into WWW-Authenticate: Negotiate ) if available. +func (v *gokrb5Validator) ContinueToken(r *http.Request) ([]byte, bool) { + h := r.Header.Get("Authorization") + if h == "" || !strings.HasPrefix(h, "Negotiate ") { + if v.logger != nil { + v.logger.Info("kerberos: ContinueToken without negotiate header", "path", r.URL.Path) + } + return nil, false + } + b64 := strings.TrimSpace(h[len("Negotiate "):]) + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + // Malformed header: ask client to continue with KRB5 mech + if tok, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { + if v.logger != nil { + v.logger.Info("kerberos: malformed negotiate token; sending incomplete KRB5 response") + } + return tok, true + } + return nil, false + } + var tok spnego.SPNEGOToken + if err := tok.Unmarshal(data); err != nil { + // Not a full SPNEGO token; still ask client to continue + if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { + if v.logger != nil { + v.logger.Info("kerberos: non-SPNEGO token; sending incomplete KRB5 response") + } + return tokb, true + } + // As a fallback, try wrapping as raw KRB5 + var k5 spnego.KRB5Token + if k5.Unmarshal(data) != nil { + if v.logger != nil { + v.logger.Info("kerberos: not KRB5 token; cannot continue") + } + return nil, false + } + tok.Init = true + tok.NegTokenInit = spnego.NegTokenInit{MechTypes: []asn1.ObjectIdentifier{k5.OID}, MechTokenBytes: data} + } + // Try continue with same options as in ValidateRequest + var sp *spnego.SPNEGO + if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { + sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) + } else { + sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) + } + _, ctx, status := sp.AcceptSecContext(&tok) + if status.Code != gssapi.StatusContinueNeeded || ctx == nil { + if v.logger != nil { + v.logger.Info("kerberos: no continuation required", "code", status.Code, "message", status.Message) + } + return nil, false + } + // Ask client to continue using standard NegTokenResp (KRB5, incomplete) + if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { + if v.logger != nil { + v.logger.Info("kerberos: continuation needed; sending incomplete KRB5 response") + } + return tokb, true + } + return nil, false +} + +// LDAP connector SPNEGO integration + +// krbLookupUserHook allows tests to inject a user entry without LDAP queries. +var krbLookupUserHook func(c *ldapConnector, username string) (ldap.Entry, bool, error) + +// TrySPNEGO attempts Kerberos auth and builds identity on success. +func (c *ldapConnector) TrySPNEGO(ctx context.Context, s connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + if !c.krbEnabled || c.krbValidator == nil { + return nil, false, nil + } + + principal, realm, ok, err := c.krbValidator.ValidateRequest(r) + if err != nil || !ok { + if !c.krbConf.FallbackToPassword { + // Try to get a continuation token to advance SPNEGO handshake + if tok, ok2 := c.krbValidator.ContinueToken(r); ok2 && len(tok) > 0 { + c.logger.Info("kerberos SPNEGO continuation required; sending response token") + w.Header().Set("WWW-Authenticate", "Negotiate "+base64.StdEncoding.EncodeToString(tok)) + w.WriteHeader(http.StatusUnauthorized) + return nil, true, nil + } + if err != nil { + c.logger.Info("kerberos SPNEGO validation error; sending Negotiate challenge", "err", err) + } else { + c.logger.Info("kerberos SPNEGO not completed or header missing; sending Negotiate challenge") + } + c.krbValidator.Challenge(w) + return nil, true, nil + } + c.logger.Info("kerberos SPNEGO fallback to password enabled; rendering login form") + return nil, false, nil + } + + if c.krbConf.ExpectedRealm != "" && !strings.EqualFold(c.krbConf.ExpectedRealm, realm) { + c.logger.Info("kerberos realm mismatch", "expected", c.krbConf.ExpectedRealm, "actual", realm) + if !c.krbConf.FallbackToPassword { + c.krbValidator.Challenge(w) + return nil, true, nil + } + c.logger.Info("kerberos realm mismatch but fallback enabled; rendering login form") + return nil, false, nil + } + + mapped := mapPrincipal(principal, realm, c.krbConf.UsernameFromPrincipal) + c.logger.Info("kerberos principal mapped", "principal", principal, "realm", realm, "mapped_username", mapped) + + var userEntry ldap.Entry + // Allow test hook override + if krbLookupUserHook != nil { + if v, found, herr := krbLookupUserHook(c, mapped); found { + if herr != nil { + return nil, true, herr + } + userEntry = v + } + } + + if userEntry.DN == "" { + // Reuse existing search logic via do() and userEntry + err = c.do(ctx, func(conn *ldap.Conn) error { + entry, found, err := c.userEntry(conn, mapped) + if err != nil { + return err + } + if !found { + return fmt.Errorf("user not found for principal") + } + userEntry = entry + return nil + }) + } + if err != nil { + c.logger.Error("kerberos user lookup failed", "principal", principal, "mapped", mapped, "err", err) + return nil, true, fmt.Errorf("ldap: user lookup failed for kerberos principal %q: %v", principal, err) + } + c.logger.Info("kerberos user lookup succeeded", "dn", userEntry.DN) + + ident, err := c.identityFromEntry(userEntry) + if err != nil { + c.logger.Info("failed to build identity from LDAP entry after kerberos SPNEGO", "err", err) + return nil, true, err + } + if s.Groups { + groups, err := c.groups(ctx, userEntry) + if err != nil { + c.logger.Info("failed to query groups after kerberos SPNEGO", "err", err) + return nil, true, fmt.Errorf("ldap: failed to query groups: %v", err) + } + ident.Groups = groups + } + + // No password -> no user bind; do not set ConnectorData unless OfflineAccess requested + if s.OfflineAccess { + refresh := refreshData{Username: mapped, Entry: userEntry} + if data, mErr := json.Marshal(refresh); mErr == nil { + ident.ConnectorData = data + } + } + + c.logger.Info("kerberos SPNEGO authentication succeeded", "username", ident.Username, "email", ident.Email, "groups_count", len(ident.Groups)) + return &ident, true, nil +} diff --git a/connector/ldap/kerberos_test.go b/connector/ldap/kerberos_test.go new file mode 100644 index 00000000..3885e40b --- /dev/null +++ b/connector/ldap/kerberos_test.go @@ -0,0 +1,452 @@ +package ldap + +import ( + "encoding/base64" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/dexidp/dex/connector" + ldaplib "github.com/go-ldap/ldap/v3" +) + +type mockKrbValidator struct { + principal string + realm string + ok bool + err error + challenged bool + contToken []byte + step int // -1 means disabled; 0->continue, then success +} + +func (m *mockKrbValidator) ValidateRequest(r *http.Request) (string, string, bool, error) { + if m.step >= 0 { + if m.step == 0 { + return "", "", false, nil + } + return m.principal, m.realm, true, nil + } + return m.principal, m.realm, m.ok, m.err +} + +func (m *mockKrbValidator) Challenge(w http.ResponseWriter) { + m.challenged = true + writeNegotiateChallenge(w) +} + +func (m *mockKrbValidator) ContinueToken(r *http.Request) ([]byte, bool) { + if m.step >= 0 { + if m.step == 0 && len(m.contToken) > 0 { + m.step++ + return m.contToken, true + } + return nil, false + } + if len(m.contToken) > 0 { + return m.contToken, true + } + return nil, false +} + +func TestKerberos_NoHeader_Returns401Negotiate(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Result().StatusCode != 401 { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected Negotiate challenge") + } +} + +func TestKerberos_ExpectedRealmMismatch_401(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart", ExpectedRealm: "EXAMPLE.COM"}} + mv := &mockKrbValidator{principal: "jdoe@OTHER.COM", realm: "OTHER.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Result().StatusCode != 401 { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected Negotiate challenge") + } +} + +func TestKerberos_FallbackToPassword_NoHeader_HandledFalse(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) { + t.Fatalf("expected not handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } +} + +func TestKerberos_ContinueNeeded_SendsResponseToken(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, contToken: []byte{0x01, 0x02, 0x03}} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate Zm9v") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Code != 401 { + t.Fatalf("expected 401, got %d", w.Code) + } + hdr := w.Header().Get("WWW-Authenticate") + if !strings.HasPrefix(hdr, "Negotiate ") { + t.Fatalf("expected Negotiate header, got %q", hdr) + } + want := "Negotiate " + base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03}) + if hdr != want { + t.Fatalf("unexpected negotiate header, got %q want %q", hdr, want) + } +} + +func TestKerberos_ContinueThenSuccess_ShortCircuitIdentity(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", contToken: []byte{0xAA, 0xBB}, step: 0} + lc.krbValidator = mv + + // First request -> 401 with continue token + r1 := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w1 := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r1.Context(), connector.Scopes{}, w1, r1) + if err != nil || !bool(handled) || ident != nil || w1.Code != 401 { + t.Fatalf("unexpected first step result: ident=%v handled=%v code=%d err=%v", ident, handled, w1.Code, err) + } + hdr := w1.Header().Get("WWW-Authenticate") + if hdr == "" || !strings.HasPrefix(hdr, "Negotiate ") { + t.Fatalf("expected Negotiate header on first step, got %q", hdr) + } + + // Second request -> validator now returns ok=true (due to step increment) + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r2 := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w2 := httptest.NewRecorder() + ident2, handled2, err2 := lc.TrySPNEGO(r2.Context(), connector.Scopes{}, w2, r2) + if err2 != nil { + t.Fatalf("unexpected err: %v", err2) + } + if !bool(handled2) || ident2 == nil { + t.Fatalf("expected handled with identity on second step") + } +} + +func TestKerberos_ContinueNeeded_FallbackTrue_NotHandled(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{contToken: []byte{0x10}, step: 0} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) || ident != nil { + t.Fatalf("expected not handled with fallback=true when continue is needed") + } + if w.Code != 200 && w.Code != 0 { + t.Fatalf("expected no response written yet, got %d", w.Code) + } +} + +func TestKerberos_mapPrincipal(t *testing.T) { + cases := []struct{ in, realm, mode, want string }{ + {"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "localpart", "jdoe"}, + {"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "sAMAccountName", "jdoe"}, + {"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "userPrincipalName", "jdoe@example.com"}, + } + for _, c := range cases { + got := mapPrincipal(c.in, c.realm, c.mode) + if got != c.want { + t.Fatalf("mapPrincipal(%q,%q,%q)=%q; want %q", c.in, c.realm, c.mode, got, c.want) + } + } +} + +func TestKerberos_UserNotFound_ReturnsError(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err == nil { + t.Fatalf("expected error for user not found") + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if !strings.Contains(err.Error(), "user lookup failed") { + t.Fatalf("expected 'user lookup failed' error, got: %v", err) + } +} + +func TestKerberos_ValidPrincipal_CompletesFlow(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident == nil { + t.Fatalf("expected identity") + } + if ident.Username == "" || ident.Email == "" || ident.UserID == "" { + t.Fatalf("expected populated identity, got %+v", *ident) + } +} + +func TestKerberos_InvalidHeader_Returns401Negotiate(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate !!!notbase64!!!") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Result().StatusCode != 401 { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected Negotiate challenge") + } +} + +func TestKerberos_UserPrincipalName_Mapping(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "userPrincipalName"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "J.Doe@Example.COM", realm: "Example.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + if username != "j.doe@example.com" { + return ldaplib.Entry{}, false, nil + } + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident == nil { + t.Fatalf("expected identity") + } +} + +func TestKerberos_OfflineAccess_SetsConnectorData(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + scopes := connector.Scopes{OfflineAccess: true} + ident, handled, err := lc.TrySPNEGO(r.Context(), scopes, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident == nil { + t.Fatalf("expected identity") + } + if len(ident.ConnectorData) == 0 { + t.Fatalf("expected connector data for offline access") + } +} + +func TestKerberos_FallbackTrue_InvalidHeader_NotHandled(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} + mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} + lc.krbValidator = mv + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate !!!") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) { + t.Fatalf("expected not handled for fallback path") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Code != 200 && w.Code != 0 { + t.Fatalf("expected no response written yet, got %d", w.Code) + } +} + +func TestKerberos_sAMAccountName_EqualsLocalpart(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "sAMAccountName"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "Admin@REALM.LOCAL", realm: "REALM.LOCAL", ok: true, err: nil, step: -1} + lc.krbValidator = mv + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + if username != "admin" { + return ldaplib.Entry{}, false, nil + } + e := ldaplib.NewEntry("cn=admin,dc=local", map[string][]string{ + c.UserSearch.IDAttr: {"uid-admin"}, + c.UserSearch.EmailAttr: {"admin@local"}, + c.UserSearch.NameAttr: {"Admin"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident == nil { + t.Fatalf("expected identity") + } +} + +func TestKerberos_ExpectedRealm_CaseInsensitive(t *testing.T) { + lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart", ExpectedRealm: "ExAmPlE.CoM"}} + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + mv := &mockKrbValidator{principal: "user@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} + lc.krbValidator = mv + krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { + e := ldaplib.NewEntry("cn=user,dc=example,dc=com", map[string][]string{ + c.UserSearch.IDAttr: {"uid-user"}, + c.UserSearch.EmailAttr: {"user@example.com"}, + c.UserSearch.NameAttr: {"User"}, + }) + return *e, true, nil + } + defer func() { krbLookupUserHook = nil }() + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident == nil { + t.Fatalf("expected identity") + } +} diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 4cb7180e..e2a94e17 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -102,6 +102,9 @@ type Config struct { // "Username". UsernamePrompt string `json:"usernamePrompt"` + // Optional Kerberos (SPNEGO) SSO configuration. + Kerberos *kerberosConfig `json:"kerberos"` + // User entry search configuration. UserSearch struct { // BaseDN to start the search from. For example "cn=users,dc=example,dc=com" @@ -164,6 +167,15 @@ type Config struct { } `json:"groupSearch"` } +// kerberosConfig defines optional Kerberos (SPNEGO) SSO settings for LDAP. +type kerberosConfig struct { + Enabled bool `json:"enabled"` + KeytabPath string `json:"keytabPath"` + ExpectedRealm string `json:"expectedRealm"` + UsernameFromPrincipal string `json:"usernameFromPrincipal"` + FallbackToPassword bool `json:"fallbackToPassword"` +} + func scopeString(i int) string { switch i { case ldap.ScopeBaseObject: @@ -217,6 +229,17 @@ func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, erro if err != nil { return nil, err } + // If Kerberos is enabled, initialize gokrb5 validator. + if lc, ok := conn.(*ldapConnector); ok && lc.krbEnabled && lc.krbValidator == nil { + v, verr := newGokrb5ValidatorWithLogger(lc.krbConf.KeytabPath, logger) + if verr != nil { + logger.Warn("failed to initialize kerberos validator; disabling kerberos", "err", verr) + lc.krbEnabled = false + } else { + lc.krbValidator = v + logger.Info("Kerberos enabled for LDAP connector", "keytab", lc.krbConf.KeytabPath, "expected_realm", lc.krbConf.ExpectedRealm) + } + } return connector.Connector(conn), nil } @@ -298,7 +321,31 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { // TODO(nabokihms): remove it after deleting deprecated groupSearch options c.GroupSearch.UserMatchers = userMatchers(c, logger) - return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil + + // Normalize Kerberos defaults + var krbEnabled bool + var krbConf kerberosConfig + if c.Kerberos != nil { + krbConf = *c.Kerberos + if krbConf.UsernameFromPrincipal == "" { + krbConf.UsernameFromPrincipal = "localpart" + } + if krbConf.Enabled { + if krbConf.KeytabPath == "" { + logger.Warn("kerberos enabled but keytabPath is empty; disabling kerberos") + } else { + krbEnabled = true + } + } + } + + lc := &ldapConnector{Config: *c, userSearchScope: userSearchScope, groupSearchScope: groupSearchScope, tlsConfig: tlsConfig, logger: logger} + if krbEnabled { + lc.krbEnabled = true + lc.krbConf = krbConf + // The actual Kerberos validator will be set later in Open() via a constructor. + } + return lc, nil } var ( @@ -315,6 +362,11 @@ type ldapConnector struct { tlsConfig *tls.Config logger *slog.Logger + + // Kerberos/SPNEGO fields + krbEnabled bool + krbConf kerberosConfig + krbValidator KerberosValidator } // do initializes a connection to the LDAP directory and passes it to the diff --git a/connector/spnego.go b/connector/spnego.go new file mode 100644 index 00000000..ab89e840 --- /dev/null +++ b/connector/spnego.go @@ -0,0 +1,22 @@ +package connector + +import ( + "context" + "net/http" +) + +// Handled indicates whether the SPNEGO-aware connector handled the request. +type Handled bool + +// SPNEGOAware is an optional extension for connectors that can authenticate +// users via Kerberos SPNEGO on the initial GET to the password login endpoint. +// +// If handled is true and ident is non-nil, the caller should complete the +// OAuth flow as with a successful password login. If handled is true and +// ident is nil, the implementation has already written an appropriate +// response (e.g., 401 with WWW-Authenticate: Negotiate) and the caller should +// return without rendering the password form. If handled is false, proceed +// with the legacy password form flow. +type SPNEGOAware interface { + TrySPNEGO(ctx context.Context, s Scopes, w http.ResponseWriter, r *http.Request) (*Identity, Handled, error) +} diff --git a/examples/ldap/config-ldap.yaml b/examples/ldap/config-ldap.yaml index 05d16618..1e4b3586 100644 --- a/examples/ldap/config-ldap.yaml +++ b/examples/ldap/config-ldap.yaml @@ -59,6 +59,15 @@ connectors: # The group name should be the "cn" value. nameAttr: cn + # Optional Kerberos (SPNEGO) SSO. When enabled, Dex will challenge with + # WWW-Authenticate: Negotiate on GET and skip the password form on success. + #kerberos: + # enabled: true + # keytabPath: /etc/dex/krb5.keytab + # expectedRealm: EXAMPLE.COM + # usernameFromPrincipal: sAMAccountName # or userPrincipalName or localpart + # fallbackToPassword: false + staticClients: - id: example-app redirectURIs: diff --git a/go.mod b/go.mod index 99e88730..d2c81a93 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 + github.com/jcmturner/gofork v1.7.6 + github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/kylelemons/godebug v1.1.0 github.com/lib/pq v1.11.2 github.com/mattermost/xml-roundtrip-validator v0.1.0 @@ -80,10 +82,15 @@ require ( github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index f735bcd6..2b8365a6 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,10 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= @@ -124,6 +128,7 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= @@ -222,14 +227,21 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= @@ -268,18 +280,26 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -287,15 +307,26 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -304,6 +335,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= @@ -332,6 +364,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/handlers.go b/server/handlers.go index a7a55aa2..ca519fad 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -494,6 +494,43 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + // Before rendering the password form, allow connectors that support SPNEGO to try Kerberos auth. + if sp, ok := pwConn.(connector.SPNEGOAware); ok { + scopes := parseScopes(authReq.Scopes) + if ident, handled, err := sp.TrySPNEGO(r.Context(), scopes, w, r); bool(handled) { + if err != nil { + // SPNEGO handled the request but reported an error (e.g., LDAP lookup failed + // after successful Kerberos auth). Log error details, show generic message to user. + s.logger.ErrorContext(r.Context(), "SPNEGO authentication error", "err", err) + s.renderError(r, w, http.StatusUnauthorized, ErrMsgAuthenticationFailed) + return + } + if ident != nil { + redirectURL, canSkipApproval, err := s.finalizeLogin(r.Context(), *ident, authReq, conn.Connector) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to finalize login", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + if canSkipApproval { + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + s.sendCodeResponse(w, r, authReq) + return + } + + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return + } + // handled with no identity typically means a Negotiate challenge was written; do not render form. + return + } + } if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } diff --git a/server/handlers_test.go b/server/handlers_test.go index 12c664f5..d89c5aa6 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -956,6 +956,181 @@ func TestHandleConnectorCallbackWithSkipApproval(t *testing.T) { } } +// SPNEGO integration test (server layer): on GET login, if connector implements SPNEGOAware +// and returns an identity, server should finalize login and redirect without rendering form. +func TestHandlePasswordLogin_SPNEGOShortCircuit(t *testing.T) { + ctx := t.Context() + connID := "mockPassword" + authReqID := "spnego" + expiry := time.Now().Add(100 * time.Second) + resTypes := []string{responseTypeCode} + + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = true + c.Now = time.Now + }) + defer httpServer.Close() + + // Create password connector which we will wrap with a SPNEGO-aware adapter in storage config + sc := storage.Connector{ + ID: connID, + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte("{\"username\": \"foo\", \"password\": \"password\"}"), + } + require.NoError(t, s.storage.CreateConnector(ctx, sc)) + _, err := s.OpenConnector(sc) + require.NoError(t, err) + + // Prepare auth request + require.NoError(t, s.storage.CreateAuthRequest(ctx, storage.AuthRequest{ + ID: authReqID, + ClientID: "client_1", + ConnectorID: connID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: resTypes, + Scopes: []string{"openid"}, + })) + + // Replace the server connector with a SPNEGO-aware fake that short-circuits + s.mu.Lock() + orig := s.connectors[connID] + s.connectors[connID] = Connector{ + ResourceVersion: orig.ResourceVersion, + Connector: spnegoShortCircuit{Identity: connector.Identity{ + UserID: "user-id", + Username: "user", + Email: "user@example.com", + EmailVerified: true, + }}, + } + s.mu.Unlock() + + // Need a client for finalizeLogin to succeed + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret", + RedirectURIs: []string{"http://127.0.0.1/callback"}, + Name: "test", + })) + + // GET login should short-circuit and redirect to /approval or code response + rr := httptest.NewRecorder() + path := fmt.Sprintf("/auth/%s/login?state=%s&back=", connID, authReqID) + s.handlePasswordLogin(rr, httptest.NewRequest("GET", path, nil)) + + // In SkipApproval mode server may directly send code response (200) or 303 redirect + if rr.Code != http.StatusSeeOther && rr.Code != http.StatusOK { + t.Fatalf("expected 200 or 303, got %d", rr.Code) + } +} + +// spnegoShortCircuit implements connector.PasswordConnector and connector.SPNEGOAware +// to simulate successful SPNEGO authentication on GET. +type spnegoShortCircuit struct{ Identity connector.Identity } + +func (s spnegoShortCircuit) Close() error { return nil } +func (s spnegoShortCircuit) Prompt() string { return "" } +func (s spnegoShortCircuit) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) { + return connector.Identity{}, false, nil +} +func (s spnegoShortCircuit) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + id := s.Identity + return &id, true, nil +} + +// spnegoError implements connector.PasswordConnector and connector.SPNEGOAware +// to simulate SPNEGO authentication that fails with an error (e.g., LDAP lookup failed). +type spnegoError struct{ Err error } + +func (s spnegoError) Close() error { return nil } +func (s spnegoError) Prompt() string { return "" } +func (s spnegoError) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) { + return connector.Identity{}, false, nil +} +func (s spnegoError) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + return nil, true, s.Err +} + +// TestHandlePasswordLogin_SPNEGOError verifies that when SPNEGO returns an error +// (e.g., Kerberos auth succeeded but LDAP lookup failed), the server renders +// an error page instead of showing an empty 401 or falling back to password form. +func TestHandlePasswordLogin_SPNEGOError(t *testing.T) { + ctx := t.Context() + connID := "mockPassword" + authReqID := "spnego-err" + expiry := time.Now().Add(100 * time.Second) + resTypes := []string{responseTypeCode} + + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = true + c.Now = time.Now + }) + defer httpServer.Close() + + // Create password connector + sc := storage.Connector{ + ID: connID, + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte("{\"username\": \"foo\", \"password\": \"password\"}"), + } + require.NoError(t, s.storage.CreateConnector(ctx, sc)) + _, err := s.OpenConnector(sc) + require.NoError(t, err) + + // Prepare auth request + require.NoError(t, s.storage.CreateAuthRequest(ctx, storage.AuthRequest{ + ID: authReqID, + ClientID: "client_1", + ConnectorID: connID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: resTypes, + Scopes: []string{"openid"}, + })) + + // Replace the server connector with a SPNEGO-aware fake that returns an error + s.mu.Lock() + orig := s.connectors[connID] + s.connectors[connID] = Connector{ + ResourceVersion: orig.ResourceVersion, + Connector: spnegoError{Err: errors.New("ldap: user lookup failed for kerberos principal")}, + } + s.mu.Unlock() + + // Need a client for the flow + require.NoError(t, s.storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret", + RedirectURIs: []string{"http://127.0.0.1/callback"}, + Name: "test", + })) + + // GET login should return 401 with error message rendered + rr := httptest.NewRecorder() + path := fmt.Sprintf("/auth/%s/login?state=%s&back=", connID, authReqID) + s.handlePasswordLogin(rr, httptest.NewRequest("GET", path, nil)) + + // Should return 401 (unauthorized) with an error page, not 200 (form) or empty response + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized, got %d", rr.Code) + } + + // The response body should contain safe generic error message (not internal details) + body := rr.Body.String() + if !strings.Contains(body, "Authentication failed") { + t.Fatalf("expected error page with 'Authentication failed', got: %s", body) + } + // Should NOT contain internal error details (per 008-hide-internal-500-error-details.patch) + if strings.Contains(body, "ldap: user lookup failed") { + t.Fatalf("error page should not contain internal error details") + } +} + func TestHandleTokenExchange(t *testing.T) { tests := []struct { name string