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:
Maksim Nabokikh
2026-04-02 11:48:46 +02:00
committed by GitHub
co-authored by Alwx
parent 58f148dd28
commit 546e66cb5d
41 changed files with 1811 additions and 317 deletions
+34 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
#
+7 -1
View File
@@ -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
+18 -2
View File
@@ -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
View File
@@ -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)
+1 -6
View File
@@ -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"},
+1 -6
View File
@@ -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"},
+40
View File
@@ -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
}
+56 -6
View File
@@ -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,
},
+7
View File
@@ -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
View File
@@ -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()
}
+383
View File
@@ -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})
}
+270
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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).
+18 -3
View File
@@ -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
}
+12
View File
@@ -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