mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat: add WebAuthn support (#4704)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com> Signed-off-by: Maksim Nabokikh <max.nabokih@gmail.com> Co-authored-by: Alwx <alwxsin@gmail.com>
This commit is contained in:
+34
-1
@@ -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"`
|
||||
}
|
||||
|
||||
+16
-2
@@ -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)
|
||||
}
|
||||
|
||||
+26
-15
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+22
-42
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+131
-105
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+18
-1
@@ -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 (
|
||||
|
||||
+1
-10
@@ -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.
|
||||
|
||||
+48
-30
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user