diff --git a/cmd/dex/config.go b/cmd/dex/config.go index e80c1c57..fdb086f0 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -143,7 +143,7 @@ func (c Config) validateMFA() error { return fmt.Errorf("mfa requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") } - knownTypes := map[string]bool{"TOTP": true} + knownTypes := map[string]bool{"TOTP": true, "WebAuthn": true} ids := make(map[string]bool, len(mfa.Authenticators)) for _, auth := range mfa.Authenticators { @@ -705,3 +705,36 @@ type TOTPConfig struct { // Issuer is the name of the service shown in the authenticator app. Issuer string `json:"issuer"` } + +// WebAuthnConfig holds configuration for a WebAuthn authenticator. +type WebAuthnConfig struct { + // RPDisplayName is the human-readable relying party name shown in the browser + // dialog during key registration and authentication (e.g., "My Company SSO"). + RPDisplayName string `json:"rpDisplayName"` + // RPID is the relying party identifier — must match the domain in the browser + // address bar. If empty, derived from the issuer URL hostname. + // Example: "auth.example.com" + RPID string `json:"rpID"` + // RPOrigins is the list of allowed origins for WebAuthn ceremonies. + // If empty, derived from the issuer URL (scheme + host). + // Example: ["https://auth.example.com"] + RPOrigins []string `json:"rpOrigins"` + // AttestationPreference controls what attestation data the authenticator should provide: + // "none" — don't request attestation (simpler, more private) + // "indirect" — authenticator may anonymize attestation (default) + // "direct" — request full attestation (for enterprise key model verification) + AttestationPreference string `json:"attestationPreference"` + // UserVerification controls whether PIN or biometric verification is required: + // "required" — always require (PIN, fingerprint, etc.) + // "preferred" — request if the authenticator supports it (default) + // "discouraged" — skip verification, presence check only + UserVerification string `json:"userVerification"` + // AuthenticatorAttachment restricts which authenticator types are allowed: + // "platform" — built-in only (Touch ID, Windows Hello) + // "cross-platform" — external only (YubiKey, USB security keys) + // "" — any authenticator (default) + AuthenticatorAttachment string `json:"authenticatorAttachment"` + // Timeout is the duration allowed for the browser WebAuthn ceremony + // (registration or login). Defaults to "60s". + Timeout string `json:"timeout"` +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 0c26006d..22eed432 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -385,7 +385,7 @@ func runServe(options serveOptions) error { ContinueOnConnectorFailure: featureflags.ContinueOnConnectorFailure.Enabled(), Signer: signerInstance, IDTokensValidFor: idTokensValidFor, - MFAProviders: buildMFAProviders(c.MFA.Authenticators, logger), + MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), DefaultMFAChain: c.MFA.DefaultMFAChain, } @@ -823,7 +823,7 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } -func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) map[string]server.MFAProvider { +func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { if len(authenticators) == 0 { return nil } @@ -839,6 +839,20 @@ func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) m } providers[auth.ID] = server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes) logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + case "WebAuthn": + var cfg WebAuthnConfig + if err := json.Unmarshal(auth.Config, &cfg); err != nil { + logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err) + continue + } + provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins, + cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes) + if err != nil { + logger.Error("failed to create WebAuthn provider", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) default: logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type) } diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 1b9da1f1..5179cb68 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -141,21 +141,32 @@ telemetry: # Multi-factor authentication configuration. # Requires DEX_SESSIONS_ENABLED=true feature flag. # mfa: -# authenticators: -# - id: totp-1 -# type: TOTP -# config: -# issuer: "dex-1" -# # Optional: limit this authenticator to specific connector types (e.g., ldap, oidc, saml). -# # If omitted or empty, applies to all connector types. -# # It is recommended to use this option to prevent MFA from being used for connectors -# # with their own MFA mechanisms, e.g., OIDC, Google, etc. (but technically, it is possible). -# connectorTypes: -# - mockCallback -# # Default MFA chain applied to clients that don't specify their own mfaChain. -# # If omitted or empty, no MFA is required by default. -# defaultMFAChain: -# - totp-1 +# authenticators: +# - id: totp-1 +# type: TOTP +# config: +# issuer: "dex-1" +# # Optional: limit this authenticator to specific connector types (e.g., ldap, oidc, saml). +# # If omitted or empty, applies to all connector types. +# # It is recommended to use this option to prevent MFA from being used for connectors +# # with their own MFA mechanisms, e.g., OIDC, Google, etc. (but technically, it is possible). +# connectorTypes: +# - mockCallback +# - id: webauthn-1 +# type: WebAuthn +# config: +# rpDisplayName: "Dex Dev" +# # rpID defaults to the hostname of the issuer URL. +# # rpID: "127.0.0.1" +# # rpOrigins defaults to the issuer URL. +# # rpOrigins: +# # - "http://127.0.0.1:5556" +# attestationPreference: "indirect" # none, indirect, or direct +# userVerification: "preferred" # required, preferred, or discouraged +# # authenticatorAttachment: "" # platform, cross-platform, or empty (any) +# timeout: "60s" +# defaultMFAChain: +# - totp-1 # Instead of reading from an external storage, use this list of clients. # diff --git a/go.mod b/go.mod index 2c68ebaf..1e1440bb 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 + github.com/go-webauthn/webauthn v0.16.1 github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -70,14 +71,18 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.19.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect @@ -114,6 +119,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.etcd.io/etcd/api/v3 v3.6.9 // indirect diff --git a/go.sum b/go.sum index a8aa547a..202a9f23 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= @@ -94,17 +96,27 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU= +github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ= +github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= +github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -250,6 +262,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= @@ -280,6 +294,8 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= diff --git a/server/handlers.go b/server/handlers.go index 825cf35e..4f615d01 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -2,7 +2,6 @@ package server import ( "context" - "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" @@ -13,7 +12,6 @@ import ( "maps" "net/http" "net/url" - "path" "sort" "strconv" "strings" @@ -887,30 +885,13 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, userIdentity = &ui } - // an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original - // flow would be unable to poll for the result at the /approval endpoint - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := h.Sum(nil) - hmacParam := base64.RawURLEncoding.EncodeToString(mac) - // Check if the client requires MFA. mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) if err != nil { return "", false, fmt.Errorf("failed to get MFA chain for client: %v", err) } if len(mfaChain) > 0 { - // Redirect to MFA verification starting with the first authenticator. - // Each authenticator redirects to the next one in the chain upon success. - // HMAC includes authenticatorID to prevent skipping steps by URL manipulation. - h.Reset() - h.Write([]byte(authReq.ID + "|" + mfaChain[0])) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", mfaChain[0]) - returnURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - return returnURL, false, nil + return s.buildMFARedirectURL(authReq, mfaChain[0]), false, nil } // No MFA required — mark as validated. @@ -933,8 +914,7 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } } - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + hmacParam - return returnURL, false, nil + return s.buildApprovalURL(authReq), false, nil } func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { @@ -944,12 +924,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") return } - mac, err := base64.RawURLEncoding.DecodeString(macEncoded) - if err != nil { - s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") - return - } - authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req")) if err != nil { if err == storage.ErrNotFound { @@ -966,7 +940,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { return } - h := hmac.New(sha256.New, authReq.HMACKey) if !authReq.MFAValidated { // Check if MFA is actually required — if so, redirect to TOTP instead of blocking. // This handles the case where MFA was enabled after the auth flow started. @@ -977,30 +950,37 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { return } if len(mfaChain) > 0 { - h.Write([]byte(authReq.ID + "|" + mfaChain[0])) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", mfaChain[0]) - h.Reset() - totpURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - http.Redirect(w, r, totpURL, http.StatusSeeOther) + http.Redirect(w, r, s.buildMFARedirectURL(authReq, mfaChain[0]), http.StatusSeeOther) return } // No MFA required but flag not set — allow through (backward compat). } - // build expected hmac with secret key - h.Write([]byte(authReq.ID)) - expectedMAC := h.Sum(nil) - // constant time comparison - if !hmac.Equal(mac, expectedMAC) { + if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, "") { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") return } switch r.Method { case http.MethodGet: + // Skip the approval page and issue the code directly if: + // 1. The client didn't force the approval prompt, AND + // 2. Either the server is configured to skip approval globally, + // or the user has already consented to all requested scopes for this client. + // This handles the MFA redirect case: after MFA completion the user lands on + // /approval via GET, and we don't want to show the consent screen again. + if !authReq.ForceApprovalPrompt { + if s.skipApproval { + s.sendCodeResponse(w, r, authReq) + return + } + ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { + s.sendCodeResponse(w, r, authReq) + return + } + } + client, err := s.storage.GetClient(ctx, authReq.ClientID) if err != nil { s.logger.ErrorContext(r.Context(), "Failed to get client", "client_id", authReq.ClientID, "err", err) diff --git a/server/handlers_approval_test.go b/server/handlers_approval_test.go index 24a72b83..4f10b654 100644 --- a/server/handlers_approval_test.go +++ b/server/handlers_approval_test.go @@ -2,9 +2,6 @@ package server import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "errors" "net/http" "net/http/httptest" @@ -89,9 +86,7 @@ func TestHandleApprovalDoubleSubmitPOST(t *testing.T) { } require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + mac := computeHMAC(authReq.HMACKey, authReq.ID, "") form := url.Values{ "approval": {"approve"}, diff --git a/server/handlers_test.go b/server/handlers_test.go index 907993e4..2f152b8d 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -3,9 +3,6 @@ package server import ( "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -832,9 +829,7 @@ func TestConsentPersistedOnApproval(t *testing.T) { } require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + mac := computeHMAC(authReq.HMACKey, authReq.ID, "") form := url.Values{ "approval": {"approve"}, diff --git a/server/hmac.go b/server/hmac.go new file mode 100644 index 00000000..39795176 --- /dev/null +++ b/server/hmac.go @@ -0,0 +1,40 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + + "google.golang.org/protobuf/proto" + + "github.com/dexidp/dex/server/internal" +) + +// computeHMAC computes a SHA-256 HMAC over a protobuf-encoded payload +// and returns the result as a base64 raw-URL-encoded string. +func computeHMAC(key []byte, values ...string) string { + msg := marshalHMACPayload(values) + h := hmac.New(sha256.New, key) + h.Write(msg) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// verifyHMAC checks that encodedMAC (base64 raw-URL) matches the +// HMAC-SHA256 of the protobuf-encoded payload under key. +func verifyHMAC(key []byte, encodedMAC string, values ...string) bool { + mac, err := base64.RawURLEncoding.DecodeString(encodedMAC) + if err != nil { + return false + } + msg := marshalHMACPayload(values) + h := hmac.New(sha256.New, key) + h.Write(msg) + return hmac.Equal(mac, h.Sum(nil)) +} + +func marshalHMACPayload(values []string) []byte { + payload := &internal.HMACPayload{Values: values} + // proto.Marshal is deterministic for the same input in the Go implementation. + data, _ := proto.Marshal(payload) + return data +} diff --git a/server/internal/types.pb.go b/server/internal/types.pb.go index 8f9cbeb8..d00e0842 100644 --- a/server/internal/types.pb.go +++ b/server/internal/types.pb.go @@ -191,6 +191,53 @@ func (x *SessionCookie) GetNonce() string { return "" } +// HMACPayload is the structured message used as HMAC input. +// Using protobuf encoding instead of string concatenation avoids +// delimiter-based ambiguities in the HMAC message. +type HMACPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HMACPayload) Reset() { + *x = HMACPayload{} + mi := &file_server_internal_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HMACPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HMACPayload) ProtoMessage() {} + +func (x *HMACPayload) ProtoReflect() protoreflect.Message { + mi := &file_server_internal_types_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HMACPayload.ProtoReflect.Descriptor instead. +func (*HMACPayload) Descriptor() ([]byte, []int) { + return file_server_internal_types_proto_rawDescGZIP(), []int{3} +} + +func (x *HMACPayload) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + var File_server_internal_types_proto protoreflect.FileDescriptor var file_server_internal_types_proto_rawDesc = string([]byte{ @@ -211,10 +258,12 @@ var file_server_internal_types_proto_rawDesc = string([]byte{ 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x6f, - 0x6e, 0x63, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x63, 0x65, 0x22, 0x25, 0x0a, 0x0b, 0x48, 0x4d, 0x41, 0x43, 0x50, 0x61, 0x79, 0x6c, 0x6f, + 0x61, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, + 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -229,11 +278,12 @@ func file_server_internal_types_proto_rawDescGZIP() []byte { return file_server_internal_types_proto_rawDescData } -var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_server_internal_types_proto_goTypes = []any{ (*RefreshToken)(nil), // 0: internal.RefreshToken (*IDTokenSubject)(nil), // 1: internal.IDTokenSubject (*SessionCookie)(nil), // 2: internal.SessionCookie + (*HMACPayload)(nil), // 3: internal.HMACPayload } var file_server_internal_types_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type @@ -254,7 +304,7 @@ func file_server_internal_types_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_server_internal_types_proto_rawDesc), len(file_server_internal_types_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/server/internal/types.proto b/server/internal/types.proto index 324e8854..d6c6c2e8 100644 --- a/server/internal/types.proto +++ b/server/internal/types.proto @@ -24,3 +24,10 @@ message SessionCookie { string connector_id = 2; string nonce = 3; } + +// HMACPayload is the structured message used as HMAC input. +// Using protobuf encoding instead of string concatenation avoids +// delimiter-based ambiguities in the HMAC message. +message HMACPayload { + repeated string values = 1; +} diff --git a/server/mfa.go b/server/mfa.go index 7d2417f6..efd24642 100644 --- a/server/mfa.go +++ b/server/mfa.go @@ -3,14 +3,11 @@ package server import ( "bytes" "context" - "crypto/hmac" - "crypto/sha256" "encoding/base64" "fmt" "image/png" "net/http" "net/url" - "path" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" @@ -59,16 +56,21 @@ func (p *TOTPProvider) generate(connID, email string) (*otp.Key, error) { }) } -func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { +// mfaRequestContext holds validated MFA request data shared across handlers. +type mfaRequestContext struct { + authReq storage.AuthRequest + identity storage.UserIdentity + authenticatorID string + approvalURL string +} + +// validateMFARequest performs common MFA request validation: HMAC check, auth request +// lookup, user identity lookup, and approval URL generation. +func (s *Server) validateMFARequest(w http.ResponseWriter, r *http.Request) (*mfaRequestContext, bool) { macEncoded := r.FormValue("hmac") if macEncoded == "" { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return - } - mac, err := base64.RawURLEncoding.DecodeString(macEncoded) - if err != nil { - s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return + return nil, false } ctx := r.Context() @@ -77,54 +79,92 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return + return nil, false } if !authReq.LoggedIn { s.logger.ErrorContext(ctx, "auth request does not have an identity for MFA verification") s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") - return + return nil, false } authenticatorID := r.FormValue("authenticator") - // Verify HMAC — includes authenticatorID to prevent skipping steps in the MFA chain. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID + "|" + authenticatorID)) - if !hmac.Equal(mac, h.Sum(nil)) { + if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, authenticatorID) { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return - } - provider, ok := s.mfaProviders[authenticatorID] - if !ok { - s.renderError(r, w, http.StatusBadRequest, "Unknown authenticator.") - return - } - - totpProvider, ok := provider.(*TOTPProvider) - if !ok { - s.renderError(r, w, http.StatusInternalServerError, "Unsupported authenticator type.") - return + return nil, false } identity, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) if err != nil { s.logger.ErrorContext(ctx, "failed to get user identity", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return + return nil, false } - // Build approval URL with an HMAC that covers only the request ID - // (MFA HMAC includes authenticatorID and is not valid for approval). - approvalH := hmac.New(sha256.New, authReq.HMACKey) - approvalH.Write([]byte(authReq.ID)) - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + - "&hmac=" + base64.RawURLEncoding.EncodeToString(approvalH.Sum(nil)) + approvalURL := s.buildApprovalURL(authReq) if authReq.MFAValidated { - http.Redirect(w, r, returnURL, http.StatusSeeOther) + http.Redirect(w, r, approvalURL, http.StatusSeeOther) + return nil, false + } + + return &mfaRequestContext{ + authReq: authReq, + identity: identity, + authenticatorID: authenticatorID, + approvalURL: approvalURL, + }, true +} + +func (s *Server) handleTOTP(w http.ResponseWriter, r *http.Request) { + mfa, ok := s.validateMFARequest(w, r) + if !ok { return } + provider, ok := s.mfaProviders[mfa.authenticatorID] + if !ok { + s.renderError(r, w, http.StatusBadRequest, "Unknown authenticator.") + return + } + totpProvider, ok := provider.(*TOTPProvider) + if !ok { + s.renderError(r, w, http.StatusBadRequest, "Not a TOTP authenticator.") + return + } + + s.handleTOTPVerify(w, r, r.Context(), mfa.authReq, mfa.identity, mfa.authenticatorID, totpProvider, mfa.approvalURL) +} + +func (s *Server) handleWebAuthn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.renderError(r, w, http.StatusMethodNotAllowed, "Unsupported request method.") + return + } + + mfa, ok := s.validateMFARequest(w, r) + if !ok { + return + } + + w.Header().Set("Cache-Control", "no-store") + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + mode := "login" + if len(user.credentials) == 0 { + mode = "register" + } + + if err := s.templates.webauthnVerify(r, w, mode, mfa.authenticatorID); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// handleTOTPVerify handles TOTP enrollment and verification. +func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request, ctx context.Context, + authReq storage.AuthRequest, identity storage.UserIdentity, + authenticatorID string, totpProvider *TOTPProvider, returnURL string, +) { secret := identity.MFASecrets[authenticatorID] switch r.Method { @@ -201,47 +241,16 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { } } - // Check if there are more authenticators in the MFA chain. - mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + redirectURL, err := s.completeMFAStep(ctx, authReq, authenticatorID) if err != nil { - s.logger.ErrorContext(ctx, "failed to get MFA chain", "err", err) + s.logger.ErrorContext(ctx, "failed to complete MFA step", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } - // Find the next authenticator in the chain after the current one. - var nextAuthenticator string - for i, id := range mfaChain { - if id == authenticatorID && i+1 < len(mfaChain) { - nextAuthenticator = mfaChain[i+1] - break - } - } - - if nextAuthenticator != "" { - // Redirect to the next authenticator in the chain. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID + "|" + nextAuthenticator)) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", nextAuthenticator) - nextURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - http.Redirect(w, r, nextURL, http.StatusSeeOther) - return - } - - // All authenticators in the chain completed — mark as validated. - if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(old storage.AuthRequest) (storage.AuthRequest, error) { - old.MFAValidated = true - return old, nil - }); err != nil { - s.logger.ErrorContext(ctx, "failed to update auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - - s.sendCodeOrRedirectToApproval(w, r, authReq, returnURL) + // completeMFAStep returns either the next MFA step URL or the approval URL. + // Redirect in both cases — the approval handler handles skipApproval logic. + http.Redirect(w, r, redirectURL, http.StatusSeeOther) default: s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") @@ -266,39 +275,6 @@ func (s *Server) renderTOTPPage(secret *storage.MFASecret, lastFail bool, issuer } } -// sendCodeOrRedirectToApproval checks skipApproval and stored consent, -// sending a code response directly if possible, or redirecting to the approval page. -func (s *Server) sendCodeOrRedirectToApproval(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, approvalURL string) { - ctx := r.Context() - - if !authReq.ForceApprovalPrompt { - if s.skipApproval { - authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - s.sendCodeResponse(w, r, authReq) - return - } - - ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) - if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { - authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - s.sendCodeResponse(w, r, authReq) - return - } - } - - http.Redirect(w, r, approvalURL, http.StatusSeeOther) -} - func generateTOTPQRCode(keyURL string) (string, error) { generated, err := otp.NewKeyFromURL(keyURL) if err != nil { @@ -362,3 +338,53 @@ func (s *Server) getConnectorType(ctx context.Context, connectorID string) (stri } return conn.Type, nil } + +// mfaPagePath returns the page URL path for the given MFA provider type. +func (s *Server) mfaPagePath(authenticatorID string) string { + provider, ok := s.mfaProviders[authenticatorID] + if ok && provider.Type() == "WebAuthn" { + return "/mfa/webauthn" + } + return "/mfa/totp" +} + +// completeMFAStep checks for the next authenticator in the MFA chain and either +// returns the URL for the next step or marks MFA as validated and returns the approval URL. +func (s *Server) completeMFAStep(ctx context.Context, authReq storage.AuthRequest, authenticatorID string) (string, error) { + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + return "", fmt.Errorf("get MFA chain: %w", err) + } + + // Find the next authenticator in the chain after the current one. + var nextAuthenticator string + for i, id := range mfaChain { + if id == authenticatorID && i+1 < len(mfaChain) { + nextAuthenticator = mfaChain[i+1] + break + } + } + + if nextAuthenticator != "" { + return s.buildMFARedirectURL(authReq, nextAuthenticator), nil + } + + // All authenticators completed — mark as validated. + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(old storage.AuthRequest) (storage.AuthRequest, error) { + old.MFAValidated = true + return old, nil + }); err != nil { + return "", fmt.Errorf("update auth request: %w", err) + } + + return s.buildApprovalURL(authReq), nil +} + +// buildMFARedirectURL builds an HMAC-protected redirect URL for the given authenticator. +func (s *Server) buildMFARedirectURL(authReq storage.AuthRequest, authenticatorID string) string { + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", computeHMAC(authReq.HMACKey, authReq.ID, authenticatorID)) + v.Set("authenticator", authenticatorID) + return s.absPath(s.mfaPagePath(authenticatorID)) + "?" + v.Encode() +} diff --git a/server/mfa_webauthn.go b/server/mfa_webauthn.go new file mode 100644 index 00000000..9ab97904 --- /dev/null +++ b/server/mfa_webauthn.go @@ -0,0 +1,383 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + gowebauthn "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/dexidp/dex/storage" +) + +// WebAuthnProvider implements WebAuthn-based multi-factor authentication. +type WebAuthnProvider struct { + wan *webauthn.WebAuthn + connectorTypes map[string]struct{} +} + +// NewWebAuthnProvider creates a new WebAuthn MFA provider. +// If rpID or rpOrigins are empty, they are derived from the issuerURL. +func NewWebAuthnProvider(rpDisplayName, rpID string, rpOrigins []string, + attestationPreference, timeout, issuerURL string, + connectorTypes []string, +) (*WebAuthnProvider, error) { + parsed, err := url.Parse(issuerURL) + if err != nil { + return nil, fmt.Errorf("parse issuer URL: %w", err) + } + + if rpID == "" { + rpID = parsed.Hostname() + } + if len(rpOrigins) == 0 { + rpOrigins = []string{parsed.Scheme + "://" + parsed.Host} + } + if rpDisplayName == "" { + rpDisplayName = rpID + } + + cfg := &webauthn.Config{ + RPID: rpID, + RPDisplayName: rpDisplayName, + RPOrigins: rpOrigins, + } + + switch attestationPreference { + case "none": + cfg.AttestationPreference = gowebauthn.PreferNoAttestation + case "direct": + cfg.AttestationPreference = gowebauthn.PreferDirectAttestation + case "", "indirect": + cfg.AttestationPreference = gowebauthn.PreferIndirectAttestation + } + + if timeout != "" { + d, err := time.ParseDuration(timeout) + if err != nil { + return nil, fmt.Errorf("parse timeout: %w", err) + } + timeoutCfg := webauthn.TimeoutConfig{Enforce: true, Timeout: d} + cfg.Timeouts = webauthn.TimeoutsConfig{ + Login: timeoutCfg, + Registration: timeoutCfg, + } + } + + wan, err := webauthn.New(cfg) + if err != nil { + return nil, fmt.Errorf("create webauthn: %w", err) + } + + m := make(map[string]struct{}, len(connectorTypes)) + for _, t := range connectorTypes { + m[t] = struct{}{} + } + + return &WebAuthnProvider{wan: wan, connectorTypes: m}, nil +} + +func (p *WebAuthnProvider) Type() string { return "WebAuthn" } + +func (p *WebAuthnProvider) EnabledForConnectorType(connectorType string) bool { + if len(p.connectorTypes) == 0 { + return true + } + _, ok := p.connectorTypes[connectorType] + return ok +} + +// webauthnUser implements the webauthn.User interface. +type webauthnUser struct { + id []byte + name string + displayName string + credentials []webauthn.Credential +} + +func (u *webauthnUser) WebAuthnID() []byte { return u.id } +func (u *webauthnUser) WebAuthnName() string { return u.name } +func (u *webauthnUser) WebAuthnDisplayName() string { return u.displayName } +func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +// buildWebAuthnUser creates a webauthn.User from a UserIdentity, using credentials for the given authenticatorID. +func buildWebAuthnUser(identity storage.UserIdentity, authenticatorID string) *webauthnUser { + stored := identity.WebAuthnCredentials[authenticatorID] + creds := make([]webauthn.Credential, 0, len(stored)) + for _, c := range stored { + transports := make([]gowebauthn.AuthenticatorTransport, len(c.Transport)) + for i, t := range c.Transport { + transports[i] = gowebauthn.AuthenticatorTransport(t) + } + creds = append(creds, webauthn.Credential{ + ID: c.CredentialID, + PublicKey: c.PublicKey, + AttestationType: c.AttestationType, + Transport: transports, + Flags: webauthn.CredentialFlags{ + BackupEligible: c.BackupEligible, + BackupState: c.BackupState, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + CloneWarning: c.CloneWarning, + }, + }) + } + + return &webauthnUser{ + id: []byte(identity.UserID + "|" + identity.ConnectorID), + name: identity.Claims.Email, + displayName: identity.Claims.PreferredUsername, + credentials: creds, + } +} + +// handleWebAuthnRegisterBegin starts the WebAuthn registration ceremony. +func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + user.credentials = nil // don't exclude existing credentials during registration + + creation, session, err := provider.wan.BeginRegistration(user) + if err != nil { + s.logger.ErrorContext(ctx, "failed to begin webauthn registration", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + if err := s.storeWebAuthnSession(ctx, mfa.authReq.ID, session); err != nil { + s.logger.ErrorContext(ctx, "failed to store session data", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + writeJSON(w, creation) +} + +// handleWebAuthnRegisterFinish completes the WebAuthn registration ceremony. +func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + session, err := s.loadWebAuthnSession(mfa.authReq) + if err != nil { + s.logger.ErrorContext(ctx, "failed to load session data", "err", err) + writeJSONError(w, http.StatusBadRequest, "Invalid session.") + return + } + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + user.credentials = nil + + credential, err := provider.wan.FinishRegistration(user, *session, r) + if err != nil { + s.logger.ErrorContext(ctx, "webauthn registration failed", "err", err) + writeJSONError(w, http.StatusBadRequest, "Registration failed: "+err.Error()) + return + } + + newCred := convertCredential(credential, s.now()) + if err := s.storage.UpdateUserIdentity(ctx, mfa.authReq.Claims.UserID, mfa.authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if old.WebAuthnCredentials == nil { + old.WebAuthnCredentials = make(map[string][]storage.WebAuthnCredential) + } + old.WebAuthnCredentials[mfa.authenticatorID] = append(old.WebAuthnCredentials[mfa.authenticatorID], newCred) + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to store credential", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + s.writeCompleteMFAStepResponse(w, r, mfa) +} + +// handleWebAuthnLoginBegin starts the WebAuthn login ceremony. +func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + if len(user.credentials) == 0 { + writeJSONError(w, http.StatusBadRequest, "No WebAuthn credentials registered.") + return + } + + assertion, session, err := provider.wan.BeginLogin(user) + if err != nil { + s.logger.ErrorContext(ctx, "failed to begin webauthn login", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + if err := s.storeWebAuthnSession(ctx, mfa.authReq.ID, session); err != nil { + s.logger.ErrorContext(ctx, "failed to store session data", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + writeJSON(w, assertion) +} + +// handleWebAuthnLoginFinish completes the WebAuthn login ceremony. +// +// TODO(nabokihms): this endpoint should be protected with a rate limit (like the auth endpoint). +// Although WebAuthn is more resistant to brute-force than TOTP (challenges are random and +// cryptographically signed), repeated attempts could still be used for denial-of-service. +// +// For now the best way is to use external rate limiting solutions. +func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + session, err := s.loadWebAuthnSession(mfa.authReq) + if err != nil { + s.logger.ErrorContext(ctx, "failed to load session data", "err", err) + writeJSONError(w, http.StatusBadRequest, "Invalid session.") + return + } + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + credential, err := provider.wan.FinishLogin(user, *session, r) + if err != nil { + s.logger.ErrorContext(ctx, "webauthn login failed", "err", err) + writeJSONError(w, http.StatusUnauthorized, "Authentication failed.") + return + } + + // Update sign count and clone warning for the matched credential. + if err := s.storage.UpdateUserIdentity(ctx, mfa.authReq.Claims.UserID, mfa.authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + creds := old.WebAuthnCredentials[mfa.authenticatorID] + for i := range creds { + if bytes.Equal(creds[i].CredentialID, credential.ID) { + creds[i].SignCount = credential.Authenticator.SignCount + creds[i].CloneWarning = credential.Authenticator.CloneWarning + break + } + } + old.WebAuthnCredentials[mfa.authenticatorID] = creds + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update credential", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + s.writeCompleteMFAStepResponse(w, r, mfa) +} + +// validateWebAuthnAPIRequest validates a WebAuthn JSON API request. +// It reuses validateMFARequest for HMAC/auth checks, then asserts the provider type +// and loads the user identity. +func (s *Server) validateWebAuthnAPIRequest(w http.ResponseWriter, r *http.Request) (*mfaRequestContext, *WebAuthnProvider, bool) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed.") + return nil, nil, false + } + + mfa, ok := s.validateMFARequest(w, r) + if !ok { + return nil, nil, false + } + + provider, ok := s.mfaProviders[mfa.authenticatorID] + if !ok { + writeJSONError(w, http.StatusBadRequest, "Unknown authenticator.") + return nil, nil, false + } + webauthnProvider, ok := provider.(*WebAuthnProvider) + if !ok { + writeJSONError(w, http.StatusBadRequest, "Not a WebAuthn authenticator.") + return nil, nil, false + } + + return mfa, webauthnProvider, true +} + +// storeWebAuthnSession marshals and stores WebAuthn session data in the auth request. +func (s *Server) storeWebAuthnSession(ctx context.Context, authReqID string, session *webauthn.SessionData) error { + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + return s.storage.UpdateAuthRequest(ctx, authReqID, func(old storage.AuthRequest) (storage.AuthRequest, error) { + old.WebAuthnSessionData = data + return old, nil + }) +} + +// loadWebAuthnSession unmarshals WebAuthn session data from an auth request. +func (s *Server) loadWebAuthnSession(authReq storage.AuthRequest) (*webauthn.SessionData, error) { + var session webauthn.SessionData + if err := json.Unmarshal(authReq.WebAuthnSessionData, &session); err != nil { + return nil, fmt.Errorf("unmarshal session: %w", err) + } + return &session, nil +} + +// writeCompleteMFAStepResponse completes the MFA step and writes a JSON redirect response. +func (s *Server) writeCompleteMFAStepResponse(w http.ResponseWriter, r *http.Request, mfa *mfaRequestContext) { + ctx := r.Context() + redirectURL, err := s.completeMFAStep(ctx, mfa.authReq, mfa.authenticatorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to complete MFA step", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + writeJSON(w, map[string]string{"status": "ok", "redirect": redirectURL}) +} + +// convertCredential converts a webauthn.Credential to a storage.WebAuthnCredential. +func convertCredential(cred *webauthn.Credential, now time.Time) storage.WebAuthnCredential { + transports := make([]string, len(cred.Transport)) + for i, t := range cred.Transport { + transports[i] = string(t) + } + return storage.WebAuthnCredential{ + CredentialID: cred.ID, + PublicKey: cred.PublicKey, + AttestationType: cred.AttestationType, + AAGUID: cred.Authenticator.AAGUID, + SignCount: cred.Authenticator.SignCount, + CloneWarning: cred.Authenticator.CloneWarning, + Transport: transports, + BackupEligible: cred.Flags.BackupEligible, + BackupState: cred.Flags.BackupState, + DisplayName: "Security Key", + CreatedAt: now, + } +} + +// Dex return JSON errors because of the dynamic nature of WebAuthn, they are received +// by a JavaScript code and printed for users. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/server/mfa_webauthn_test.go b/server/mfa_webauthn_test.go new file mode 100644 index 00000000..d74f6753 --- /dev/null +++ b/server/mfa_webauthn_test.go @@ -0,0 +1,270 @@ +package server + +import ( + "crypto/rand" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestNewWebAuthnProvider(t *testing.T) { + tests := []struct { + name string + rpDisplay string + rpID string + rpOrigins []string + timeout string + issuerURL string + wantErr bool + errContains string + }{ + { + name: "derives rpID and origin from issuer", + rpDisplay: "Test App", + issuerURL: "https://auth.example.com/dex", + }, + { + name: "explicit rpID and origins", + rpDisplay: "Test App", + rpID: "example.com", + rpOrigins: []string{"https://auth.example.com"}, + issuerURL: "https://auth.example.com/dex", + }, + { + name: "defaults rpDisplayName from hostname", + issuerURL: "https://auth.example.com", + }, + { + name: "invalid timeout", + rpDisplay: "Test", + timeout: "not-a-duration", + issuerURL: "https://auth.example.com", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider, err := NewWebAuthnProvider( + tc.rpDisplay, tc.rpID, tc.rpOrigins, + "", tc.timeout, tc.issuerURL, + nil, + ) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, "WebAuthn", provider.Type()) + }) + } +} + +func TestWebAuthnProviderConnectorTypeFiltering(t *testing.T) { + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "https://example.com", []string{"ldap", "oidc"}) + require.NoError(t, err) + + require.True(t, provider.EnabledForConnectorType("ldap")) + require.True(t, provider.EnabledForConnectorType("oidc")) + require.False(t, provider.EnabledForConnectorType("saml")) + + // No filter — all types allowed. + providerAll, err := NewWebAuthnProvider("Test", "", nil, "", "", + "https://example.com", nil) + require.NoError(t, err) + require.True(t, providerAll.EnabledForConnectorType("anything")) +} + +func TestBuildWebAuthnUser(t *testing.T) { + identity := storage.UserIdentity{ + UserID: "user-123", + ConnectorID: "conn-1", + Claims: storage.Claims{ + Email: "user@example.com", + PreferredUsername: "User Name", + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn-1": { + { + CredentialID: []byte("cred-1"), + PublicKey: []byte("pk-1"), + AttestationType: "none", + AAGUID: []byte("aaguid-1"), + SignCount: 5, + Transport: []string{"usb"}, + }, + }, + "webauthn-2": { + { + CredentialID: []byte("cred-2"), + PublicKey: []byte("pk-2"), + }, + }, + }, + } + + user := buildWebAuthnUser(identity, "webauthn-1") + require.Equal(t, []byte("user-123|conn-1"), user.WebAuthnID()) + require.Equal(t, "user@example.com", user.WebAuthnName()) + require.Equal(t, "User Name", user.WebAuthnDisplayName()) + require.Len(t, user.WebAuthnCredentials(), 1) + require.Equal(t, []byte("cred-1"), user.WebAuthnCredentials()[0].ID) + require.Equal(t, uint32(5), user.WebAuthnCredentials()[0].Authenticator.SignCount) + + // Different authenticator — should get the other credential. + user2 := buildWebAuthnUser(identity, "webauthn-2") + require.Len(t, user2.WebAuthnCredentials(), 1) + require.Equal(t, []byte("cred-2"), user2.WebAuthnCredentials()[0].ID) + + // Unknown authenticator — no credentials. + user3 := buildWebAuthnUser(identity, "unknown") + require.Empty(t, user3.WebAuthnCredentials()) +} + +func TestCompleteMFAStep(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "http://127.0.0.1", nil) + require.NoError(t, err) + + c.MFAProviders = map[string]MFAProvider{ + "webauthn-1": provider, + "webauthn-2": provider, + } + c.DefaultMFAChain = []string{"webauthn-1", "webauthn-2"} + }) + defer httpServer.Close() + + ctx := t.Context() + + hmacKey := make([]byte, 32) + _, err := rand.Read(hmacKey) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: "test-req-chain", + ClientID: "example-app", + Expiry: time.Now().Add(time.Hour), + HMACKey: hmacKey, + LoggedIn: true, + Claims: storage.Claims{ + UserID: "user-1", + Email: "user@example.com", + }, + ConnectorID: "mock", + } + require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: "example-app", + Secret: "secret", + })) + + // Completing first step should redirect to second. + redirectURL, err := server.completeMFAStep(ctx, authReq, "webauthn-1") + require.NoError(t, err) + require.Contains(t, redirectURL, "/mfa/webauthn") + require.Contains(t, redirectURL, "authenticator=webauthn-2") + + // Completing second (last) step should redirect to approval. + redirectURL, err = server.completeMFAStep(ctx, authReq, "webauthn-2") + require.NoError(t, err) + require.Contains(t, redirectURL, "/approval") + + // Verify MFAValidated was set. + updated, err := server.storage.GetAuthRequest(ctx, authReq.ID) + require.NoError(t, err) + require.True(t, updated.MFAValidated) +} + +func TestWebAuthnHandlersMissingHMAC(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + }) + defer httpServer.Close() + + endpoints := []string{ + "/mfa/webauthn/register/begin", + "/mfa/webauthn/register/finish", + "/mfa/webauthn/login/begin", + "/mfa/webauthn/login/finish", + } + + for _, ep := range endpoints { + t.Run(ep, func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, ep, nil) + server.ServeHTTP(rr, req) + // Should fail with unauthorized (no hmac). + require.Equal(t, http.StatusUnauthorized, rr.Code) + }) + } +} + +func TestWebAuthnVerifyPageRender(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "http://127.0.0.1", nil) + require.NoError(t, err) + + c.MFAProviders = map[string]MFAProvider{ + "webauthn-1": provider, + } + c.DefaultMFAChain = []string{"webauthn-1"} + }) + defer httpServer.Close() + + ctx := t.Context() + + hmacKey := make([]byte, 32) + _, err := rand.Read(hmacKey) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: "test-webauthn-verify", + ClientID: "example-app", + Expiry: time.Now().Add(time.Hour), + HMACKey: hmacKey, + LoggedIn: true, + Claims: storage.Claims{ + UserID: "user-1", + Email: "user@example.com", + }, + ConnectorID: "mock", + } + require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) + + // Create user identity without WebAuthn credentials (enrollment mode). + require.NoError(t, server.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: authReq.Claims, + Consents: map[string][]string{}, + MFASecrets: map[string]*storage.MFASecret{}, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{}, + CreatedAt: time.Now(), + LastLogin: time.Now(), + })) + + // Generate HMAC for the request. + hmacVal := computeHMAC(hmacKey, authReq.ID, "webauthn-1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/mfa/webauthn?req="+authReq.ID+"&hmac="+hmacVal+"&authenticator=webauthn-1", nil) + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "Register security key") + require.Contains(t, body, "startWebAuthn") +} diff --git a/server/server.go b/server/server.go index 711ef7f0..49626d1e 100644 --- a/server/server.go +++ b/server/server.go @@ -551,11 +551,20 @@ func newServer(ctx context.Context, c Config) (*Server, error) { // "authproxy" connector. handleFunc("/callback/{connector}", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) + // OIDC RP-Initiated logout endpoints, DEX_SESSIONS_ENABLED=true feature flag is required. if c.SessionConfig != nil { handleFunc("/logout", s.handleLogout) handleFunc("/logout/callback", s.handleLogoutCallback) } - handleFunc("/mfa/verify", s.handleMFAVerify) + // MFA verification endpoints, DEX_SESSIONS_ENABLED=true feature flag is required. + if c.SessionConfig != nil { + handleFunc("/mfa/totp", s.handleTOTP) + handleFunc("/mfa/webauthn", s.handleWebAuthn) + handleFunc("/mfa/webauthn/register/begin", s.handleWebAuthnRegisterBegin) + handleFunc("/mfa/webauthn/register/finish", s.handleWebAuthnRegisterFinish) + handleFunc("/mfa/webauthn/login/begin", s.handleWebAuthnLoginBegin) + handleFunc("/mfa/webauthn/login/finish", s.handleWebAuthnLoginFinish) + } handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.HealthChecker.IsHealthy() { s.renderError(r, w, http.StatusInternalServerError, "Health check failed.") @@ -843,6 +852,14 @@ func (s *Server) getConnector(ctx context.Context, id string) (Connector, error) return conn, nil } +// buildApprovalURL builds an HMAC-protected approval URL. +func (s *Server) buildApprovalURL(authReq storage.AuthRequest) string { + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", computeHMAC(authReq.HMACKey, authReq.ID, "")) + return s.absPath("/approval") + "?" + v.Encode() +} + type logRequestKey string const ( diff --git a/server/session.go b/server/session.go index 3d0e0ec2..27de56d1 100644 --- a/server/session.go +++ b/server/session.go @@ -4,15 +4,12 @@ import ( "context" "crypto/aes" "crypto/cipher" - "crypto/hmac" "crypto/rand" - "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "net/http" - "path" "time" "github.com/dexidp/dex/server/internal" @@ -375,11 +372,6 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return old, nil }) - // Build HMAC for approval URL. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - // Skip approval if globally configured or user already consented to the requested scopes. if !authReq.ForceApprovalPrompt && (s.skipApproval || scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes)) { // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above). @@ -392,8 +384,7 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return "", true } - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + mac - return returnURL, true + return s.buildApprovalURL(*authReq), true } // updateSessionTokenIssuedAt updates the session's LastTokenIssuedAt for the given client. diff --git a/server/templates.go b/server/templates.go index dd94025d..81da3d4d 100644 --- a/server/templates.go +++ b/server/templates.go @@ -15,16 +15,17 @@ import ( ) const ( - tmplApproval = "approval.html" - tmplLogin = "login.html" - tmplPassword = "password.html" - tmplOOB = "oob.html" - tmplError = "error.html" - tmplDevice = "device.html" - tmplDeviceSuccess = "device_success.html" - tmplTOTPVerify = "totp_verify.html" - tmplHome = "home.html" - tmplLogout = "logout.html" + tmplApproval = "approval.html" + tmplLogin = "login.html" + tmplPassword = "password.html" + tmplOOB = "oob.html" + tmplError = "error.html" + tmplDevice = "device.html" + tmplDeviceSuccess = "device_success.html" + tmplTOTPVerify = "totp_verify.html" + tmplWebAuthnVerify = "webauthn_verify.html" + tmplHome = "home.html" + tmplLogout = "logout.html" ) var requiredTmpls = []string{ @@ -35,19 +36,24 @@ var requiredTmpls = []string{ tmplError, tmplDevice, tmplDeviceSuccess, + tmplTOTPVerify, + tmplWebAuthnVerify, + tmplHome, + tmplLogout, } type templates struct { - loginTmpl *template.Template - approvalTmpl *template.Template - passwordTmpl *template.Template - oobTmpl *template.Template - errorTmpl *template.Template - deviceTmpl *template.Template - deviceSuccessTmpl *template.Template - totpVerifyTmpl *template.Template - homeTmpl *template.Template - logoutTmpl *template.Template + loginTmpl *template.Template + approvalTmpl *template.Template + passwordTmpl *template.Template + oobTmpl *template.Template + errorTmpl *template.Template + deviceTmpl *template.Template + deviceSuccessTmpl *template.Template + totpVerifyTmpl *template.Template + webauthnVerifyTmpl *template.Template + homeTmpl *template.Template + logoutTmpl *template.Template } type webConfig struct { @@ -168,16 +174,17 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { return nil, fmt.Errorf("missing template(s): %s", missingTmpls) } return &templates{ - loginTmpl: tmpls.Lookup(tmplLogin), - approvalTmpl: tmpls.Lookup(tmplApproval), - passwordTmpl: tmpls.Lookup(tmplPassword), - oobTmpl: tmpls.Lookup(tmplOOB), - errorTmpl: tmpls.Lookup(tmplError), - deviceTmpl: tmpls.Lookup(tmplDevice), - deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), - totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), - homeTmpl: tmpls.Lookup(tmplHome), - logoutTmpl: tmpls.Lookup(tmplLogout), + loginTmpl: tmpls.Lookup(tmplLogin), + approvalTmpl: tmpls.Lookup(tmplApproval), + passwordTmpl: tmpls.Lookup(tmplPassword), + oobTmpl: tmpls.Lookup(tmplOOB), + errorTmpl: tmpls.Lookup(tmplError), + deviceTmpl: tmpls.Lookup(tmplDevice), + deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), + totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), + webauthnVerifyTmpl: tmpls.Lookup(tmplWebAuthnVerify), + homeTmpl: tmpls.Lookup(tmplHome), + logoutTmpl: tmpls.Lookup(tmplLogout), }, nil } @@ -398,6 +405,17 @@ func (t *templates) logout(r *http.Request, w http.ResponseWriter, backURL strin return renderTemplate(w, t.logoutTmpl, data) } +func (t *templates) webauthnVerify(r *http.Request, w http.ResponseWriter, mode, authenticatorID string) error { + data := struct { + // Mode must be server-controlled ("register" or "login") and never derived + // from user input to prevent XSS in the template's script context. + Mode string + AuthenticatorID string + ReqPath string + }{mode, authenticatorID, r.URL.Path} + return renderTemplate(w, t.webauthnVerifyTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/storage/ent/client/authrequest.go b/storage/ent/client/authrequest.go index c1a3dfdb..74b51a1c 100644 --- a/storage/ent/client/authrequest.go +++ b/storage/ent/client/authrequest.go @@ -33,6 +33,7 @@ func (d *Database) CreateAuthRequest(ctx context.Context, authRequest storage.Au SetConnectorData(authRequest.ConnectorData). SetHmacKey(authRequest.HMACKey). SetMfaValidated(authRequest.MFAValidated). + SetWebauthnSessionData(authRequest.WebAuthnSessionData). SetPrompt(authRequest.Prompt). SetMaxAge(authRequest.MaxAge). SetAuthTime(authRequest.AuthTime). @@ -101,6 +102,7 @@ func (d *Database) UpdateAuthRequest(ctx context.Context, id string, updater fun SetConnectorData(newAuthRequest.ConnectorData). SetHmacKey(newAuthRequest.HMACKey). SetMfaValidated(newAuthRequest.MFAValidated). + SetWebauthnSessionData(newAuthRequest.WebAuthnSessionData). SetPrompt(newAuthRequest.Prompt). SetMaxAge(newAuthRequest.MaxAge). SetAuthTime(newAuthRequest.AuthTime). diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 14f248fa..ddb36eb9 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -47,9 +47,15 @@ func toStorageAuthRequest(a *db.AuthRequest) storage.AuthRequest { }, HMACKey: a.HmacKey, MFAValidated: a.MfaValidated, - Prompt: a.Prompt, - MaxAge: a.MaxAge, - AuthTime: a.AuthTime, + WebAuthnSessionData: func() []byte { + if a.WebauthnSessionData != nil { + return *a.WebauthnSessionData + } + return nil + }(), + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -212,6 +218,15 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { } else { s.MFASecrets = make(map[string]*storage.MFASecret) } + + if wc := u.WebauthnCredentials; wc != nil { + if err := json.Unmarshal(*wc, &s.WebAuthnCredentials); err != nil { + panic(err) + } + } + if s.WebAuthnCredentials == nil { + s.WebAuthnCredentials = make(map[string][]storage.WebAuthnCredential) + } return s } diff --git a/storage/ent/client/useridentity.go b/storage/ent/client/useridentity.go index 31e9d049..f66f9017 100644 --- a/storage/ent/client/useridentity.go +++ b/storage/ent/client/useridentity.go @@ -26,6 +26,11 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User return fmt.Errorf("encode mfa secrets user identity: %w", err) } + encodedWebAuthnCreds, err := json.Marshal(identity.WebAuthnCredentials) + if err != nil { + return fmt.Errorf("encode webauthn credentials user identity: %w", err) + } + id := compositeKeyID(identity.UserID, identity.ConnectorID, d.hasher) _, err = d.client.UserIdentity.Create(). SetID(id). @@ -39,6 +44,7 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User SetClaimsGroups(identity.Claims.Groups). SetConsents(encodedConsents). SetMfaSecrets(encodedMFASecrets). + SetWebauthnCredentials(encodedWebAuthnCreds). SetCreatedAt(identity.CreatedAt). SetLastLogin(identity.LastLogin). SetBlockedUntil(identity.BlockedUntil). @@ -108,6 +114,11 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec return rollback(tx, "encode mfa secrets user identity: %w", err) } + encodedWebAuthnCreds, err := json.Marshal(newUserIdentity.WebAuthnCredentials) + if err != nil { + return rollback(tx, "encode webauthn credentials user identity: %w", err) + } + _, err = tx.UserIdentity.UpdateOneID(id). SetUserID(newUserIdentity.UserID). SetConnectorID(newUserIdentity.ConnectorID). @@ -119,6 +130,7 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec SetClaimsGroups(newUserIdentity.Claims.Groups). SetConsents(encodedConsents). SetMfaSecrets(encodedMFASecrets). + SetWebauthnCredentials(encodedWebAuthnCreds). SetCreatedAt(newUserIdentity.CreatedAt). SetLastLogin(newUserIdentity.LastLogin). SetBlockedUntil(newUserIdentity.BlockedUntil). diff --git a/storage/ent/db/authrequest.go b/storage/ent/db/authrequest.go index 82619eeb..938b9e78 100644 --- a/storage/ent/db/authrequest.go +++ b/storage/ent/db/authrequest.go @@ -60,6 +60,8 @@ type AuthRequest struct { HmacKey []byte `json:"hmac_key,omitempty"` // MfaValidated holds the value of the "mfa_validated" field. MfaValidated bool `json:"mfa_validated,omitempty"` + // WebauthnSessionData holds the value of the "webauthn_session_data" field. + WebauthnSessionData *[]byte `json:"webauthn_session_data,omitempty"` // Prompt holds the value of the "prompt" field. Prompt string `json:"prompt,omitempty"` // MaxAge holds the value of the "max_age" field. @@ -74,7 +76,7 @@ func (*AuthRequest) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case authrequest.FieldScopes, authrequest.FieldResponseTypes, authrequest.FieldClaimsGroups, authrequest.FieldConnectorData, authrequest.FieldHmacKey: + case authrequest.FieldScopes, authrequest.FieldResponseTypes, authrequest.FieldClaimsGroups, authrequest.FieldConnectorData, authrequest.FieldHmacKey, authrequest.FieldWebauthnSessionData: values[i] = new([]byte) case authrequest.FieldForceApprovalPrompt, authrequest.FieldLoggedIn, authrequest.FieldClaimsEmailVerified, authrequest.FieldMfaValidated: values[i] = new(sql.NullBool) @@ -237,6 +239,12 @@ func (_m *AuthRequest) assignValues(columns []string, values []any) error { } else if value.Valid { _m.MfaValidated = value.Bool } + case authrequest.FieldWebauthnSessionData: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field webauthn_session_data", values[i]) + } else if value != nil { + _m.WebauthnSessionData = value + } case authrequest.FieldPrompt: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field prompt", values[i]) @@ -356,6 +364,11 @@ func (_m *AuthRequest) String() string { builder.WriteString("mfa_validated=") builder.WriteString(fmt.Sprintf("%v", _m.MfaValidated)) builder.WriteString(", ") + if v := _m.WebauthnSessionData; v != nil { + builder.WriteString("webauthn_session_data=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("prompt=") builder.WriteString(_m.Prompt) builder.WriteString(", ") diff --git a/storage/ent/db/authrequest/authrequest.go b/storage/ent/db/authrequest/authrequest.go index 33c52c8f..5ac1e4b2 100644 --- a/storage/ent/db/authrequest/authrequest.go +++ b/storage/ent/db/authrequest/authrequest.go @@ -53,6 +53,8 @@ const ( FieldHmacKey = "hmac_key" // FieldMfaValidated holds the string denoting the mfa_validated field in the database. FieldMfaValidated = "mfa_validated" + // FieldWebauthnSessionData holds the string denoting the webauthn_session_data field in the database. + FieldWebauthnSessionData = "webauthn_session_data" // FieldPrompt holds the string denoting the prompt field in the database. FieldPrompt = "prompt" // FieldMaxAge holds the string denoting the max_age field in the database. @@ -87,6 +89,7 @@ var Columns = []string{ FieldCodeChallengeMethod, FieldHmacKey, FieldMfaValidated, + FieldWebauthnSessionData, FieldPrompt, FieldMaxAge, FieldAuthTime, diff --git a/storage/ent/db/authrequest/where.go b/storage/ent/db/authrequest/where.go index f87780b1..27fd9131 100644 --- a/storage/ent/db/authrequest/where.go +++ b/storage/ent/db/authrequest/where.go @@ -154,6 +154,11 @@ func MfaValidated(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldMfaValidated, v)) } +// WebauthnSessionData applies equality check predicate on the "webauthn_session_data" field. It's identical to WebauthnSessionDataEQ. +func WebauthnSessionData(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldWebauthnSessionData, v)) +} + // Prompt applies equality check predicate on the "prompt" field. It's identical to PromptEQ. func Prompt(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) @@ -1084,6 +1089,56 @@ func MfaValidatedNEQ(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldNEQ(FieldMfaValidated, v)) } +// WebauthnSessionDataEQ applies the EQ predicate on the "webauthn_session_data" field. +func WebauthnSessionDataEQ(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataNEQ applies the NEQ predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNEQ(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataIn applies the In predicate on the "webauthn_session_data" field. +func WebauthnSessionDataIn(vs ...[]byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIn(FieldWebauthnSessionData, vs...)) +} + +// WebauthnSessionDataNotIn applies the NotIn predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNotIn(vs ...[]byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotIn(FieldWebauthnSessionData, vs...)) +} + +// WebauthnSessionDataGT applies the GT predicate on the "webauthn_session_data" field. +func WebauthnSessionDataGT(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGT(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataGTE applies the GTE predicate on the "webauthn_session_data" field. +func WebauthnSessionDataGTE(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGTE(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataLT applies the LT predicate on the "webauthn_session_data" field. +func WebauthnSessionDataLT(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLT(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataLTE applies the LTE predicate on the "webauthn_session_data" field. +func WebauthnSessionDataLTE(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLTE(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataIsNil applies the IsNil predicate on the "webauthn_session_data" field. +func WebauthnSessionDataIsNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIsNull(FieldWebauthnSessionData)) +} + +// WebauthnSessionDataNotNil applies the NotNil predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNotNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotNull(FieldWebauthnSessionData)) +} + // PromptEQ applies the EQ predicate on the "prompt" field. func PromptEQ(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) diff --git a/storage/ent/db/authrequest_create.go b/storage/ent/db/authrequest_create.go index 324c99d9..1a341534 100644 --- a/storage/ent/db/authrequest_create.go +++ b/storage/ent/db/authrequest_create.go @@ -178,6 +178,12 @@ func (_c *AuthRequestCreate) SetNillableMfaValidated(v *bool) *AuthRequestCreate return _c } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_c *AuthRequestCreate) SetWebauthnSessionData(v []byte) *AuthRequestCreate { + _c.mutation.SetWebauthnSessionData(v) + return _c +} + // SetPrompt sets the "prompt" field. func (_c *AuthRequestCreate) SetPrompt(v string) *AuthRequestCreate { _c.mutation.SetPrompt(v) @@ -470,6 +476,10 @@ func (_c *AuthRequestCreate) createSpec() (*AuthRequest, *sqlgraph.CreateSpec) { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) _node.MfaValidated = value } + if value, ok := _c.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + _node.WebauthnSessionData = &value + } if value, ok := _c.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) _node.Prompt = value diff --git a/storage/ent/db/authrequest_update.go b/storage/ent/db/authrequest_update.go index 7edece02..96595ed5 100644 --- a/storage/ent/db/authrequest_update.go +++ b/storage/ent/db/authrequest_update.go @@ -325,6 +325,18 @@ func (_u *AuthRequestUpdate) SetNillableMfaValidated(v *bool) *AuthRequestUpdate return _u } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_u *AuthRequestUpdate) SetWebauthnSessionData(v []byte) *AuthRequestUpdate { + _u.mutation.SetWebauthnSessionData(v) + return _u +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (_u *AuthRequestUpdate) ClearWebauthnSessionData() *AuthRequestUpdate { + _u.mutation.ClearWebauthnSessionData() + return _u +} + // SetPrompt sets the "prompt" field. func (_u *AuthRequestUpdate) SetPrompt(v string) *AuthRequestUpdate { _u.mutation.SetPrompt(v) @@ -511,6 +523,12 @@ func (_u *AuthRequestUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + } + if _u.mutation.WebauthnSessionDataCleared() { + _spec.ClearField(authrequest.FieldWebauthnSessionData, field.TypeBytes) + } if value, ok := _u.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) } @@ -842,6 +860,18 @@ func (_u *AuthRequestUpdateOne) SetNillableMfaValidated(v *bool) *AuthRequestUpd return _u } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_u *AuthRequestUpdateOne) SetWebauthnSessionData(v []byte) *AuthRequestUpdateOne { + _u.mutation.SetWebauthnSessionData(v) + return _u +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (_u *AuthRequestUpdateOne) ClearWebauthnSessionData() *AuthRequestUpdateOne { + _u.mutation.ClearWebauthnSessionData() + return _u +} + // SetPrompt sets the "prompt" field. func (_u *AuthRequestUpdateOne) SetPrompt(v string) *AuthRequestUpdateOne { _u.mutation.SetPrompt(v) @@ -1058,6 +1088,12 @@ func (_u *AuthRequestUpdateOne) sqlSave(ctx context.Context) (_node *AuthRequest if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + } + if _u.mutation.WebauthnSessionDataCleared() { + _spec.ClearField(authrequest.FieldWebauthnSessionData, field.TypeBytes) + } if value, ok := _u.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index bf1d086d..ff066308 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -58,6 +58,7 @@ var ( {Name: "code_challenge_method", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "hmac_key", Type: field.TypeBytes}, {Name: "mfa_validated", Type: field.TypeBool, Default: false}, + {Name: "webauthn_session_data", Type: field.TypeBytes, Nullable: true}, {Name: "prompt", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "max_age", Type: field.TypeInt, Default: -1}, {Name: "auth_time", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, @@ -240,6 +241,7 @@ var ( {Name: "claims_groups", Type: field.TypeJSON, Nullable: true}, {Name: "consents", Type: field.TypeBytes}, {Name: "mfa_secrets", Type: field.TypeBytes, Nullable: true}, + {Name: "webauthn_credentials", Type: field.TypeBytes, Nullable: true}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "last_login", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "blocked_until", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index ddd3c5b3..fad85c3d 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -1336,6 +1336,7 @@ type AuthRequestMutation struct { code_challenge_method *string hmac_key *[]byte mfa_validated *bool + webauthn_session_data *[]byte prompt *string max_age *int addmax_age *int @@ -2306,6 +2307,55 @@ func (m *AuthRequestMutation) ResetMfaValidated() { m.mfa_validated = nil } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (m *AuthRequestMutation) SetWebauthnSessionData(b []byte) { + m.webauthn_session_data = &b +} + +// WebauthnSessionData returns the value of the "webauthn_session_data" field in the mutation. +func (m *AuthRequestMutation) WebauthnSessionData() (r []byte, exists bool) { + v := m.webauthn_session_data + if v == nil { + return + } + return *v, true +} + +// OldWebauthnSessionData returns the old "webauthn_session_data" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldWebauthnSessionData(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldWebauthnSessionData is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldWebauthnSessionData requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldWebauthnSessionData: %w", err) + } + return oldValue.WebauthnSessionData, nil +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (m *AuthRequestMutation) ClearWebauthnSessionData() { + m.webauthn_session_data = nil + m.clearedFields[authrequest.FieldWebauthnSessionData] = struct{}{} +} + +// WebauthnSessionDataCleared returns if the "webauthn_session_data" field was cleared in this mutation. +func (m *AuthRequestMutation) WebauthnSessionDataCleared() bool { + _, ok := m.clearedFields[authrequest.FieldWebauthnSessionData] + return ok +} + +// ResetWebauthnSessionData resets all changes to the "webauthn_session_data" field. +func (m *AuthRequestMutation) ResetWebauthnSessionData() { + m.webauthn_session_data = nil + delete(m.clearedFields, authrequest.FieldWebauthnSessionData) +} + // SetPrompt sets the "prompt" field. func (m *AuthRequestMutation) SetPrompt(s string) { m.prompt = &s @@ -2481,7 +2531,7 @@ func (m *AuthRequestMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthRequestMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.client_id != nil { fields = append(fields, authrequest.FieldClientID) } @@ -2545,6 +2595,9 @@ func (m *AuthRequestMutation) Fields() []string { if m.mfa_validated != nil { fields = append(fields, authrequest.FieldMfaValidated) } + if m.webauthn_session_data != nil { + fields = append(fields, authrequest.FieldWebauthnSessionData) + } if m.prompt != nil { fields = append(fields, authrequest.FieldPrompt) } @@ -2604,6 +2657,8 @@ func (m *AuthRequestMutation) Field(name string) (ent.Value, bool) { return m.HmacKey() case authrequest.FieldMfaValidated: return m.MfaValidated() + case authrequest.FieldWebauthnSessionData: + return m.WebauthnSessionData() case authrequest.FieldPrompt: return m.Prompt() case authrequest.FieldMaxAge: @@ -2661,6 +2716,8 @@ func (m *AuthRequestMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldHmacKey(ctx) case authrequest.FieldMfaValidated: return m.OldMfaValidated(ctx) + case authrequest.FieldWebauthnSessionData: + return m.OldWebauthnSessionData(ctx) case authrequest.FieldPrompt: return m.OldPrompt(ctx) case authrequest.FieldMaxAge: @@ -2823,6 +2880,13 @@ func (m *AuthRequestMutation) SetField(name string, value ent.Value) error { } m.SetMfaValidated(v) return nil + case authrequest.FieldWebauthnSessionData: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetWebauthnSessionData(v) + return nil case authrequest.FieldPrompt: v, ok := value.(string) if !ok { @@ -2901,6 +2965,9 @@ func (m *AuthRequestMutation) ClearedFields() []string { if m.FieldCleared(authrequest.FieldConnectorData) { fields = append(fields, authrequest.FieldConnectorData) } + if m.FieldCleared(authrequest.FieldWebauthnSessionData) { + fields = append(fields, authrequest.FieldWebauthnSessionData) + } if m.FieldCleared(authrequest.FieldAuthTime) { fields = append(fields, authrequest.FieldAuthTime) } @@ -2930,6 +2997,9 @@ func (m *AuthRequestMutation) ClearField(name string) error { case authrequest.FieldConnectorData: m.ClearConnectorData() return nil + case authrequest.FieldWebauthnSessionData: + m.ClearWebauthnSessionData() + return nil case authrequest.FieldAuthTime: m.ClearAuthTime() return nil @@ -3004,6 +3074,9 @@ func (m *AuthRequestMutation) ResetField(name string) error { case authrequest.FieldMfaValidated: m.ResetMfaValidated() return nil + case authrequest.FieldWebauthnSessionData: + m.ResetWebauthnSessionData() + return nil case authrequest.FieldPrompt: m.ResetPrompt() return nil @@ -9801,6 +9874,7 @@ type UserIdentityMutation struct { appendclaims_groups []string consents *[]byte mfa_secrets *[]byte + webauthn_credentials *[]byte created_at *time.Time last_login *time.Time blocked_until *time.Time @@ -10316,6 +10390,55 @@ func (m *UserIdentityMutation) ResetMfaSecrets() { delete(m.clearedFields, useridentity.FieldMfaSecrets) } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (m *UserIdentityMutation) SetWebauthnCredentials(b []byte) { + m.webauthn_credentials = &b +} + +// WebauthnCredentials returns the value of the "webauthn_credentials" field in the mutation. +func (m *UserIdentityMutation) WebauthnCredentials() (r []byte, exists bool) { + v := m.webauthn_credentials + if v == nil { + return + } + return *v, true +} + +// OldWebauthnCredentials returns the old "webauthn_credentials" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldWebauthnCredentials(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldWebauthnCredentials is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldWebauthnCredentials requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldWebauthnCredentials: %w", err) + } + return oldValue.WebauthnCredentials, nil +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (m *UserIdentityMutation) ClearWebauthnCredentials() { + m.webauthn_credentials = nil + m.clearedFields[useridentity.FieldWebauthnCredentials] = struct{}{} +} + +// WebauthnCredentialsCleared returns if the "webauthn_credentials" field was cleared in this mutation. +func (m *UserIdentityMutation) WebauthnCredentialsCleared() bool { + _, ok := m.clearedFields[useridentity.FieldWebauthnCredentials] + return ok +} + +// ResetWebauthnCredentials resets all changes to the "webauthn_credentials" field. +func (m *UserIdentityMutation) ResetWebauthnCredentials() { + m.webauthn_credentials = nil + delete(m.clearedFields, useridentity.FieldWebauthnCredentials) +} + // SetCreatedAt sets the "created_at" field. func (m *UserIdentityMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -10458,7 +10581,7 @@ func (m *UserIdentityMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserIdentityMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.user_id != nil { fields = append(fields, useridentity.FieldUserID) } @@ -10489,6 +10612,9 @@ func (m *UserIdentityMutation) Fields() []string { if m.mfa_secrets != nil { fields = append(fields, useridentity.FieldMfaSecrets) } + if m.webauthn_credentials != nil { + fields = append(fields, useridentity.FieldWebauthnCredentials) + } if m.created_at != nil { fields = append(fields, useridentity.FieldCreatedAt) } @@ -10526,6 +10652,8 @@ func (m *UserIdentityMutation) Field(name string) (ent.Value, bool) { return m.Consents() case useridentity.FieldMfaSecrets: return m.MfaSecrets() + case useridentity.FieldWebauthnCredentials: + return m.WebauthnCredentials() case useridentity.FieldCreatedAt: return m.CreatedAt() case useridentity.FieldLastLogin: @@ -10561,6 +10689,8 @@ func (m *UserIdentityMutation) OldField(ctx context.Context, name string) (ent.V return m.OldConsents(ctx) case useridentity.FieldMfaSecrets: return m.OldMfaSecrets(ctx) + case useridentity.FieldWebauthnCredentials: + return m.OldWebauthnCredentials(ctx) case useridentity.FieldCreatedAt: return m.OldCreatedAt(ctx) case useridentity.FieldLastLogin: @@ -10646,6 +10776,13 @@ func (m *UserIdentityMutation) SetField(name string, value ent.Value) error { } m.SetMfaSecrets(v) return nil + case useridentity.FieldWebauthnCredentials: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetWebauthnCredentials(v) + return nil case useridentity.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -10703,6 +10840,9 @@ func (m *UserIdentityMutation) ClearedFields() []string { if m.FieldCleared(useridentity.FieldMfaSecrets) { fields = append(fields, useridentity.FieldMfaSecrets) } + if m.FieldCleared(useridentity.FieldWebauthnCredentials) { + fields = append(fields, useridentity.FieldWebauthnCredentials) + } return fields } @@ -10723,6 +10863,9 @@ func (m *UserIdentityMutation) ClearField(name string) error { case useridentity.FieldMfaSecrets: m.ClearMfaSecrets() return nil + case useridentity.FieldWebauthnCredentials: + m.ClearWebauthnCredentials() + return nil } return fmt.Errorf("unknown UserIdentity nullable field %s", name) } @@ -10761,6 +10904,9 @@ func (m *UserIdentityMutation) ResetField(name string) error { case useridentity.FieldMfaSecrets: m.ResetMfaSecrets() return nil + case useridentity.FieldWebauthnCredentials: + m.ResetWebauthnCredentials() + return nil case useridentity.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 2c1c5404..df7d7d49 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -89,11 +89,11 @@ func init() { // authrequest.DefaultMfaValidated holds the default value on creation for the mfa_validated field. authrequest.DefaultMfaValidated = authrequestDescMfaValidated.Default.(bool) // authrequestDescPrompt is the schema descriptor for prompt field. - authrequestDescPrompt := authrequestFields[22].Descriptor() + authrequestDescPrompt := authrequestFields[23].Descriptor() // authrequest.DefaultPrompt holds the default value on creation for the prompt field. authrequest.DefaultPrompt = authrequestDescPrompt.Default.(string) // authrequestDescMaxAge is the schema descriptor for max_age field. - authrequestDescMaxAge := authrequestFields[23].Descriptor() + authrequestDescMaxAge := authrequestFields[24].Descriptor() // authrequest.DefaultMaxAge holds the default value on creation for the max_age field. authrequest.DefaultMaxAge = authrequestDescMaxAge.Default.(int) // authrequestDescID is the schema descriptor for id field. diff --git a/storage/ent/db/useridentity.go b/storage/ent/db/useridentity.go index 91f291de..eaa0edbf 100644 --- a/storage/ent/db/useridentity.go +++ b/storage/ent/db/useridentity.go @@ -38,6 +38,8 @@ type UserIdentity struct { Consents []byte `json:"consents,omitempty"` // MfaSecrets holds the value of the "mfa_secrets" field. MfaSecrets *[]byte `json:"mfa_secrets,omitempty"` + // WebauthnCredentials holds the value of the "webauthn_credentials" field. + WebauthnCredentials *[]byte `json:"webauthn_credentials,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` // LastLogin holds the value of the "last_login" field. @@ -52,7 +54,7 @@ func (*UserIdentity) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case useridentity.FieldClaimsGroups, useridentity.FieldConsents, useridentity.FieldMfaSecrets: + case useridentity.FieldClaimsGroups, useridentity.FieldConsents, useridentity.FieldMfaSecrets, useridentity.FieldWebauthnCredentials: values[i] = new([]byte) case useridentity.FieldClaimsEmailVerified: values[i] = new(sql.NullBool) @@ -143,6 +145,12 @@ func (_m *UserIdentity) assignValues(columns []string, values []any) error { } else if value != nil { _m.MfaSecrets = value } + case useridentity.FieldWebauthnCredentials: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field webauthn_credentials", values[i]) + } else if value != nil { + _m.WebauthnCredentials = value + } case useridentity.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -229,6 +237,11 @@ func (_m *UserIdentity) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.WebauthnCredentials; v != nil { + builder.WriteString("webauthn_credentials=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(", ") diff --git a/storage/ent/db/useridentity/useridentity.go b/storage/ent/db/useridentity/useridentity.go index 9fae1444..63781c78 100644 --- a/storage/ent/db/useridentity/useridentity.go +++ b/storage/ent/db/useridentity/useridentity.go @@ -31,6 +31,8 @@ const ( FieldConsents = "consents" // FieldMfaSecrets holds the string denoting the mfa_secrets field in the database. FieldMfaSecrets = "mfa_secrets" + // FieldWebauthnCredentials holds the string denoting the webauthn_credentials field in the database. + FieldWebauthnCredentials = "webauthn_credentials" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldLastLogin holds the string denoting the last_login field in the database. @@ -54,6 +56,7 @@ var Columns = []string{ FieldClaimsGroups, FieldConsents, FieldMfaSecrets, + FieldWebauthnCredentials, FieldCreatedAt, FieldLastLogin, FieldBlockedUntil, diff --git a/storage/ent/db/useridentity/where.go b/storage/ent/db/useridentity/where.go index c3e3d911..e902d359 100644 --- a/storage/ent/db/useridentity/where.go +++ b/storage/ent/db/useridentity/where.go @@ -109,6 +109,11 @@ func MfaSecrets(v []byte) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldMfaSecrets, v)) } +// WebauthnCredentials applies equality check predicate on the "webauthn_credentials" field. It's identical to WebauthnCredentialsEQ. +func WebauthnCredentials(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldWebauthnCredentials, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) @@ -624,6 +629,56 @@ func MfaSecretsNotNil() predicate.UserIdentity { return predicate.UserIdentity(sql.FieldNotNull(FieldMfaSecrets)) } +// WebauthnCredentialsEQ applies the EQ predicate on the "webauthn_credentials" field. +func WebauthnCredentialsEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsNEQ applies the NEQ predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsIn applies the In predicate on the "webauthn_credentials" field. +func WebauthnCredentialsIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldWebauthnCredentials, vs...)) +} + +// WebauthnCredentialsNotIn applies the NotIn predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNotIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldWebauthnCredentials, vs...)) +} + +// WebauthnCredentialsGT applies the GT predicate on the "webauthn_credentials" field. +func WebauthnCredentialsGT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsGTE applies the GTE predicate on the "webauthn_credentials" field. +func WebauthnCredentialsGTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsLT applies the LT predicate on the "webauthn_credentials" field. +func WebauthnCredentialsLT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsLTE applies the LTE predicate on the "webauthn_credentials" field. +func WebauthnCredentialsLTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsIsNil applies the IsNil predicate on the "webauthn_credentials" field. +func WebauthnCredentialsIsNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIsNull(FieldWebauthnCredentials)) +} + +// WebauthnCredentialsNotNil applies the NotNil predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNotNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotNull(FieldWebauthnCredentials)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) diff --git a/storage/ent/db/useridentity_create.go b/storage/ent/db/useridentity_create.go index 0f4b355a..adb17b25 100644 --- a/storage/ent/db/useridentity_create.go +++ b/storage/ent/db/useridentity_create.go @@ -120,6 +120,12 @@ func (_c *UserIdentityCreate) SetMfaSecrets(v []byte) *UserIdentityCreate { return _c } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_c *UserIdentityCreate) SetWebauthnCredentials(v []byte) *UserIdentityCreate { + _c.mutation.SetWebauthnCredentials(v) + return _c +} + // SetCreatedAt sets the "created_at" field. func (_c *UserIdentityCreate) SetCreatedAt(v time.Time) *UserIdentityCreate { _c.mutation.SetCreatedAt(v) @@ -326,6 +332,10 @@ func (_c *UserIdentityCreate) createSpec() (*UserIdentity, *sqlgraph.CreateSpec) _spec.SetField(useridentity.FieldMfaSecrets, field.TypeBytes, value) _node.MfaSecrets = &value } + if value, ok := _c.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + _node.WebauthnCredentials = &value + } if value, ok := _c.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value diff --git a/storage/ent/db/useridentity_update.go b/storage/ent/db/useridentity_update.go index 9de8e51f..ada98ead 100644 --- a/storage/ent/db/useridentity_update.go +++ b/storage/ent/db/useridentity_update.go @@ -163,6 +163,18 @@ func (_u *UserIdentityUpdate) ClearMfaSecrets() *UserIdentityUpdate { return _u } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_u *UserIdentityUpdate) SetWebauthnCredentials(v []byte) *UserIdentityUpdate { + _u.mutation.SetWebauthnCredentials(v) + return _u +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (_u *UserIdentityUpdate) ClearWebauthnCredentials() *UserIdentityUpdate { + _u.mutation.ClearWebauthnCredentials() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdate) SetCreatedAt(v time.Time) *UserIdentityUpdate { _u.mutation.SetCreatedAt(v) @@ -305,6 +317,12 @@ func (_u *UserIdentityUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.MfaSecretsCleared() { _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) } + if value, ok := _u.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + } + if _u.mutation.WebauthnCredentialsCleared() { + _spec.ClearField(useridentity.FieldWebauthnCredentials, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } @@ -468,6 +486,18 @@ func (_u *UserIdentityUpdateOne) ClearMfaSecrets() *UserIdentityUpdateOne { return _u } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_u *UserIdentityUpdateOne) SetWebauthnCredentials(v []byte) *UserIdentityUpdateOne { + _u.mutation.SetWebauthnCredentials(v) + return _u +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (_u *UserIdentityUpdateOne) ClearWebauthnCredentials() *UserIdentityUpdateOne { + _u.mutation.ClearWebauthnCredentials() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdateOne) SetCreatedAt(v time.Time) *UserIdentityUpdateOne { _u.mutation.SetCreatedAt(v) @@ -640,6 +670,12 @@ func (_u *UserIdentityUpdateOne) sqlSave(ctx context.Context) (_node *UserIdenti if _u.mutation.MfaSecretsCleared() { _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) } + if value, ok := _u.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + } + if _u.mutation.WebauthnCredentialsCleared() { + _spec.ClearField(useridentity.FieldWebauthnCredentials, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } diff --git a/storage/ent/schema/authrequest.go b/storage/ent/schema/authrequest.go index be24c29f..2d6cfebf 100644 --- a/storage/ent/schema/authrequest.go +++ b/storage/ent/schema/authrequest.go @@ -90,6 +90,9 @@ func (AuthRequest) Fields() []ent.Field { field.Bytes("hmac_key"), field.Bool("mfa_validated"). Default(false), + field.Bytes("webauthn_session_data"). + Nillable(). + Optional(), field.Text("prompt").SchemaType(textSchema).Default(""), field.Int("max_age").Default(-1), field.Time("auth_time").SchemaType(timeSchema).Optional(), diff --git a/storage/ent/schema/useridentity.go b/storage/ent/schema/useridentity.go index f8a4f2b8..b4fe75d0 100644 --- a/storage/ent/schema/useridentity.go +++ b/storage/ent/schema/useridentity.go @@ -44,6 +44,9 @@ func (UserIdentity) Fields() []ent.Field { field.Bytes("mfa_secrets"). Nillable(). Optional(), + field.Bytes("webauthn_credentials"). + Nillable(). + Optional(), field.Time("created_at"). SchemaType(timeSchema), field.Time("last_login"). diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 7be8bcf8..aabb16f6 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -93,6 +93,8 @@ type AuthRequest struct { MFAValidated bool `json:"mfa_validated"` + WebAuthnSessionData []byte `json:"webauthn_session_data,omitempty"` + Prompt string `json:"prompt,omitempty"` MaxAge int `json:"max_age"` AuthTime time.Time `json:"auth_time"` @@ -117,6 +119,7 @@ func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, Prompt: a.Prompt, MaxAge: a.MaxAge, AuthTime: a.AuthTime, @@ -142,11 +145,12 @@ func toStorageAuthRequest(a AuthRequest) storage.AuthRequest { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, - HMACKey: a.HMACKey, - MFAValidated: a.MFAValidated, - Prompt: a.Prompt, - MaxAge: a.MaxAge, - AuthTime: a.AuthTime, + HMACKey: a.HMACKey, + MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -276,39 +280,42 @@ func toStorageOfflineSessions(o OfflineSessions) storage.OfflineSessions { // UserIdentity is a mirrored struct from storage with JSON struct tags type UserIdentity struct { - UserID string `json:"user_id,omitempty"` - ConnectorID string `json:"connector_id,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - MFASecrets map[string]*storage.MFASecret `json:"mfa_secrets,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastLogin time.Time `json:"last_login"` - BlockedUntil time.Time `json:"blocked_until"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfa_secrets,omitempty"` + WebAuthnCredentials map[string][]storage.WebAuthnCredential `json:"webauthn_credentials,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login"` + BlockedUntil time.Time `json:"blocked_until"` } func fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { return UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: fromStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } } func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { s := storage.UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: toStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } if s.Consents == nil { // Server code assumes this will be non-nil. diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 5d40acb2..cec39f3f 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -407,6 +407,8 @@ type AuthRequest struct { MFAValidated bool `json:"mfa_validated"` + WebAuthnSessionData []byte `json:"webauthn_session_data,omitempty"` + Prompt string `json:"prompt,omitempty"` MaxAge int `json:"maxAge"` AuthTime time.Time `json:"authTime,omitempty"` @@ -438,11 +440,12 @@ func toStorageAuthRequest(req AuthRequest) storage.AuthRequest { CodeChallenge: req.CodeChallenge, CodeChallengeMethod: req.CodeChallengeMethod, }, - HMACKey: req.HMACKey, - MFAValidated: req.MFAValidated, - Prompt: req.Prompt, - MaxAge: req.MaxAge, - AuthTime: req.AuthTime, + HMACKey: req.HMACKey, + MFAValidated: req.MFAValidated, + WebAuthnSessionData: req.WebAuthnSessionData, + Prompt: req.Prompt, + MaxAge: req.MaxAge, + AuthTime: req.AuthTime, } return a } @@ -473,6 +476,7 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, Prompt: a.Prompt, MaxAge: a.MaxAge, AuthTime: a.AuthTime, @@ -939,14 +943,15 @@ type UserIdentity struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` - UserID string `json:"userID,omitempty"` - ConnectorID string `json:"connectorID,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - MFASecrets map[string]*storage.MFASecret `json:"mfaSecrets,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - LastLogin time.Time `json:"lastLogin,omitempty"` - BlockedUntil time.Time `json:"blockedUntil,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfaSecrets,omitempty"` + WebAuthnCredentials map[string][]storage.WebAuthnCredential `json:"webauthnCredentials,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastLogin time.Time `json:"lastLogin,omitempty"` + BlockedUntil time.Time `json:"blockedUntil,omitempty"` } // UserIdentityList is a list of UserIdentities. @@ -966,27 +971,29 @@ func (cli *client) fromStorageUserIdentity(u storage.UserIdentity) UserIdentity Name: cli.offlineTokenName(u.UserID, u.ConnectorID), Namespace: cli.namespace, }, - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: fromStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } } func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { s := storage.UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: toStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } if s.Consents == nil { // Server code assumes this will be non-nil. diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 2864ebb3..e9c2faf6 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -144,10 +144,11 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err code_challenge, code_challenge_method, hmac_key, mfa_validated, + webauthn_session_data, prompt, max_age, auth_time ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26 ); `, a.ID, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, @@ -159,6 +160,7 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.WebAuthnSessionData, a.Prompt, a.MaxAge, a.AuthTime, ) if err != nil { @@ -194,8 +196,9 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a code_challenge = $18, code_challenge_method = $19, hmac_key = $20, mfa_validated = $21, - prompt = $22, max_age = $23, auth_time = $24 - where id = $25; + webauthn_session_data = $22, + prompt = $23, max_age = $24, auth_time = $25 + where id = $26; `, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, a.ForceApprovalPrompt, a.LoggedIn, @@ -206,6 +209,7 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.WebAuthnSessionData, a.Prompt, a.MaxAge, a.AuthTime, r.ID, ) @@ -230,6 +234,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe connector_id, connector_data, expiry, code_challenge, code_challenge_method, hmac_key, mfa_validated, + webauthn_session_data, prompt, max_age, auth_time from auth_request where id = $1; `, id).Scan( @@ -241,6 +246,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe &a.ConnectorID, &a.ConnectorData, &a.Expiry, &a.PKCE.CodeChallenge, &a.PKCE.CodeChallengeMethod, &a.HMACKey, &a.MFAValidated, + &a.WebAuthnSessionData, &a.Prompt, &a.MaxAge, &a.AuthTime, ) if err != nil { @@ -819,17 +825,17 @@ func (c *conn) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) e user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ); `, u.UserID, u.ConnectorID, u.Claims.UserID, u.Claims.Username, u.Claims.PreferredUsername, u.Claims.Email, u.Claims.EmailVerified, encoder(u.Claims.Groups), - encoder(u.Consents), encoder(u.MFASecrets), + encoder(u.Consents), encoder(u.MFASecrets), encoder(u.WebAuthnCredentials), u.CreatedAt, u.LastLogin, u.BlockedUntil, ) if err != nil { @@ -863,14 +869,15 @@ func (c *conn) UpdateUserIdentity(ctx context.Context, userID, connectorID strin claims_groups = $6, consents = $7, mfa_secrets = $8, - created_at = $9, - last_login = $10, - blocked_until = $11 - where user_id = $12 AND connector_id = $13; + webauthn_credentials = $9, + created_at = $10, + last_login = $11, + blocked_until = $12 + where user_id = $13 AND connector_id = $14; `, newIdentity.Claims.UserID, newIdentity.Claims.Username, newIdentity.Claims.PreferredUsername, newIdentity.Claims.Email, newIdentity.Claims.EmailVerified, encoder(newIdentity.Claims.Groups), - encoder(newIdentity.Consents), encoder(newIdentity.MFASecrets), + encoder(newIdentity.Consents), encoder(newIdentity.MFASecrets), encoder(newIdentity.WebAuthnCredentials), newIdentity.CreatedAt, newIdentity.LastLogin, newIdentity.BlockedUntil, u.UserID, u.ConnectorID, ) @@ -891,7 +898,7 @@ func getUserIdentity(ctx context.Context, q querier, userID, connectorID string) user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until from user_identity where user_id = $1 AND connector_id = $2; @@ -904,7 +911,7 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until from user_identity; `) @@ -928,12 +935,12 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, } func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { - var mfaSecrets []byte + var mfaSecrets, webauthnCreds []byte err = s.Scan( &u.UserID, &u.ConnectorID, &u.Claims.UserID, &u.Claims.Username, &u.Claims.PreferredUsername, &u.Claims.Email, &u.Claims.EmailVerified, decoder(&u.Claims.Groups), - decoder(&u.Consents), &mfaSecrets, + decoder(&u.Consents), &mfaSecrets, &webauthnCreds, &u.CreatedAt, &u.LastLogin, &u.BlockedUntil, ) if err != nil { @@ -950,6 +957,11 @@ func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { return u, fmt.Errorf("unmarshal user identity mfa secrets: %v", err) } } + if len(webauthnCreds) > 0 { + if err := json.Unmarshal(webauthnCreds, &u.WebAuthnCredentials); err != nil { + return u, fmt.Errorf("unmarshal user identity webauthn credentials: %v", err) + } + } return u, nil } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 942b119a..36a42135 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -454,4 +454,10 @@ var migrations = []migration{ `alter table auth_session add column logout_state bytea;`, }, }, + { + stmts: []string{ + `alter table auth_request add column webauthn_session_data bytea;`, + `alter table user_identity add column webauthn_credentials bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 21d1e971..7484474b 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -275,6 +275,10 @@ type AuthRequest struct { // MFAValidated is set to true if the user has completed multi-factor authentication. MFAValidated bool + + // WebAuthnSessionData stores temporary WebAuthn ceremony data (challenge, etc.) + // between Begin and Finish calls. JSON-encoded webauthn.SessionData. + WebAuthnSessionData []byte } // AuthCode represents a code which can be exchanged for an OAuth2 token response. @@ -373,16 +377,32 @@ type MFASecret struct { CreatedAt time.Time `json:"createdAt"` } +// WebAuthnCredential stores a registered WebAuthn credential for a user. +type WebAuthnCredential struct { + CredentialID []byte `json:"credentialID"` + PublicKey []byte `json:"publicKey"` + AttestationType string `json:"attestationType"` + AAGUID []byte `json:"aaguid"` + SignCount uint32 `json:"signCount"` + CloneWarning bool `json:"cloneWarning"` + Transport []string `json:"transport"` + BackupEligible bool `json:"backupEligible"` + BackupState bool `json:"backupState"` + DisplayName string `json:"displayName"` + CreatedAt time.Time `json:"createdAt"` +} + // UserIdentity represents persistent per-user identity data. type UserIdentity struct { - UserID string - ConnectorID string - Claims Claims - Consents map[string][]string // clientID -> approved scopes - MFASecrets map[string]*MFASecret // authenticatorID -> secret - CreatedAt time.Time - LastLogin time.Time - BlockedUntil time.Time + UserID string + ConnectorID string + Claims Claims + Consents map[string][]string // clientID -> approved scopes + MFASecrets map[string]*MFASecret // authenticatorID -> secret + WebAuthnCredentials map[string][]WebAuthnCredential // authenticatorID -> credentials + CreatedAt time.Time + LastLogin time.Time + BlockedUntil time.Time } // ClientAuthState represents authentication state for a specific client within an auth session. diff --git a/web/templates/webauthn_verify.html b/web/templates/webauthn_verify.html new file mode 100644 index 00000000..7f5d90f3 --- /dev/null +++ b/web/templates/webauthn_verify.html @@ -0,0 +1,173 @@ +{{ template "header.html" . }} + +
Register a security key for two-factor authentication.
+ {{ else }} +Use your security key to verify your identity.
+ {{ end }} + + + + + +