Skip to content

Commit 546e66c

Browse files
nabokihmsAlwxSin
andauthored
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>
1 parent 58f148d commit 546e66c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1807
-313
lines changed

cmd/dex/config.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func (c Config) validateMFA() error {
143143
return fmt.Errorf("mfa requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)")
144144
}
145145

146-
knownTypes := map[string]bool{"TOTP": true}
146+
knownTypes := map[string]bool{"TOTP": true, "WebAuthn": true}
147147
ids := make(map[string]bool, len(mfa.Authenticators))
148148

149149
for _, auth := range mfa.Authenticators {
@@ -705,3 +705,36 @@ type TOTPConfig struct {
705705
// Issuer is the name of the service shown in the authenticator app.
706706
Issuer string `json:"issuer"`
707707
}
708+
709+
// WebAuthnConfig holds configuration for a WebAuthn authenticator.
710+
type WebAuthnConfig struct {
711+
// RPDisplayName is the human-readable relying party name shown in the browser
712+
// dialog during key registration and authentication (e.g., "My Company SSO").
713+
RPDisplayName string `json:"rpDisplayName"`
714+
// RPID is the relying party identifier — must match the domain in the browser
715+
// address bar. If empty, derived from the issuer URL hostname.
716+
// Example: "auth.example.com"
717+
RPID string `json:"rpID"`
718+
// RPOrigins is the list of allowed origins for WebAuthn ceremonies.
719+
// If empty, derived from the issuer URL (scheme + host).
720+
// Example: ["https://auth.example.com"]
721+
RPOrigins []string `json:"rpOrigins"`
722+
// AttestationPreference controls what attestation data the authenticator should provide:
723+
// "none" — don't request attestation (simpler, more private)
724+
// "indirect" — authenticator may anonymize attestation (default)
725+
// "direct" — request full attestation (for enterprise key model verification)
726+
AttestationPreference string `json:"attestationPreference"`
727+
// UserVerification controls whether PIN or biometric verification is required:
728+
// "required" — always require (PIN, fingerprint, etc.)
729+
// "preferred" — request if the authenticator supports it (default)
730+
// "discouraged" — skip verification, presence check only
731+
UserVerification string `json:"userVerification"`
732+
// AuthenticatorAttachment restricts which authenticator types are allowed:
733+
// "platform" — built-in only (Touch ID, Windows Hello)
734+
// "cross-platform" — external only (YubiKey, USB security keys)
735+
// "" — any authenticator (default)
736+
AuthenticatorAttachment string `json:"authenticatorAttachment"`
737+
// Timeout is the duration allowed for the browser WebAuthn ceremony
738+
// (registration or login). Defaults to "60s".
739+
Timeout string `json:"timeout"`
740+
}

cmd/dex/serve.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ func runServe(options serveOptions) error {
385385
ContinueOnConnectorFailure: featureflags.ContinueOnConnectorFailure.Enabled(),
386386
Signer: signerInstance,
387387
IDTokensValidFor: idTokensValidFor,
388-
MFAProviders: buildMFAProviders(c.MFA.Authenticators, logger),
388+
MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger),
389389
DefaultMFAChain: c.MFA.DefaultMFAChain,
390390
}
391391

@@ -823,7 +823,7 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
823823
return sc, nil
824824
}
825825

826-
func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) map[string]server.MFAProvider {
826+
func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider {
827827
if len(authenticators) == 0 {
828828
return nil
829829
}
@@ -839,6 +839,20 @@ func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) m
839839
}
840840
providers[auth.ID] = server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes)
841841
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
842+
case "WebAuthn":
843+
var cfg WebAuthnConfig
844+
if err := json.Unmarshal(auth.Config, &cfg); err != nil {
845+
logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err)
846+
continue
847+
}
848+
provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins,
849+
cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes)
850+
if err != nil {
851+
logger.Error("failed to create WebAuthn provider", "id", auth.ID, "err", err)
852+
continue
853+
}
854+
providers[auth.ID] = provider
855+
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
842856
default:
843857
logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type)
844858
}

examples/config-dev.yaml

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,21 +141,32 @@ telemetry:
141141
# Multi-factor authentication configuration.
142142
# Requires DEX_SESSIONS_ENABLED=true feature flag.
143143
# mfa:
144-
# authenticators:
145-
# - id: totp-1
146-
# type: TOTP
147-
# config:
148-
# issuer: "dex-1"
149-
# # Optional: limit this authenticator to specific connector types (e.g., ldap, oidc, saml).
150-
# # If omitted or empty, applies to all connector types.
151-
# # It is recommended to use this option to prevent MFA from being used for connectors
152-
# # with their own MFA mechanisms, e.g., OIDC, Google, etc. (but technically, it is possible).
153-
# connectorTypes:
154-
# - mockCallback
155-
# # Default MFA chain applied to clients that don't specify their own mfaChain.
156-
# # If omitted or empty, no MFA is required by default.
157-
# defaultMFAChain:
158-
# - totp-1
144+
# authenticators:
145+
# - id: totp-1
146+
# type: TOTP
147+
# config:
148+
# issuer: "dex-1"
149+
# # Optional: limit this authenticator to specific connector types (e.g., ldap, oidc, saml).
150+
# # If omitted or empty, applies to all connector types.
151+
# # It is recommended to use this option to prevent MFA from being used for connectors
152+
# # with their own MFA mechanisms, e.g., OIDC, Google, etc. (but technically, it is possible).
153+
# connectorTypes:
154+
# - mockCallback
155+
# - id: webauthn-1
156+
# type: WebAuthn
157+
# config:
158+
# rpDisplayName: "Dex Dev"
159+
# # rpID defaults to the hostname of the issuer URL.
160+
# # rpID: "127.0.0.1"
161+
# # rpOrigins defaults to the issuer URL.
162+
# # rpOrigins:
163+
# # - "http://127.0.0.1:5556"
164+
# attestationPreference: "indirect" # none, indirect, or direct
165+
# userVerification: "preferred" # required, preferred, or discouraged
166+
# # authenticatorAttachment: "" # platform, cross-platform, or empty (any)
167+
# timeout: "60s"
168+
# defaultMFAChain:
169+
# - totp-1
159170

160171
# Instead of reading from an external storage, use this list of clients.
161172
#

go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/go-jose/go-jose/v4 v4.1.4
1717
github.com/go-ldap/ldap/v3 v3.4.13
1818
github.com/go-sql-driver/mysql v1.9.3
19+
github.com/go-webauthn/webauthn v0.16.1
1920
github.com/google/cel-go v0.27.0
2021
github.com/google/uuid v1.6.0
2122
github.com/gorilla/handlers v1.5.2
@@ -70,14 +71,18 @@ require (
7071
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
7172
github.com/fatih/color v1.18.0 // indirect
7273
github.com/felixge/httpsnoop v1.0.4 // indirect
74+
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
7375
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
7476
github.com/go-logr/logr v1.4.3 // indirect
7577
github.com/go-logr/stdr v1.2.2 // indirect
7678
github.com/go-openapi/inflect v0.19.0 // indirect
77-
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
79+
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
80+
github.com/go-webauthn/x v0.2.2 // indirect
7881
github.com/gogo/protobuf v1.3.2 // indirect
82+
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
7983
github.com/golang/protobuf v1.5.4 // indirect
8084
github.com/google/go-cmp v0.7.0 // indirect
85+
github.com/google/go-tpm v0.9.8 // indirect
8186
github.com/google/s2a-go v0.1.9 // indirect
8287
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
8388
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
@@ -114,6 +119,7 @@ require (
114119
github.com/shopspring/decimal v1.4.0 // indirect
115120
github.com/spf13/cast v1.7.0 // indirect
116121
github.com/spf13/pflag v1.0.9 // indirect
122+
github.com/x448/float16 v0.8.4 // indirect
117123
github.com/zclconf/go-cty v1.14.4 // indirect
118124
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
119125
go.etcd.io/etcd/api/v3 v3.6.9 // indirect

go.sum

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
7575
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
7676
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
7777
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
78+
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
79+
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
7880
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
7981
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
8082
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
9496
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
9597
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
9698
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
97-
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
98-
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
99+
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
100+
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
101+
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
102+
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
103+
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
104+
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
99105
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
100106
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
101107
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
108+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
109+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
102110
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
103111
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
104112
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
105113
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
106114
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
107115
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
116+
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
117+
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
118+
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
119+
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
108120
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
109121
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
110122
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
250262
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
251263
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
252264
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
265+
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
266+
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
253267
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
254268
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
255269
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
280294
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
281295
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
282296
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
297+
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
298+
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
283299
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
284300
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
285301
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=

server/handlers.go

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package server
22

33
import (
44
"context"
5-
"crypto/hmac"
65
"crypto/sha256"
76
"crypto/subtle"
87
"encoding/base64"
@@ -13,7 +12,6 @@ import (
1312
"maps"
1413
"net/http"
1514
"net/url"
16-
"path"
1715
"sort"
1816
"strconv"
1917
"strings"
@@ -887,30 +885,13 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity,
887885
userIdentity = &ui
888886
}
889887

890-
// an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original
891-
// flow would be unable to poll for the result at the /approval endpoint
892-
h := hmac.New(sha256.New, authReq.HMACKey)
893-
h.Write([]byte(authReq.ID))
894-
mac := h.Sum(nil)
895-
hmacParam := base64.RawURLEncoding.EncodeToString(mac)
896-
897888
// Check if the client requires MFA.
898889
mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID)
899890
if err != nil {
900891
return "", false, fmt.Errorf("failed to get MFA chain for client: %v", err)
901892
}
902893
if len(mfaChain) > 0 {
903-
// Redirect to MFA verification starting with the first authenticator.
904-
// Each authenticator redirects to the next one in the chain upon success.
905-
// HMAC includes authenticatorID to prevent skipping steps by URL manipulation.
906-
h.Reset()
907-
h.Write([]byte(authReq.ID + "|" + mfaChain[0]))
908-
v := url.Values{}
909-
v.Set("req", authReq.ID)
910-
v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil)))
911-
v.Set("authenticator", mfaChain[0])
912-
returnURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode()
913-
return returnURL, false, nil
894+
return s.buildMFARedirectURL(authReq, mfaChain[0]), false, nil
914895
}
915896

916897
// No MFA required — mark as validated.
@@ -933,8 +914,7 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity,
933914
}
934915
}
935916

936-
returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + hmacParam
937-
return returnURL, false, nil
917+
return s.buildApprovalURL(authReq), false, nil
938918
}
939919

940920
func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
@@ -944,12 +924,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
944924
s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
945925
return
946926
}
947-
mac, err := base64.RawURLEncoding.DecodeString(macEncoded)
948-
if err != nil {
949-
s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
950-
return
951-
}
952-
953927
authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req"))
954928
if err != nil {
955929
if err == storage.ErrNotFound {
@@ -966,7 +940,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
966940
return
967941
}
968942

969-
h := hmac.New(sha256.New, authReq.HMACKey)
970943
if !authReq.MFAValidated {
971944
// Check if MFA is actually required — if so, redirect to TOTP instead of blocking.
972945
// 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) {
977950
return
978951
}
979952
if len(mfaChain) > 0 {
980-
h.Write([]byte(authReq.ID + "|" + mfaChain[0]))
981-
v := url.Values{}
982-
v.Set("req", authReq.ID)
983-
v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil)))
984-
v.Set("authenticator", mfaChain[0])
985-
h.Reset()
986-
totpURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode()
987-
http.Redirect(w, r, totpURL, http.StatusSeeOther)
953+
http.Redirect(w, r, s.buildMFARedirectURL(authReq, mfaChain[0]), http.StatusSeeOther)
988954
return
989955
}
990956
// No MFA required but flag not set — allow through (backward compat).
991957
}
992958

993-
// build expected hmac with secret key
994-
h.Write([]byte(authReq.ID))
995-
expectedMAC := h.Sum(nil)
996-
// constant time comparison
997-
if !hmac.Equal(mac, expectedMAC) {
959+
if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, "") {
998960
s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
999961
return
1000962
}
1001963

1002964
switch r.Method {
1003965
case http.MethodGet:
966+
// Skip the approval page and issue the code directly if:
967+
// 1. The client didn't force the approval prompt, AND
968+
// 2. Either the server is configured to skip approval globally,
969+
// or the user has already consented to all requested scopes for this client.
970+
// This handles the MFA redirect case: after MFA completion the user lands on
971+
// /approval via GET, and we don't want to show the consent screen again.
972+
if !authReq.ForceApprovalPrompt {
973+
if s.skipApproval {
974+
s.sendCodeResponse(w, r, authReq)
975+
return
976+
}
977+
ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID)
978+
if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) {
979+
s.sendCodeResponse(w, r, authReq)
980+
return
981+
}
982+
}
983+
1004984
client, err := s.storage.GetClient(ctx, authReq.ClientID)
1005985
if err != nil {
1006986
s.logger.ErrorContext(r.Context(), "Failed to get client", "client_id", authReq.ClientID, "err", err)

server/handlers_approval_test.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ package server
22

33
import (
44
"context"
5-
"crypto/hmac"
6-
"crypto/sha256"
7-
"encoding/base64"
85
"errors"
96
"net/http"
107
"net/http/httptest"
@@ -89,9 +86,7 @@ func TestHandleApprovalDoubleSubmitPOST(t *testing.T) {
8986
}
9087
require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq))
9188

92-
h := hmac.New(sha256.New, authReq.HMACKey)
93-
h.Write([]byte(authReq.ID))
94-
mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
89+
mac := computeHMAC(authReq.HMACKey, authReq.ID, "")
9590

9691
form := url.Values{
9792
"approval": {"approve"},

server/handlers_test.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ package server
33
import (
44
"bytes"
55
"context"
6-
"crypto/hmac"
7-
"crypto/sha256"
8-
"encoding/base64"
96
"encoding/json"
107
"errors"
118
"fmt"
@@ -832,9 +829,7 @@ func TestConsentPersistedOnApproval(t *testing.T) {
832829
}
833830
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
834831

835-
h := hmac.New(sha256.New, authReq.HMACKey)
836-
h.Write([]byte(authReq.ID))
837-
mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
832+
mac := computeHMAC(authReq.HMACKey, authReq.ID, "")
838833

839834
form := url.Values{
840835
"approval": {"approve"},

0 commit comments

Comments
 (0)