+ Counts rather than contents: a user with forty groups would otherwise be the
+ whole table. Everything about one โ claims, consents, factors โ is on its page.
+
+ {{else}}
+
+ ListUserIdentities returns nothing. An identity appears once someone signs
+ in through a connector โ and the method itself needs dex started with
+ DEX_API_SESSIONS_IDENTITIES_CRUD=true.
+
What dex reports about itself through GetDiscovery.
+
+ {{range .Endpoints}}
+
{{.Name}}
{{.Value}}
+ {{end}}
+ {{range .Capabilities}}
+
+
{{.Name}}
+
{{range .Values}}{{.}} {{end}}
+
+ {{end}}
+
+ {{end}}
+
+ {{if eq .Section "sessions"}}
+
+ Pick a user from the Users tab, or name one here. Sessions are keyed by the
+ ID the connector gave the user; refresh tokens by the sub claim, which the
+ app derives from that ID and the connector. Listing sessions needs dex
+ started with DEX_API_SESSIONS_IDENTITIES_CRUD=true.
+
+ The API's UpdateClient does not carry a secret or the public flag, so
+ neither can be changed here: dex expects a client that needs a new secret
+ to be deleted and created again.
+
+ None of this is editable: it is what the connector said about the user at
+ their last sign-in, and dex re-records it at the next one. For the local
+ connector, the entry behind it is on the Passwords tab.
+
+
+
+
+
Consents
+ {{if .Consents}}
+
+
Client
Scopes
+ {{range .Consents}}
+
+
{{.ClientID}}
+
{{range .Scopes}}{{.}} {{end}}
+
+
+
+
+
+
+ {{end}}
+
+
Revoking one puts the consent screen back in front of the user for that client.
+ {{else}}
+
Nothing approved yet, or the client skips the consent screen.
+ Enter the code at the verification URL. This page polls the token endpoint
+ every {{.PollInterval}}s until the provider says the code has been approved.
+
Re-checked with the provider every {{.SessionCheck}} using prompt=none, so signing out of dex elsewhere ends this session too.
+ {{end}}
+
+{{end}}
+{{/* Rendered whichever way round, so a back-channel logout can swap the two
+ without the page having to build markup the template already owns. */}}
+
+
Not signed in
+
+ The example client for dex. Pick a flow to run it and see what comes back.
+ Configuration for each one is in the
+ README.
+
+
+
+
+
Flows through the browser
+
+
+
+
Authorization code{{if .PKCE}} with PKCE{{end}}
+
The one web applications use. Redirects to dex and comes back with a code.
+
+
+
+ {{if .DeviceSupported}}
+
+
+
Device code
+
For input-constrained devices: shows a code to type in elsewhere while this page polls.
+
+
+
+ {{end}}
+
+
+
+ Authorization request options
+
These apply to both flows above.
+
+
+
+
+ {{range .ScopesSupported}}
+
+
+
+
+ {{end}}
+
+ {{if eq (len .ScopesSupported) 0}}
+ The provider advertises no scopes โ add them by hand.
+ {{end}}
+
+
+
+
+ openid is always requested. offline_access is what gets you a refresh token.
+
+
+
+
+
+
+
+
+
+
+ Sent as audience:server:client_id:<id>, which asks dex for a token another client will accept.
+
+
+
+
+
+
+ Skips dex's connector selection screen.
+
+
+
+
+
+
+
Flows without a browser
+
These call the token endpoint directly. Each one asks for its own parameters.
+
+ {{if .ClientCredentialsSupported}}
+
+
+
Client credentials
+
A token for this application itself, with no user involved.
+ Two of these answer questions that sound alike and are not:
+ introspection asks dex whether it still honours a token, verification checks
+ the signature and lifetime for yourself. A revoked token passes verification
+ and fails introspection.
+
+{{end}}
diff --git a/examples/example-app/server/token.go b/examples/example-app/server/token.go
new file mode 100644
index 0000000000..6aee20dfb8
--- /dev/null
+++ b/examples/example-app/server/token.go
@@ -0,0 +1,196 @@
+package server
+
+import (
+ "bytes"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// renderToken shows the result of a grant that produced a token through the
+// oauth2 library.
+func (s *Server) renderToken(w http.ResponseWriter, r *http.Request, grant string, token *oauth2.Token) {
+ rawID, _ := token.Extra("id_token").(string)
+ s.sessions.RememberTokens(s.session(w, r), grant, token, rawID)
+
+ data := TokenPageData{
+ LogoURI: dexLogoDataURI,
+ AdminEnabled: s.admin != nil,
+ Grant: grant,
+ AccessToken: token.AccessToken,
+ RefreshToken: token.RefreshToken,
+ RedirectURL: s.redirectURI,
+ PublicKeyPEM: s.fetchPublicKeyPEM(),
+ }
+
+ if rawIDToken, ok := token.Extra("id_token").(string); ok {
+ data.IDToken = rawIDToken
+ data.IDTokenJWTLink = jwtIOLink(rawIDToken)
+ data.Claims, _ = decodeJWTClaims(rawIDToken)
+ }
+ if data.AccessToken != "" {
+ data.AccessTokenJWTLink = jwtIOLink(data.AccessToken)
+ if data.Claims == "" {
+ // A grant without an ID token โ client credentials, token exchange
+ // โ still says who the token is for, in the access token itself.
+ data.Claims, _ = decodeJWTClaims(data.AccessToken)
+ }
+ }
+ if !token.Expiry.IsZero() {
+ data.ExpiresIn = time.Until(token.Expiry).Round(time.Second).String()
+ }
+
+ s.renderer.RenderTokenPage(w, data)
+}
+
+// renderRawToken shows the result of a grant whose response the app reads
+// directly, because it does not have the shape the oauth2 library expects.
+func (s *Server) renderRawToken(w http.ResponseWriter, r *http.Request, grant string, body []byte) {
+ var resp struct {
+ AccessToken string `json:"access_token"`
+ IDToken string `json:"id_token"`
+ RefreshToken string `json:"refresh_token"`
+ IssuedTokenType string `json:"issued_token_type"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ http.Error(w, fmt.Sprintf("token response is not JSON: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ data := TokenPageData{
+ LogoURI: dexLogoDataURI,
+ AdminEnabled: s.admin != nil,
+ Grant: grant,
+ AccessToken: resp.AccessToken,
+ IDToken: resp.IDToken,
+ RefreshToken: resp.RefreshToken,
+ IssuedTokenType: resp.IssuedTokenType,
+ RedirectURL: s.redirectURI,
+ PublicKeyPEM: s.fetchPublicKeyPEM(),
+ RawResponse: indentJSON(body),
+ }
+ if resp.ExpiresIn > 0 {
+ data.ExpiresIn = (time.Duration(resp.ExpiresIn) * time.Second).String()
+ }
+
+ remembered := (&oauth2.Token{AccessToken: resp.AccessToken, RefreshToken: resp.RefreshToken}).
+ WithExtra(map[string]any{"id_token": resp.IDToken})
+ s.sessions.RememberTokens(s.session(w, r), grant, remembered, resp.IDToken)
+ if data.IDToken != "" {
+ data.IDTokenJWTLink = jwtIOLink(data.IDToken)
+ data.Claims, _ = decodeJWTClaims(data.IDToken)
+ }
+ if data.AccessToken != "" {
+ data.AccessTokenJWTLink = jwtIOLink(data.AccessToken)
+ if data.Claims == "" {
+ data.Claims, _ = decodeJWTClaims(data.AccessToken)
+ }
+ }
+
+ s.renderer.RenderTokenPage(w, data)
+}
+
+// decodeJWTClaims pretty-prints a JWT payload without verifying it: this is for
+// looking at a token, and the verify tool is what says whether to believe it.
+func decodeJWTClaims(token string) (string, bool) {
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ return "", false
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return "", false
+ }
+ return indentJSON(payload), true
+}
+
+func indentJSON(raw []byte) string {
+ buf := new(bytes.Buffer)
+ if err := json.Indent(buf, raw, "", " "); err != nil {
+ return string(raw)
+ }
+ return buf.String()
+}
+
+// jwtIOLink creates a jwt.io debugger URL for the given token.
+func jwtIOLink(token string) string {
+ return "https://jwt.io/#debugger-io?token=" + url.QueryEscape(token)
+}
+
+// fetchPublicKeyPEM fetches the provider's JWKS and returns the first RSA public key as PEM.
+func (s *Server) fetchPublicKeyPEM() string {
+ if s.jwksURL == "" {
+ return ""
+ }
+
+ resp, err := s.client.Get(s.jwksURL)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ var jwks struct {
+ Keys []json.RawMessage `json:"keys"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil || len(jwks.Keys) == 0 {
+ return ""
+ }
+
+ var key struct {
+ N string `json:"n"`
+ E string `json:"e"`
+ Kty string `json:"kty"`
+ }
+ if err := json.Unmarshal(jwks.Keys[0], &key); err != nil || key.Kty != "RSA" {
+ return ""
+ }
+
+ nBytes, err1 := base64.RawURLEncoding.DecodeString(key.N)
+ eBytes, err2 := base64.RawURLEncoding.DecodeString(key.E)
+ if err1 != nil || err2 != nil {
+ return ""
+ }
+
+ var eInt int
+ for _, b := range eBytes {
+ eInt = eInt<<8 | int(b)
+ }
+
+ pubKey := &rsa.PublicKey{
+ N: new(big.Int).SetBytes(nBytes),
+ E: eInt,
+ }
+
+ pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
+ if err != nil {
+ return ""
+ }
+
+ return string(pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: pubKeyBytes,
+ }))
+}
+
+// handleTokens re-renders the last tokens this browser was given. Without it a
+// tool result is a dead end: the page holding the tokens you were working with
+// is gone, and nothing in the app can bring it back.
+func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
+ sess := s.session(w, r)
+ if sess.LastTokens == nil {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ s.renderToken(w, r, sess.LastGrant, sess.LastTokens)
+}
diff --git a/examples/example-app/server/tools.go b/examples/example-app/server/tools.go
new file mode 100644
index 0000000000..6e91ead6d7
--- /dev/null
+++ b/examples/example-app/server/tools.go
@@ -0,0 +1,171 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+)
+
+// handleTools renders the page for looking at tokens you already hold.
+func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
+ s.renderer.RenderToolsPage(w, ToolsPageData{
+ LogoURI: dexLogoDataURI,
+ AdminEnabled: s.admin != nil,
+ })
+}
+
+// handleIntrospect asks the provider what it thinks of a token.
+//
+// This is the answer the app cannot work out for itself: an access token can
+// look perfectly valid and still have been revoked, and only the issuer knows.
+func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ token := strings.TrimSpace(r.FormValue("token"))
+ if token == "" {
+ http.Error(w, "token is required", http.StatusBadRequest)
+ return
+ }
+
+ form := url.Values{"token": {token}}
+ if hint := r.FormValue("token_type_hint"); hint != "" {
+ form.Set("token_type_hint", hint)
+ }
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, s.introspectURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(url.QueryEscape(s.clientID), url.QueryEscape(s.clientSecret))
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("introspection request failed: %v", err), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if resp.StatusCode != http.StatusOK {
+ s.renderResult(w, r, "Introspection", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), "")
+ return
+ }
+
+ var result struct {
+ Active bool `json:"active"`
+ }
+ _ = json.Unmarshal(body, &result)
+
+ verdict := "The provider says this token is active."
+ if !result.Active {
+ verdict = "The provider says this token is not active โ expired, revoked, or never issued by it."
+ }
+
+ s.renderResult(w, r, "Introspection", indentJSON(body), verdict)
+}
+
+// handleVerify checks a token's signature and claims locally, the way a
+// resource server would: fetch the issuer's keys, verify, then look at what the
+// token says. It is deliberately separate from introspection โ this answers
+// "was this signed by the issuer and is it still within its lifetime", not "is
+// the issuer still honouring it".
+func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ raw := strings.TrimSpace(r.FormValue("token"))
+ if raw == "" {
+ http.Error(w, "token is required", http.StatusBadRequest)
+ return
+ }
+
+ ctx := oidc.ClientContext(r.Context(), s.client)
+
+ // Skipping the audience check keeps the tool useful for tokens issued to
+ // another client โ the claims below still show who the audience is.
+ verifier := s.provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
+ idToken, err := verifier.Verify(ctx, raw)
+ if err != nil {
+ claims, _ := decodeJWTClaims(raw)
+ s.renderResult(w, r, "Local verification", claims, "Signature or claims rejected: "+err.Error())
+ return
+ }
+
+ claims, _ := decodeJWTClaims(raw)
+ verdict := fmt.Sprintf("Signature valid. Issued by %s for %v, expires %s.",
+ idToken.Issuer, idToken.Audience, idToken.Expiry.Format(time.RFC3339))
+ if idToken.Audience != nil && !containsScope(idToken.Audience, s.clientID) {
+ verdict += " Note: this application is not in the audience."
+ }
+
+ s.renderResult(w, r, "Local verification", claims, verdict)
+}
+
+// handleUserInfo calls the provider's UserInfo endpoint with an access token.
+func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ accessToken := strings.TrimSpace(r.FormValue("access_token"))
+ if accessToken == "" {
+ http.Error(w, "access_token is required", http.StatusBadRequest)
+ return
+ }
+ if s.userInfoURL == "" {
+ http.Error(w, "the provider does not advertise a userinfo endpoint", http.StatusBadRequest)
+ return
+ }
+
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.userInfoURL, nil)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("userinfo request failed: %v", err), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ s.renderResult(w, r, "UserInfo", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), "")
+ return
+ }
+
+ s.renderResult(w, r, "UserInfo", indentJSON(body), "")
+}
+
+// renderResult shows the output of a tool.
+func (s *Server) renderResult(w http.ResponseWriter, r *http.Request, title, body, verdict string) {
+ s.renderer.RenderResultPage(w, ResultPageData{
+ LogoURI: dexLogoDataURI,
+ AdminEnabled: s.admin != nil,
+ Title: title,
+ Verdict: verdict,
+ Body: body,
+ LastGrant: s.session(w, r).LastGrant,
+ })
+}
diff --git a/examples/example-app/server/transport.go b/examples/example-app/server/transport.go
new file mode 100644
index 0000000000..a262667fbf
--- /dev/null
+++ b/examples/example-app/server/transport.go
@@ -0,0 +1,83 @@
+package server
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "os"
+ "time"
+)
+
+// newHTTPClient creates an *http.Client with optional custom root CAs and debug logging.
+func newHTTPClient(rootCAs string, debug bool) (*http.Client, error) {
+ var client *http.Client
+
+ if rootCAs != "" {
+ tlsConfig := &tls.Config{RootCAs: x509.NewCertPool()}
+ rootCABytes, err := os.ReadFile(rootCAs)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read root-ca: %v", err)
+ }
+ if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
+ return nil, fmt.Errorf("no certs found in root CA file %q", rootCAs)
+ }
+ client = &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ },
+ }
+ }
+
+ if debug {
+ if client == nil {
+ client = &http.Client{
+ Transport: debugTransport{http.DefaultTransport},
+ }
+ } else {
+ client.Transport = debugTransport{client.Transport}
+ }
+ }
+
+ if client == nil {
+ client = http.DefaultClient
+ }
+
+ return client, nil
+}
+
+// debugTransport wraps an http.RoundTripper and logs full request/response details.
+type debugTransport struct {
+ t http.RoundTripper
+}
+
+func (d debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ reqDump, err := httputil.DumpRequest(req, true)
+ if err != nil {
+ return nil, err
+ }
+ log.Printf("%s", reqDump)
+
+ resp, err := d.t.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+
+ respDump, err := httputil.DumpResponse(resp, true)
+ if err != nil {
+ resp.Body.Close()
+ return nil, err
+ }
+ log.Printf("%s", respDump)
+ return resp, nil
+}
diff --git a/examples/example-app/session/session.go b/examples/example-app/session/session.go
new file mode 100644
index 0000000000..7d02cb78a6
--- /dev/null
+++ b/examples/example-app/session/session.go
@@ -0,0 +1,385 @@
+// Package session keeps the example app's own state: who is signed in to this
+// application, and what it is waiting for from the provider.
+package session
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "net/http"
+ "sync"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// CookieName is the app's session cookie. It identifies the browser, not the
+// user: a session exists from the first request, before anyone signs in, so
+// that a login can be tied to the browser that started it.
+const CookieName = "example_app_session"
+
+// ttl is how long an idle session is kept. The app is a demo; the number only
+// has to be long enough that nobody loses a session mid-experiment.
+const ttl = 12 * time.Hour
+
+// UserClaims holds basic user identity claims from an ID token.
+type UserClaims struct {
+ Subject string `json:"sub"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ PreferredUsername string `json:"preferred_username"`
+
+ // SessionID is the "sid" claim: the provider's session this token was issued
+ // under. The app keeps it so a back-channel logout token, which carries the
+ // same value, can be matched to the browser it is about.
+ SessionID string `json:"sid"`
+}
+
+// PendingAuth is one authorization the app has started and not yet finished.
+// Everything in it belongs to a single authorization request: reusing any of it
+// across requests is what the state and PKCE parameters exist to prevent.
+type PendingAuth struct {
+ State string
+ Nonce string
+ CodeVerifier string
+ // Silent marks a prompt=none request, whose failure is an answer ("no
+ // session at the provider") rather than an error to show the user.
+ Silent bool
+ Created time.Time
+}
+
+// Device is a device authorization this browser started. It lives on the
+// session for the same reason everything else here does: two people trying the
+// device flow at once should not share one user code.
+type Device struct {
+ DeviceCode string
+ UserCode string
+ VerificationURI string
+ PollInterval int
+ Token *oauth2.Token
+}
+
+// Session is one browser's state.
+type Session struct {
+ ID string
+
+ // Claims and Token are the result of the last completed sign-in.
+ Claims *UserClaims
+ Token *oauth2.Token
+ IDToken string
+
+ // Device is the device authorization in progress, if any.
+ Device *Device
+
+ // LastTokens is whatever the last flow produced, sign-in or not. Client
+ // credentials and token exchange do not sign anyone in, and a tool that
+ // forgets the tokens you just fetched is a tool you have to run twice.
+ LastTokens *oauth2.Token
+ LastIDToken string
+ LastGrant string
+
+ // LastProviderCheck is when the app last confirmed with the provider that
+ // the session there still exists. Without it the app would keep showing a
+ // user who signed out of the provider in another tab.
+ LastProviderCheck time.Time
+
+ // watchers are open SSE streams for this session, notified when a logout
+ // token arrives for it.
+ watchers []chan Notice
+
+ pending map[string]*PendingAuth
+ expires time.Time
+}
+
+// SignedIn reports whether this browser has completed a sign-in.
+func (s *Session) SignedIn() bool { return s.Claims != nil }
+
+// Store keeps sessions for the process. A demo app has no reason to persist
+// them, but it does have a reason to keep them apart: one global session would
+// mean every browser hitting this app shares one identity.
+type Store struct {
+ mu sync.Mutex
+ sessions map[string]*Session
+}
+
+// NewStore returns an empty in-memory store.
+func NewStore() *Store {
+ return &Store{sessions: make(map[string]*Session)}
+}
+
+// FromRequest returns the session for this browser, creating one and setting
+// the cookie if the request carries none. The returned session is the store's
+// own: the methods below change it in place.
+func (st *Store) FromRequest(w http.ResponseWriter, r *http.Request, secure bool) *Session {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ st.sweepLocked()
+
+ if c, err := r.Cookie(CookieName); err == nil {
+ if s, ok := st.sessions[c.Value]; ok {
+ s.expires = time.Now().Add(ttl)
+ return s
+ }
+ }
+
+ s := &Session{
+ ID: randomString(),
+ pending: make(map[string]*PendingAuth),
+ expires: time.Now().Add(ttl),
+ }
+ st.sessions[s.ID] = s
+
+ http.SetCookie(w, &http.Cookie{
+ Name: CookieName,
+ Value: s.ID,
+ Path: "/",
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: int(ttl.Seconds()),
+ })
+
+ return s
+}
+
+// SignIn records a completed sign-in.
+func (st *Store) SignIn(s *Session, claims *UserClaims, token *oauth2.Token, rawIDToken string) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ s.Claims = claims
+ s.Token = token
+ if rawIDToken != "" {
+ s.IDToken = rawIDToken
+ }
+ s.LastProviderCheck = time.Now()
+ s.expires = time.Now().Add(ttl)
+}
+
+// Confirm records that the provider still knows this user, without touching the
+// tokens the app is holding. A silent check asks for the scopes it needs to
+// identify someone, not the ones the sign-in asked for, so the tokens it comes
+// back with are narrower โ overwriting with them loses the refresh token the
+// original flow was given.
+func (st *Store) Confirm(s *Session, claims *UserClaims) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ if claims != nil {
+ s.Claims = claims
+ }
+ s.LastProviderCheck = time.Now()
+ s.expires = time.Now().Add(ttl)
+}
+
+// SignOut drops what the app knows about the user and returns the last ID
+// token, which RP-initiated logout sends back to the provider as a hint.
+func (st *Store) SignOut(s *Session) string {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ return signOutLocked(s)
+}
+
+// Notice is what a browser watching its session is told when a logout token
+// ends it. It travels to the page over SSE, which is the only way an application
+// can show a back channel doing its job: a message that waits for the next page
+// load proves nothing, since a page load is exactly when the app's own
+// prompt=none check would have noticed anyway.
+type Notice struct {
+ At time.Time `json:"at"`
+ SessionID string `json:"sid,omitempty"`
+}
+
+// Watch returns a channel carrying notices for one session, and a function that
+// stops watching. Every open page gets its own.
+func (st *Store) Watch(sessionID string) (<-chan Notice, func()) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ ch := make(chan Notice, 1)
+ if s, ok := st.sessions[sessionID]; ok {
+ s.watchers = append(s.watchers, ch)
+ }
+
+ return ch, func() {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ s, ok := st.sessions[sessionID]
+ if !ok {
+ return
+ }
+ for i, w := range s.watchers {
+ if w == ch {
+ s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
+ break
+ }
+ }
+ }
+}
+
+// SignOutByBackchannel ends every session the provider's logout token is about
+// and returns how many it ended. A token carrying a sid names one session, which
+// is the precise case; matching on the subject alone is the fallback for a
+// provider that sends only sub, and ends every session that user has here.
+//
+// Any page watching an ended session is told at once.
+func (st *Store) SignOutByBackchannel(sid, subject string) int {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ now := time.Now()
+ matched := 0
+ for _, s := range st.sessions {
+ if s.Claims == nil {
+ continue
+ }
+
+ switch {
+ case sid != "" && s.Claims.SessionID != "":
+ if s.Claims.SessionID != sid {
+ continue
+ }
+ case subject != "":
+ if s.Claims.Subject != subject {
+ continue
+ }
+ default:
+ continue
+ }
+
+ // Read the sid before signing out, which drops the claims it lives on.
+ notice := Notice{At: now, SessionID: s.Claims.SessionID}
+ signOutLocked(s)
+ matched++
+
+ // Non-blocking: a page that has not drained its last notice is already
+ // being told, and a wedged reader must not stall the logout.
+ for _, w := range s.watchers {
+ select {
+ case w <- notice:
+ default:
+ }
+ }
+ }
+ return matched
+}
+
+// signOutLocked clears the user from a session. Callers hold the store's lock.
+func signOutLocked(s *Session) string {
+ idToken := s.IDToken
+ s.Claims = nil
+ s.Token = nil
+ s.IDToken = ""
+ // The tools prefill from the last flow's result. Keeping it past a sign-out
+ // would put a signed-out user's access token back on screen, which reads as
+ // the app inventing tokens.
+ s.LastTokens = nil
+ s.LastIDToken = ""
+ s.LastGrant = ""
+ s.Device = nil
+ // LastProviderCheck deliberately survives: it records when the provider was
+ // last asked, and signing out here does not make that answer any older.
+ // Clearing it would send the next page load straight back into a check,
+ // which is a redirect loop when the answer is "no session".
+ return idToken
+}
+
+// StartAuth records an authorization the app is about to send the browser off
+// to complete, and returns it. Each one carries its own state and PKCE
+// verifier.
+func (st *Store) StartAuth(s *Session, silent bool) *PendingAuth {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ // Anything still pending from a much earlier request was abandoned.
+ for state, p := range s.pending {
+ if time.Since(p.Created) > 10*time.Minute {
+ delete(s.pending, state)
+ }
+ }
+
+ p := &PendingAuth{
+ State: randomString(),
+ Nonce: randomString(),
+ CodeVerifier: oauth2.GenerateVerifier(),
+ Silent: silent,
+ Created: time.Now(),
+ }
+ s.pending[p.State] = p
+ return p
+}
+
+// TakeAuth returns the pending authorization matching a callback's state
+// parameter and forgets it, so a state cannot be replayed. A miss means the
+// callback did not come from a request this browser started.
+func (st *Store) TakeAuth(s *Session, state string) (*PendingAuth, bool) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ p, ok := s.pending[state]
+ if ok {
+ delete(s.pending, state)
+ }
+ return p, ok
+}
+
+// RememberTokens keeps the result of the last flow so the tools can offer it
+// and the token page can be reached again.
+func (st *Store) RememberTokens(s *Session, grant string, token *oauth2.Token, rawIDToken string) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ s.LastTokens = token
+ s.LastIDToken = rawIDToken
+ s.LastGrant = grant
+ s.expires = time.Now().Add(ttl)
+}
+
+// StartDevice records a device authorization for this browser.
+func (st *Store) StartDevice(s *Session, d *Device) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ s.Device = d
+ s.expires = time.Now().Add(ttl)
+}
+
+// SetDeviceToken attaches the token a device authorization ended with.
+func (st *Store) SetDeviceToken(s *Session, token *oauth2.Token) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ if s.Device != nil {
+ s.Device.Token = token
+ }
+}
+
+// MarkChecked records that the provider was just asked about the session,
+// whatever the answer, so a failing check cannot spin.
+func (st *Store) MarkChecked(s *Session) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ s.LastProviderCheck = time.Now()
+}
+
+// sweepLocked drops expired sessions. Called on the way in, which is often
+// enough for a process that only serves a handful of browsers.
+func (st *Store) sweepLocked() {
+ now := time.Now()
+ for id, s := range st.sessions {
+ if now.After(s.expires) {
+ delete(st.sessions, id)
+ }
+ }
+}
+
+func randomString() string {
+ b := make([]byte, 24)
+ if _, err := rand.Read(b); err != nil {
+ panic("example-app: out of randomness: " + err.Error())
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
diff --git a/examples/example-app/templates.go b/examples/example-app/templates.go
deleted file mode 100644
index a9425ead27..0000000000
--- a/examples/example-app/templates.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package main
-
-import (
- "html/template"
- "log"
- "net/http"
-)
-
-var indexTmpl = template.Must(template.New("index.html").Parse(`
-
-
-
-
-
-
-`))
-
-func renderIndex(w http.ResponseWriter) {
- renderTemplate(w, indexTmpl, nil)
-}
-
-type tokenTmplData struct {
- IDToken string
- AccessToken string
- RefreshToken string
- RedirectURL string
- Claims string
-}
-
-var tokenTmpl = template.Must(template.New("token.html").Parse(`
-
-
-
-
-
ID Token:
{{ .IDToken }}
-
Access Token:
{{ .AccessToken }}
-
Claims:
{{ .Claims }}
- {{ if .RefreshToken }}
-
Refresh Token:
{{ .RefreshToken }}
-
- {{ end }}
-
-
-`))
-
-func renderToken(w http.ResponseWriter, redirectURL, idToken, accessToken, refreshToken, claims string) {
- renderTemplate(w, tokenTmpl, tokenTmplData{
- IDToken: idToken,
- AccessToken: accessToken,
- RefreshToken: refreshToken,
- RedirectURL: redirectURL,
- Claims: claims,
- })
-}
-
-func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) {
- err := tmpl.Execute(w, data)
- if err == nil {
- return
- }
-
- switch err := err.(type) {
- case *template.Error:
- // An ExecError guarantees that Execute has not written to the underlying reader.
- log.Printf("Error rendering template %s: %s", tmpl.Name(), err)
-
- // TODO(ericchiang): replace with better internal server error.
- http.Error(w, "Internal server error", http.StatusInternalServerError)
- default:
- // An error with the underlying write, such as the connection being
- // dropped. Ignore for now.
- }
-}
diff --git a/examples/go.mod b/examples/go.mod
index d66c118a7f..3cd54c4828 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,25 +1,27 @@
module github.com/dexidp/dex/examples
-go 1.17
+go 1.25.0
require (
- github.com/coreos/go-oidc/v3 v3.1.0
- github.com/dexidp/dex/api/v2 v2.0.0
- github.com/spf13/cobra v1.3.0
- golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
- google.golang.org/grpc v1.43.0
+ github.com/coreos/go-oidc/v3 v3.20.0
+ github.com/dexidp/dex/api/v2 v2.4.0
+ github.com/spf13/cobra v1.10.2
+ golang.org/x/crypto v0.54.0
+ golang.org/x/oauth2 v0.36.0
+ google.golang.org/grpc v1.83.0
)
require (
- github.com/golang/protobuf v1.5.2 // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
- golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d // indirect
- golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
- golang.org/x/text v0.3.7 // indirect
- google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0 // indirect
- google.golang.org/protobuf v1.27.1 // indirect
- gopkg.in/square/go-jose.v2 v2.6.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
)
+
+// The example lives in this repository, so it demonstrates this repository's
+// API rather than the last published version of it.
+replace github.com/dexidp/dex/api/v2 => ../api/v2
diff --git a/examples/go.sum b/examples/go.sum
index 7907afde92..80bac09741 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -1,788 +1,56 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
-cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
-cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
-cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
-cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
-cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
-github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw=
-github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dexidp/dex/api/v2 v2.0.0 h1:bvge1sRmzVzWPWp4WlMzS04lcNQA+jFzHqKV3066bRw=
-github.com/dexidp/dex/api/v2 v2.0.0/go.mod h1:k5arBJT1QYvpsEY3sEd0NXJp3hKWKuUUfzJ3BlcqPdM=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
-github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
-github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
-github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
-github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
-github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
-github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
-github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
-github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
-github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
-github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
+github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
-github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
-github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0=
-github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
-go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI=
-golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
-golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
-google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
-google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
-google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
-google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
-google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
-google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
-google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0 h1:aCsSLXylHWFno0r4S3joLpiaWayvqd2Mn4iSvx4WZZc=
-google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
-google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
+google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
-gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/examples/grpc-client/README.md b/examples/grpc-client/README.md
index 59629e0590..6a78df9199 100644
--- a/examples/grpc-client/README.md
+++ b/examples/grpc-client/README.md
@@ -50,6 +50,9 @@ Running the gRPC client will cause the following API calls to be made to the ser
2. ListPasswords
3. VerifyPassword
4. DeletePassword
+5. CreateClient
+6. ListClients
+7. DeleteClient
## Cleaning up
diff --git a/examples/grpc-client/client.go b/examples/grpc-client/client.go
index fb8d4aaf06..7bbbac6494 100644
--- a/examples/grpc-client/client.go
+++ b/examples/grpc-client/client.go
@@ -58,7 +58,7 @@ func createPassword(cli api.DexClient) error {
// Create password.
if resp, err := cli.CreatePassword(context.TODO(), createReq); err != nil || resp.AlreadyExists {
- if resp != nil && resp.AlreadyExists {
+ if resp != nil && resp.AlreadyExists {
return fmt.Errorf("Password %s already exists", createReq.Password.Email)
}
return fmt.Errorf("failed to create password: %v", err)
@@ -125,6 +125,57 @@ func createPassword(cli api.DexClient) error {
return nil
}
+func createAndListClients(cli api.DexClient) error {
+ client := &api.Client{
+ Id: "example-client",
+ Secret: "example-secret",
+ RedirectUris: []string{"http://localhost:8080/callback"},
+ TrustedPeers: []string{},
+ Public: false,
+ Name: "Example Client",
+ LogoUrl: "http://example.com/logo.png",
+ }
+
+ createReq := &api.CreateClientReq{
+ Client: client,
+ }
+
+ if resp, err := cli.CreateClient(context.TODO(), createReq); err != nil || resp.AlreadyExists {
+ if resp != nil && resp.AlreadyExists {
+ log.Printf("Client %s already exists", createReq.Client.Id)
+ } else {
+ return fmt.Errorf("failed to create client: %v", err)
+ }
+ } else {
+ log.Printf("Created client with ID %s", createReq.Client.Id)
+ }
+
+ listResp, err := cli.ListClients(context.TODO(), &api.ListClientReq{})
+ if err != nil {
+ return fmt.Errorf("failed to list clients: %v", err)
+ }
+
+ log.Print("Listing Clients:\n")
+ for _, client := range listResp.Clients {
+ log.Printf("ID: %s, Name: %s, Public: %t, RedirectURIs: %v",
+ client.Id, client.Name, client.Public, client.RedirectUris)
+ }
+
+ deleteReq := &api.DeleteClientReq{
+ Id: client.Id,
+ }
+
+ if resp, err := cli.DeleteClient(context.TODO(), deleteReq); err != nil || resp.NotFound {
+ if resp != nil && resp.NotFound {
+ return fmt.Errorf("Client %s not found", deleteReq.Id)
+ }
+ return fmt.Errorf("failed to delete client: %v", err)
+ }
+ log.Printf("Deleted client with ID %s", deleteReq.Id)
+
+ return nil
+}
+
func main() {
caCrt := flag.String("ca-crt", "", "CA certificate")
clientCrt := flag.String("client-crt", "", "Client certificate")
@@ -143,4 +194,8 @@ func main() {
if err := createPassword(client); err != nil {
log.Fatalf("testPassword failed: %v", err)
}
+
+ if err := createAndListClients(client); err != nil {
+ log.Fatalf("testClients failed: %v", err)
+ }
}
diff --git a/examples/k8s/dex.yaml b/examples/k8s/dex.yaml
index 89ac40b223..c20d268774 100644
--- a/examples/k8s/dex.yaml
+++ b/examples/k8s/dex.yaml
@@ -23,7 +23,7 @@ spec:
spec:
serviceAccountName: dex # This is created below
containers:
- - image: ghcr.io/dexidp/dex:v2.30.0
+ - image: ghcr.io/dexidp/dex:v2.32.0
name: dex
command: ["/usr/local/bin/dex", "serve", "/etc/dex/cfg/config.yaml"]
@@ -106,6 +106,12 @@ data:
# bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
+ name: "Admin User"
+ emailVerified: true
+ preferredUsername: "admin"
+ groups:
+ - "team-a"
+ - "team-a/admins"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
---
apiVersion: v1
diff --git a/examples/ldap/config-ldap.yaml b/examples/ldap/config-ldap.yaml
index 05d1661826..49a7e25fff 100644
--- a/examples/ldap/config-ldap.yaml
+++ b/examples/ldap/config-ldap.yaml
@@ -59,6 +59,19 @@ connectors:
# The group name should be the "cn" value.
nameAttr: cn
+ # Optional Kerberos (SPNEGO) SSO. When enabled, Dex will challenge with
+ # WWW-Authenticate: Negotiate on GET and skip the password form on success.
+ #kerberos:
+ # enabled: true
+ # keytabPath: /etc/dex/krb5.keytab
+ # expectedRealm: EXAMPLE.COM
+ # usernameFromPrincipal: sAMAccountName # or userPrincipalName or localpart
+ # fallbackToPassword: false
+ # # Optional gokrb5 service settings โ leave empty to use library defaults.
+ # spn: HTTP/dex.example.com # service principal name expected in tickets
+ # keytabPrincipal: HTTP/dex.example.com@EXAMPLE.COM # explicit principal to load from the keytab
+ # maxClockSkew: 300 # tolerated clock skew, seconds (default 300)
+
staticClients:
- id: example-app
redirectURIs:
diff --git a/examples/oidc-conformance/config.yaml.tmpl b/examples/oidc-conformance/config.yaml.tmpl
new file mode 100644
index 0000000000..1f89afc64b
--- /dev/null
+++ b/examples/oidc-conformance/config.yaml.tmpl
@@ -0,0 +1,36 @@
+# Dex configuration for OIDC Conformance Testing.
+# See https://dexidp.io/docs/development/oidc-certification/
+#
+# This template is processed by run.sh which replaces ISSUER_URL and ALIAS
+# with actual values before starting Dex.
+
+issuer: ISSUER_URL/dex
+
+storage:
+ type: sqlite3
+ config:
+ file: examples/oidc-conformance/dex.db
+
+web:
+ http: 0.0.0.0:5556
+
+enablePasswordDB: true
+
+staticPasswords:
+- email: "admin@example.com"
+ # bcrypt hash of the string "password"
+ hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
+ username: "admin"
+
+staticClients:
+ - id: first_client
+ secret: 89d6205220381728e85c4cf5
+ redirectURIs:
+ - https://www.certification.openid.net/test/a/ALIAS/callback
+ name: First client
+
+ - id: second_client
+ secret: 51c612288018fd384b05d6ad
+ redirectURIs:
+ - https://www.certification.openid.net/test/a/ALIAS/callback
+ name: Second client
diff --git a/examples/oidc-conformance/run.sh b/examples/oidc-conformance/run.sh
new file mode 100755
index 0000000000..702928bd18
--- /dev/null
+++ b/examples/oidc-conformance/run.sh
@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+#
+# OIDC Conformance Test Runner
+#
+# Starts Dex with a test configuration and exposes it via a public tunnel
+# for use with https://www.certification.openid.net/
+#
+# Usage:
+# ./run.sh # uses cloudflared (default)
+# ./run.sh --tunnel ngrok # uses ngrok
+# ./run.sh --url https://my.url # uses a pre-existing public URL (no tunnel)
+# ./run.sh --alias my-dex # custom alias for the test plan (default: dex)
+#
+# Prerequisites:
+# - Dex binary in PATH or ../../bin/dex
+# - ngrok or cloudflared installed (unless --url is provided)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+DEX_PORT=5556
+TUNNEL_TYPE="cloudflared"
+PUBLIC_URL=""
+ALIAS="dex"
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --tunnel) TUNNEL_TYPE="$2"; shift 2 ;;
+ --url) PUBLIC_URL="$2"; shift 2 ;;
+ --alias) ALIAS="$2"; shift 2 ;;
+ -h|--help)
+ sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
+ exit 0
+ ;;
+ *) echo "Unknown option: $1"; exit 1 ;;
+ esac
+done
+
+# Find dex binary.
+DEX_BIN=""
+for candidate in "dex" "$ROOT_DIR/bin/dex"; do
+ if command -v "$candidate" &>/dev/null || [[ -x "$candidate" ]]; then
+ DEX_BIN="$candidate"
+ break
+ fi
+done
+if [[ -z "$DEX_BIN" ]]; then
+ echo "Error: dex binary not found. Run 'make build' first or install dex."
+ exit 1
+fi
+
+cleanup() {
+ echo ""
+ echo "Shutting down..."
+ kill "${TUNNEL_PID:-}" "${DEX_PID:-}" 2>/dev/null || true
+ rm -f "${CONFIG_FILE:-}"
+ wait 2>/dev/null
+}
+trap cleanup EXIT
+
+# Start tunnel if no URL provided.
+TUNNEL_PID=""
+if [[ -z "$PUBLIC_URL" ]]; then
+ case "$TUNNEL_TYPE" in
+ ngrok)
+ if ! command -v ngrok &>/dev/null; then
+ echo "Error: ngrok not found. Install it from https://ngrok.com/ or use --url."
+ exit 1
+ fi
+ ngrok http "$DEX_PORT" --log=stdout --log-level=warn &>/dev/null &
+ TUNNEL_PID=$!
+ echo "Waiting for ngrok tunnel..."
+ sleep 3
+ PUBLIC_URL=$(curl -s http://localhost:4040/api/tunnels | grep -o '"public_url":"https://[^"]*' | head -1 | cut -d'"' -f4)
+ if [[ -z "$PUBLIC_URL" ]]; then
+ echo "Error: failed to get ngrok public URL. Is ngrok running?"
+ exit 1
+ fi
+ ;;
+ cloudflared)
+ if ! command -v cloudflared &>/dev/null; then
+ echo "Error: cloudflared not found. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
+ exit 1
+ fi
+ CLOUDFLARED_LOG=$(mktemp)
+ cloudflared tunnel --url "http://localhost:$DEX_PORT" --no-autoupdate 2>"$CLOUDFLARED_LOG" &
+ TUNNEL_PID=$!
+ echo "Waiting for cloudflared tunnel..."
+ for _ in $(seq 1 30); do
+ PUBLIC_URL=$(grep -o 'https://[^ ]*\.trycloudflare\.com' "$CLOUDFLARED_LOG" | head -1) && break
+ sleep 1
+ done
+ rm -f "$CLOUDFLARED_LOG"
+ if [[ -z "$PUBLIC_URL" ]]; then
+ echo "Error: failed to get cloudflared URL."
+ exit 1
+ fi
+ ;;
+ *)
+ echo "Error: unknown tunnel type '$TUNNEL_TYPE'. Use 'ngrok' or 'cloudflared'."
+ exit 1
+ ;;
+ esac
+fi
+
+PUBLIC_URL="${PUBLIC_URL%/}"
+echo "Public URL: $PUBLIC_URL"
+
+# Generate config from template.
+CONFIG_FILE=$(mktemp)
+sed -e "s|ISSUER_URL|$PUBLIC_URL|g" -e "s|ALIAS|$ALIAS|g" "$SCRIPT_DIR/config.yaml.tmpl" > "$CONFIG_FILE"
+
+echo "Starting Dex on port $DEX_PORT..."
+"$DEX_BIN" serve "$CONFIG_FILE" &
+DEX_PID=$!
+sleep 2
+
+DISCOVERY_URL="$PUBLIC_URL/dex/.well-known/openid-configuration"
+
+echo ""
+echo "============================================================"
+echo " OIDC Conformance Test Setup Ready"
+echo "============================================================"
+echo ""
+echo " Discovery URL: $DISCOVERY_URL"
+echo " Alias: $ALIAS"
+echo ""
+echo " Client 1: id=first_client secret=89d6205220381728e85c4cf5"
+echo " Client 2: id=second_client secret=51c612288018fd384b05d6ad"
+echo ""
+echo " Steps:"
+echo " 1. Open https://www.certification.openid.net/"
+echo " 2. Log in with Google or GitLab"
+echo " 3. Create a new test plan:"
+echo " - Plan: OpenID Connect Core: Basic Certification Profile"
+echo " - Server metadata: discovery"
+echo " - Client registration: static_client"
+echo " - Alias: $ALIAS"
+echo " - Discovery URL: $DISCOVERY_URL"
+echo " - Enter both client credentials above"
+echo " 4. Run tests and follow instructions"
+echo ""
+echo " Press Ctrl+C to stop."
+echo "============================================================"
+
+wait "$DEX_PID"
diff --git a/flake.lock b/flake.lock
deleted file mode 100644
index b67b61d98b..0000000000
--- a/flake.lock
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "nodes": {
- "flake-utils": {
- "locked": {
- "lastModified": 1659877975,
- "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
- "owner": "numtide",
- "repo": "flake-utils",
- "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
- "type": "github"
- },
- "original": {
- "owner": "numtide",
- "repo": "flake-utils",
- "type": "github"
- }
- },
- "nixpkgs": {
- "locked": {
- "lastModified": 1662019588,
- "narHash": "sha256-oPEjHKGGVbBXqwwL+UjsveJzghWiWV0n9ogo1X6l4cw=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "2da64a81275b68fdad38af669afeda43d401e94b",
- "type": "github"
- },
- "original": {
- "id": "nixpkgs",
- "ref": "nixos-unstable",
- "type": "indirect"
- }
- },
- "root": {
- "inputs": {
- "flake-utils": "flake-utils",
- "nixpkgs": "nixpkgs"
- }
- }
- },
- "root": "root",
- "version": 7
-}
diff --git a/flake.nix b/flake.nix
deleted file mode 100644
index 155ebf99e3..0000000000
--- a/flake.nix
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- description = "OpenID Connect (OIDC) identity and OAuth 2.0 provider with pluggable connectors";
-
- inputs = {
- nixpkgs.url = "nixpkgs/nixos-unstable";
- flake-utils.url = "github:numtide/flake-utils";
- };
-
- outputs = { self, nixpkgs, flake-utils, ... }:
- flake-utils.lib.eachDefaultSystem (
- system:
- let
- pkgs = nixpkgs.legacyPackages.${system};
- buildDeps = with pkgs; [ git go_1_19 gnumake ];
- devDeps = with pkgs;
- buildDeps ++ [
- golangci-lint
- gotestsum
- protobuf
- protoc-gen-go
- protoc-gen-go-grpc
- kind
- ];
- in
- { devShell = pkgs.mkShell { buildInputs = devDeps; }; }
- );
-}
diff --git a/go.mod b/go.mod
index d15823aa1b..d791224c2e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,95 +1,158 @@
module github.com/dexidp/dex
-go 1.19
+go 1.25.8
require (
- entgo.io/ent v0.11.2
- github.com/AppsFlyer/go-sundheit v0.5.0
+ cloud.google.com/go/compute/metadata v0.9.0
+ entgo.io/ent v0.14.6
+ github.com/AppsFlyer/go-sundheit v0.6.0
github.com/Masterminds/semver v1.5.0
- github.com/Masterminds/sprig/v3 v3.2.2
- github.com/beevik/etree v1.1.0
- github.com/coreos/go-oidc/v3 v3.3.0
- github.com/dexidp/dex/api/v2 v2.1.0
- github.com/felixge/httpsnoop v1.0.3
+ github.com/Masterminds/sprig/v3 v3.3.0
+ github.com/beevik/etree v1.7.0
+ github.com/coreos/go-oidc/v3 v3.19.0
+ github.com/dexidp/dex/api/v2 v2.4.0
+ github.com/fsnotify/fsnotify v1.10.1
github.com/ghodss/yaml v1.0.0
- github.com/go-ldap/ldap/v3 v3.4.4
- github.com/go-sql-driver/mysql v1.6.0
- github.com/gorilla/handlers v1.5.1
- github.com/gorilla/mux v1.8.0
+ github.com/go-jose/go-jose/v4 v4.1.4
+ github.com/go-ldap/ldap/v3 v3.4.14
+ github.com/go-sql-driver/mysql v1.10.0
+ github.com/go-webauthn/webauthn v0.17.4
+ github.com/google/cel-go v0.30.0
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/handlers v1.5.2
+ github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
+ github.com/jcmturner/goidentity/v6 v6.0.1
+ github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/kylelemons/godebug v1.1.0
- github.com/lib/pq v1.10.5
+ github.com/lib/pq v1.12.3
github.com/mattermost/xml-roundtrip-validator v0.1.0
- github.com/mattn/go-sqlite3 v1.14.15
- github.com/oklog/run v1.1.0
+ github.com/mattn/go-sqlite3 v1.14.48
+ github.com/oklog/run v1.2.0
+ github.com/openbao/openbao/api/v2 v2.6.0
github.com/pkg/errors v0.9.1
- github.com/prometheus/client_golang v1.13.0
- github.com/russellhaering/goxmldsig v1.2.0
- github.com/sirupsen/logrus v1.9.0
- github.com/spf13/cobra v1.5.0
- github.com/stretchr/testify v1.8.0
- go.etcd.io/etcd/client/pkg/v3 v3.5.4
- go.etcd.io/etcd/client/v3 v3.5.4
- golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
- golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
- golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094
- google.golang.org/api v0.94.0
- google.golang.org/grpc v1.49.0
- google.golang.org/protobuf v1.28.1
- gopkg.in/square/go-jose.v2 v2.6.0
+ github.com/pquerna/otp v1.5.0
+ github.com/prometheus/client_golang v1.24.1
+ github.com/russellhaering/goxmldsig v1.6.0
+ github.com/spf13/cobra v1.10.2
+ github.com/stretchr/testify v1.11.1
+ go.etcd.io/etcd/client/pkg/v3 v3.6.13
+ go.etcd.io/etcd/client/v3 v3.6.13
+ golang.org/x/crypto v0.54.0
+ golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
+ golang.org/x/net v0.57.0
+ golang.org/x/oauth2 v0.36.0
+ google.golang.org/api v0.291.0
+ google.golang.org/grpc v1.82.1
+ google.golang.org/protobuf v1.36.11
)
require (
- ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e // indirect
- cloud.google.com/go/compute v1.7.0 // indirect
- github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
+ ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1 // indirect
+ cel.dev/expr v0.25.1 // indirect
+ cloud.google.com/go/auth v0.22.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ dario.cat/mergo v1.0.1 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/Masterminds/semver/v3 v3.1.1 // indirect
- github.com/agext/levenshtein v1.2.1 // indirect
- github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+ github.com/Masterminds/semver/v3 v3.3.0 // indirect
+ github.com/agext/levenshtein v1.2.3 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
- github.com/cespare/xxhash/v2 v2.1.2 // indirect
- github.com/coreos/go-semver v0.3.0 // indirect
- github.com/coreos/go-systemd/v22 v22.3.2 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
+ github.com/bmatcuk/doublestar v1.3.4 // indirect
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/clipperhouse/displaywidth v0.6.2 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+ github.com/coreos/go-semver v0.3.1 // indirect
+ github.com/coreos/go-systemd/v22 v22.5.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/fatih/color v1.19.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.8 // 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.5.0 // indirect
+ github.com/go-webauthn/x v0.2.6 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/golang/protobuf v1.5.2 // indirect
- github.com/google/go-cmp v0.5.8 // indirect
- github.com/google/uuid v1.3.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
- github.com/googleapis/gax-go/v2 v2.4.0 // indirect
- github.com/hashicorp/hcl/v2 v2.10.0 // indirect
- github.com/huandu/xstrings v1.3.1 // indirect
- github.com/imdario/mergo v0.3.11 // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
- github.com/jonboulle/clockwork v0.2.2 // indirect
- github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
- github.com/mitchellh/copystructure v1.0.0 // indirect
- github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
- github.com/mitchellh/reflectwalk v1.0.0 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/prometheus/client_model v0.2.0 // indirect
- github.com/prometheus/common v0.37.0 // indirect
- github.com/prometheus/procfs v0.8.0 // indirect
- github.com/shopspring/decimal v1.2.0 // indirect
- github.com/spf13/cast v1.4.1 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- github.com/zclconf/go-cty v1.8.0 // indirect
- go.etcd.io/etcd/api/v3 v3.5.4 // indirect
- go.opencensus.io v0.23.0 // indirect
- go.uber.org/atomic v1.7.0 // indirect
- go.uber.org/multierr v1.6.0 // indirect
- go.uber.org/zap v1.17.0 // indirect
- golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
- golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
- golang.org/x/text v0.3.7 // indirect
- google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f // 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.19 // indirect
+ github.com/googleapis/gax-go/v2 v2.23.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
+ github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
+ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+ github.com/hashicorp/go-sockaddr v1.0.7 // indirect
+ github.com/hashicorp/go-uuid v1.0.3 // indirect
+ github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
+ github.com/hashicorp/hcl/v2 v2.18.1 // indirect
+ github.com/huandu/xstrings v1.5.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jcmturner/aescts/v2 v2.0.0 // indirect
+ github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
+ github.com/jcmturner/gofork v1.7.6 // indirect
+ github.com/jcmturner/rpc/v2 v2.0.3 // indirect
+ github.com/jonboulle/clockwork v0.5.0 // indirect
+ github.com/mattn/go-colorable v0.1.15 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mitchellh/copystructure v1.2.0 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
+ github.com/olekukonko/errors v1.1.0 // indirect
+ github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
+ github.com/olekukonko/tablewriter v1.1.3 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/ryanuber/go-glob v1.0.0 // indirect
+ 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/tinylib/msgp v1.6.4 // 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.13 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.37.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.47.0 // indirect
+ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/dexidp/dex/api/v2 => ./api/v2
+
+tool entgo.io/ent/cmd/ent
diff --git a/go.sum b/go.sum
index 27fdbb8eea..ea24ba1d10 100644
--- a/go.sum
+++ b/go.sum
@@ -1,923 +1,415 @@
-ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e h1:/r1xGMwmLg4LZ2V3/wWui9TtM3+STh1fp5ExSVRNFZo=
-ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e/go.mod h1:ofVetkJqlaWle3mvYmaS2uyFGFcc7dSq436tmxa/Mzk=
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
-cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
-cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
-cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
-cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
-cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
-cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
-cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
-cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
-cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
-cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
-cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk=
-cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-entgo.io/ent v0.11.2 h1:UM2/BUhF2FfsxPHRxLjQbhqJNaDdVlOwNIAMLs2jyto=
-entgo.io/ent v0.11.2/go.mod h1:YGHEQnmmIUgtD5b1ICD5vg74dS3npkNnmC5K+0J+IHU=
-github.com/AppsFlyer/go-sundheit v0.5.0 h1:/VxpyigCfJrq1r97mn9HPiAB2qrhcTFHwNIIDr15CZM=
-github.com/AppsFlyer/go-sundheit v0.5.0/go.mod h1:2ZM0BnfqT/mljBQO224VbL5XH06TgWuQ6Cn+cTtCpTY=
-github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU=
-github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1 h1:NPPfBaVZgz4LKBCIc0FbMogCjvXN+yGf7CZwotOwJo8=
+ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1/go.mod h1:Ex5l1xHsnWQUc3wYnrJ9gD7RUEzG76P7ZRQp8wNr0wc=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
+cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+entgo.io/ent v0.14.6 h1:/f2696BpwuWAEEG6PVGWflg6+Inrpq4pRWuNlWz/Skk=
+entgo.io/ent v0.14.6/go.mod h1:z46QBUdGC+BATwsedbDuREfSS0oSCV+csdEYlL4p73s=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=
+github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME=
+github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
+github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
+github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
-github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
-github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=
-github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
-github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
-github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
-github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
-github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
-github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
-github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
+github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
+github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
+github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
+github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
+github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA=
+github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
-github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/coreos/go-oidc/v3 v3.3.0 h1:Y1LV3mP+QT3MEycATZpAiwfyN+uxZLqVbAHJUuOJEe4=
-github.com/coreos/go-oidc/v3 v3.3.0/go.mod h1:eHUXhZtXPQLgEaDrOVTgwbgmz1xGOkJNye6h3zkD2Pw=
-github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
+github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
+github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
+github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
+github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
+github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
-github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/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.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
-github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
-github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
-github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
-github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
-github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
+github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
+github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
-github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
+github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
+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.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
+github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
+github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
+github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
-github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
-github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
-github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
-github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
-github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
-github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
-github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
-github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
-github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
-github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
-github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
-github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
-github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+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.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
+github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
+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=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4=
+github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
+github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
+github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
+github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
+github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/hcl/v2 v2.10.0 h1:1S1UnuhDGlv3gRFV4+0EdwB+znNP5HmcGbIqwnSCByg=
-github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
-github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
-github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
-github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
-github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
+github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
+github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
+github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
+github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
+github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
+github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
+github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
+github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
-github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
+github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
-github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
-github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
-github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
-github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
-github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
-github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
+github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
+github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
+github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
+github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
+github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
+github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
+github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
+github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
+github.com/openbao/openbao/api/v2 v2.6.0 h1:KvfspAaL9bab9hI8jFYkV2cgtSrwWtaG+k9AUTHWU4M=
+github.com/openbao/openbao/api/v2 v2.6.0/go.mod h1:H4IWiH+2rgF/TbrsUbsfrMyGoqojkLqxPCRLENSMnSo=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
-github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
-github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
-github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU=
-github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
-github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
-github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
-github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
-github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
-github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
-github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg=
-github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
+github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
-github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
-github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
-github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
-github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
+github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
-github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
-github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
-github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+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.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
-github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA=
-github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
-github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
-go.etcd.io/etcd/api/v3 v3.5.4 h1:OHVyt3TopwtUQ2GKdd5wu3PmmipR4FTwCqoEjSyRdIc=
-go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
-go.etcd.io/etcd/client/pkg/v3 v3.5.4 h1:lrneYvz923dvC14R54XcA7FXoZ3mlGZAgmwhfm7HqOg=
-go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v3 v3.5.4 h1:p83BUL3tAYS0OT/r0qglgc3M1JjhM0diV8DSWAhVXv4=
-go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
+github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
+github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
+github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
+go.etcd.io/etcd/api/v3 v3.6.13 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0=
+go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk=
+go.etcd.io/etcd/client/pkg/v3 v3.6.13 h1:7QeMOisYByx8dBA7/CKcwCaPWfjb5C0xpmrIov/8WyY=
+go.etcd.io/etcd/client/pkg/v3 v3.6.13/go.mod h1:Dn2zUBOCu/6xYcd6iAjB7LgoY16OTQjDZfWHLwvuQj4=
+go.etcd.io/etcd/client/v3 v3.6.13 h1:0E+9ZYGpMsi9KlOJVoCdONh9PUDawKDTy5mSNY8wOEI=
+go.etcd.io/etcd/client/v3 v3.6.13/go.mod h1:rtVI3vwobljb8xlTGcp1Yhz7hBIuBWULXwB848kqJGw=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+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=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
-golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
+golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
-golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
-golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8=
-golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
-golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
+golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
-golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
-google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
-google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
-google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
-google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
-google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
-google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
-google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
-google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
-google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
-google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
-google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
-google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
-google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
-google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
-google.golang.org/api v0.94.0 h1:KtKM9ru3nzQioV1HLlUf1cR7vMYJIpgls5VhAYQXIwA=
-google.golang.org/api v0.94.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
-google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f h1:hJ/Y5SqPXbarffmAsApliUlcvMU+wScNGfyop4bZm8o=
-google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
-google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
-google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
-google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0=
+google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
+google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
+google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
+google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
-gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go
new file mode 100644
index 0000000000..8dd686ba72
--- /dev/null
+++ b/pkg/cel/cel.go
@@ -0,0 +1,232 @@
+package cel
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/checker"
+ "github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/ext"
+
+ "github.com/dexidp/dex/pkg/cel/library"
+)
+
+// EnvironmentVersion represents the version of the CEL environment.
+// New variables, functions, or libraries are introduced in new versions.
+type EnvironmentVersion uint32
+
+const (
+ // EnvironmentV1 is the initial CEL environment.
+ EnvironmentV1 EnvironmentVersion = 1
+)
+
+// CompilationResult holds a compiled CEL program ready for evaluation.
+type CompilationResult struct {
+ Program cel.Program
+ OutputType *cel.Type
+ Expression string
+
+ ast *cel.Ast
+}
+
+// CompilerOption configures a Compiler.
+type CompilerOption func(*compilerConfig)
+
+type compilerConfig struct {
+ costBudget uint64
+ version EnvironmentVersion
+}
+
+func defaultCompilerConfig() *compilerConfig {
+ return &compilerConfig{
+ costBudget: DefaultCostBudget,
+ version: EnvironmentV1,
+ }
+}
+
+// WithCostBudget sets a custom cost budget for expression evaluation.
+func WithCostBudget(budget uint64) CompilerOption {
+ return func(cfg *compilerConfig) {
+ cfg.costBudget = budget
+ }
+}
+
+// WithVersion sets the target environment version for the compiler.
+// Defaults to the latest version. Specifying an older version ensures
+// that only functions/types available at that version are used.
+func WithVersion(v EnvironmentVersion) CompilerOption {
+ return func(cfg *compilerConfig) {
+ cfg.version = v
+ }
+}
+
+// Compiler compiles CEL expressions against a specific environment.
+type Compiler struct {
+ env *cel.Env
+ cfg *compilerConfig
+}
+
+// NewCompiler creates a new CEL compiler with the specified variable
+// declarations and options.
+//
+// All custom Dex libraries are automatically included.
+// The environment is configured with cost limits and safe defaults.
+func NewCompiler(variables []VariableDeclaration, opts ...CompilerOption) (*Compiler, error) {
+ cfg := defaultCompilerConfig()
+ for _, opt := range opts {
+ opt(cfg)
+ }
+
+ envOpts := make([]cel.EnvOption, 0, 8+len(variables))
+ envOpts = append(envOpts,
+ cel.DefaultUTCTimeZone(true),
+
+ // Standard extension libraries (same set as Kubernetes)
+ ext.Strings(),
+ ext.Encoders(),
+ ext.Lists(),
+ ext.Sets(),
+ ext.Math(),
+
+ // Native Go types for typed variable access.
+ // This gives compile-time field checking: identity.emial โ error at config load.
+ ext.NativeTypes(
+ ext.ParseStructTags(true),
+ reflect.TypeOf(IdentityVal{}),
+ reflect.TypeOf(RequestVal{}),
+ ),
+
+ // Custom Dex libraries
+ cel.Lib(&library.Email{}),
+ cel.Lib(&library.Groups{}),
+
+ // Presence tests like has(field) and 'key' in map are O(1) hash
+ // lookups on map(string, dyn) variables, so they should not count
+ // toward the cost budget. Without this, expressions with multiple
+ // 'in' checks (e.g. "'admin' in identity.groups") would accumulate
+ // inflated cost estimates. This matches Kubernetes CEL behavior
+ // where presence tests are free for CRD validation rules.
+ cel.CostEstimatorOptions(
+ checker.PresenceTestHasCost(false),
+ ),
+ )
+
+ for _, v := range variables {
+ envOpts = append(envOpts, cel.Variable(v.Name, v.Type))
+ }
+
+ env, err := cel.NewEnv(envOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create CEL environment: %w", err)
+ }
+
+ return &Compiler{env: env, cfg: cfg}, nil
+}
+
+// CompileBool compiles a CEL expression that must evaluate to bool.
+func (c *Compiler) CompileBool(expression string) (*CompilationResult, error) {
+ return c.compile(expression, cel.BoolType)
+}
+
+// CompileString compiles a CEL expression that must evaluate to string.
+func (c *Compiler) CompileString(expression string) (*CompilationResult, error) {
+ return c.compile(expression, cel.StringType)
+}
+
+// CompileStringList compiles a CEL expression that must evaluate to list(string).
+func (c *Compiler) CompileStringList(expression string) (*CompilationResult, error) {
+ return c.compile(expression, cel.ListType(cel.StringType))
+}
+
+// Compile compiles a CEL expression with any output type.
+func (c *Compiler) Compile(expression string) (*CompilationResult, error) {
+ return c.compile(expression, nil)
+}
+
+func (c *Compiler) compile(expression string, expectedType *cel.Type) (*CompilationResult, error) {
+ if len(expression) > MaxExpressionLength {
+ return nil, fmt.Errorf("expression exceeds maximum length of %d characters", MaxExpressionLength)
+ }
+
+ ast, issues := c.env.Compile(expression)
+ if issues != nil && issues.Err() != nil {
+ return nil, fmt.Errorf("CEL compilation failed: %w", issues.Err())
+ }
+
+ if expectedType != nil && !ast.OutputType().IsEquivalentType(expectedType) {
+ return nil, fmt.Errorf(
+ "expected expression output type %s, got %s",
+ expectedType, ast.OutputType(),
+ )
+ }
+
+ // Estimate cost at compile time and reject expressions that are too expensive.
+ costEst, err := c.env.EstimateCost(ast, &defaultCostEstimator{})
+ if err != nil {
+ return nil, fmt.Errorf("CEL cost estimation failed: %w", err)
+ }
+
+ if costEst.Max > c.cfg.costBudget {
+ return nil, fmt.Errorf(
+ "CEL expression estimated cost %d exceeds budget %d",
+ costEst.Max, c.cfg.costBudget,
+ )
+ }
+
+ prog, err := c.env.Program(ast,
+ cel.EvalOptions(cel.OptOptimize),
+ cel.CostLimit(c.cfg.costBudget),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("CEL program creation failed: %w", err)
+ }
+
+ return &CompilationResult{
+ Program: prog,
+ OutputType: ast.OutputType(),
+ Expression: expression,
+ ast: ast,
+ }, nil
+}
+
+// Eval evaluates a compiled program against the given variables.
+func Eval(ctx context.Context, result *CompilationResult, variables map[string]any) (ref.Val, error) {
+ out, _, err := result.Program.ContextEval(ctx, variables)
+ if err != nil {
+ return nil, fmt.Errorf("CEL evaluation failed: %w", err)
+ }
+
+ return out, nil
+}
+
+// EvalBool is a convenience function that evaluates and asserts bool output.
+func EvalBool(ctx context.Context, result *CompilationResult, variables map[string]any) (bool, error) {
+ out, err := Eval(ctx, result, variables)
+ if err != nil {
+ return false, err
+ }
+
+ v, ok := out.Value().(bool)
+ if !ok {
+ return false, fmt.Errorf("expected bool result, got %T", out.Value())
+ }
+
+ return v, nil
+}
+
+// EvalString is a convenience function that evaluates and asserts string output.
+func EvalString(ctx context.Context, result *CompilationResult, variables map[string]any) (string, error) {
+ out, err := Eval(ctx, result, variables)
+ if err != nil {
+ return "", err
+ }
+
+ v, ok := out.Value().(string)
+ if !ok {
+ return "", fmt.Errorf("expected string result, got %T", out.Value())
+ }
+
+ return v, nil
+}
diff --git a/pkg/cel/cel_test.go b/pkg/cel/cel_test.go
new file mode 100644
index 0000000000..b211f344b4
--- /dev/null
+++ b/pkg/cel/cel_test.go
@@ -0,0 +1,280 @@
+package cel_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/connector"
+ dexcel "github.com/dexidp/dex/pkg/cel"
+)
+
+func TestCompileBool(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ wantErr bool
+ }{
+ "true literal": {
+ expr: "true",
+ },
+ "comparison": {
+ expr: "1 == 1",
+ },
+ "string type mismatch": {
+ expr: "'hello'",
+ wantErr: true,
+ },
+ "int type mismatch": {
+ expr: "42",
+ wantErr: true,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ result, err := compiler.CompileBool(tc.expr)
+ if tc.wantErr {
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ } else {
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ }
+ })
+ }
+}
+
+func TestCompileString(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ wantErr bool
+ }{
+ "string literal": {
+ expr: "'hello'",
+ },
+ "string concatenation": {
+ expr: "'hello' + ' ' + 'world'",
+ },
+ "bool type mismatch": {
+ expr: "true",
+ wantErr: true,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ result, err := compiler.CompileString(tc.expr)
+ if tc.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ }
+ })
+ }
+}
+
+func TestCompileStringList(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ result, err := compiler.CompileStringList("['a', 'b', 'c']")
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+
+ _, err = compiler.CompileStringList("'not a list'")
+ assert.Error(t, err)
+}
+
+func TestCompile(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ // Compile accepts any type
+ result, err := compiler.Compile("true")
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+
+ result, err = compiler.Compile("'hello'")
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+
+ result, err = compiler.Compile("42")
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+}
+
+func TestCompileErrors(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ }{
+ "syntax error": {
+ expr: "1 +",
+ },
+ "undefined variable": {
+ expr: "undefined_var",
+ },
+ "undefined function": {
+ expr: "undefinedFunc()",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ _, err := compiler.Compile(tc.expr)
+ assert.Error(t, err)
+ })
+ }
+}
+
+func TestCompileRejectsUnknownFields(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ // Typo in field name: should fail at compile time with ObjectType
+ _, err = compiler.CompileBool("identity.emial == 'test@example.com'")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "compilation failed")
+
+ // Type mismatch: comparing string field to int should fail at compile time
+ _, err = compiler.CompileBool("identity.email == 123")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "compilation failed")
+
+ // Valid field: should compile fine
+ _, err = compiler.CompileBool("identity.email == 'test@example.com'")
+ assert.NoError(t, err)
+}
+
+func TestMaxExpressionLength(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ longExpr := "'" + strings.Repeat("a", dexcel.MaxExpressionLength) + "'"
+ _, err = compiler.Compile(longExpr)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "maximum length")
+}
+
+func TestEvalBool(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ identity dexcel.IdentityVal
+ want bool
+ }{
+ "email endsWith": {
+ expr: "identity.email.endsWith('@example.com')",
+ identity: dexcel.IdentityVal{Email: "user@example.com"},
+ want: true,
+ },
+ "email endsWith false": {
+ expr: "identity.email.endsWith('@example.com')",
+ identity: dexcel.IdentityVal{Email: "user@other.com"},
+ want: false,
+ },
+ "email_verified": {
+ expr: "identity.email_verified == true",
+ identity: dexcel.IdentityVal{EmailVerified: true},
+ want: true,
+ },
+ "group membership": {
+ expr: "identity.groups.exists(g, g == 'admin')",
+ identity: dexcel.IdentityVal{Groups: []string{"admin", "dev"}},
+ want: true,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.CompileBool(tc.expr)
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{
+ "identity": tc.identity,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, result)
+ })
+ }
+}
+
+func TestEvalString(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ // With ObjectType, identity.email is typed as string, so CompileString works.
+ prog, err := compiler.CompileString("identity.email")
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalString(context.Background(), prog, map[string]any{
+ "identity": dexcel.IdentityVal{Email: "user@example.com"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "user@example.com", result)
+}
+
+func TestEvalWithIdentityAndRequest(t *testing.T) {
+ vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...)
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ prog, err := compiler.CompileBool(
+ `identity.email.endsWith('@example.com') && 'admin' in identity.groups && request.connector_id == 'okta'`,
+ )
+ require.NoError(t, err)
+
+ identity := dexcel.IdentityFromConnector(connector.Identity{
+ UserID: "123",
+ Username: "john",
+ Email: "john@example.com",
+ Groups: []string{"admin", "dev"},
+ })
+ request := dexcel.RequestFromContext(dexcel.RequestContext{
+ ClientID: "my-app",
+ ConnectorID: "okta",
+ Scopes: []string{"openid", "email"},
+ })
+
+ result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{
+ "identity": identity,
+ "request": request,
+ })
+ require.NoError(t, err)
+ assert.True(t, result)
+}
+
+func TestNewCompilerWithVariables(t *testing.T) {
+ // Claims variable โ remains map(string, dyn)
+ compiler, err := dexcel.NewCompiler(dexcel.ClaimsVariable())
+ require.NoError(t, err)
+
+ // claims.email returns dyn from map access, use Compile (not CompileString)
+ prog, err := compiler.Compile("claims.email")
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalString(context.Background(), prog, map[string]any{
+ "claims": map[string]any{
+ "email": "test@example.com",
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "test@example.com", result)
+}
diff --git a/pkg/cel/cost.go b/pkg/cel/cost.go
new file mode 100644
index 0000000000..d7a09102b1
--- /dev/null
+++ b/pkg/cel/cost.go
@@ -0,0 +1,105 @@
+package cel
+
+import (
+ "fmt"
+
+ "github.com/google/cel-go/checker"
+)
+
+// DefaultCostBudget is the default cost budget for a single expression
+// evaluation. Aligned with Kubernetes defaults: enough for typical identity
+// operations but prevents runaway expressions.
+const DefaultCostBudget uint64 = 10_000_000
+
+// MaxExpressionLength is the maximum length of a CEL expression string.
+const MaxExpressionLength = 10_240
+
+// DefaultStringMaxLength is the estimated max length of string values
+// (emails, usernames, group names, etc.) used for compile-time cost estimation.
+const DefaultStringMaxLength = 256
+
+// DefaultListMaxLength is the estimated max length of list values
+// (groups, scopes) used for compile-time cost estimation.
+const DefaultListMaxLength = 100
+
+// CostEstimate holds the estimated cost range for a compiled expression.
+type CostEstimate struct {
+ Min uint64
+ Max uint64
+}
+
+// EstimateCost returns the estimated cost range for a compiled expression.
+// This is computed statically at compile time without evaluating the expression.
+func (c *Compiler) EstimateCost(result *CompilationResult) (CostEstimate, error) {
+ costEst, err := c.env.EstimateCost(result.ast, &defaultCostEstimator{})
+ if err != nil {
+ return CostEstimate{}, fmt.Errorf("CEL cost estimation failed: %w", err)
+ }
+
+ return CostEstimate{Min: costEst.Min, Max: costEst.Max}, nil
+}
+
+// defaultCostEstimator provides size hints for compile-time cost estimation.
+// Without these hints, the CEL cost estimator assumes unbounded sizes for
+// variables, leading to wildly overestimated max costs.
+type defaultCostEstimator struct{}
+
+func (defaultCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate {
+ // Provide size hints for map(string, dyn) variables: identity, request, claims.
+ // Without these, the estimator assumes lists/strings can be infinitely large.
+ if element.Path() == nil {
+ return nil
+ }
+
+ path := element.Path()
+ if len(path) == 0 {
+ return nil
+ }
+
+ root := path[0]
+
+ switch root {
+ case "identity", "request", "claims":
+ // Nested field access (e.g. identity.email, identity.groups)
+ if len(path) >= 2 {
+ field := path[1]
+ switch field {
+ case "groups", "scopes":
+ // list(string) fields
+ return &checker.SizeEstimate{Min: 0, Max: DefaultListMaxLength}
+ case "email_verified":
+ // bool field โ size is always 1
+ return &checker.SizeEstimate{Min: 1, Max: 1}
+ default:
+ // string fields (email, username, user_id, client_id, etc.)
+ return &checker.SizeEstimate{Min: 0, Max: DefaultStringMaxLength}
+ }
+ }
+ // The map itself: number of keys
+ return &checker.SizeEstimate{Min: 0, Max: 20}
+ }
+
+ return nil
+}
+
+func (defaultCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate {
+ switch function {
+ case "dex.emailDomain", "dex.emailLocalPart":
+ // Simple string split โ O(n) where n is string length, bounded.
+ return &checker.CallEstimate{
+ CostEstimate: checker.CostEstimate{Min: 1, Max: 2},
+ }
+ case "dex.groupMatches":
+ // Iterates over groups list and matches each against a pattern.
+ return &checker.CallEstimate{
+ CostEstimate: checker.CostEstimate{Min: 1, Max: DefaultListMaxLength},
+ }
+ case "dex.groupFilter":
+ // Builds a set from allowed list, then iterates groups.
+ return &checker.CallEstimate{
+ CostEstimate: checker.CostEstimate{Min: 1, Max: 2 * DefaultListMaxLength},
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/cel/cost_test.go b/pkg/cel/cost_test.go
new file mode 100644
index 0000000000..9a068be406
--- /dev/null
+++ b/pkg/cel/cost_test.go
@@ -0,0 +1,137 @@
+package cel_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ dexcel "github.com/dexidp/dex/pkg/cel"
+)
+
+func TestEstimateCost(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ }{
+ "simple bool": {
+ expr: "true",
+ },
+ "string comparison": {
+ expr: "identity.email == 'test@example.com'",
+ },
+ "group membership": {
+ expr: "identity.groups.exists(g, g == 'admin')",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.Compile(tc.expr)
+ require.NoError(t, err)
+
+ est, err := compiler.EstimateCost(prog)
+ require.NoError(t, err)
+ assert.True(t, est.Max >= est.Min, "max cost should be >= min cost")
+ assert.True(t, est.Max <= dexcel.DefaultCostBudget,
+ "estimated max cost %d should be within default budget %d", est.Max, dexcel.DefaultCostBudget)
+ })
+ }
+}
+
+func TestCompileTimeCostAcceptsSimpleExpressions(t *testing.T) {
+ vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...)
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ tests := map[string]string{
+ "literal": "true",
+ "email endsWith": "identity.email.endsWith('@example.com')",
+ "group check": "'admin' in identity.groups",
+ "emailDomain": `dex.emailDomain(identity.email)`,
+ "groupMatches": `dex.groupMatches(identity.groups, "team:*")`,
+ "groupFilter": `dex.groupFilter(identity.groups, ["admin", "dev"])`,
+ "combined policy": `identity.email.endsWith('@example.com') && 'admin' in identity.groups`,
+ "complex policy": `identity.email.endsWith('@example.com') &&
+ identity.groups.exists(g, g == 'admin') &&
+ request.connector_id == 'okta' &&
+ request.scopes.exists(s, s == 'openid')`,
+ "filter+map chain": `identity.groups
+ .filter(g, g.startsWith('team:'))
+ .map(g, g.replace('team:', ''))
+ .size() > 0`,
+ }
+
+ for name, expr := range tests {
+ t.Run(name, func(t *testing.T) {
+ _, err := compiler.Compile(expr)
+ assert.NoError(t, err, "expression should compile within default budget")
+ })
+ }
+}
+
+func TestCompileTimeCostRejection(t *testing.T) {
+ vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...)
+
+ tests := map[string]struct {
+ budget uint64
+ expr string
+ }{
+ "simple exists exceeds tiny budget": {
+ budget: 1,
+ expr: "identity.groups.exists(g, g == 'admin')",
+ },
+ "endsWith exceeds tiny budget": {
+ budget: 2,
+ expr: "identity.email.endsWith('@example.com')",
+ },
+ "nested comprehension over groups exceeds moderate budget": {
+ // Two nested iterations over groups: O(n^2) where n=100 โ ~280K
+ budget: 10_000,
+ expr: `identity.groups.exists(g1,
+ identity.groups.exists(g2,
+ g1 != g2 && g1.startsWith(g2)
+ )
+ )`,
+ },
+ "cross-variable comprehension exceeds moderate budget": {
+ // filter groups then check each against scopes: O(n*m) โ ~162K
+ budget: 10_000,
+ expr: `identity.groups
+ .filter(g, g.startsWith('team:'))
+ .exists(g, request.scopes.exists(s, s == g))`,
+ },
+ "chained filter+map+filter+map exceeds small budget": {
+ budget: 1000,
+ expr: `identity.groups
+ .filter(g, g.startsWith('team:'))
+ .map(g, g.replace('team:', ''))
+ .filter(g, g.size() > 3)
+ .map(g, g.upperAscii())
+ .size() > 0`,
+ },
+ "many independent exists exceeds small budget": {
+ budget: 5000,
+ expr: `identity.groups.exists(g, g.contains('a')) &&
+ identity.groups.exists(g, g.contains('b')) &&
+ identity.groups.exists(g, g.contains('c')) &&
+ identity.groups.exists(g, g.contains('d')) &&
+ identity.groups.exists(g, g.contains('e'))`,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(vars, dexcel.WithCostBudget(tc.budget))
+ require.NoError(t, err)
+
+ _, err = compiler.Compile(tc.expr)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "estimated cost")
+ assert.Contains(t, err.Error(), "exceeds budget")
+ })
+ }
+}
diff --git a/pkg/cel/doc.go b/pkg/cel/doc.go
new file mode 100644
index 0000000000..64c1dbd303
--- /dev/null
+++ b/pkg/cel/doc.go
@@ -0,0 +1,5 @@
+// Package cel provides a safe, sandboxed CEL (Common Expression Language)
+// environment for policy evaluation, claim mapping, and token customization
+// in Dex. It includes cost budgets, Kubernetes-grade compatibility guarantees,
+// and a curated set of extension libraries.
+package cel
diff --git a/pkg/cel/library/doc.go b/pkg/cel/library/doc.go
new file mode 100644
index 0000000000..1452d2b939
--- /dev/null
+++ b/pkg/cel/library/doc.go
@@ -0,0 +1,4 @@
+// Package library provides custom CEL function libraries for Dex.
+// Each library implements the cel.Library interface and can be registered
+// in a CEL environment.
+package library
diff --git a/pkg/cel/library/email.go b/pkg/cel/library/email.go
new file mode 100644
index 0000000000..38fe0dee94
--- /dev/null
+++ b/pkg/cel/library/email.go
@@ -0,0 +1,73 @@
+package library
+
+import (
+ "strings"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/common/types/ref"
+)
+
+// Email provides email-related CEL functions.
+//
+// Functions (V1):
+//
+// dex.emailDomain(email: string) -> string
+// Returns the domain portion of an email address.
+// Example: dex.emailDomain("user@example.com") == "example.com"
+//
+// dex.emailLocalPart(email: string) -> string
+// Returns the local part of an email address.
+// Example: dex.emailLocalPart("user@example.com") == "user"
+type Email struct{}
+
+func (Email) CompileOptions() []cel.EnvOption {
+ return []cel.EnvOption{
+ cel.Function("dex.emailDomain",
+ cel.Overload("dex_email_domain_string",
+ []*cel.Type{cel.StringType},
+ cel.StringType,
+ cel.UnaryBinding(emailDomainImpl),
+ ),
+ ),
+ cel.Function("dex.emailLocalPart",
+ cel.Overload("dex_email_local_part_string",
+ []*cel.Type{cel.StringType},
+ cel.StringType,
+ cel.UnaryBinding(emailLocalPartImpl),
+ ),
+ ),
+ }
+}
+
+func (Email) ProgramOptions() []cel.ProgramOption {
+ return nil
+}
+
+func emailDomainImpl(arg ref.Val) ref.Val {
+ email, ok := arg.Value().(string)
+ if !ok {
+ return types.NewErr("dex.emailDomain: expected string argument")
+ }
+
+ _, domain, found := strings.Cut(email, "@")
+ if !found {
+ return types.String("")
+ }
+
+ return types.String(domain)
+}
+
+func emailLocalPartImpl(arg ref.Val) ref.Val {
+ email, ok := arg.Value().(string)
+ if !ok {
+ return types.NewErr("dex.emailLocalPart: expected string argument")
+ }
+
+ localPart, _, found := strings.Cut(email, "@")
+ if !found {
+ return types.String(email)
+ }
+
+ return types.String(localPart)
+}
diff --git a/pkg/cel/library/email_test.go b/pkg/cel/library/email_test.go
new file mode 100644
index 0000000000..d13e73a1dd
--- /dev/null
+++ b/pkg/cel/library/email_test.go
@@ -0,0 +1,106 @@
+package library_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ dexcel "github.com/dexidp/dex/pkg/cel"
+)
+
+func TestEmailDomain(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ want string
+ }{
+ "standard email": {
+ expr: `dex.emailDomain("user@example.com")`,
+ want: "example.com",
+ },
+ "subdomain": {
+ expr: `dex.emailDomain("admin@sub.domain.org")`,
+ want: "sub.domain.org",
+ },
+ "no at sign": {
+ expr: `dex.emailDomain("nodomain")`,
+ want: "",
+ },
+ "empty string": {
+ expr: `dex.emailDomain("")`,
+ want: "",
+ },
+ "multiple at signs": {
+ expr: `dex.emailDomain("user@name@example.com")`,
+ want: "name@example.com",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.CompileString(tc.expr)
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalString(context.Background(), prog, map[string]any{})
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, result)
+ })
+ }
+}
+
+func TestEmailLocalPart(t *testing.T) {
+ compiler, err := dexcel.NewCompiler(nil)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ want string
+ }{
+ "standard email": {
+ expr: `dex.emailLocalPart("user@example.com")`,
+ want: "user",
+ },
+ "no at sign": {
+ expr: `dex.emailLocalPart("justuser")`,
+ want: "justuser",
+ },
+ "empty string": {
+ expr: `dex.emailLocalPart("")`,
+ want: "",
+ },
+ "multiple at signs": {
+ expr: `dex.emailLocalPart("user@name@example.com")`,
+ want: "user",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.CompileString(tc.expr)
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalString(context.Background(), prog, map[string]any{})
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, result)
+ })
+ }
+}
+
+func TestEmailDomainWithIdentityVariable(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ prog, err := compiler.CompileString(`dex.emailDomain(identity.email)`)
+ require.NoError(t, err)
+
+ result, err := dexcel.EvalString(context.Background(), prog, map[string]any{
+ "identity": dexcel.IdentityVal{Email: "admin@corp.example.com"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "corp.example.com", result)
+}
diff --git a/pkg/cel/library/groups.go b/pkg/cel/library/groups.go
new file mode 100644
index 0000000000..fd7f3603f1
--- /dev/null
+++ b/pkg/cel/library/groups.go
@@ -0,0 +1,123 @@
+package library
+
+import (
+ "path"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/common/types/traits"
+)
+
+// Groups provides group-related CEL functions.
+//
+// Functions (V1):
+//
+// dex.groupMatches(groups: list(string), pattern: string) -> list(string)
+// Returns groups matching a glob pattern.
+// Example: dex.groupMatches(["team:dev", "team:ops", "admin"], "team:*")
+//
+// dex.groupFilter(groups: list(string), allowed: list(string)) -> list(string)
+// Returns only groups present in the allowed list.
+// Example: dex.groupFilter(["admin", "dev", "ops"], ["admin", "ops"])
+type Groups struct{}
+
+func (Groups) CompileOptions() []cel.EnvOption {
+ return []cel.EnvOption{
+ cel.Function("dex.groupMatches",
+ cel.Overload("dex_group_matches_list_string",
+ []*cel.Type{cel.ListType(cel.StringType), cel.StringType},
+ cel.ListType(cel.StringType),
+ cel.BinaryBinding(groupMatchesImpl),
+ ),
+ ),
+ cel.Function("dex.groupFilter",
+ cel.Overload("dex_group_filter_list_list",
+ []*cel.Type{cel.ListType(cel.StringType), cel.ListType(cel.StringType)},
+ cel.ListType(cel.StringType),
+ cel.BinaryBinding(groupFilterImpl),
+ ),
+ ),
+ }
+}
+
+func (Groups) ProgramOptions() []cel.ProgramOption {
+ return nil
+}
+
+func groupMatchesImpl(lhs, rhs ref.Val) ref.Val {
+ groupList, ok := lhs.(traits.Lister)
+ if !ok {
+ return types.NewErr("dex.groupMatches: expected list(string) as first argument")
+ }
+
+ pattern, ok := rhs.Value().(string)
+ if !ok {
+ return types.NewErr("dex.groupMatches: expected string pattern as second argument")
+ }
+
+ iter := groupList.Iterator()
+ var matched []ref.Val
+
+ for iter.HasNext() == types.True {
+ item := iter.Next()
+
+ group, ok := item.Value().(string)
+ if !ok {
+ continue
+ }
+
+ ok, err := path.Match(pattern, group)
+ if err != nil {
+ return types.NewErr("dex.groupMatches: invalid pattern %q: %v", pattern, err)
+ }
+ if ok {
+ matched = append(matched, types.String(group))
+ }
+ }
+
+ return types.NewRefValList(types.DefaultTypeAdapter, matched)
+}
+
+func groupFilterImpl(lhs, rhs ref.Val) ref.Val {
+ groupList, ok := lhs.(traits.Lister)
+ if !ok {
+ return types.NewErr("dex.groupFilter: expected list(string) as first argument")
+ }
+
+ allowedList, ok := rhs.(traits.Lister)
+ if !ok {
+ return types.NewErr("dex.groupFilter: expected list(string) as second argument")
+ }
+
+ allowed := make(map[string]struct{})
+ iter := allowedList.Iterator()
+ for iter.HasNext() == types.True {
+ item := iter.Next()
+
+ s, ok := item.Value().(string)
+ if !ok {
+ continue
+ }
+
+ allowed[s] = struct{}{}
+ }
+
+ var filtered []ref.Val
+ iter = groupList.Iterator()
+
+ for iter.HasNext() == types.True {
+ item := iter.Next()
+
+ group, ok := item.Value().(string)
+ if !ok {
+ continue
+ }
+
+ if _, exists := allowed[group]; exists {
+ filtered = append(filtered, types.String(group))
+ }
+ }
+
+ return types.NewRefValList(types.DefaultTypeAdapter, filtered)
+}
diff --git a/pkg/cel/library/groups_test.go b/pkg/cel/library/groups_test.go
new file mode 100644
index 0000000000..70a68fb211
--- /dev/null
+++ b/pkg/cel/library/groups_test.go
@@ -0,0 +1,141 @@
+package library_test
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ dexcel "github.com/dexidp/dex/pkg/cel"
+)
+
+func TestGroupMatches(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ groups []string
+ want []string
+ }{
+ "wildcard pattern": {
+ expr: `dex.groupMatches(identity.groups, "team:*")`,
+ groups: []string{"team:dev", "team:ops", "admin"},
+ want: []string{"team:dev", "team:ops"},
+ },
+ "exact match": {
+ expr: `dex.groupMatches(identity.groups, "admin")`,
+ groups: []string{"team:dev", "admin", "user"},
+ want: []string{"admin"},
+ },
+ "no matches": {
+ expr: `dex.groupMatches(identity.groups, "nonexistent")`,
+ groups: []string{"team:dev", "admin"},
+ want: []string{},
+ },
+ "question mark pattern": {
+ expr: `dex.groupMatches(identity.groups, "team?")`,
+ groups: []string{"teamA", "teamB", "teams-long"},
+ want: []string{"teamA", "teamB"},
+ },
+ "match all": {
+ expr: `dex.groupMatches(identity.groups, "*")`,
+ groups: []string{"a", "b", "c"},
+ want: []string{"a", "b", "c"},
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.CompileStringList(tc.expr)
+ require.NoError(t, err)
+
+ out, err := dexcel.Eval(context.Background(), prog, map[string]any{
+ "identity": dexcel.IdentityVal{Groups: tc.groups},
+ })
+ require.NoError(t, err)
+
+ nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{}))
+ require.NoError(t, err)
+
+ got, ok := nativeVal.([]string)
+ require.True(t, ok, "expected []string, got %T", nativeVal)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestGroupMatchesInvalidPattern(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ prog, err := compiler.CompileStringList(`dex.groupMatches(identity.groups, "[invalid")`)
+ require.NoError(t, err)
+
+ _, err = dexcel.Eval(context.Background(), prog, map[string]any{
+ "identity": dexcel.IdentityVal{Groups: []string{"admin"}},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid pattern")
+}
+
+func TestGroupFilter(t *testing.T) {
+ vars := dexcel.IdentityVariables()
+ compiler, err := dexcel.NewCompiler(vars)
+ require.NoError(t, err)
+
+ tests := map[string]struct {
+ expr string
+ groups []string
+ want []string
+ }{
+ "filter to allowed": {
+ expr: `dex.groupFilter(identity.groups, ["admin", "ops"])`,
+ groups: []string{"admin", "dev", "ops"},
+ want: []string{"admin", "ops"},
+ },
+ "no overlap": {
+ expr: `dex.groupFilter(identity.groups, ["marketing"])`,
+ groups: []string{"admin", "dev"},
+ want: []string{},
+ },
+ "all allowed": {
+ expr: `dex.groupFilter(identity.groups, ["a", "b", "c"])`,
+ groups: []string{"a", "b", "c"},
+ want: []string{"a", "b", "c"},
+ },
+ "empty allowed list": {
+ expr: `dex.groupFilter(identity.groups, [])`,
+ groups: []string{"admin", "dev"},
+ want: []string{},
+ },
+ "preserves order": {
+ expr: `dex.groupFilter(identity.groups, ["z", "a"])`,
+ groups: []string{"a", "b", "z"},
+ want: []string{"a", "z"},
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ prog, err := compiler.CompileStringList(tc.expr)
+ require.NoError(t, err)
+
+ out, err := dexcel.Eval(context.Background(), prog, map[string]any{
+ "identity": dexcel.IdentityVal{Groups: tc.groups},
+ })
+ require.NoError(t, err)
+
+ nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{}))
+ require.NoError(t, err)
+
+ got, ok := nativeVal.([]string)
+ require.True(t, ok, "expected []string, got %T", nativeVal)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/pkg/cel/types.go b/pkg/cel/types.go
new file mode 100644
index 0000000000..4e65792290
--- /dev/null
+++ b/pkg/cel/types.go
@@ -0,0 +1,109 @@
+package cel
+
+import (
+ "github.com/google/cel-go/cel"
+
+ "github.com/dexidp/dex/connector"
+)
+
+// VariableDeclaration declares a named variable and its CEL type
+// that will be available in expressions.
+type VariableDeclaration struct {
+ Name string
+ Type *cel.Type
+}
+
+// IdentityVal is the CEL native type for the identity variable.
+// Fields are typed so that the CEL compiler rejects unknown field access
+// (e.g. identity.emial) at config load time rather than at evaluation time.
+type IdentityVal struct {
+ UserID string `cel:"user_id"`
+ Username string `cel:"username"`
+ PreferredUsername string `cel:"preferred_username"`
+ Email string `cel:"email"`
+ EmailVerified bool `cel:"email_verified"`
+ Groups []string `cel:"groups"`
+}
+
+// RequestVal is the CEL native type for the request variable.
+type RequestVal struct {
+ ClientID string `cel:"client_id"`
+ ConnectorID string `cel:"connector_id"`
+ Scopes []string `cel:"scopes"`
+ RedirectURI string `cel:"redirect_uri"`
+}
+
+// identityTypeName is the CEL type name for IdentityVal.
+// Derived by ext.NativeTypes as simplePkgAlias(pkgPath) + "." + structName.
+const identityTypeName = "cel.IdentityVal"
+
+// requestTypeName is the CEL type name for RequestVal.
+const requestTypeName = "cel.RequestVal"
+
+// IdentityVariables provides the 'identity' variable with typed fields.
+//
+// identity.user_id โ string
+// identity.username โ string
+// identity.preferred_username โ string
+// identity.email โ string
+// identity.email_verified โ bool
+// identity.groups โ list(string)
+func IdentityVariables() []VariableDeclaration {
+ return []VariableDeclaration{
+ {Name: "identity", Type: cel.ObjectType(identityTypeName)},
+ }
+}
+
+// RequestVariables provides the 'request' variable with typed fields.
+//
+// request.client_id โ string
+// request.connector_id โ string
+// request.scopes โ list(string)
+// request.redirect_uri โ string
+func RequestVariables() []VariableDeclaration {
+ return []VariableDeclaration{
+ {Name: "request", Type: cel.ObjectType(requestTypeName)},
+ }
+}
+
+// ClaimsVariable provides a 'claims' map for raw upstream claims.
+// Claims remain map(string, dyn) because their shape is genuinely
+// unknown โ they carry arbitrary upstream IdP data.
+//
+// claims โ map(string, dyn)
+func ClaimsVariable() []VariableDeclaration {
+ return []VariableDeclaration{
+ {Name: "claims", Type: cel.MapType(cel.StringType, cel.DynType)},
+ }
+}
+
+// IdentityFromConnector converts a connector.Identity to a CEL-compatible IdentityVal.
+func IdentityFromConnector(id connector.Identity) IdentityVal {
+ return IdentityVal{
+ UserID: id.UserID,
+ Username: id.Username,
+ PreferredUsername: id.PreferredUsername,
+ Email: id.Email,
+ EmailVerified: id.EmailVerified,
+ Groups: id.Groups,
+ }
+}
+
+// RequestContext represents the authentication/token request context
+// available as the 'request' variable in CEL expressions.
+type RequestContext struct {
+ ClientID string
+ ConnectorID string
+ Scopes []string
+ RedirectURI string
+}
+
+// RequestFromContext converts a RequestContext to a CEL-compatible RequestVal.
+func RequestFromContext(rc RequestContext) RequestVal {
+ return RequestVal{
+ ClientID: rc.ClientID,
+ ConnectorID: rc.ConnectorID,
+ Scopes: rc.Scopes,
+ RedirectURI: rc.RedirectURI,
+ }
+}
diff --git a/pkg/featureflags/doc.go b/pkg/featureflags/doc.go
new file mode 100644
index 0000000000..2703329361
--- /dev/null
+++ b/pkg/featureflags/doc.go
@@ -0,0 +1,3 @@
+// Package featureflags provides a mechanism for toggling experimental or
+// optional Dex features via environment variables (DEX_).
+package featureflags
diff --git a/pkg/featureflags/flag.go b/pkg/featureflags/flag.go
new file mode 100644
index 0000000000..98729ac9ed
--- /dev/null
+++ b/pkg/featureflags/flag.go
@@ -0,0 +1,33 @@
+package featureflags
+
+import (
+ "os"
+ "strconv"
+ "strings"
+)
+
+type flag struct {
+ Name string
+ Default bool
+}
+
+func (f *flag) env() string {
+ return "DEX_" + strings.ToUpper(f.Name)
+}
+
+func (f *flag) Enabled() bool {
+ raw := os.Getenv(f.env())
+ if raw == "" {
+ return f.Default
+ }
+
+ res, err := strconv.ParseBool(raw)
+ if err != nil {
+ return f.Default
+ }
+ return res
+}
+
+func newFlag(s string, d bool) *flag {
+ return &flag{Name: s, Default: d}
+}
diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go
new file mode 100644
index 0000000000..a63da72ce0
--- /dev/null
+++ b/pkg/featureflags/set.go
@@ -0,0 +1,30 @@
+package featureflags
+
+var (
+ // EntEnabled enables experimental ent-based engine for the database storages.
+ // https://entgo.io/
+ EntEnabled = newFlag("ent_enabled", false)
+
+ // ExpandEnv can enable or disable env expansion in the config which can be useful in environments where, e.g.,
+ // $ sign is a part of the password for LDAP user.
+ ExpandEnv = newFlag("expand_env", true)
+
+ // APIConnectorsCRUD allows CRUD operations on connectors through the gRPC API
+ APIConnectorsCRUD = newFlag("api_connectors_crud", false)
+
+ // ContinueOnConnectorFailure allows the server to start even if some connectors fail to initialize.
+ ContinueOnConnectorFailure = newFlag("continue_on_connector_failure", true)
+
+ // ConfigDisallowUnknownFields enables to forbid unknown fields in the config while unmarshaling.
+ ConfigDisallowUnknownFields = newFlag("config_disallow_unknown_fields", false)
+
+ // ClientCredentialGrantEnabledByDefault enables the client_credentials grant type by default
+ // without requiring explicit configuration in oauth2.grantTypes.
+ ClientCredentialGrantEnabledByDefault = newFlag("client_credential_grant_enabled_by_default", false)
+
+ // SessionsEnabled enables experimental auth sessions support.
+ SessionsEnabled = newFlag("sessions_enabled", false)
+
+ // APISessionsIdentitiesCRUD allows CRUD operations on auth sessions and user identities through the gRPC API.
+ APISessionsIdentitiesCRUD = newFlag("api_sessions_identities_crud", false)
+)
diff --git a/pkg/groups/doc.go b/pkg/groups/doc.go
new file mode 100644
index 0000000000..f1a21d02b8
--- /dev/null
+++ b/pkg/groups/doc.go
@@ -0,0 +1,2 @@
+// Package groups contains helper functions related to groups.
+package groups
diff --git a/pkg/groups/groups.go b/pkg/groups/groups.go
index 5dde65ab83..d31a5dee3b 100644
--- a/pkg/groups/groups.go
+++ b/pkg/groups/groups.go
@@ -1,4 +1,3 @@
-// Package groups contains helper functions related to groups
package groups
// Filter filters out any groups of given that are not in required. Thus it may
diff --git a/pkg/httpclient/doc.go b/pkg/httpclient/doc.go
new file mode 100644
index 0000000000..3d028a3a1f
--- /dev/null
+++ b/pkg/httpclient/doc.go
@@ -0,0 +1,3 @@
+// Package httpclient provides a configurable HTTP client constructor with
+// support for custom CA certificates, root CAs, and TLS settings.
+package httpclient
diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go
new file mode 100644
index 0000000000..671e0e7754
--- /dev/null
+++ b/pkg/httpclient/httpclient.go
@@ -0,0 +1,64 @@
+package httpclient
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "time"
+)
+
+func extractCAs(input []string) [][]byte {
+ result := make([][]byte, 0, len(input))
+ for _, ca := range input {
+ if ca == "" {
+ continue
+ }
+
+ pemData, err := os.ReadFile(ca)
+ if err != nil {
+ pemData, err = base64.StdEncoding.DecodeString(ca)
+ if err != nil {
+ pemData = []byte(ca)
+ }
+ }
+
+ result = append(result, pemData)
+ }
+ return result
+}
+
+func NewHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) {
+ pool, err := x509.SystemCertPool()
+ if err != nil {
+ return nil, err
+ }
+
+ tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify}
+ for index, rootCABytes := range extractCAs(rootCAs) {
+ if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
+ return nil, fmt.Errorf("rootCAs.%d is not in PEM format, certificate must be "+
+ "a PEM encoded string, a base64 encoded bytes that contain PEM encoded string, "+
+ "or a path to a PEM encoded certificate", index)
+ }
+ }
+
+ return &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tlsConfig,
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ DualStack: true,
+ }).DialContext,
+ MaxIdleConns: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ },
+ }, nil
+}
diff --git a/pkg/httpclient/httpclient_test.go b/pkg/httpclient/httpclient_test.go
new file mode 100644
index 0000000000..6f561c1030
--- /dev/null
+++ b/pkg/httpclient/httpclient_test.go
@@ -0,0 +1,83 @@
+package httpclient_test
+
+import (
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/dexidp/dex/pkg/httpclient"
+)
+
+func TestRootCAs(t *testing.T) {
+ ts, err := NewLocalHTTPSTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "Hello, client")
+ }))
+ assert.Nil(t, err)
+ defer ts.Close()
+
+ runTest := func(name string, certs []string) {
+ t.Run(name, func(t *testing.T) {
+ rootCAs := certs
+ testClient, err := httpclient.NewHTTPClient(rootCAs, false)
+ assert.Nil(t, err)
+
+ res, err := testClient.Get(ts.URL)
+ assert.Nil(t, err)
+
+ greeting, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ assert.Nil(t, err)
+
+ assert.Equal(t, "Hello, client", string(greeting))
+ })
+ }
+
+ runTest("From file", []string{"testdata/rootCA.pem"})
+
+ content, err := os.ReadFile("testdata/rootCA.pem")
+ assert.NoError(t, err)
+ runTest("From string", []string{string(content)})
+
+ contentStr := base64.StdEncoding.EncodeToString(content)
+ runTest("From bytes", []string{contentStr})
+}
+
+func TestInsecureSkipVerify(t *testing.T) {
+ ts, err := NewLocalHTTPSTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "Hello, client")
+ }))
+ assert.Nil(t, err)
+ defer ts.Close()
+
+ insecureSkipVerify := true
+
+ testClient, err := httpclient.NewHTTPClient(nil, insecureSkipVerify)
+ assert.Nil(t, err)
+
+ res, err := testClient.Get(ts.URL)
+ assert.Nil(t, err)
+
+ greeting, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ assert.Nil(t, err)
+
+ assert.Equal(t, "Hello, client", string(greeting))
+}
+
+func NewLocalHTTPSTestServer(handler http.Handler) (*httptest.Server, error) {
+ ts := httptest.NewUnstartedServer(handler)
+ cert, err := tls.LoadX509KeyPair("testdata/server.crt", "testdata/server.key")
+ if err != nil {
+ return nil, err
+ }
+ ts.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
+ ts.StartTLS()
+ return ts, nil
+}
diff --git a/pkg/httpclient/readme.md b/pkg/httpclient/readme.md
new file mode 100644
index 0000000000..cc26252293
--- /dev/null
+++ b/pkg/httpclient/readme.md
@@ -0,0 +1,44 @@
+# Regenerate testdata
+
+### server.csr.cnf
+
+```
+[req]
+default_bits = 2048
+prompt = no
+default_md = sha256
+distinguished_name = dn
+
+[dn]
+C=US
+ST=RandomState
+L=RandomCity
+O=RandomOrganization
+OU=RandomOrganizationUnit
+emailAddress=hello@example.com
+CN = localhost
+```
+
+and
+
+### v3.ext
+```
+authorityKeyIdentifier=keyid,issuer
+basicConstraints=CA:FALSE
+keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = localhost
+IP.1 = 127.0.0.1
+```
+
+### Then enter the following commands:
+
+`openssl genrsa -out rootCA.key 2048`
+
+`openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.pem -config server.csr.cnf`
+
+`openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key -config server.csr.cnf`
+
+`openssl x509 -req -in server.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out server.crt -days 3650 -sha256 -extfile v3.ext`
diff --git a/pkg/httpclient/testdata/rootCA.key b/pkg/httpclient/testdata/rootCA.key
new file mode 100644
index 0000000000..9c4eeee12a
--- /dev/null
+++ b/pkg/httpclient/testdata/rootCA.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA4dB5aQCjCmMsW71u9F0WNm1TYjXQBZ4p7oNT+BQwCc/MZ2xc
+5NexS2O86nbRkw5jwyfAAMSMKRr9s2FluVTHqiln78rg+XUgmrmNT3ZroLmW6QL6
+Ca8dbMPky+tQclZsvMd3HAeCyyrs4pf7wM1AyUJD7H0xAlVD1fsohkg7jhBFUfV+
+q2VMMdnsaV5vFrW/2vPBWz1SNPW/Xm+Ilny7xg9njQLcPMNtVtF+7EPB6sxD6qrj
+BC+Kj5zQ3bZOfdrh7yy63dbh/Kh+3NScgO+k+x92HlAjRIvj5y4KrbGZl7CmOth5
+y7fPywApVbDfZRWJChI1PVflOyDdnC+vhMLbHQIDAQABAoIBAEmjrrQrXP/6L3EL
+aa+O27uME3Enk1sBpTL+6Ncx3iiU91eS4whNvqeTMvxTGy0VuDrgL6EQd5TAFJP2
+4zF5EFPRhO+R/aPcKnHKqOaM+7RCUZBTRC78SGA70dUeO/HNdVBqy9D8Mg8HRJDw
+d0z8om//iB8LBHx6SdDyQtjnnWRKFTzQRurBBoyLe2vPMFtINKtNUkahjc8HE4GO
+aIv1LICJUzf4ZnkntKd5cFHZ42R2Tmfj0Y9G9DyJbuSA3+0u5IhYB39Uy6jFxLi8
+I5PoIVhgYZ0aivsVBIviShwQ9kgv6807YBxt22eSNovBDrSp+cAnIF9+p0b3MnkU
+aCHSiBECgYEA84lssi6AqfCEsSiQMSM9kMCXJ4KQI/l7pmrIA50+V5HSEby9lg2Y
+N6XJ4V4q46t8FcZBjmMvzn9fwiPMRw5e995cVNBQ31a1FX/1Hy6RNtEiLZRnkHI5
+WznY9IxQ+c9JXJeFY1sO0BfO0TS3WvOf1rwqOb92q+cQaItnPQ+4Ya8CgYEA7V7e
+IqW3PpO4H+c5hH9egM0BjAxH71C9YpYzZpF9uiPIkuMnJ8nm9bB6RiuDaYCxvrfE
+A0h/SQewoYJKL4OfKGjrbG7U4zLMZHIWlf8Za55Zik5BNjvgBqFFrrSgLUGxdRTX
+N0+TlWlW1bvJblWpdjIbJbg/6kCU98TzK852fvMCgYAWYa/apElw1MjtGyQ9T9bN
+odWCbQ5gMAJ8Jd4h7uaW17DtrmHiE3fEzXjDPItGhzENMz49HsJ7ANvFFNMmSJzT
+vNzRcp+sFuTnh+34Iqh32DqC49usu8KnrqZQu0CJ5NICL26z1d+DolyAf47GThOH
+gZ2D1yPJ4p9wbDddtj8kwwKBgCFKB68mPG+rOcxHmjppvnAj0A66/i+izBySYf0F
+dHNxZ0SqVKhw2VIlgNBsc86M/OB5VyT6utccG/paklrdg6mgJTwcwwBl9GI12dMJ
+ZqBAIeCSnvSjKwTjAynALSKLrv5zgMdCArmWf1YUMuilXNG1rzb4AwawLfQdi9jd
+6KJfAoGBALFl6ldywl3sGPk9K2xCDYYhb1TNQyheA5YvoZzZ6XCo1q0Lbwy/FamZ
+0TSWkoEmGB/Hck3HgtZDRo3CTI1vYfbpAtgI7oD1NA1zMaLulNQxKjH3iVvyb+R7
+ZcIT7EVPZgkUwr0bsp22yVDekh/CHoB6FZPCyoAb8WnfJfooTBzB
+-----END RSA PRIVATE KEY-----
diff --git a/pkg/httpclient/testdata/rootCA.pem b/pkg/httpclient/testdata/rootCA.pem
new file mode 100644
index 0000000000..c03bdac0c0
--- /dev/null
+++ b/pkg/httpclient/testdata/rootCA.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID1jCCAr4CCQCG4JBeSi6cDjANBgkqhkiG9w0BAQsFADCBrDELMAkGA1UEBhMC
+VVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQHDApSYW5kb21DaXR5MRsw
+GQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNVBAsMFlJhbmRvbU9yZ2Fu
+aXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxvQGV4YW1wbGUuY29tMRIw
+EAYDVQQDDAlsb2NhbGhvc3QwHhcNMjIxMDA3MjIwNjQwWhcNMzIxMDA0MjIwNjQw
+WjCBrDELMAkGA1UEBhMCVVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQH
+DApSYW5kb21DaXR5MRswGQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNV
+BAsMFlJhbmRvbU9yZ2FuaXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxv
+QGV4YW1wbGUuY29tMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDh0HlpAKMKYyxbvW70XRY2bVNiNdAFninug1P4FDAJ
+z8xnbFzk17FLY7zqdtGTDmPDJ8AAxIwpGv2zYWW5VMeqKWfvyuD5dSCauY1Pdmug
+uZbpAvoJrx1sw+TL61ByVmy8x3ccB4LLKuzil/vAzUDJQkPsfTECVUPV+yiGSDuO
+EEVR9X6rZUwx2expXm8Wtb/a88FbPVI09b9eb4iWfLvGD2eNAtw8w21W0X7sQ8Hq
+zEPqquMEL4qPnNDdtk592uHvLLrd1uH8qH7c1JyA76T7H3YeUCNEi+PnLgqtsZmX
+sKY62HnLt8/LAClVsN9lFYkKEjU9V+U7IN2cL6+EwtsdAgMBAAEwDQYJKoZIhvcN
+AQELBQADggEBAN6g0qit/3R2X+KdR0LgRXF/h4qQFgcV6cxnhRAmLIDNJlxKSHqN
+IE5+bxzCbkblzGfr/jNPqW0s+yaN4CyMgKNYSzkLBPE4FF+19Uv+dyYfFms3mDJ7
+0rGjS5bCscThWhpaSw20LcwQcr/+X+/fGzJ01dVFK1UOjBKg4d4dMwxklbIkZqIq
+siRW0GMy26mgVZ/BSjeh5kEjs6h6H3cJsGl7xYT+BI7wnxHwGeT9tkBgiyT5FwaS
+vtdZkBpQ9q8f7FwsEm3woLHdWuOnrtUtVpY/oc6WFGdROQdGzjSk0D3kHs9YhueC
+GSzZKrqX+TSIgpPrLYNHX4uxlo5TAwP/5GM=
+-----END CERTIFICATE-----
diff --git a/pkg/httpclient/testdata/rootCA.srl b/pkg/httpclient/testdata/rootCA.srl
new file mode 100644
index 0000000000..214ae68bf1
--- /dev/null
+++ b/pkg/httpclient/testdata/rootCA.srl
@@ -0,0 +1 @@
+C1B35F0051A641BB
diff --git a/pkg/httpclient/testdata/server.crt b/pkg/httpclient/testdata/server.crt
new file mode 100644
index 0000000000..9b0f12ec58
--- /dev/null
+++ b/pkg/httpclient/testdata/server.crt
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIE5TCCA82gAwIBAgIJAMGzXwBRpkG7MA0GCSqGSIb3DQEBCwUAMIGsMQswCQYD
+VQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzARBgNVBAcMClJhbmRvbUNp
+dHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEfMB0GA1UECwwWUmFuZG9t
+T3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYRaGVsbG9AZXhhbXBsZS5j
+b20xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMjEwMDcyMjA3MDhaFw0zMjEwMDQy
+MjA3MDhaMIGsMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzAR
+BgNVBAcMClJhbmRvbUNpdHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEf
+MB0GA1UECwwWUmFuZG9tT3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYR
+aGVsbG9AZXhhbXBsZS5jb20xEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAMuKdpXP87Q7Kg3iafXzvBuVIyV1K5UmMYiN
+koztkC5XrCzHaQRS/CoIb7/nUqmtAxx7RL0jzhZ93zBN4HY/Zcnrd9tXoPPxi0mG
+ZZWfFU6nN8nOkMHWzEbHVBmhxpfGtwmLcajQ4HrK1TZwJUn6GqclHQRy/gjxkiw5
+KPqzfVOVlA6ht4KdKstKazQkWZ5gdWT4d8yrEy/IT4oaW05xALBMQ7YGjkzWKsSF
+6ygXI7xqF9rg9jCnUsPYg4f8ut3N0c00KjsfKOOj2dF/ZyjedQ5c0u4hHmxSo3Ka
+0ZTmIrMfbVXgGjxRG2HZXLpPvQKoCf/fOX8Irdr+lahFVKASxN0CAwEAAaOCAQYw
+ggECMIHLBgNVHSMEgcMwgcChgbKkga8wgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQI
+DAtSYW5kb21TdGF0ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFu
+ZG9tT3JnYW5pemF0aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0
+MSAwHgYJKoZIhvcNAQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJbG9j
+YWxob3N0ggkAhuCQXkounA4wCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0R
+BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCWmh5ebpkm
+v2B1yQgarSCSSkLZ5DZSAJjrPgW2IJqCW2q2D1HworbW1Yn5jqrM9FKGnJfjCyve
+zBB5AOlGp+0bsZGgMRMCavgv4QhTThXUoJqqHcfEu4wHndcgrqSadxmV5aisSR4u
+gXnjW43o3akby+h1K40RR3vVkpzPaoC3/bgk7WVpfpPiP32E24a01gETozRb/of/
+ATN3JBe0xh+e63CrPX1sago5+u3UETIoOr0fW8M/gU9GApmJiFAXwHag6j54hLCG
+23EtVDwmlarG8Pj+i0yru8s22QqzAJi5E0OwR4aB8tqicLKYBVfzyLCOielIBUrK
+OkuFKp+VjxQX
+-----END CERTIFICATE-----
diff --git a/pkg/httpclient/testdata/server.csr b/pkg/httpclient/testdata/server.csr
new file mode 100644
index 0000000000..f422a853c3
--- /dev/null
+++ b/pkg/httpclient/testdata/server.csr
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIC8jCCAdoCAQAwgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQIDAtSYW5kb21TdGF0
+ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFuZG9tT3JnYW5pemF0
+aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0MSAwHgYJKoZIhvcN
+AQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN
+BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy4p2lc/ztDsqDeJp9fO8G5UjJXUr
+lSYxiI2SjO2QLlesLMdpBFL8Kghvv+dSqa0DHHtEvSPOFn3fME3gdj9lyet321eg
+8/GLSYZllZ8VTqc3yc6QwdbMRsdUGaHGl8a3CYtxqNDgesrVNnAlSfoapyUdBHL+
+CPGSLDko+rN9U5WUDqG3gp0qy0prNCRZnmB1ZPh3zKsTL8hPihpbTnEAsExDtgaO
+TNYqxIXrKBcjvGoX2uD2MKdSw9iDh/y63c3RzTQqOx8o46PZ0X9nKN51DlzS7iEe
+bFKjcprRlOYisx9tVeAaPFEbYdlcuk+9AqgJ/985fwit2v6VqEVUoBLE3QIDAQAB
+oAAwDQYJKoZIhvcNAQELBQADggEBADjuujIFoDJllR6Xo/w7j5vfNOeHO5GSgxF2
+XnuuDOI9Tomi7vURFZNbz3VAYiehpxRxYqLwFoQUwFtux2qRuGyg0P9fP1iQXPUE
+QUfFXmvB80uf2bG4lkbUwnmlZLFOEwhGZyPxpvsrxp2Ei2ppkUopCkzOMsSk3m0X
+MC50ZsTHOxfkA3r1WmS7oE2c0p0Fvyx+UJw0URAXFvDS1X0ONgww3FxqbBbm9W37
+5N4FZzGAK6j1wzuynKKXrn20YDCANXYH55PZyupfCeSZT0H0AZifWL7rz/G9uqme
+RzbIYc/CNQQTympjinBegQdVeB3yjVNZIvpGOuPSKQqhwFtmDFo=
+-----END CERTIFICATE REQUEST-----
diff --git a/pkg/httpclient/testdata/server.csr.cnf b/pkg/httpclient/testdata/server.csr.cnf
new file mode 100644
index 0000000000..6ff57d1a35
--- /dev/null
+++ b/pkg/httpclient/testdata/server.csr.cnf
@@ -0,0 +1,14 @@
+[req]
+default_bits = 2048
+prompt = no
+default_md = sha256
+distinguished_name = dn
+
+[dn]
+C=US
+ST=RandomState
+L=RandomCity
+O=RandomOrganization
+OU=RandomOrganizationUnit
+emailAddress=hello@example.com
+CN = localhost
diff --git a/pkg/httpclient/testdata/server.key b/pkg/httpclient/testdata/server.key
new file mode 100644
index 0000000000..9708e1e6ea
--- /dev/null
+++ b/pkg/httpclient/testdata/server.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLinaVz/O0OyoN
+4mn187wblSMldSuVJjGIjZKM7ZAuV6wsx2kEUvwqCG+/51KprQMce0S9I84Wfd8w
+TeB2P2XJ63fbV6Dz8YtJhmWVnxVOpzfJzpDB1sxGx1QZocaXxrcJi3Go0OB6ytU2
+cCVJ+hqnJR0Ecv4I8ZIsOSj6s31TlZQOobeCnSrLSms0JFmeYHVk+HfMqxMvyE+K
+GltOcQCwTEO2Bo5M1irEhesoFyO8ahfa4PYwp1LD2IOH/LrdzdHNNCo7Hyjjo9nR
+f2co3nUOXNLuIR5sUqNymtGU5iKzH21V4Bo8URth2Vy6T70CqAn/3zl/CK3a/pWo
+RVSgEsTdAgMBAAECggEAU6cxu7q+54kVbKVsdThaTF/MFR4F7oPHAd9lpuQQSOuh
+iLngMHXGy6OyAgYZlEDWMYN8KdwoXFgZPaoUIaVGuWk8Vnq6XOgeHfbNk2PRhwT0
+yc1K80/Lnx9XMj2p+EEkgxi7eu12BSGN5ZTLzo6rG50GQwjb3WMjd2d6rybL0GjC
+wg2arcBk3sSMYmvZOqlAsaQmtgwkJhvhVkVfEQSD3VKF7g0dh/h3LIPyM0Ff4M67
+KpLMPPwzUJ/0Z4ewAP06mMKUA86R93M+dWs2eh1oBGnRkVQdhCJLXJpuGHZ6BTiB
+Ry0AeorHfnVXPbtpUeAq6m5/BBl6qX0ooB08BIFwAQKBgQDqJpTZS/ZzqL6Kcs14
+MyFu+7DungSxQ5oK9ju7EFSosanSk4UEa/lw992kM6nsIMwgSVQgba5zKcVMeSmk
+AVbpznegQD1BYCwOGwbGvkJ8jbhPy+WLbbRjWT/E6AItZgUK+fyTIcNvSehcQqsT
+fhgWsK7ueZCmLQfVhK1AxtvY3QKBgQDeiKuo8plsH/7IxDn7KVHBOHKPC2ZPzg03
+i7La6zomiRckwwPnhicRSYsjtfCCW6Ms+uzjTEItgFM+5PdrXheeku+z/sExRtZu
+emqPqDomixlXDRQ6RN3gnBSk4RU+ROB1u1uBLWXqRz8Gp2zJGRxhHfYt2zefBv4w
+/cIuPC3cAQKBgD2UsAkGJWb9tj8LOmama+CYaUwYWvuT3+uKHuNvxBQpxZQQICet
+jgjb53rL66Cib4z+PBXbQsoe7jjSlNUBVS5gkq2et31+IZgEG6AhYbMIQrUZ1uD4
+lTybuF289vWhoynj3T2E37VhJq89CWky/HrbNOabKiPKLAlHv5kNs7wxAoGBANEJ
+XQbU7J2O6Iy7FyQBSlTQq3wHX1Iz4mJ9DcNrFzK/sEfOEMrZT7WDefpPm984KW3F
+P+S766ZGVuxLtMbcmh9RM23HLr8VJbSdtZ/AjO9L1r/Y/1lE+49TzmibLpNRq++r
+0WbkuEl8J44ek6fLuMbZmDi3JeZycTCgDlnUGdgBAoGAYdliovtURZCm46t1uE3F
+idCLCXCccjkt1hcNGNjck/b0trHA7wOEqICIguoWDlEBTc0PDvHEq6PfKyqptGkj
+AgaZTMF/aZiGqlT7VRpBuzxM/uV5xzCg+i2ViaW/p3xq0z2PRljVZiEfe5aWcjiM
+ouTtnC3TgmcjhTgGmb48QQE=
+-----END PRIVATE KEY-----
diff --git a/pkg/httpclient/testdata/v3.ext b/pkg/httpclient/testdata/v3.ext
new file mode 100644
index 0000000000..68e35be863
--- /dev/null
+++ b/pkg/httpclient/testdata/v3.ext
@@ -0,0 +1,8 @@
+authorityKeyIdentifier=keyid,issuer
+basicConstraints=CA:FALSE
+keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = localhost
+IP.1 = 127.0.0.1
diff --git a/pkg/log/deprecated.go b/pkg/log/deprecated.go
deleted file mode 100644
index f20e8b4cb8..0000000000
--- a/pkg/log/deprecated.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package log
-
-func Deprecated(logger Logger, f string, args ...interface{}) {
- logger.Warnf("Deprecated: "+f, args...)
-}
diff --git a/pkg/log/logger.go b/pkg/log/logger.go
deleted file mode 100644
index 4f3cdd3851..0000000000
--- a/pkg/log/logger.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// Package log provides a logger interface for logger libraries
-// so that dex does not depend on any of them directly.
-// It also includes a default implementation using Logrus (used by dex previously).
-package log
-
-// Logger serves as an adapter interface for logger libraries
-// so that dex does not depend on any of them directly.
-type Logger interface {
- Debug(args ...interface{})
- Info(args ...interface{})
- Warn(args ...interface{})
- Error(args ...interface{})
-
- Debugf(format string, args ...interface{})
- Infof(format string, args ...interface{})
- Warnf(format string, args ...interface{})
- Errorf(format string, args ...interface{})
-}
diff --git a/scripts/git-diff b/scripts/git-diff
deleted file mode 100755
index 302ac2ce3e..0000000000
--- a/scripts/git-diff
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash -e
-
-DIFF=$( git diff . )
-if [ "$DIFF" != "" ]; then
- echo "$DIFF" >&2
- exit 1
-fi
diff --git a/scripts/git-version b/scripts/git-version
index 936641cb0b..a78a2716d0 100755
--- a/scripts/git-version
+++ b/scripts/git-version
@@ -1,15 +1,42 @@
#!/bin/sh -e
-# Since this script will be run in a rkt container, use "/bin/sh" instead of "/bin/bash"
# parse the current git commit hash
-COMMIT=`git rev-parse HEAD`
+COMMIT=`git rev-parse --short=8 HEAD`
-# check if the current commit has a matching tag
-TAG=$(git describe --exact-match --abbrev=0 --tags ${COMMIT} 2> /dev/null || true)
+# check if the current commit has a matching tag (filter for v* tags, excluding api/)
+TAG=$(git describe --exact-match --abbrev=0 --tags --match="v[0-9]*" 2> /dev/null || true)
# use the matching tag as the version, if available
if [ -z "$TAG" ]; then
- VERSION=$COMMIT
+ # No exact tag on current commit, find the last version tag and bump minor version
+ # Get all tags matching v[0-9]*, sort them, and take the last one
+ LAST_TAG=$(git tag --list "v[0-9]*" --sort=-version:refname | head -1)
+
+ if [ -z "$LAST_TAG" ]; then
+ # No tags found, use v0.1.0 as fallback
+ BASE_VERSION="v0.1.0"
+ else
+ # Parse the last tag and bump minor version
+ # Remove 'v' prefix
+ TAG_WITHOUT_V="${LAST_TAG#v}"
+
+ # Split version into parts (major.minor.patch)
+ MAJOR=$(echo "$TAG_WITHOUT_V" | cut -d. -f1)
+ MINOR=$(echo "$TAG_WITHOUT_V" | cut -d. -f2)
+ PATCH=$(echo "$TAG_WITHOUT_V" | cut -d. -f3)
+
+ # Bump minor version
+ MINOR=$((MINOR + 1))
+
+ # Construct base version with bumped minor
+ BASE_VERSION="v${MAJOR}.${MINOR}.0"
+ fi
+
+ # Get commit timestamp in YYYYMMDDhhmmss format
+ TIMESTAMP=$(git log -1 --format=%ci HEAD | sed 's/[-: ]//g' | cut -c1-14)
+
+ # Construct pseudo-version
+ VERSION="${BASE_VERSION}-${TIMESTAMP}-${COMMIT}"
else
VERSION=$TAG
fi
diff --git a/scripts/update-gomplate b/scripts/update-gomplate
new file mode 100755
index 0000000000..4f8d59fd3f
--- /dev/null
+++ b/scripts/update-gomplate
@@ -0,0 +1,53 @@
+#!/bin/sh -e
+# Script to check for a new gomplate version and update it in Dockerfile
+
+GOMPLATE_REPO="hairyhenderson/gomplate"
+DOCKERFILE="${1:-.}/Dockerfile"
+
+# Check if Dockerfile exists
+if [ ! -f "$DOCKERFILE" ]; then
+ echo "Error: Dockerfile not found at $DOCKERFILE"
+ exit 1
+fi
+
+# Get the latest release version from GitHub
+echo "Checking for the latest gomplate version on GitHub..."
+LATEST_VERSION=$(curl -s "https://api.github.com/repos/${GOMPLATE_REPO}/releases/latest" | grep -o '"tag_name": "[^"]*"' | head -1 | cut -d'"' -f4)
+
+if [ -z "$LATEST_VERSION" ]; then
+ echo "Error: Could not fetch the latest version from GitHub"
+ exit 1
+fi
+
+echo "Latest gomplate version: $LATEST_VERSION"
+
+# Get the current version from Dockerfile
+CURRENT_VERSION=$(grep 'ENV GOMPLATE_VERSION' "$DOCKERFILE" | sed 's/.*GOMPLATE_VERSION=//;s/[[:space:]]*$//')
+
+echo "Current gomplate version in Dockerfile: $CURRENT_VERSION"
+
+# Check if versions are different
+if [ "$LATEST_VERSION" = "$CURRENT_VERSION" ]; then
+ echo "โ Already on the latest version ($LATEST_VERSION)"
+ exit 0
+fi
+
+echo "โ New version available: $LATEST_VERSION"
+echo "Updating Dockerfile..."
+
+# Update the Dockerfile - use a more specific pattern to avoid multiple replacements
+sed -i '' "s/ENV GOMPLATE_VERSION=.*/ENV GOMPLATE_VERSION=${LATEST_VERSION}/" "$DOCKERFILE"
+
+if grep -q "ENV GOMPLATE_VERSION=${LATEST_VERSION}" "$DOCKERFILE"; then
+ echo "โ Successfully updated Dockerfile to version $LATEST_VERSION"
+ echo ""
+ echo "Changes made:"
+ echo " - GOMPLATE_VERSION: $CURRENT_VERSION โ $LATEST_VERSION"
+else
+ echo "Error: Failed to update Dockerfile"
+ exit 1
+fi
+
+
+
+
diff --git a/server/api.go b/server/api.go
deleted file mode 100644
index a68742b3cc..0000000000
--- a/server/api.go
+++ /dev/null
@@ -1,368 +0,0 @@
-package server
-
-import (
- "context"
- "errors"
- "fmt"
-
- "golang.org/x/crypto/bcrypt"
-
- "github.com/dexidp/dex/api/v2"
- "github.com/dexidp/dex/pkg/log"
- "github.com/dexidp/dex/server/internal"
- "github.com/dexidp/dex/storage"
-)
-
-// apiVersion increases every time a new call is added to the API. Clients should use this info
-// to determine if the server supports specific features.
-const apiVersion = 2
-
-const (
- // recCost is the recommended bcrypt cost, which balances hash strength and
- // efficiency.
- recCost = 12
-
- // upBoundCost is a sane upper bound on bcrypt cost determined by benchmarking:
- // high enough to ensure secure encryption, low enough to not put unnecessary
- // load on a dex server.
- upBoundCost = 16
-)
-
-// NewAPI returns a server which implements the gRPC API interface.
-func NewAPI(s storage.Storage, logger log.Logger, version string) api.DexServer {
- return dexAPI{
- s: s,
- logger: logger,
- version: version,
- }
-}
-
-type dexAPI struct {
- api.UnimplementedDexServer
-
- s storage.Storage
- logger log.Logger
- version string
-}
-
-func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*api.CreateClientResp, error) {
- if req.Client == nil {
- return nil, errors.New("no client supplied")
- }
-
- if req.Client.Id == "" {
- req.Client.Id = storage.NewID()
- }
- if req.Client.Secret == "" && !req.Client.Public {
- req.Client.Secret = storage.NewID() + storage.NewID()
- }
-
- c := storage.Client{
- ID: req.Client.Id,
- Secret: req.Client.Secret,
- RedirectURIs: req.Client.RedirectUris,
- TrustedPeers: req.Client.TrustedPeers,
- Public: req.Client.Public,
- Name: req.Client.Name,
- LogoURL: req.Client.LogoUrl,
- }
- if err := d.s.CreateClient(c); err != nil {
- if err == storage.ErrAlreadyExists {
- return &api.CreateClientResp{AlreadyExists: true}, nil
- }
- d.logger.Errorf("api: failed to create client: %v", err)
- return nil, fmt.Errorf("create client: %v", err)
- }
-
- return &api.CreateClientResp{
- Client: req.Client,
- }, nil
-}
-
-func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*api.UpdateClientResp, error) {
- if req.Id == "" {
- return nil, errors.New("update client: no client ID supplied")
- }
-
- err := d.s.UpdateClient(req.Id, func(old storage.Client) (storage.Client, error) {
- if req.RedirectUris != nil {
- old.RedirectURIs = req.RedirectUris
- }
- if req.TrustedPeers != nil {
- old.TrustedPeers = req.TrustedPeers
- }
- if req.Name != "" {
- old.Name = req.Name
- }
- if req.LogoUrl != "" {
- old.LogoURL = req.LogoUrl
- }
- return old, nil
- })
- if err != nil {
- if err == storage.ErrNotFound {
- return &api.UpdateClientResp{NotFound: true}, nil
- }
- d.logger.Errorf("api: failed to update the client: %v", err)
- return nil, fmt.Errorf("update client: %v", err)
- }
- return &api.UpdateClientResp{}, nil
-}
-
-func (d dexAPI) DeleteClient(ctx context.Context, req *api.DeleteClientReq) (*api.DeleteClientResp, error) {
- err := d.s.DeleteClient(req.Id)
- if err != nil {
- if err == storage.ErrNotFound {
- return &api.DeleteClientResp{NotFound: true}, nil
- }
- d.logger.Errorf("api: failed to delete client: %v", err)
- return nil, fmt.Errorf("delete client: %v", err)
- }
- return &api.DeleteClientResp{}, nil
-}
-
-// checkCost returns an error if the hash provided does not meet lower or upper
-// bound cost requirements.
-func checkCost(hash []byte) error {
- actual, err := bcrypt.Cost(hash)
- if err != nil {
- return fmt.Errorf("parsing bcrypt hash: %v", err)
- }
- if actual < bcrypt.DefaultCost {
- return fmt.Errorf("given hash cost = %d does not meet minimum cost requirement = %d", actual, bcrypt.DefaultCost)
- }
- if actual > upBoundCost {
- return fmt.Errorf("given hash cost = %d is above upper bound cost = %d, recommended cost = %d", actual, upBoundCost, recCost)
- }
- return nil
-}
-
-func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq) (*api.CreatePasswordResp, error) {
- if req.Password == nil {
- return nil, errors.New("no password supplied")
- }
- if req.Password.UserId == "" {
- return nil, errors.New("no user ID supplied")
- }
- if req.Password.Hash != nil {
- if err := checkCost(req.Password.Hash); err != nil {
- return nil, err
- }
- } else {
- return nil, errors.New("no hash of password supplied")
- }
-
- p := storage.Password{
- Email: req.Password.Email,
- Hash: req.Password.Hash,
- Username: req.Password.Username,
- UserID: req.Password.UserId,
- }
- if err := d.s.CreatePassword(p); err != nil {
- if err == storage.ErrAlreadyExists {
- return &api.CreatePasswordResp{AlreadyExists: true}, nil
- }
- d.logger.Errorf("api: failed to create password: %v", err)
- return nil, fmt.Errorf("create password: %v", err)
- }
-
- return &api.CreatePasswordResp{}, nil
-}
-
-func (d dexAPI) UpdatePassword(ctx context.Context, req *api.UpdatePasswordReq) (*api.UpdatePasswordResp, error) {
- if req.Email == "" {
- return nil, errors.New("no email supplied")
- }
- if req.NewHash == nil && req.NewUsername == "" {
- return nil, errors.New("nothing to update")
- }
-
- if req.NewHash != nil {
- if err := checkCost(req.NewHash); err != nil {
- return nil, err
- }
- }
-
- updater := func(old storage.Password) (storage.Password, error) {
- if req.NewHash != nil {
- old.Hash = req.NewHash
- }
-
- if req.NewUsername != "" {
- old.Username = req.NewUsername
- }
-
- return old, nil
- }
-
- if err := d.s.UpdatePassword(req.Email, updater); err != nil {
- if err == storage.ErrNotFound {
- return &api.UpdatePasswordResp{NotFound: true}, nil
- }
- d.logger.Errorf("api: failed to update password: %v", err)
- return nil, fmt.Errorf("update password: %v", err)
- }
-
- return &api.UpdatePasswordResp{}, nil
-}
-
-func (d dexAPI) DeletePassword(ctx context.Context, req *api.DeletePasswordReq) (*api.DeletePasswordResp, error) {
- if req.Email == "" {
- return nil, errors.New("no email supplied")
- }
-
- err := d.s.DeletePassword(req.Email)
- if err != nil {
- if err == storage.ErrNotFound {
- return &api.DeletePasswordResp{NotFound: true}, nil
- }
- d.logger.Errorf("api: failed to delete password: %v", err)
- return nil, fmt.Errorf("delete password: %v", err)
- }
- return &api.DeletePasswordResp{}, nil
-}
-
-func (d dexAPI) GetVersion(ctx context.Context, req *api.VersionReq) (*api.VersionResp, error) {
- return &api.VersionResp{
- Server: d.version,
- Api: apiVersion,
- }, nil
-}
-
-func (d dexAPI) ListPasswords(ctx context.Context, req *api.ListPasswordReq) (*api.ListPasswordResp, error) {
- passwordList, err := d.s.ListPasswords()
- if err != nil {
- d.logger.Errorf("api: failed to list passwords: %v", err)
- return nil, fmt.Errorf("list passwords: %v", err)
- }
-
- passwords := make([]*api.Password, 0, len(passwordList))
- for _, password := range passwordList {
- p := api.Password{
- Email: password.Email,
- Username: password.Username,
- UserId: password.UserID,
- }
- passwords = append(passwords, &p)
- }
-
- return &api.ListPasswordResp{
- Passwords: passwords,
- }, nil
-}
-
-func (d dexAPI) VerifyPassword(ctx context.Context, req *api.VerifyPasswordReq) (*api.VerifyPasswordResp, error) {
- if req.Email == "" {
- return nil, errors.New("no email supplied")
- }
-
- if req.Password == "" {
- return nil, errors.New("no password to verify supplied")
- }
-
- password, err := d.s.GetPassword(req.Email)
- if err != nil {
- if err == storage.ErrNotFound {
- return &api.VerifyPasswordResp{
- NotFound: true,
- }, nil
- }
- d.logger.Errorf("api: there was an error retrieving the password: %v", err)
- return nil, fmt.Errorf("verify password: %v", err)
- }
-
- if err := bcrypt.CompareHashAndPassword(password.Hash, []byte(req.Password)); err != nil {
- d.logger.Infof("api: password check failed: %v", err)
- return &api.VerifyPasswordResp{
- Verified: false,
- }, nil
- }
- return &api.VerifyPasswordResp{
- Verified: true,
- }, nil
-}
-
-func (d dexAPI) ListRefresh(ctx context.Context, req *api.ListRefreshReq) (*api.ListRefreshResp, error) {
- id := new(internal.IDTokenSubject)
- if err := internal.Unmarshal(req.UserId, id); err != nil {
- d.logger.Errorf("api: failed to unmarshal ID Token subject: %v", err)
- return nil, err
- }
-
- offlineSessions, err := d.s.GetOfflineSessions(id.UserId, id.ConnId)
- if err != nil {
- if err == storage.ErrNotFound {
- // This means that this user-client pair does not have a refresh token yet.
- // An empty list should be returned instead of an error.
- return &api.ListRefreshResp{}, nil
- }
- d.logger.Errorf("api: failed to list refresh tokens %t here : %v", err == storage.ErrNotFound, err)
- return nil, err
- }
-
- refreshTokenRefs := make([]*api.RefreshTokenRef, 0, len(offlineSessions.Refresh))
- for _, session := range offlineSessions.Refresh {
- r := api.RefreshTokenRef{
- Id: session.ID,
- ClientId: session.ClientID,
- CreatedAt: session.CreatedAt.Unix(),
- LastUsed: session.LastUsed.Unix(),
- }
- refreshTokenRefs = append(refreshTokenRefs, &r)
- }
-
- return &api.ListRefreshResp{
- RefreshTokens: refreshTokenRefs,
- }, nil
-}
-
-func (d dexAPI) RevokeRefresh(ctx context.Context, req *api.RevokeRefreshReq) (*api.RevokeRefreshResp, error) {
- id := new(internal.IDTokenSubject)
- if err := internal.Unmarshal(req.UserId, id); err != nil {
- d.logger.Errorf("api: failed to unmarshal ID Token subject: %v", err)
- return nil, err
- }
-
- var (
- refreshID string
- notFound bool
- )
- updater := func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
- refreshRef := old.Refresh[req.ClientId]
- if refreshRef == nil || refreshRef.ID == "" {
- d.logger.Errorf("api: refresh token issued to client %q for user %q not found for deletion", req.ClientId, id.UserId)
- notFound = true
- return old, storage.ErrNotFound
- }
-
- refreshID = refreshRef.ID
-
- // Remove entry from Refresh list of the OfflineSession object.
- delete(old.Refresh, req.ClientId)
-
- return old, nil
- }
-
- if err := d.s.UpdateOfflineSessions(id.UserId, id.ConnId, updater); err != nil {
- if err == storage.ErrNotFound {
- return &api.RevokeRefreshResp{NotFound: true}, nil
- }
- d.logger.Errorf("api: failed to update offline session object: %v", err)
- return nil, err
- }
-
- if notFound {
- return &api.RevokeRefreshResp{NotFound: true}, nil
- }
-
- // Delete the refresh token from the storage
- //
- // TODO(ericchiang): we don't have any good recourse if this call fails.
- // Consider garbage collection of refresh tokens with no associated ref.
- if err := d.s.DeleteRefresh(refreshID); err != nil {
- d.logger.Errorf("failed to delete refresh token: %v", err)
- return nil, err
- }
-
- return &api.RevokeRefreshResp{}, nil
-}
diff --git a/server/api_test.go b/server/api_test.go
deleted file mode 100644
index 01c59cf875..0000000000
--- a/server/api_test.go
+++ /dev/null
@@ -1,509 +0,0 @@
-package server
-
-import (
- "context"
- "net"
- "os"
- "testing"
- "time"
-
- "github.com/sirupsen/logrus"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
-
- "github.com/dexidp/dex/api/v2"
- "github.com/dexidp/dex/pkg/log"
- "github.com/dexidp/dex/server/internal"
- "github.com/dexidp/dex/storage"
- "github.com/dexidp/dex/storage/memory"
-)
-
-// apiClient is a test gRPC client. When constructed, it runs a server in
-// the background to exercise the serialization and network configuration
-// instead of just this package's server implementation.
-type apiClient struct {
- // Embedded gRPC client to talk to the server.
- api.DexClient
- // Close releases resources associated with this client, including shutting
- // down the background server.
- Close func()
-}
-
-// newAPI constructs a gRCP client connected to a backing server.
-func newAPI(s storage.Storage, logger log.Logger, t *testing.T) *apiClient {
- l, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
-
- serv := grpc.NewServer()
- api.RegisterDexServer(serv, NewAPI(s, logger, "test"))
- go serv.Serve(l)
-
- // Dial will retry automatically if the serv.Serve() goroutine
- // hasn't started yet.
- conn, err := grpc.Dial(l.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
- if err != nil {
- t.Fatal(err)
- }
-
- return &apiClient{
- DexClient: api.NewDexClient(conn),
- Close: func() {
- conn.Close()
- serv.Stop()
- l.Close()
- },
- }
-}
-
-// Attempts to create, update and delete a test Password
-func TestPassword(t *testing.T) {
- logger := &logrus.Logger{
- Out: os.Stderr,
- Formatter: &logrus.TextFormatter{DisableColors: true},
- Level: logrus.DebugLevel,
- }
-
- s := memory.New(logger)
- client := newAPI(s, logger, t)
- defer client.Close()
-
- ctx := context.Background()
- email := "test@example.com"
- p := api.Password{
- Email: email,
- // bcrypt hash of the value "test1" with cost 10
- Hash: []byte("$2a$10$XVMN/Fid.Ks4CXgzo8fpR.iU1khOMsP5g9xQeXuBm1wXjRX8pjUtO"),
- Username: "test",
- UserId: "test123",
- }
-
- createReq := api.CreatePasswordReq{
- Password: &p,
- }
-
- if resp, err := client.CreatePassword(ctx, &createReq); err != nil || resp.AlreadyExists {
- if resp.AlreadyExists {
- t.Fatalf("Unable to create password since %s already exists", createReq.Password.Email)
- }
- t.Fatalf("Unable to create password: %v", err)
- }
-
- // Attempt to create a password that already exists.
- if resp, _ := client.CreatePassword(ctx, &createReq); !resp.AlreadyExists {
- t.Fatalf("Created password %s twice", createReq.Password.Email)
- }
-
- // Attempt to verify valid password and email
- goodVerifyReq := &api.VerifyPasswordReq{
- Email: email,
- Password: "test1",
- }
- goodVerifyResp, err := client.VerifyPassword(ctx, goodVerifyReq)
- if err != nil {
- t.Fatalf("Unable to run verify password we expected to be valid for correct email: %v", err)
- }
- if !goodVerifyResp.Verified {
- t.Fatalf("verify password failed for password expected to be valid for correct email. expected %t, found %t", true, goodVerifyResp.Verified)
- }
- if goodVerifyResp.NotFound {
- t.Fatalf("verify password failed to return not found response. expected %t, found %t", false, goodVerifyResp.NotFound)
- }
-
- // Check not found response for valid password with wrong email
- badEmailVerifyReq := &api.VerifyPasswordReq{
- Email: "somewrongaddress@email.com",
- Password: "test1",
- }
- badEmailVerifyResp, err := client.VerifyPassword(ctx, badEmailVerifyReq)
- if err != nil {
- t.Fatalf("Unable to run verify password for incorrect email: %v", err)
- }
- if badEmailVerifyResp.Verified {
- t.Fatalf("verify password passed for password expected to be not found. expected %t, found %t", false, badEmailVerifyResp.Verified)
- }
- if !badEmailVerifyResp.NotFound {
- t.Fatalf("expected not found response for verify password with bad email. expected %t, found %t", true, badEmailVerifyResp.NotFound)
- }
-
- // Check that wrong password fails
- badPassVerifyReq := &api.VerifyPasswordReq{
- Email: email,
- Password: "wrong_password",
- }
- badPassVerifyResp, err := client.VerifyPassword(ctx, badPassVerifyReq)
- if err != nil {
- t.Fatalf("Unable to run verify password for password we expected to be invalid: %v", err)
- }
- if badPassVerifyResp.Verified {
- t.Fatalf("verify password passed for password we expected to fail. expected %t, found %t", false, badPassVerifyResp.Verified)
- }
- if badPassVerifyResp.NotFound {
- t.Fatalf("did not expect expected not found response for verify password with bad email. expected %t, found %t", false, badPassVerifyResp.NotFound)
- }
-
- updateReq := api.UpdatePasswordReq{
- Email: email,
- NewUsername: "test1",
- }
-
- if _, err := client.UpdatePassword(ctx, &updateReq); err != nil {
- t.Fatalf("Unable to update password: %v", err)
- }
-
- pass, err := s.GetPassword(updateReq.Email)
- if err != nil {
- t.Fatalf("Unable to retrieve password: %v", err)
- }
-
- if pass.Username != updateReq.NewUsername {
- t.Fatalf("UpdatePassword failed. Expected username %s retrieved %s", updateReq.NewUsername, pass.Username)
- }
-
- deleteReq := api.DeletePasswordReq{
- Email: "test@example.com",
- }
-
- if _, err := client.DeletePassword(ctx, &deleteReq); err != nil {
- t.Fatalf("Unable to delete password: %v", err)
- }
-}
-
-// Ensures checkCost returns expected values
-func TestCheckCost(t *testing.T) {
- logger := &logrus.Logger{
- Out: os.Stderr,
- Formatter: &logrus.TextFormatter{DisableColors: true},
- Level: logrus.DebugLevel,
- }
-
- s := memory.New(logger)
- client := newAPI(s, logger, t)
- defer client.Close()
-
- tests := []struct {
- name string
- inputHash []byte
-
- wantErr bool
- }{
- {
- name: "valid cost",
- // bcrypt hash of the value "test1" with cost 12 (default)
- inputHash: []byte("$2a$12$M2Ot95Qty1MuQdubh1acWOiYadJDzeVg3ve4n5b.dgcgPdjCseKx2"),
- },
- {
- name: "invalid hash",
- inputHash: []byte(""),
- wantErr: true,
- },
- {
- name: "cost below default",
- // bcrypt hash of the value "test1" with cost 4
- inputHash: []byte("$2a$04$8bSTbuVCLpKzaqB3BmgI7edDigG5tIQKkjYUu/mEO9gQgIkw9m7eG"),
- wantErr: true,
- },
- {
- name: "cost above recommendation",
- // bcrypt hash of the value "test1" with cost 17
- inputHash: []byte("$2a$17$tWuZkTxtSmRyWZAGWVHQE.7npdl.TgP8adjzLJD.SyjpFznKBftPe"),
- wantErr: true,
- },
- }
-
- for _, tc := range tests {
- if err := checkCost(tc.inputHash); err != nil {
- if !tc.wantErr {
- t.Errorf("%s: %s", tc.name, err)
- }
- continue
- }
-
- if tc.wantErr {
- t.Errorf("%s: expected err", tc.name)
- continue
- }
- }
-}
-
-// Attempts to list and revoke an existing refresh token.
-func TestRefreshToken(t *testing.T) {
- logger := &logrus.Logger{
- Out: os.Stderr,
- Formatter: &logrus.TextFormatter{DisableColors: true},
- Level: logrus.DebugLevel,
- }
-
- s := memory.New(logger)
- client := newAPI(s, logger, t)
- defer client.Close()
-
- ctx := context.Background()
-
- // Creating a storage with an existing refresh token and offline session for the user.
- id := storage.NewID()
- r := storage.RefreshToken{
- ID: id,
- Token: "bar",
- Nonce: "foo",
- ClientID: "client_id",
- ConnectorID: "client_secret",
- Scopes: []string{"openid", "email", "profile"},
- CreatedAt: time.Now().UTC().Round(time.Millisecond),
- LastUsed: time.Now().UTC().Round(time.Millisecond),
- Claims: storage.Claims{
- UserID: "1",
- Username: "jane",
- Email: "jane.doe@example.com",
- EmailVerified: true,
- Groups: []string{"a", "b"},
- },
- ConnectorData: []byte(`{"some":"data"}`),
- }
-
- if err := s.CreateRefresh(r); err != nil {
- t.Fatalf("create refresh token: %v", err)
- }
-
- tokenRef := storage.RefreshTokenRef{
- ID: r.ID,
- ClientID: r.ClientID,
- CreatedAt: r.CreatedAt,
- LastUsed: r.LastUsed,
- }
-
- session := storage.OfflineSessions{
- UserID: r.Claims.UserID,
- ConnID: r.ConnectorID,
- Refresh: make(map[string]*storage.RefreshTokenRef),
- }
- session.Refresh[tokenRef.ClientID] = &tokenRef
-
- if err := s.CreateOfflineSessions(session); err != nil {
- t.Fatalf("create offline session: %v", err)
- }
-
- subjectString, err := internal.Marshal(&internal.IDTokenSubject{
- UserId: r.Claims.UserID,
- ConnId: r.ConnectorID,
- })
- if err != nil {
- t.Errorf("failed to marshal offline session ID: %v", err)
- }
-
- // Testing the api.
- listReq := api.ListRefreshReq{
- UserId: subjectString,
- }
-
- listResp, err := client.ListRefresh(ctx, &listReq)
- if err != nil {
- t.Fatalf("Unable to list refresh tokens for user: %v", err)
- }
-
- for _, tok := range listResp.RefreshTokens {
- if tok.CreatedAt != r.CreatedAt.Unix() {
- t.Errorf("Expected CreatedAt timestamp %v, got %v", r.CreatedAt.Unix(), tok.CreatedAt)
- }
-
- if tok.LastUsed != r.LastUsed.Unix() {
- t.Errorf("Expected LastUsed timestamp %v, got %v", r.LastUsed.Unix(), tok.LastUsed)
- }
- }
-
- revokeReq := api.RevokeRefreshReq{
- UserId: subjectString,
- ClientId: r.ClientID,
- }
-
- resp, err := client.RevokeRefresh(ctx, &revokeReq)
- if err != nil {
- t.Fatalf("Unable to revoke refresh tokens for user: %v", err)
- }
- if resp.NotFound {
- t.Errorf("refresh token session wasn't found")
- }
-
- // Try to delete again.
- //
- // See https://github.com/dexidp/dex/issues/1055
- resp, err = client.RevokeRefresh(ctx, &revokeReq)
- if err != nil {
- t.Fatalf("Unable to revoke refresh tokens for user: %v", err)
- }
- if !resp.NotFound {
- t.Errorf("refresh token session was found")
- }
-
- if resp, _ := client.ListRefresh(ctx, &listReq); len(resp.RefreshTokens) != 0 {
- t.Fatalf("Refresh token returned inspite of revoking it.")
- }
-}
-
-func TestUpdateClient(t *testing.T) {
- logger := &logrus.Logger{
- Out: os.Stderr,
- Formatter: &logrus.TextFormatter{DisableColors: true},
- Level: logrus.DebugLevel,
- }
-
- s := memory.New(logger)
- client := newAPI(s, logger, t)
- defer client.Close()
- ctx := context.Background()
-
- createClient := func(t *testing.T, clientId string) {
- resp, err := client.CreateClient(ctx, &api.CreateClientReq{
- Client: &api.Client{
- Id: clientId,
- Secret: "",
- RedirectUris: []string{},
- TrustedPeers: nil,
- Public: true,
- Name: "",
- LogoUrl: "",
- },
- })
- if err != nil {
- t.Fatalf("unable to create the client: %v", err)
- }
-
- if resp == nil {
- t.Fatalf("create client returned no response")
- }
- if resp.AlreadyExists {
- t.Error("existing client was found")
- }
-
- if resp.Client == nil {
- t.Fatalf("no client created")
- }
- }
-
- deleteClient := func(t *testing.T, clientId string) {
- resp, err := client.DeleteClient(ctx, &api.DeleteClientReq{
- Id: clientId,
- })
- if err != nil {
- t.Fatalf("unable to delete the client: %v", err)
- }
- if resp == nil {
- t.Fatalf("delete client delete client returned no response")
- }
- }
-
- tests := map[string]struct {
- setup func(t *testing.T, clientId string)
- cleanup func(t *testing.T, clientId string)
- req *api.UpdateClientReq
- wantErr bool
- want *api.UpdateClientResp
- }{
- "update client": {
- setup: createClient,
- cleanup: deleteClient,
- req: &api.UpdateClientReq{
- Id: "test",
- RedirectUris: []string{"https://redirect"},
- TrustedPeers: []string{"test"},
- Name: "test",
- LogoUrl: "https://logout",
- },
- wantErr: false,
- want: &api.UpdateClientResp{
- NotFound: false,
- },
- },
- "update client without ID": {
- setup: createClient,
- cleanup: deleteClient,
- req: &api.UpdateClientReq{
- Id: "",
- RedirectUris: nil,
- TrustedPeers: nil,
- Name: "test",
- LogoUrl: "test",
- },
- wantErr: true,
- want: &api.UpdateClientResp{
- NotFound: false,
- },
- },
- "update client which not exists ": {
- req: &api.UpdateClientReq{
- Id: "test",
- RedirectUris: nil,
- TrustedPeers: nil,
- Name: "test",
- LogoUrl: "test",
- },
- wantErr: true,
- want: &api.UpdateClientResp{
- NotFound: false,
- },
- },
- }
-
- for name, tc := range tests {
- t.Run(name, func(t *testing.T) {
- if tc.setup != nil {
- tc.setup(t, tc.req.Id)
- }
- resp, err := client.UpdateClient(ctx, tc.req)
- if err != nil && !tc.wantErr {
- t.Fatalf("failed to update the client: %v", err)
- }
-
- if !tc.wantErr {
- if resp == nil {
- t.Fatalf("update client response not found")
- }
-
- if tc.want.NotFound != resp.NotFound {
- t.Errorf("expected in response NotFound: %t", tc.want.NotFound)
- }
-
- client, err := s.GetClient(tc.req.Id)
- if err != nil {
- t.Errorf("no client found in the storage: %v", err)
- }
-
- if tc.req.Id != client.ID {
- t.Errorf("expected stored client with ID: %s, found %s", tc.req.Id, client.ID)
- }
- if tc.req.Name != client.Name {
- t.Errorf("expected stored client with Name: %s, found %s", tc.req.Name, client.Name)
- }
- if tc.req.LogoUrl != client.LogoURL {
- t.Errorf("expected stored client with LogoURL: %s, found %s", tc.req.LogoUrl, client.LogoURL)
- }
- for _, redirectURI := range tc.req.RedirectUris {
- found := find(redirectURI, client.RedirectURIs)
- if !found {
- t.Errorf("expected redirect URI: %s", redirectURI)
- }
- }
- for _, peer := range tc.req.TrustedPeers {
- found := find(peer, client.TrustedPeers)
- if !found {
- t.Errorf("expected trusted peer: %s", peer)
- }
- }
- }
-
- if tc.cleanup != nil {
- tc.cleanup(t, tc.req.Id)
- }
- })
- }
-}
-
-func find(item string, items []string) bool {
- for _, i := range items {
- if item == i {
- return true
- }
- }
- return false
-}
diff --git a/server/apiserver/api.go b/server/apiserver/api.go
new file mode 100644
index 0000000000..32201383fe
--- /dev/null
+++ b/server/apiserver/api.go
@@ -0,0 +1,84 @@
+package apiserver
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/server/backchannel"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/discovery"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// apiVersion increases every time a new call is added to the API. Clients should use this info
+// to determine if the server supports specific features.
+const apiVersion = 4
+
+// NewAPI returns a server which implements the gRPC API interface. It takes only
+// the narrow dependencies it needs โ the connector cache to invalidate on
+// connector CRUD, the discovery handler to serve the same document as HTTP, and
+// the back-channel notifier to tell relying parties about the sessions it ends โ
+// rather than the whole Server.
+func NewAPI(s storage.Storage, logger *slog.Logger, version string, conns *connectors.Cache, disc *discovery.Handler, bc *backchannel.Notifier) api.DexServer {
+ apiLogger := logger.With("component", "api")
+ return dexAPI{
+ s: s,
+ logger: apiLogger,
+ version: version,
+ connectors: conns,
+ discovery: disc,
+ backchannel: bc,
+ refresh: tokens.NewRefreshStore(s, time.Now, apiLogger),
+ }
+}
+
+type dexAPI struct {
+ api.UnimplementedDexServer
+
+ s storage.Storage
+ logger *slog.Logger
+ version string
+ connectors *connectors.Cache
+ discovery *discovery.Handler
+ backchannel *backchannel.Notifier
+ refresh *tokens.RefreshStore
+}
+
+func (d dexAPI) GetVersion(ctx context.Context, req *api.VersionReq) (*api.VersionResp, error) {
+ return &api.VersionResp{
+ Server: d.version,
+ Api: apiVersion,
+ }, nil
+}
+
+func (d dexAPI) GetDiscovery(ctx context.Context, req *api.DiscoveryReq) (*api.DiscoveryResp, error) {
+ if d.discovery == nil {
+ return nil, fmt.Errorf("discovery is not configured")
+ }
+ discoveryDoc := d.discovery.Construct(ctx)
+ data, err := json.Marshal(discoveryDoc)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal discovery data: %v", err)
+ }
+ resp := api.DiscoveryResp{}
+ err = json.Unmarshal(data, &resp)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal discovery data: %v", err)
+ }
+ return &resp, nil
+}
+
+// unixOrZero returns the Unix timestamp for t, or 0 when t is the zero value.
+// A naive t.Unix() on a zero time.Time yields -62135596800 (a year-1 epoch),
+// which is a misleading value to expose through the API; callers want 0/unset.
+func unixOrZero(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.Unix()
+}
diff --git a/server/apiserver/api_test.go b/server/apiserver/api_test.go
new file mode 100644
index 0000000000..00abe1d4bf
--- /dev/null
+++ b/server/apiserver/api_test.go
@@ -0,0 +1,1776 @@
+package apiserver
+
+import (
+ "bytes"
+ "log/slog"
+ "net"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+// apiClient is a test gRPC client. When constructed, it runs a server in
+// the background to exercise the serialization and network configuration
+// instead of just this package's server implementation.
+type apiClient struct {
+ // Embedded gRPC client to talk to the server.
+ api.DexClient
+ // Close releases resources associated with this client, including shutting
+ // down the background server.
+ Close func()
+}
+
+func newLogger(t *testing.T) *slog.Logger {
+ return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug}))
+}
+
+// newAPI constructs a gRCP client connected to a backing server.
+func newAPI(t *testing.T, s storage.Storage, logger *slog.Logger) *apiClient {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ serv := grpc.NewServer()
+ api.RegisterDexServer(serv, NewAPI(s, logger, "test", nil, nil, nil))
+ go serv.Serve(l)
+
+ // NewClient will retry automatically if the serv.Serve() goroutine
+ // hasn't started yet.
+ conn, err := grpc.NewClient(l.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return &apiClient{
+ DexClient: api.NewDexClient(conn),
+ Close: func() {
+ conn.Close()
+ serv.Stop()
+ l.Close()
+ },
+ }
+}
+
+// Attempts to create, update and delete a test Password
+func TestPassword(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ email := "test@example.com"
+ p := api.Password{
+ Email: email,
+ // bcrypt hash of the value "test1" with cost 10
+ Hash: []byte("$2a$10$XVMN/Fid.Ks4CXgzo8fpR.iU1khOMsP5g9xQeXuBm1wXjRX8pjUtO"),
+ Username: "test",
+ UserId: "test123",
+ }
+
+ createReq := api.CreatePasswordReq{
+ Password: &p,
+ }
+
+ if resp, err := client.CreatePassword(ctx, &createReq); err != nil || resp.AlreadyExists {
+ if resp.AlreadyExists {
+ t.Fatalf("Unable to create password since %s already exists", createReq.Password.Email)
+ }
+ t.Fatalf("Unable to create password: %v", err)
+ }
+
+ // Attempt to create a password that already exists.
+ if resp, _ := client.CreatePassword(ctx, &createReq); !resp.AlreadyExists {
+ t.Fatalf("Created password %s twice", createReq.Password.Email)
+ }
+
+ // Attempt to verify valid password and email
+ goodVerifyReq := &api.VerifyPasswordReq{
+ Email: email,
+ Password: "test1",
+ }
+ goodVerifyResp, err := client.VerifyPassword(ctx, goodVerifyReq)
+ if err != nil {
+ t.Fatalf("Unable to run verify password we expected to be valid for correct email: %v", err)
+ }
+ if !goodVerifyResp.Verified {
+ t.Fatalf("verify password failed for password expected to be valid for correct email. expected %t, found %t", true, goodVerifyResp.Verified)
+ }
+ if goodVerifyResp.NotFound {
+ t.Fatalf("verify password failed to return not found response. expected %t, found %t", false, goodVerifyResp.NotFound)
+ }
+
+ // Check not found response for valid password with wrong email
+ badEmailVerifyReq := &api.VerifyPasswordReq{
+ Email: "somewrongaddress@email.com",
+ Password: "test1",
+ }
+ badEmailVerifyResp, err := client.VerifyPassword(ctx, badEmailVerifyReq)
+ if err != nil {
+ t.Fatalf("Unable to run verify password for incorrect email: %v", err)
+ }
+ if badEmailVerifyResp.Verified {
+ t.Fatalf("verify password passed for password expected to be not found. expected %t, found %t", false, badEmailVerifyResp.Verified)
+ }
+ if !badEmailVerifyResp.NotFound {
+ t.Fatalf("expected not found response for verify password with bad email. expected %t, found %t", true, badEmailVerifyResp.NotFound)
+ }
+
+ // Check that wrong password fails
+ badPassVerifyReq := &api.VerifyPasswordReq{
+ Email: email,
+ Password: "wrong_password",
+ }
+ badPassVerifyResp, err := client.VerifyPassword(ctx, badPassVerifyReq)
+ if err != nil {
+ t.Fatalf("Unable to run verify password for password we expected to be invalid: %v", err)
+ }
+ if badPassVerifyResp.Verified {
+ t.Fatalf("verify password passed for password we expected to fail. expected %t, found %t", false, badPassVerifyResp.Verified)
+ }
+ if badPassVerifyResp.NotFound {
+ t.Fatalf("did not expect expected not found response for verify password with bad email. expected %t, found %t", false, badPassVerifyResp.NotFound)
+ }
+
+ updateReq := api.UpdatePasswordReq{
+ Email: email,
+ NewUsername: "test1",
+ }
+
+ if _, err := client.UpdatePassword(ctx, &updateReq); err != nil {
+ t.Fatalf("Unable to update password: %v", err)
+ }
+
+ pass, err := s.GetPassword(ctx, updateReq.Email)
+ if err != nil {
+ t.Fatalf("Unable to retrieve password: %v", err)
+ }
+
+ if pass.Username != updateReq.NewUsername {
+ t.Fatalf("UpdatePassword failed. Expected username %s retrieved %s", updateReq.NewUsername, pass.Username)
+ }
+
+ deleteReq := api.DeletePasswordReq{
+ Email: "test@example.com",
+ }
+
+ if _, err := client.DeletePassword(ctx, &deleteReq); err != nil {
+ t.Fatalf("Unable to delete password: %v", err)
+ }
+}
+
+// Attempts to list and revoke an existing refresh token.
+func TestRefreshToken(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ // Creating a storage with an existing refresh token and offline session for the user.
+ id := storage.NewID()
+ r := storage.RefreshToken{
+ ID: id,
+ Token: "bar",
+ Nonce: "foo",
+ ClientID: "client_id",
+ ConnectorID: "client_secret",
+ Scopes: []string{"openid", "email", "profile"},
+ CreatedAt: time.Now().UTC().Round(time.Millisecond),
+ LastUsed: time.Now().UTC().Round(time.Millisecond),
+ Claims: storage.Claims{
+ UserID: "1",
+ Username: "jane",
+ Email: "jane.doe@example.com",
+ EmailVerified: true,
+ Groups: []string{"a", "b"},
+ },
+ ConnectorData: []byte(`{"some":"data"}`),
+ }
+
+ if err := s.CreateRefresh(ctx, r); err != nil {
+ t.Fatalf("create refresh token: %v", err)
+ }
+
+ tokenRef := storage.RefreshTokenRef{
+ ID: r.ID,
+ ClientID: r.ClientID,
+ CreatedAt: r.CreatedAt,
+ LastUsed: r.LastUsed,
+ }
+
+ session := storage.OfflineSessions{
+ UserID: r.Claims.UserID,
+ ConnID: r.ConnectorID,
+ Refresh: make(map[string]*storage.RefreshTokenRef),
+ }
+ session.Refresh[tokenRef.ClientID] = &tokenRef
+
+ if err := s.CreateOfflineSessions(ctx, session); err != nil {
+ t.Fatalf("create offline session: %v", err)
+ }
+
+ subjectString, err := internal.Marshal(&internal.IDTokenSubject{
+ UserId: r.Claims.UserID,
+ ConnId: r.ConnectorID,
+ })
+ if err != nil {
+ t.Errorf("failed to marshal offline session ID: %v", err)
+ }
+
+ // Testing the api.
+ listReq := api.ListRefreshReq{
+ UserId: subjectString,
+ }
+
+ listResp, err := client.ListRefresh(ctx, &listReq)
+ if err != nil {
+ t.Fatalf("Unable to list refresh tokens for user: %v", err)
+ }
+
+ for _, tok := range listResp.RefreshTokens {
+ if tok.CreatedAt != r.CreatedAt.Unix() {
+ t.Errorf("Expected CreatedAt timestamp %v, got %v", r.CreatedAt.Unix(), tok.CreatedAt)
+ }
+
+ if tok.LastUsed != r.LastUsed.Unix() {
+ t.Errorf("Expected LastUsed timestamp %v, got %v", r.LastUsed.Unix(), tok.LastUsed)
+ }
+ }
+
+ revokeReq := api.RevokeRefreshReq{
+ UserId: subjectString,
+ ClientId: r.ClientID,
+ }
+
+ resp, err := client.RevokeRefresh(ctx, &revokeReq)
+ if err != nil {
+ t.Fatalf("Unable to revoke refresh tokens for user: %v", err)
+ }
+ if resp.NotFound {
+ t.Errorf("refresh token session wasn't found")
+ }
+
+ // Try to delete again.
+ //
+ // See https://github.com/dexidp/dex/issues/1055
+ resp, err = client.RevokeRefresh(ctx, &revokeReq)
+ if err != nil {
+ t.Fatalf("Unable to revoke refresh tokens for user: %v", err)
+ }
+ if !resp.NotFound {
+ t.Errorf("refresh token session was found")
+ }
+
+ if resp, _ := client.ListRefresh(ctx, &listReq); len(resp.RefreshTokens) != 0 {
+ t.Fatalf("Refresh token returned in spite of revoking it.")
+ }
+}
+
+func TestUpdateClient(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ createClient := func(t *testing.T, clientId string) {
+ resp, err := client.CreateClient(ctx, &api.CreateClientReq{
+ Client: &api.Client{
+ Id: clientId,
+ Secret: "",
+ RedirectUris: []string{},
+ TrustedPeers: nil,
+ Public: true,
+ Name: "",
+ LogoUrl: "",
+ },
+ })
+ if err != nil {
+ t.Fatalf("unable to create the client: %v", err)
+ }
+
+ if resp == nil {
+ t.Fatalf("create client returned no response")
+ }
+ if resp.AlreadyExists {
+ t.Error("existing client was found")
+ }
+
+ if resp.Client == nil {
+ t.Fatalf("no client created")
+ }
+ }
+
+ deleteClient := func(t *testing.T, clientId string) {
+ resp, err := client.DeleteClient(ctx, &api.DeleteClientReq{
+ Id: clientId,
+ })
+ if err != nil {
+ t.Fatalf("unable to delete the client: %v", err)
+ }
+ if resp == nil {
+ t.Fatalf("delete client delete client returned no response")
+ }
+ }
+
+ tests := map[string]struct {
+ setup func(t *testing.T, clientId string)
+ cleanup func(t *testing.T, clientId string)
+ req *api.UpdateClientReq
+ wantErr bool
+ want *api.UpdateClientResp
+ }{
+ "update client": {
+ setup: createClient,
+ cleanup: deleteClient,
+ req: &api.UpdateClientReq{
+ Id: "test",
+ RedirectUris: []string{"https://redirect"},
+ TrustedPeers: []string{"test"},
+ Name: "test",
+ LogoUrl: "https://logout",
+ },
+ wantErr: false,
+ want: &api.UpdateClientResp{
+ NotFound: false,
+ },
+ },
+ "update client without ID": {
+ setup: createClient,
+ cleanup: deleteClient,
+ req: &api.UpdateClientReq{
+ Id: "",
+ RedirectUris: nil,
+ TrustedPeers: nil,
+ Name: "test",
+ LogoUrl: "test",
+ },
+ wantErr: true,
+ want: &api.UpdateClientResp{
+ NotFound: false,
+ },
+ },
+ "update client which not exists ": {
+ req: &api.UpdateClientReq{
+ Id: "test",
+ RedirectUris: nil,
+ TrustedPeers: nil,
+ Name: "test",
+ LogoUrl: "test",
+ },
+ wantErr: true,
+ want: &api.UpdateClientResp{
+ NotFound: false,
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ if tc.setup != nil {
+ tc.setup(t, tc.req.Id)
+ }
+ resp, err := client.UpdateClient(ctx, tc.req)
+ if err != nil && !tc.wantErr {
+ t.Fatalf("failed to update the client: %v", err)
+ }
+
+ if !tc.wantErr {
+ if resp == nil {
+ t.Fatalf("update client response not found")
+ }
+
+ if tc.want.NotFound != resp.NotFound {
+ t.Errorf("expected in response NotFound: %t", tc.want.NotFound)
+ }
+
+ client, err := s.GetClient(ctx, tc.req.Id)
+ if err != nil {
+ t.Errorf("no client found in the storage: %v", err)
+ }
+
+ if tc.req.Id != client.ID {
+ t.Errorf("expected stored client with ID: %s, found %s", tc.req.Id, client.ID)
+ }
+ if tc.req.Name != client.Name {
+ t.Errorf("expected stored client with Name: %s, found %s", tc.req.Name, client.Name)
+ }
+ if tc.req.LogoUrl != client.LogoURL {
+ t.Errorf("expected stored client with LogoURL: %s, found %s", tc.req.LogoUrl, client.LogoURL)
+ }
+ for _, redirectURI := range tc.req.RedirectUris {
+ found := slices.Contains(client.RedirectURIs, redirectURI)
+ if !found {
+ t.Errorf("expected redirect URI: %s", redirectURI)
+ }
+ }
+ for _, peer := range tc.req.TrustedPeers {
+ found := slices.Contains(client.TrustedPeers, peer)
+ if !found {
+ t.Errorf("expected trusted peer: %s", peer)
+ }
+ }
+ }
+
+ if tc.cleanup != nil {
+ tc.cleanup(t, tc.req.Id)
+ }
+ })
+ }
+}
+
+func TestCreateConnector(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ connectorID := "connector123"
+ connectorName := "TestConnector"
+ connectorType := "TestType"
+ connectorConfig := []byte(`{"key": "value"}`)
+
+ createReq := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connectorID,
+ Name: connectorName,
+ Type: connectorType,
+ Config: connectorConfig,
+ },
+ }
+
+ // Test valid connector creation
+ if resp, err := client.CreateConnector(ctx, &createReq); err != nil || resp.AlreadyExists {
+ if err != nil {
+ t.Fatalf("Unable to create connector: %v", err)
+ } else if resp.AlreadyExists {
+ t.Fatalf("Unable to create connector since %s already exists", connectorID)
+ }
+ t.Fatalf("Unable to create connector: %v", err)
+ }
+
+ // Test creating the same connector again (expecting failure)
+ if resp, _ := client.CreateConnector(ctx, &createReq); !resp.AlreadyExists {
+ t.Fatalf("Created connector %s twice", connectorID)
+ }
+
+ createReq.Connector.Config = []byte("invalid_json")
+
+ // Test invalid JSON config
+ if _, err := client.CreateConnector(ctx, &createReq); err == nil {
+ t.Fatal("Expected an error for invalid JSON config, but none occurred")
+ } else if !strings.Contains(err.Error(), "invalid config supplied") {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+}
+
+func TestUpdateConnector(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ connectorID := "connector123"
+ newConnectorName := "UpdatedConnector"
+ newConnectorType := "UpdatedType"
+ newConnectorConfig := []byte(`{"updated_key": "updated_value"}`)
+
+ // Create a connector for testing
+ createReq := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connectorID,
+ Name: "TestConnector",
+ Type: "TestType",
+ Config: []byte(`{"key": "value"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq)
+
+ updateReq := api.UpdateConnectorReq{
+ Id: connectorID,
+ NewName: newConnectorName,
+ NewType: newConnectorType,
+ NewConfig: newConnectorConfig,
+ }
+
+ // Test valid connector update
+ if _, err := client.UpdateConnector(ctx, &updateReq); err != nil {
+ t.Fatalf("Unable to update connector: %v", err)
+ }
+
+ resp, err := client.ListConnectors(ctx, &api.ListConnectorReq{})
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ for _, connector := range resp.Connectors {
+ if connector.Id == connectorID {
+ if connector.Name != newConnectorName {
+ t.Fatal("connector name should have been updated")
+ }
+ if string(connector.Config) != string(newConnectorConfig) {
+ t.Fatal("connector config should have been updated")
+ }
+ if connector.Type != newConnectorType {
+ t.Fatal("connector type should have been updated")
+ }
+ }
+ }
+
+ updateReq.NewConfig = []byte("invalid_json")
+
+ // Test invalid JSON config in update request
+ if _, err := client.UpdateConnector(ctx, &updateReq); err == nil {
+ t.Fatal("Expected an error for invalid JSON config in update, but none occurred")
+ } else if !strings.Contains(err.Error(), "invalid config supplied") {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+}
+
+func TestUpdateConnectorGrantTypes(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ connectorID := "connector-gt"
+
+ // Create a connector without grant types
+ createReq := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connectorID,
+ Name: "TestConnector",
+ Type: "TestType",
+ Config: []byte(`{"key": "value"}`),
+ },
+ }
+ _, err := client.CreateConnector(ctx, &createReq)
+ if err != nil {
+ t.Fatalf("failed to create connector: %v", err)
+ }
+
+ // Set grant types
+ _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{
+ Id: connectorID,
+ NewGrantTypes: &api.GrantTypes{GrantTypes: []string{"authorization_code", "refresh_token"}},
+ })
+ if err != nil {
+ t.Fatalf("failed to update connector grant types: %v", err)
+ }
+
+ resp, err := client.ListConnectors(ctx, &api.ListConnectorReq{})
+ if err != nil {
+ t.Fatalf("failed to list connectors: %v", err)
+ }
+ for _, c := range resp.Connectors {
+ if c.Id == connectorID {
+ if !slices.Equal(c.GrantTypes, []string{"authorization_code", "refresh_token"}) {
+ t.Fatalf("expected grant types [authorization_code refresh_token], got %v", c.GrantTypes)
+ }
+ }
+ }
+
+ // Clear grant types by passing empty GrantTypes message
+ _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{
+ Id: connectorID,
+ NewGrantTypes: &api.GrantTypes{},
+ })
+ if err != nil {
+ t.Fatalf("failed to clear connector grant types: %v", err)
+ }
+
+ resp, err = client.ListConnectors(ctx, &api.ListConnectorReq{})
+ if err != nil {
+ t.Fatalf("failed to list connectors: %v", err)
+ }
+ for _, c := range resp.Connectors {
+ if c.Id == connectorID {
+ if len(c.GrantTypes) != 0 {
+ t.Fatalf("expected empty grant types after clear, got %v", c.GrantTypes)
+ }
+ }
+ }
+
+ // Reject invalid grant type on update
+ _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{
+ Id: connectorID,
+ NewGrantTypes: &api.GrantTypes{GrantTypes: []string{"bogus"}},
+ })
+ if err == nil {
+ t.Fatal("expected error for invalid grant type, got nil")
+ }
+ if !strings.Contains(err.Error(), `unknown grant type "bogus"`) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Reject invalid grant type on create
+ _, err = client.CreateConnector(ctx, &api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: "bad-gt",
+ Name: "Bad",
+ Type: "TestType",
+ Config: []byte(`{}`),
+ GrantTypes: []string{"invalid_type"},
+ },
+ })
+ if err == nil {
+ t.Fatal("expected error for invalid grant type on create, got nil")
+ }
+ if !strings.Contains(err.Error(), `unknown grant type "invalid_type"`) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestDeleteConnector(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ connectorID := "connector123"
+
+ // Create a connector for testing
+ createReq := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connectorID,
+ Name: "TestConnector",
+ Type: "TestType",
+ Config: []byte(`{"key": "value"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq)
+
+ deleteReq := api.DeleteConnectorReq{
+ Id: connectorID,
+ }
+
+ // Test valid connector deletion
+ if _, err := client.DeleteConnector(ctx, &deleteReq); err != nil {
+ t.Fatalf("Unable to delete connector: %v", err)
+ }
+
+ // Test non existent connector deletion
+ resp, err := client.DeleteConnector(ctx, &deleteReq)
+ if err != nil {
+ t.Fatalf("Unable to delete connector: %v", err)
+ }
+
+ if !resp.NotFound {
+ t.Fatal("Should return not found")
+ }
+}
+
+func TestListConnectors(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ // Create connectors for testing
+ createReq1 := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: "connector1",
+ Name: "Connector1",
+ Type: "Type1",
+ Config: []byte(`{"key": "value1"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq1)
+
+ createReq2 := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: "connector2",
+ Name: "Connector2",
+ Type: "Type2",
+ Config: []byte(`{"key": "value2"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq2)
+
+ listReq := api.ListConnectorReq{}
+
+ // Test listing connectors
+ if resp, err := client.ListConnectors(ctx, &listReq); err != nil {
+ t.Fatalf("Unable to list connectors: %v", err)
+ } else if len(resp.Connectors) != 2 { // Check the number of connectors in the response
+ t.Fatalf("Expected 2 connectors, found %d", len(resp.Connectors))
+ }
+}
+
+func TestMissingConnectorsCRUDFeatureFlag(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ // Create connectors for testing
+ createReq1 := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: "connector1",
+ Name: "Connector1",
+ Type: "Type1",
+ Config: []byte(`{"key": "value1"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq1)
+
+ createReq2 := api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: "connector2",
+ Name: "Connector2",
+ Type: "Type2",
+ Config: []byte(`{"key": "value2"}`),
+ },
+ }
+ client.CreateConnector(ctx, &createReq2)
+
+ listReq := api.ListConnectorReq{}
+
+ if _, err := client.ListConnectors(ctx, &listReq); err == nil {
+ t.Fatal("ListConnectors should have returned an error")
+ }
+}
+
+func TestListClients(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ // List Clients
+ listResp, err := client.ListClients(ctx, &api.ListClientReq{})
+ if err != nil {
+ t.Fatalf("Unable to list clients: %v", err)
+ }
+ if len(listResp.Clients) != 0 {
+ t.Fatalf("Expected 0 clients, got %d", len(listResp.Clients))
+ }
+
+ client1 := &api.Client{
+ Id: "client1",
+ Secret: "secret1",
+ RedirectUris: []string{"http://localhost:8080/callback"},
+ TrustedPeers: []string{"peer1"},
+ Public: false,
+ Name: "Test Client 1",
+ LogoUrl: "http://example.com/logo1.png",
+ }
+
+ client2 := &api.Client{
+ Id: "client2",
+ Secret: "secret2",
+ RedirectUris: []string{"http://localhost:8081/callback"},
+ TrustedPeers: []string{"peer2"},
+ Public: true,
+ Name: "Test Client 2",
+ LogoUrl: "http://example.com/logo2.png",
+ }
+
+ _, err = client.CreateClient(ctx, &api.CreateClientReq{Client: client1})
+ if err != nil {
+ t.Fatalf("Unable to create client1: %v", err)
+ }
+
+ _, err = client.CreateClient(ctx, &api.CreateClientReq{Client: client2})
+ if err != nil {
+ t.Fatalf("Unable to create client2: %v", err)
+ }
+
+ listResp, err = client.ListClients(ctx, &api.ListClientReq{})
+ if err != nil {
+ t.Fatalf("Unable to list clients: %v", err)
+ }
+
+ if len(listResp.Clients) != 2 {
+ t.Fatalf("Expected 2 clients, got %d", len(listResp.Clients))
+ }
+
+ clientMap := make(map[string]*api.ClientInfo)
+ for _, c := range listResp.Clients {
+ clientMap[c.Id] = c
+ }
+
+ if c1, exists := clientMap["client1"]; !exists {
+ t.Fatal("client1 not found in list")
+ } else {
+ if c1.Name != "Test Client 1" {
+ t.Errorf("Expected client1 name 'Test Client 1', got '%s'", c1.Name)
+ }
+ if len(c1.RedirectUris) != 1 || c1.RedirectUris[0] != "http://localhost:8080/callback" {
+ t.Errorf("Expected client1 redirect URIs ['http://localhost:8080/callback'], got %v", c1.RedirectUris)
+ }
+ if c1.Public != false {
+ t.Errorf("Expected client1 public false, got %v", c1.Public)
+ }
+ if c1.LogoUrl != "http://example.com/logo1.png" {
+ t.Errorf("Expected client1 logo URL 'http://example.com/logo1.png', got '%s'", c1.LogoUrl)
+ }
+ }
+
+ if c2, exists := clientMap["client2"]; !exists {
+ t.Fatal("client2 not found in list")
+ } else {
+ if c2.Name != "Test Client 2" {
+ t.Errorf("Expected client2 name 'Test Client 2', got '%s'", c2.Name)
+ }
+ if len(c2.RedirectUris) != 1 || c2.RedirectUris[0] != "http://localhost:8081/callback" {
+ t.Errorf("Expected client2 redirect URIs ['http://localhost:8081/callback'], got %v", c2.RedirectUris)
+ }
+ if c2.Public != true {
+ t.Errorf("Expected client2 public true, got %v", c2.Public)
+ }
+ if c2.LogoUrl != "http://example.com/logo2.png" {
+ t.Errorf("Expected client2 logo URL 'http://example.com/logo2.png', got '%s'", c2.LogoUrl)
+ }
+ }
+}
+
+func TestGetAuthSession(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ session := storage.AuthSession{
+ UserID: "user1",
+ ConnectorID: "conn1",
+ ID: "nonce123", Secret: "nonce123",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now,
+ LastActivity: now,
+ LastTokenIssuedAt: now,
+ },
+ },
+ CreatedAt: now,
+ LastActivity: now,
+ IPAddress: "10.0.0.1",
+ UserAgent: "TestAgent/1.0",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(1 * time.Hour),
+ }
+
+ if err := s.CreateAuthSession(ctx, session); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+
+ resp, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "nonce123"})
+ if err != nil {
+ t.Fatalf("get auth session: %v", err)
+ }
+
+ if resp.Session.UserId != "user1" {
+ t.Errorf("expected user_id 'user1', got '%s'", resp.Session.UserId)
+ }
+ if resp.Session.IpAddress != "10.0.0.1" {
+ t.Errorf("expected ip_address '10.0.0.1', got '%s'", resp.Session.IpAddress)
+ }
+ if len(resp.Session.ClientStates) != 1 {
+ t.Fatalf("expected 1 client state, got %d", len(resp.Session.ClientStates))
+ }
+ cs := resp.Session.ClientStates[0]
+ if cs.ClientId != "client-a" {
+ t.Errorf("expected client_id 'client-a', got '%s'", cs.ClientId)
+ }
+ if cs.AuthenticatedAt == 0 {
+ t.Error("expected client state to record an authentication")
+ }
+ if resp.Session.Id != "nonce123" {
+ t.Errorf("expected session id 'nonce123', got '%s'", resp.Session.Id)
+ }
+
+ // Not found case.
+ _, err = client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "nonexistent"})
+ if err == nil {
+ t.Fatal("expected error for non-existent session")
+ }
+}
+
+func TestListAuthSessions(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ for _, sess := range []storage.AuthSession{
+ {UserID: "user1", ConnectorID: "conn1", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "user1", ConnectorID: "conn2", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "user2", ConnectorID: "conn1", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ } {
+ if err := s.CreateAuthSession(ctx, sess); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+ }
+
+ // List all.
+ resp, err := client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{})
+ if err != nil {
+ t.Fatalf("list auth sessions: %v", err)
+ }
+ if len(resp.Sessions) != 3 {
+ t.Fatalf("expected 3 sessions, got %d", len(resp.Sessions))
+ }
+
+ // Filter by user_id.
+ resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{UserId: "user1"})
+ if err != nil {
+ t.Fatalf("list auth sessions with filter: %v", err)
+ }
+ if len(resp.Sessions) != 2 {
+ t.Fatalf("expected 2 sessions for user1, got %d", len(resp.Sessions))
+ }
+
+ // Filter by connector_id, and by both: one user signed in through two
+ // connectors is two sessions, and a caller may want either or one of them.
+ resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{ConnectorId: "conn1"})
+ if err != nil {
+ t.Fatalf("list auth sessions by connector: %v", err)
+ }
+ if len(resp.Sessions) != 2 {
+ t.Fatalf("expected 2 sessions on conn1, got %d", len(resp.Sessions))
+ }
+
+ resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{UserId: "user1", ConnectorId: "conn2"})
+ if err != nil {
+ t.Fatalf("list auth sessions by user and connector: %v", err)
+ }
+ if len(resp.Sessions) != 1 {
+ t.Fatalf("expected 1 session for user1 on conn2, got %d", len(resp.Sessions))
+ }
+}
+
+func TestDeleteAuthSession(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+
+ // Create session.
+ session := storage.AuthSession{
+ UserID: "user1", ConnectorID: "conn1", ID: "n1", Secret: "n1",
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now,
+ LastActivity: now,
+ AbsoluteExpiry: now.Add(time.Hour),
+ IdleExpiry: now.Add(time.Hour),
+ }
+ if err := s.CreateAuthSession(ctx, session); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+
+ // Create refresh token + offline session to verify cascading revocation.
+ refreshID := storage.NewID()
+ if err := s.CreateRefresh(ctx, storage.RefreshToken{
+ ID: refreshID, Token: "tok", Nonce: "n", ClientID: "client1", ConnectorID: "conn1",
+ Scopes: []string{"openid"}, CreatedAt: now, LastUsed: now,
+ Claims: storage.Claims{UserID: "user1", Username: "test", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("create refresh: %v", err)
+ }
+ if err := s.CreateOfflineSessions(ctx, storage.OfflineSessions{
+ UserID: "user1", ConnID: "conn1",
+ Refresh: map[string]*storage.RefreshTokenRef{
+ "client1": {ID: refreshID, ClientID: "client1", CreatedAt: now, LastUsed: now},
+ },
+ }); err != nil {
+ t.Fatalf("create offline sessions: %v", err)
+ }
+
+ // Delete session.
+ resp, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "n1"})
+ if err != nil {
+ t.Fatalf("delete auth session: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected session to be found")
+ }
+
+ // Verify session is gone.
+ _, err = s.GetAuthSession(ctx, "n1")
+ if err == nil {
+ t.Error("expected auth session to be deleted")
+ }
+
+ // Verify refresh token was revoked.
+ _, err = s.GetRefresh(ctx, refreshID)
+ if err == nil {
+ t.Error("expected refresh token to be revoked")
+ }
+
+ // Not found case.
+ resp, err = client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "n1"})
+ if err != nil {
+ t.Fatalf("delete auth session: %v", err)
+ }
+ if !resp.NotFound {
+ t.Error("expected not_found for already deleted session")
+ }
+}
+
+func TestTerminateSessionsByConnector(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ for _, sess := range []storage.AuthSession{
+ {UserID: "user1", ConnectorID: "target-conn", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "user2", ConnectorID: "target-conn", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "user3", ConnectorID: "other-conn", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ } {
+ if err := s.CreateAuthSession(ctx, sess); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+ }
+
+ resp, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{
+ ConnectorId: "target-conn",
+ })
+ if err != nil {
+ t.Fatalf("terminate sessions by connector: %v", err)
+ }
+ if resp.SessionsTerminated != 2 {
+ t.Errorf("expected 2 terminated, got %d", resp.SessionsTerminated)
+ }
+
+ // Verify remaining session is untouched.
+ remaining, err := s.ListAuthSessions(ctx)
+ if err != nil {
+ t.Fatalf("list auth sessions: %v", err)
+ }
+ if len(remaining) != 1 || remaining[0].ConnectorID != "other-conn" {
+ t.Errorf("expected only other-conn session to remain, got %v", remaining)
+ }
+}
+
+func TestTerminateSessionsByUser(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ for _, sess := range []storage.AuthSession{
+ {UserID: "target-user", ConnectorID: "conn1", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "target-user", ConnectorID: "conn2", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ {UserID: "other-user", ConnectorID: "conn1", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)},
+ } {
+ if err := s.CreateAuthSession(ctx, sess); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+ }
+
+ resp, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{
+ UserId: "target-user",
+ })
+ if err != nil {
+ t.Fatalf("terminate sessions by user: %v", err)
+ }
+ if resp.SessionsTerminated != 2 {
+ t.Errorf("expected 2 terminated, got %d", resp.SessionsTerminated)
+ }
+
+ remaining, err := s.ListAuthSessions(ctx)
+ if err != nil {
+ t.Fatalf("list auth sessions: %v", err)
+ }
+ if len(remaining) != 1 || remaining[0].UserID != "other-user" {
+ t.Errorf("expected only other-user session to remain")
+ }
+}
+
+func TestGetUserIdentity(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ identity := storage.UserIdentity{
+ UserID: "user1",
+ ConnectorID: "conn1",
+ Claims: storage.Claims{
+ UserID: "user1",
+ Username: "testuser",
+ Email: "test@example.com",
+ EmailVerified: true,
+ Groups: []string{"admins"},
+ },
+ Consents: map[string][]string{
+ "client-a": {"openid", "email"},
+ },
+ MFASecrets: map[string]*storage.MFASecret{
+ "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "secret123", Confirmed: true, CreatedAt: now},
+ },
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
+ "webauthn-1": {{CredentialID: []byte("cred1"), AttestationType: "none", DisplayName: "YubiKey", CreatedAt: now}},
+ },
+ CreatedAt: now,
+ LastLogin: now,
+ }
+
+ if err := s.CreateUserIdentity(ctx, identity); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ resp, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{
+ UserId: "user1", ConnectorId: "conn1",
+ })
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+
+ if resp.Identity.Email != "test@example.com" {
+ t.Errorf("expected email 'test@example.com', got '%s'", resp.Identity.Email)
+ }
+ if !resp.Identity.EmailVerified {
+ t.Error("expected email_verified true")
+ }
+ if resp.Identity.Username != "testuser" {
+ t.Errorf("expected username 'testuser', got '%s'", resp.Identity.Username)
+ }
+ if len(resp.Identity.Consents) != 1 {
+ t.Fatalf("expected 1 consent entry, got %d", len(resp.Identity.Consents))
+ }
+ if len(resp.Identity.MfaDevices) == 0 {
+ t.Fatal("expected MFA devices")
+ }
+}
+
+func TestListUserIdentities(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ for _, id := range []storage.UserIdentity{
+ {UserID: "user1", ConnectorID: "conn1", Claims: storage.Claims{Email: "a@test.com"}, CreatedAt: now, LastLogin: now},
+ {UserID: "user2", ConnectorID: "conn1", Claims: storage.Claims{Email: "b@test.com"}, CreatedAt: now, LastLogin: now},
+ } {
+ if err := s.CreateUserIdentity(ctx, id); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+ }
+
+ resp, err := client.ListUserIdentities(ctx, &api.ListUserIdentitiesReq{})
+ if err != nil {
+ t.Fatalf("list user identities: %v", err)
+ }
+ if len(resp.Identities) != 2 {
+ t.Fatalf("expected 2 identities, got %d", len(resp.Identities))
+ }
+}
+
+func TestDeleteUserIdentity(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+
+ // Create identity + session + offline sessions + refresh token.
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+ if err := s.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user1", ConnectorID: "conn1", ID: "n", Secret: "n",
+ ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now,
+ AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour),
+ }); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+ refreshID := storage.NewID()
+ if err := s.CreateRefresh(ctx, storage.RefreshToken{
+ ID: refreshID, Token: "tok", Nonce: "n", ClientID: "c1", ConnectorID: "conn1",
+ Scopes: []string{"openid"}, CreatedAt: now, LastUsed: now,
+ Claims: storage.Claims{UserID: "user1", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("create refresh: %v", err)
+ }
+ if err := s.CreateOfflineSessions(ctx, storage.OfflineSessions{
+ UserID: "user1", ConnID: "conn1",
+ Refresh: map[string]*storage.RefreshTokenRef{
+ "c1": {ID: refreshID, ClientID: "c1", CreatedAt: now, LastUsed: now},
+ },
+ }); err != nil {
+ t.Fatalf("create offline sessions: %v", err)
+ }
+ // Password record linked by the identity's email โ must be purged too (GDPR).
+ if err := s.CreatePassword(ctx, storage.Password{
+ Email: "test@test.com", Hash: []byte("$2y$10$XXXXXXXXXXXXXXXXXXXXXX"), Username: "test", UserID: "user1",
+ }); err != nil {
+ t.Fatalf("create password: %v", err)
+ }
+
+ // Delete identity (cascading).
+ resp, err := client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{
+ UserId: "user1", ConnectorId: "conn1",
+ })
+ if err != nil {
+ t.Fatalf("delete user identity: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected identity to be found")
+ }
+
+ // Verify everything is deleted.
+ if _, err := s.GetUserIdentity(ctx, "user1", "conn1"); err == nil {
+ t.Error("expected user identity to be deleted")
+ }
+ if _, err := s.GetAuthSession(ctx, "n"); err == nil {
+ t.Error("expected auth session to be deleted")
+ }
+ if _, err := s.GetRefresh(ctx, refreshID); err == nil {
+ t.Error("expected refresh token to be deleted")
+ }
+ if _, err := s.GetOfflineSessions(ctx, "user1", "conn1"); err == nil {
+ t.Error("expected offline sessions to be deleted")
+ }
+ if _, err := s.GetPassword(ctx, "test@test.com"); err == nil {
+ t.Error("expected password record to be deleted")
+ }
+
+ // Not found case.
+ resp, err = client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{
+ UserId: "user1", ConnectorId: "conn1",
+ })
+ if err != nil {
+ t.Fatalf("delete user identity: %v", err)
+ }
+ if !resp.NotFound {
+ t.Error("expected not_found for already deleted identity")
+ }
+}
+
+func TestResetMFA(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ MFASecrets: map[string]*storage.MFASecret{
+ "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "s", Confirmed: true, CreatedAt: now},
+ },
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
+ "webauthn-1": {{CredentialID: []byte("c1"), CreatedAt: now}},
+ },
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ resp, err := client.ResetMFA(ctx, &api.ResetMFAReq{
+ UserId: "user1", ConnectorId: "conn1",
+ })
+ if err != nil {
+ t.Fatalf("reset MFA: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected identity to be found")
+ }
+
+ // Verify MFA data is cleared.
+ identity, err := s.GetUserIdentity(ctx, "user1", "conn1")
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+ if len(identity.MFASecrets) != 0 {
+ t.Errorf("expected MFASecrets to be cleared, got %d", len(identity.MFASecrets))
+ }
+ if len(identity.WebAuthnCredentials) != 0 {
+ t.Errorf("expected WebAuthnCredentials to be cleared, got %d", len(identity.WebAuthnCredentials))
+ }
+ // Verify other fields are preserved.
+ if identity.Claims.Email != "test@test.com" {
+ t.Errorf("expected email to be preserved, got '%s'", identity.Claims.Email)
+ }
+}
+
+func TestListMFADevices(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ MFASecrets: map[string]*storage.MFASecret{
+ "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "secret123", Confirmed: true, CreatedAt: now},
+ },
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
+ "webauthn-1": {
+ {CredentialID: []byte("cred1"), PublicKey: []byte("pk1"), DisplayName: "Key1", CreatedAt: now},
+ {CredentialID: []byte("cred2"), PublicKey: []byte("pk2"), DisplayName: "Key2", CreatedAt: now},
+ },
+ },
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ resp, err := client.ListMFADevices(ctx, &api.ListMFADevicesReq{
+ UserId: "user1", ConnectorId: "conn1",
+ })
+ if err != nil {
+ t.Fatalf("list MFA devices: %v", err)
+ }
+ if len(resp.Devices) != 2 {
+ t.Fatalf("expected 2 device groups, got %d", len(resp.Devices))
+ }
+
+ // Find the TOTP device and verify secret is not exposed.
+ for _, device := range resp.Devices {
+ if device.AuthenticatorId == "totp-1" {
+ if device.MfaSecret == nil {
+ t.Fatal("expected MFA secret for totp-1")
+ }
+ if device.MfaSecret.Type != "TOTP" {
+ t.Errorf("expected type TOTP, got %s", device.MfaSecret.Type)
+ }
+ }
+ if device.AuthenticatorId == "webauthn-1" {
+ if len(device.WebauthnCredentials) != 2 {
+ t.Errorf("expected 2 webauthn credentials, got %d", len(device.WebauthnCredentials))
+ }
+ }
+ }
+}
+
+func TestDeleteWebAuthnCredential(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
+ "auth-1": {
+ {CredentialID: []byte("cred-to-delete"), DisplayName: "Key1", CreatedAt: now},
+ {CredentialID: []byte("cred-to-keep"), DisplayName: "Key2", CreatedAt: now},
+ },
+ },
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ // Delete one credential.
+ resp, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{
+ UserId: "user1", ConnectorId: "conn1", CredentialId: []byte("cred-to-delete"),
+ })
+ if err != nil {
+ t.Fatalf("delete webauthn credential: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected credential to be found")
+ }
+
+ // Verify only one credential remains.
+ identity, err := s.GetUserIdentity(ctx, "user1", "conn1")
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+ creds := identity.WebAuthnCredentials["auth-1"]
+ if len(creds) != 1 {
+ t.Fatalf("expected 1 credential remaining, got %d", len(creds))
+ }
+ if !bytes.Equal(creds[0].CredentialID, []byte("cred-to-keep")) {
+ t.Error("wrong credential was deleted")
+ }
+
+ // Not found case.
+ resp, err = client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{
+ UserId: "user1", ConnectorId: "conn1", CredentialId: []byte("nonexistent"),
+ })
+ if err != nil {
+ t.Fatalf("delete webauthn credential: %v", err)
+ }
+ if !resp.NotFound {
+ t.Error("expected not_found for nonexistent credential")
+ }
+}
+
+func TestDeleteMFASecret(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ MFASecrets: map[string]*storage.MFASecret{
+ "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "s", Confirmed: true, CreatedAt: now},
+ "totp-2": {AuthenticatorID: "totp-2", Type: "TOTP", Secret: "s2", Confirmed: true, CreatedAt: now},
+ },
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
+ "totp-1": {{CredentialID: []byte("c1"), CreatedAt: now}},
+ },
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ // Delete totp-1 (should also remove associated webauthn credentials).
+ resp, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{
+ UserId: "user1", ConnectorId: "conn1", AuthenticatorId: "totp-1",
+ })
+ if err != nil {
+ t.Fatalf("delete MFA secret: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected authenticator to be found")
+ }
+
+ identity, err := s.GetUserIdentity(ctx, "user1", "conn1")
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+ if _, ok := identity.MFASecrets["totp-1"]; ok {
+ t.Error("expected totp-1 to be deleted")
+ }
+ if _, ok := identity.MFASecrets["totp-2"]; !ok {
+ t.Error("expected totp-2 to remain")
+ }
+ if _, ok := identity.WebAuthnCredentials["totp-1"]; ok {
+ t.Error("expected webauthn credentials for totp-1 to be deleted")
+ }
+
+ // Not found case.
+ resp, err = client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{
+ UserId: "user1", ConnectorId: "conn1", AuthenticatorId: "nonexistent",
+ })
+ if err != nil {
+ t.Fatalf("delete MFA secret: %v", err)
+ }
+ if !resp.NotFound {
+ t.Error("expected not_found for nonexistent authenticator")
+ }
+}
+
+func TestRevokeConsent(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now,
+ Consents: map[string][]string{
+ "client-a": {"openid", "email"},
+ "client-b": {"openid"},
+ },
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ // Revoke consent for client-a.
+ resp, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{
+ UserId: "user1", ConnectorId: "conn1", ClientId: "client-a",
+ })
+ if err != nil {
+ t.Fatalf("revoke consent: %v", err)
+ }
+ if resp.NotFound {
+ t.Error("expected consent to be found")
+ }
+
+ // Verify only client-b consent remains.
+ identity, err := s.GetUserIdentity(ctx, "user1", "conn1")
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+ if _, ok := identity.Consents["client-a"]; ok {
+ t.Error("expected client-a consent to be revoked")
+ }
+ if _, ok := identity.Consents["client-b"]; !ok {
+ t.Error("expected client-b consent to remain")
+ }
+
+ // Not found case.
+ resp, err = client.RevokeConsent(ctx, &api.RevokeConsentReq{
+ UserId: "user1", ConnectorId: "conn1", ClientId: "nonexistent",
+ })
+ if err != nil {
+ t.Fatalf("revoke consent: %v", err)
+ }
+ if !resp.NotFound {
+ t.Error("expected not_found for nonexistent consent")
+ }
+}
+
+func TestMissingSessionsIdentitiesCRUDFeatureFlag(t *testing.T) {
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ if _, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "s"}); err == nil {
+ t.Error("GetAuthSession should fail without feature flag")
+ }
+ if _, err := client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{}); err == nil {
+ t.Error("ListAuthSessions should fail without feature flag")
+ }
+ if _, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "s"}); err == nil {
+ t.Error("DeleteAuthSession should fail without feature flag")
+ }
+ if _, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{ConnectorId: "c"}); err == nil {
+ t.Error("TerminateSessionsByConnector should fail without feature flag")
+ }
+ if _, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{UserId: "u"}); err == nil {
+ t.Error("TerminateSessionsByUser should fail without feature flag")
+ }
+ if _, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("GetUserIdentity should fail without feature flag")
+ }
+ if _, err := client.ListUserIdentities(ctx, &api.ListUserIdentitiesReq{}); err == nil {
+ t.Error("ListUserIdentities should fail without feature flag")
+ }
+ if _, err := client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("DeleteUserIdentity should fail without feature flag")
+ }
+ if _, err := client.ResetMFA(ctx, &api.ResetMFAReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("ResetMFA should fail without feature flag")
+ }
+ if _, err := client.ListMFADevices(ctx, &api.ListMFADevicesReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("ListMFADevices should fail without feature flag")
+ }
+ if _, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{UserId: "u", ConnectorId: "c", CredentialId: []byte("cred")}); err == nil {
+ t.Error("DeleteWebAuthnCredential should fail without feature flag")
+ }
+ if _, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{UserId: "u", ConnectorId: "c", AuthenticatorId: "a"}); err == nil {
+ t.Error("DeleteMFASecret should fail without feature flag")
+ }
+ if _, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{UserId: "u", ConnectorId: "c", ClientId: "cl"}); err == nil {
+ t.Error("RevokeConsent should fail without feature flag")
+ }
+}
+
+// TestSessionsIdentitiesZeroTimeConversion verifies that unset time.Time fields
+// serialize to 0 rather than the misleading year-1 epoch (-62135596800) that a
+// naive t.Unix() produces.
+func TestSessionsIdentitiesZeroTimeConversion(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ now := time.Now().UTC().Round(time.Second)
+
+ // Client authenticated but no token issued yet: LastTokenIssuedAt is zero.
+ if err := s.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user1", ConnectorID: "conn1", ID: "n", Secret: "n",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {AuthenticatedAt: now, LastActivity: now},
+ },
+ CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour),
+ }); err != nil {
+ t.Fatalf("create auth session: %v", err)
+ }
+
+ sessResp, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "n"})
+ if err != nil {
+ t.Fatalf("get auth session: %v", err)
+ }
+ if len(sessResp.Session.ClientStates) != 1 {
+ t.Fatalf("expected 1 client state, got %d", len(sessResp.Session.ClientStates))
+ }
+ if got := sessResp.Session.ClientStates[0].LastTokenIssuedAt; got != 0 {
+ t.Errorf("expected last_token_issued_at 0 for unset time, got %d", got)
+ }
+
+ // Identity that has never logged in and is not blocked: LastLogin and
+ // BlockedUntil are zero.
+ if err := s.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user1", ConnectorID: "conn1",
+ Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("create user identity: %v", err)
+ }
+
+ idResp, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{UserId: "user1", ConnectorId: "conn1"})
+ if err != nil {
+ t.Fatalf("get user identity: %v", err)
+ }
+ if got := idResp.Identity.LastLogin; got != 0 {
+ t.Errorf("expected last_login 0 for unset time, got %d", got)
+ }
+ if got := idResp.Identity.BlockedUntil; got != 0 {
+ t.Errorf("expected blocked_until 0 for unset time, got %d", got)
+ }
+}
+
+// TestSessionsIdentitiesValidation verifies that handlers reject requests
+// missing required fields.
+func TestSessionsIdentitiesValidation(t *testing.T) {
+ t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ client := newAPI(t, s, logger)
+ defer client.Close()
+
+ ctx := t.Context()
+
+ if _, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{}); err == nil {
+ t.Error("GetAuthSession should reject an empty id")
+ }
+ if _, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{}); err == nil {
+ t.Error("DeleteAuthSession should reject an empty id")
+ }
+ if _, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{}); err == nil {
+ t.Error("TerminateSessionsByConnector should reject empty connector_id")
+ }
+ if _, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{}); err == nil {
+ t.Error("TerminateSessionsByUser should reject empty user_id")
+ }
+ if _, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("DeleteWebAuthnCredential should reject empty credential_id")
+ }
+ if _, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("DeleteMFASecret should reject empty authenticator_id")
+ }
+ if _, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{UserId: "u", ConnectorId: "c"}); err == nil {
+ t.Error("RevokeConsent should reject empty client_id")
+ }
+}
diff --git a/server/apiserver/clients.go b/server/apiserver/clients.go
new file mode 100644
index 0000000000..7b08b447da
--- /dev/null
+++ b/server/apiserver/clients.go
@@ -0,0 +1,169 @@
+package apiserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/storage"
+)
+
+func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetClientResp, error) {
+ c, err := d.s.GetClient(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+
+ return &api.GetClientResp{
+ Client: &api.Client{
+ Id: c.ID,
+ Name: c.Name,
+ Secret: c.Secret,
+ RedirectUris: c.RedirectURIs,
+ TrustedPeers: c.TrustedPeers,
+ Public: c.Public,
+ LogoUrl: c.LogoURL,
+ AllowedConnectors: c.AllowedConnectors,
+ SsoSharedWith: c.SSOSharedWith,
+ BackchannelLogoutUri: c.BackchannelLogoutURI,
+ PostLogoutRedirectUris: c.PostLogoutRedirectURIs,
+ RefreshTokenLifetime: c.RefreshTokenLifetime,
+ },
+ }, nil
+}
+
+func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*api.CreateClientResp, error) {
+ if req.Client == nil {
+ return nil, errors.New("no client supplied")
+ }
+
+ if req.Client.Id == "" {
+ req.Client.Id = storage.NewID()
+ }
+ if req.Client.Secret == "" && !req.Client.Public {
+ req.Client.Secret = storage.NewID() + storage.NewID()
+ }
+
+ if err := storage.ValidateRefreshTokenLifetime(req.Client.RefreshTokenLifetime); err != nil {
+ return nil, err
+ }
+
+ c := storage.Client{
+ ID: req.Client.Id,
+ Secret: req.Client.Secret,
+ RedirectURIs: req.Client.RedirectUris,
+ TrustedPeers: req.Client.TrustedPeers,
+ Public: req.Client.Public,
+ Name: req.Client.Name,
+ LogoURL: req.Client.LogoUrl,
+ AllowedConnectors: req.Client.AllowedConnectors,
+ SSOSharedWith: req.Client.SsoSharedWith,
+ BackchannelLogoutURI: req.Client.BackchannelLogoutUri,
+ PostLogoutRedirectURIs: req.Client.PostLogoutRedirectUris,
+ RefreshTokenLifetime: req.Client.RefreshTokenLifetime,
+ }
+ if err := d.s.CreateClient(ctx, c); err != nil {
+ if err == storage.ErrAlreadyExists {
+ return &api.CreateClientResp{AlreadyExists: true}, nil
+ }
+ d.logger.Error("failed to create client", "err", err)
+ return nil, fmt.Errorf("create client: %v", err)
+ }
+
+ return &api.CreateClientResp{
+ Client: req.Client,
+ }, nil
+}
+
+func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*api.UpdateClientResp, error) {
+ if req.Id == "" {
+ return nil, errors.New("update client: no client ID supplied")
+ }
+ if err := storage.ValidateRefreshTokenLifetime(req.GetRefreshTokenLifetime()); err != nil {
+ return nil, err
+ }
+
+ err := d.s.UpdateClient(ctx, req.Id, func(old storage.Client) (storage.Client, error) {
+ if req.RedirectUris != nil {
+ old.RedirectURIs = req.RedirectUris
+ }
+ if req.TrustedPeers != nil {
+ old.TrustedPeers = req.TrustedPeers
+ }
+ if req.Name != "" {
+ old.Name = req.Name
+ }
+ if req.LogoUrl != "" {
+ old.LogoURL = req.LogoUrl
+ }
+ if req.AllowedConnectors != nil {
+ old.AllowedConnectors = req.AllowedConnectors
+ }
+ if req.SsoSharedWith != nil {
+ old.SSOSharedWith = req.SsoSharedWith
+ }
+ // Explicit presence, so that sending an empty string clears the URI rather
+ // than being indistinguishable from not mentioning it.
+ if req.BackchannelLogoutUri != nil {
+ old.BackchannelLogoutURI = req.GetBackchannelLogoutUri()
+ }
+ if req.PostLogoutRedirectUris != nil {
+ old.PostLogoutRedirectURIs = req.PostLogoutRedirectUris
+ }
+ if req.RefreshTokenLifetime != nil {
+ old.RefreshTokenLifetime = req.GetRefreshTokenLifetime()
+ }
+ return old, nil
+ })
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return &api.UpdateClientResp{NotFound: true}, nil
+ }
+ d.logger.Error("failed to update the client", "err", err)
+ return nil, fmt.Errorf("update client: %v", err)
+ }
+ return &api.UpdateClientResp{}, nil
+}
+
+func (d dexAPI) DeleteClient(ctx context.Context, req *api.DeleteClientReq) (*api.DeleteClientResp, error) {
+ err := d.s.DeleteClient(ctx, req.Id)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return &api.DeleteClientResp{NotFound: true}, nil
+ }
+ d.logger.Error("failed to delete client", "err", err)
+ return nil, fmt.Errorf("delete client: %v", err)
+ }
+ return &api.DeleteClientResp{}, nil
+}
+
+func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.ListClientResp, error) {
+ clientList, err := d.s.ListClients(ctx)
+ if err != nil {
+ d.logger.Error("failed to list clients", "err", err)
+ return nil, fmt.Errorf("list clients: %v", err)
+ }
+
+ clients := make([]*api.ClientInfo, 0, len(clientList))
+ for _, client := range clientList {
+ c := api.ClientInfo{
+ Id: client.ID,
+ Name: client.Name,
+ RedirectUris: client.RedirectURIs,
+ TrustedPeers: client.TrustedPeers,
+ Public: client.Public,
+ LogoUrl: client.LogoURL,
+ AllowedConnectors: client.AllowedConnectors,
+ SsoSharedWith: client.SSOSharedWith,
+ BackchannelLogoutUri: client.BackchannelLogoutURI,
+ PostLogoutRedirectUris: client.PostLogoutRedirectURIs,
+ RefreshTokenLifetime: client.RefreshTokenLifetime,
+ }
+ clients = append(clients, &c)
+ }
+
+ return &api.ListClientResp{
+ Clients: clients,
+ }, nil
+}
diff --git a/server/apiserver/connectors.go b/server/apiserver/connectors.go
new file mode 100644
index 0000000000..ed800227ed
--- /dev/null
+++ b/server/apiserver/connectors.go
@@ -0,0 +1,194 @@
+package apiserver
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/storage"
+)
+
+func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq) (*api.CreateConnectorResp, error) {
+ if !featureflags.APIConnectorsCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name)
+ }
+
+ if req.Connector == nil {
+ return nil, errors.New("no connector supplied")
+ }
+
+ if req.Connector.Id == "" {
+ return nil, errors.New("no id supplied")
+ }
+
+ if req.Connector.Type == "" {
+ return nil, errors.New("no type supplied")
+ }
+
+ if req.Connector.Name == "" {
+ return nil, errors.New("no name supplied")
+ }
+
+ if len(req.Connector.Config) == 0 {
+ return nil, errors.New("no config supplied")
+ }
+
+ if !json.Valid(req.Connector.Config) {
+ return nil, errors.New("invalid config supplied")
+ }
+
+ for _, gt := range req.Connector.GrantTypes {
+ if !connectors.ConnectorGrantTypes[gt] {
+ return nil, fmt.Errorf("unknown grant type %q", gt)
+ }
+ }
+
+ c := storage.Connector{
+ ID: req.Connector.Id,
+ Name: req.Connector.Name,
+ Type: req.Connector.Type,
+ ResourceVersion: "1",
+ Config: req.Connector.Config,
+ GrantTypes: req.Connector.GrantTypes,
+ }
+ if err := d.s.CreateConnector(ctx, c); err != nil {
+ if err == storage.ErrAlreadyExists {
+ return &api.CreateConnectorResp{AlreadyExists: true}, nil
+ }
+ d.logger.Error("api: failed to create connector", "err", err)
+ return nil, fmt.Errorf("create connector: %v", err)
+ }
+
+ // Make sure we don't reuse stale entries in the cache
+ if d.connectors != nil {
+ d.connectors.Close(req.Connector.Id)
+ }
+
+ return &api.CreateConnectorResp{}, nil
+}
+
+func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq) (*api.UpdateConnectorResp, error) {
+ if !featureflags.APIConnectorsCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name)
+ }
+
+ if req.Id == "" {
+ return nil, errors.New("no email supplied")
+ }
+
+ hasUpdate := len(req.NewConfig) != 0 ||
+ req.NewName != "" ||
+ req.NewType != "" ||
+ req.NewGrantTypes != nil
+ if !hasUpdate {
+ return nil, errors.New("nothing to update")
+ }
+
+ if len(req.NewConfig) != 0 && !json.Valid(req.NewConfig) {
+ return nil, errors.New("invalid config supplied")
+ }
+
+ if req.NewGrantTypes != nil {
+ for _, gt := range req.NewGrantTypes.GrantTypes {
+ if !connectors.ConnectorGrantTypes[gt] {
+ return nil, fmt.Errorf("unknown grant type %q", gt)
+ }
+ }
+ }
+
+ updater := func(old storage.Connector) (storage.Connector, error) {
+ if req.NewType != "" {
+ old.Type = req.NewType
+ }
+
+ if req.NewName != "" {
+ old.Name = req.NewName
+ }
+
+ if len(req.NewConfig) != 0 {
+ old.Config = req.NewConfig
+ }
+
+ if req.NewGrantTypes != nil {
+ old.GrantTypes = req.NewGrantTypes.GrantTypes
+ }
+
+ if rev, err := strconv.Atoi(defaultTo(old.ResourceVersion, "0")); err == nil {
+ old.ResourceVersion = strconv.Itoa(rev + 1)
+ }
+
+ return old, nil
+ }
+
+ if err := d.s.UpdateConnector(ctx, req.Id, updater); err != nil {
+ if err == storage.ErrNotFound {
+ return &api.UpdateConnectorResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to update connector", "err", err)
+ return nil, fmt.Errorf("update connector: %v", err)
+ }
+
+ return &api.UpdateConnectorResp{}, nil
+}
+
+func (d dexAPI) DeleteConnector(ctx context.Context, req *api.DeleteConnectorReq) (*api.DeleteConnectorResp, error) {
+ if !featureflags.APIConnectorsCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name)
+ }
+
+ if req.Id == "" {
+ return nil, errors.New("no id supplied")
+ }
+
+ err := d.s.DeleteConnector(ctx, req.Id)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return &api.DeleteConnectorResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to delete connector", "err", err)
+ return nil, fmt.Errorf("delete connector: %v", err)
+ }
+
+ return &api.DeleteConnectorResp{}, nil
+}
+
+func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) (*api.ListConnectorResp, error) {
+ if !featureflags.APIConnectorsCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name)
+ }
+
+ connectorList, err := d.s.ListConnectors(ctx)
+ if err != nil {
+ d.logger.Error("api: failed to list connectors", "err", err)
+ return nil, fmt.Errorf("list connectors: %v", err)
+ }
+
+ connectors := make([]*api.Connector, 0, len(connectorList))
+ for _, connector := range connectorList {
+ c := api.Connector{
+ Id: connector.ID,
+ Name: connector.Name,
+ Type: connector.Type,
+ Config: connector.Config,
+ GrantTypes: connector.GrantTypes,
+ }
+ connectors = append(connectors, &c)
+ }
+
+ return &api.ListConnectorResp{
+ Connectors: connectors,
+ }, nil
+}
+
+func defaultTo[T comparable](v, def T) T {
+ var zeroT T
+ if v == zeroT {
+ return def
+ }
+ return v
+}
diff --git a/server/apiserver/connectors_test.go b/server/apiserver/connectors_test.go
new file mode 100644
index 0000000000..c0e6f2da16
--- /dev/null
+++ b/server/apiserver/connectors_test.go
@@ -0,0 +1,136 @@
+package apiserver
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/connector/mock"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+func TestConnectorCacheInvalidation(t *testing.T) {
+ t.Setenv("DEX_API_CONNECTORS_CRUD", "true")
+
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ // Only the connector type this test creates needs to resolve; the config map
+ // is injected, so the API's tests need none of dex's real connectors.
+ conns := connectors.NewCache(s, connectors.Resolver(s, logger, map[string]func() connectors.ConnectorConfig{
+ "mockPassword": func() connectors.ConnectorConfig { return new(mock.PasswordConfig) },
+ }))
+
+ // This test exercises connector-cache invalidation, not discovery, so no
+ // discovery handler is wired (GetDiscovery guards against nil).
+ apiServer := NewAPI(s, logger, "test", conns, nil, nil)
+ ctx := context.Background()
+
+ connID := "mock-conn"
+
+ // 1. Create a connector via API
+ config1 := mock.PasswordConfig{
+ Username: "user",
+ Password: "first-password",
+ }
+ config1Bytes, _ := json.Marshal(config1)
+
+ _, err := apiServer.CreateConnector(ctx, &api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connID,
+ Type: "mockPassword",
+ Name: "Mock",
+ Config: config1Bytes,
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create connector: %v", err)
+ }
+
+ // 2. Load it into server cache
+ c1, err := conns.Get(ctx, connID)
+ if err != nil {
+ t.Fatalf("failed to get connector: %v", err)
+ }
+
+ pc1 := c1.Connector.(connector.PasswordConnector)
+ _, valid, err := pc1.Login(ctx, connector.Scopes{}, "user", "first-password")
+ if err != nil || !valid {
+ t.Fatalf("failed to login with first password: %v", err)
+ }
+
+ // 3. Delete it via API
+ _, err = apiServer.DeleteConnector(ctx, &api.DeleteConnectorReq{Id: connID})
+ if err != nil {
+ t.Fatalf("failed to delete connector: %v", err)
+ }
+
+ // 4. Create it again with different password
+ config2 := mock.PasswordConfig{
+ Username: "user",
+ Password: "second-password",
+ }
+ config2Bytes, _ := json.Marshal(config2)
+
+ _, err = apiServer.CreateConnector(ctx, &api.CreateConnectorReq{
+ Connector: &api.Connector{
+ Id: connID,
+ Type: "mockPassword",
+ Name: "Mock",
+ Config: config2Bytes,
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create connector: %v", err)
+ }
+
+ // 5. Load it again
+ c2, err := conns.Get(ctx, connID)
+ if err != nil {
+ t.Fatalf("failed to get connector second time: %v", err)
+ }
+
+ pc2 := c2.Connector.(connector.PasswordConnector)
+
+ // If the fix works, it should now use the second password.
+ _, valid2, err := pc2.Login(ctx, connector.Scopes{}, "user", "second-password")
+ if err != nil || !valid2 {
+ t.Errorf("failed to login with second password, cache might still be stale")
+ }
+
+ _, valid1, _ := pc2.Login(ctx, connector.Scopes{}, "user", "first-password")
+ if valid1 {
+ t.Errorf("unexpectedly logged in with first password, cache is definitely stale")
+ }
+
+ // 6. Update it via API with a third password
+ config3 := mock.PasswordConfig{
+ Username: "user",
+ Password: "third-password",
+ }
+ config3Bytes, _ := json.Marshal(config3)
+
+ _, err = apiServer.UpdateConnector(ctx, &api.UpdateConnectorReq{
+ Id: connID,
+ NewConfig: config3Bytes,
+ })
+ if err != nil {
+ t.Fatalf("failed to update connector: %v", err)
+ }
+
+ // 7. Load it again
+ c3, err := conns.Get(ctx, connID)
+ if err != nil {
+ t.Fatalf("failed to get connector third time: %v", err)
+ }
+
+ pc3 := c3.Connector.(connector.PasswordConnector)
+
+ _, valid3, err := pc3.Login(ctx, connector.Scopes{}, "user", "third-password")
+ if err != nil || !valid3 {
+ t.Errorf("failed to login with third password, UpdateConnector might be missing cache invalidation")
+ }
+}
diff --git a/server/apiserver/doc.go b/server/apiserver/doc.go
new file mode 100644
index 0000000000..a2ab309184
--- /dev/null
+++ b/server/apiserver/doc.go
@@ -0,0 +1,6 @@
+// Package apiserver implements the gRPC management API (api.DexServer): the CRUD
+// and administrative calls for clients, passwords, connectors, refresh tokens,
+// auth sessions, user identities and MFA devices. Each domain lives in its own
+// file. It depends only on storage, the connector cache and a discovery-document
+// builder, not on the whole Server.
+package apiserver
diff --git a/server/apiserver/identities.go b/server/apiserver/identities.go
new file mode 100644
index 0000000000..db96dceba4
--- /dev/null
+++ b/server/apiserver/identities.go
@@ -0,0 +1,151 @@
+package apiserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/storage"
+)
+
+func storageUserIdentityToAPI(u storage.UserIdentity) *api.UserIdentity {
+ consents := make([]*api.ConsentEntry, 0, len(u.Consents))
+ for clientID, scopes := range u.Consents {
+ consents = append(consents, &api.ConsentEntry{
+ ClientId: clientID,
+ Scopes: scopes,
+ })
+ }
+
+ identity := &api.UserIdentity{
+ UserId: u.UserID,
+ ConnectorId: u.ConnectorID,
+ Email: u.Claims.Email,
+ EmailVerified: u.Claims.EmailVerified,
+ Username: u.Claims.Username,
+ Groups: u.Claims.Groups,
+ Consents: consents,
+ MfaDevices: storageMFADevicesToAPI(u.MFASecrets, u.WebAuthnCredentials),
+ CreatedAt: unixOrZero(u.CreatedAt),
+ LastLogin: unixOrZero(u.LastLogin),
+ BlockedUntil: unixOrZero(u.BlockedUntil),
+ }
+
+ return identity
+}
+
+func (d dexAPI) GetUserIdentity(ctx context.Context, req *api.GetUserIdentityReq) (*api.GetUserIdentityResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+
+ identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return nil, storage.ErrNotFound
+ }
+ d.logger.Error("api: failed to get user identity", "err", err)
+ return nil, fmt.Errorf("get user identity: %v", err)
+ }
+
+ return &api.GetUserIdentityResp{
+ Identity: storageUserIdentityToAPI(identity),
+ }, nil
+}
+
+func (d dexAPI) ListUserIdentities(ctx context.Context, req *api.ListUserIdentitiesReq) (*api.ListUserIdentitiesResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ identityList, err := d.s.ListUserIdentities(ctx)
+ if err != nil {
+ d.logger.Error("api: failed to list user identities", "err", err)
+ return nil, fmt.Errorf("list user identities: %v", err)
+ }
+
+ identities := make([]*api.UserIdentity, 0, len(identityList))
+ for _, u := range identityList {
+ identities = append(identities, storageUserIdentityToAPI(u))
+ }
+
+ return &api.ListUserIdentitiesResp{
+ Identities: identities,
+ }, nil
+}
+
+func (d dexAPI) DeleteUserIdentity(ctx context.Context, req *api.DeleteUserIdentityReq) (*api.DeleteUserIdentityResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+
+ // Look up the identity first: report not-found cleanly without performing any
+ // cascade, and capture the email needed to purge the linked password record.
+ identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteUserIdentityResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to get user identity during purge", "err", err)
+ return nil, fmt.Errorf("delete user identity: %v", err)
+ }
+
+ // Cascade deletes. A real (non-not-found) failure aborts the purge and returns
+ // an error so the caller is never told a GDPR purge succeeded while data was
+ // left behind.
+
+ // Cascade: delete every session this identity signed in with, telling each
+ // session's relying parties on the way out.
+ if _, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool {
+ return s.UserID == req.UserId && s.ConnectorID == req.ConnectorId
+ }); err != nil {
+ return nil, fmt.Errorf("purge auth sessions: %v", err)
+ }
+
+ // Cascade: revoke all refresh tokens (best-effort). A purge has to take every
+ // credential with it, so the unscoped revoke is the right one here.
+ d.revokeUserRefreshTokens(ctx, req.UserId, req.ConnectorId)
+
+ // Cascade: delete offline sessions.
+ if err := d.s.DeleteOfflineSessions(ctx, req.UserId, req.ConnectorId); err != nil && !errors.Is(err, storage.ErrNotFound) {
+ d.logger.Error("api: failed to delete offline sessions during identity purge", "err", err)
+ return nil, fmt.Errorf("purge offline sessions: %v", err)
+ }
+
+ // Cascade: delete the password record (keyed by email, may not exist for
+ // non-password connectors).
+ if email := identity.Claims.Email; email != "" {
+ if err := d.s.DeletePassword(ctx, email); err != nil && !errors.Is(err, storage.ErrNotFound) {
+ d.logger.Error("api: failed to delete password during identity purge", "err", err)
+ return nil, fmt.Errorf("purge password: %v", err)
+ }
+ }
+
+ // Delete the user identity itself.
+ if err := d.s.DeleteUserIdentity(ctx, req.UserId, req.ConnectorId); err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteUserIdentityResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to delete user identity", "err", err)
+ return nil, fmt.Errorf("delete user identity: %v", err)
+ }
+
+ d.logger.Info("api: purged user identity", "user_id", req.UserId, "connector_id", req.ConnectorId)
+ return &api.DeleteUserIdentityResp{}, nil
+}
diff --git a/server/apiserver/mfa.go b/server/apiserver/mfa.go
new file mode 100644
index 0000000000..45dac50d83
--- /dev/null
+++ b/server/apiserver/mfa.go
@@ -0,0 +1,195 @@
+package apiserver
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/storage"
+)
+
+// errIdentityUnchanged signals that an UpdateUserIdentity callback found nothing
+// to change, so the mutation (and its resource-version bump) is skipped.
+var errIdentityUnchanged = errors.New("identity unchanged")
+
+func storageMFADevicesToAPI(secrets map[string]*storage.MFASecret, credentials map[string][]storage.WebAuthnCredential) []*api.MFADeviceInfo {
+ // Collect all authenticator IDs from both maps.
+ authIDs := make(map[string]struct{})
+ for id := range secrets {
+ authIDs[id] = struct{}{}
+ }
+ for id := range credentials {
+ authIDs[id] = struct{}{}
+ }
+
+ devices := make([]*api.MFADeviceInfo, 0, len(authIDs))
+ for authID := range authIDs {
+ device := &api.MFADeviceInfo{
+ AuthenticatorId: authID,
+ }
+
+ if secret, ok := secrets[authID]; ok {
+ device.MfaSecret = &api.MFASecret{
+ AuthenticatorId: secret.AuthenticatorID,
+ Type: secret.Type,
+ Confirmed: secret.Confirmed,
+ CreatedAt: unixOrZero(secret.CreatedAt),
+ }
+ }
+
+ if creds, ok := credentials[authID]; ok {
+ apiCreds := make([]*api.WebAuthnCredential, 0, len(creds))
+ for _, c := range creds {
+ apiCreds = append(apiCreds, &api.WebAuthnCredential{
+ CredentialId: c.CredentialID,
+ AttestationType: c.AttestationType,
+ Aaguid: c.AAGUID,
+ SignCount: c.SignCount,
+ CloneWarning: c.CloneWarning,
+ Transport: c.Transport,
+ BackupEligible: c.BackupEligible,
+ BackupState: c.BackupState,
+ DisplayName: c.DisplayName,
+ CreatedAt: unixOrZero(c.CreatedAt),
+ })
+ }
+ device.WebauthnCredentials = apiCreds
+ }
+
+ devices = append(devices, device)
+ }
+ return devices
+}
+
+func (d dexAPI) ResetMFA(ctx context.Context, req *api.ResetMFAReq) (*api.ResetMFAResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+
+ if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ old.MFASecrets = nil
+ old.WebAuthnCredentials = nil
+ return old, nil
+ }); err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return &api.ResetMFAResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to reset MFA", "err", err)
+ return nil, fmt.Errorf("reset MFA: %v", err)
+ }
+
+ d.logger.Info("api: reset MFA", "user_id", req.UserId, "connector_id", req.ConnectorId)
+ return &api.ResetMFAResp{}, nil
+}
+
+func (d dexAPI) ListMFADevices(ctx context.Context, req *api.ListMFADevicesReq) (*api.ListMFADevicesResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+
+ identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return nil, storage.ErrNotFound
+ }
+ d.logger.Error("api: failed to get user identity for MFA devices", "err", err)
+ return nil, fmt.Errorf("list MFA devices: %v", err)
+ }
+
+ return &api.ListMFADevicesResp{
+ Devices: storageMFADevicesToAPI(identity.MFASecrets, identity.WebAuthnCredentials),
+ }, nil
+}
+
+func (d dexAPI) DeleteWebAuthnCredential(ctx context.Context, req *api.DeleteWebAuthnCredentialReq) (*api.DeleteWebAuthnCredentialResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+ if len(req.CredentialId) == 0 {
+ return nil, errors.New("no credential_id supplied")
+ }
+
+ if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ for authID, creds := range old.WebAuthnCredentials {
+ for i, cred := range creds {
+ if bytes.Equal(cred.CredentialID, req.CredentialId) {
+ old.WebAuthnCredentials[authID] = slices.Delete(creds, i, i+1)
+ if len(old.WebAuthnCredentials[authID]) == 0 {
+ delete(old.WebAuthnCredentials, authID)
+ }
+ return old, nil
+ }
+ }
+ }
+ return old, errIdentityUnchanged
+ }); err != nil {
+ if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteWebAuthnCredentialResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to delete WebAuthn credential", "err", err)
+ return nil, fmt.Errorf("delete WebAuthn credential: %v", err)
+ }
+
+ d.logger.Info("api: deleted WebAuthn credential", "user_id", req.UserId, "connector_id", req.ConnectorId)
+ return &api.DeleteWebAuthnCredentialResp{}, nil
+}
+
+func (d dexAPI) DeleteMFASecret(ctx context.Context, req *api.DeleteMFASecretReq) (*api.DeleteMFASecretResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+ if req.AuthenticatorId == "" {
+ return nil, errors.New("no authenticator_id supplied")
+ }
+
+ if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ if _, ok := old.MFASecrets[req.AuthenticatorId]; !ok {
+ return old, errIdentityUnchanged
+ }
+ delete(old.MFASecrets, req.AuthenticatorId)
+ // Also remove associated WebAuthn credentials for the same authenticator.
+ delete(old.WebAuthnCredentials, req.AuthenticatorId)
+ return old, nil
+ }); err != nil {
+ if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteMFASecretResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to delete MFA secret", "err", err)
+ return nil, fmt.Errorf("delete MFA secret: %v", err)
+ }
+
+ d.logger.Info("api: deleted MFA secret", "user_id", req.UserId, "connector_id", req.ConnectorId)
+ return &api.DeleteMFASecretResp{}, nil
+}
diff --git a/server/apiserver/passwords.go b/server/apiserver/passwords.go
new file mode 100644
index 0000000000..db1c7946be
--- /dev/null
+++ b/server/apiserver/passwords.go
@@ -0,0 +1,151 @@
+package apiserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/server/passwords"
+ "github.com/dexidp/dex/storage"
+)
+
+func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq) (*api.CreatePasswordResp, error) {
+ if req.Password == nil {
+ return nil, errors.New("no password supplied")
+ }
+ if req.Password.UserId == "" {
+ return nil, errors.New("no user ID supplied")
+ }
+ if req.Password.Hash != nil {
+ if err := passwords.CheckCost(req.Password.Hash); err != nil {
+ return nil, err
+ }
+ } else {
+ return nil, errors.New("no hash of password supplied")
+ }
+
+ p := storage.Password{
+ Email: req.Password.Email,
+ Hash: req.Password.Hash,
+ Username: req.Password.Username,
+ UserID: req.Password.UserId,
+ }
+ if err := d.s.CreatePassword(ctx, p); err != nil {
+ if err == storage.ErrAlreadyExists {
+ return &api.CreatePasswordResp{AlreadyExists: true}, nil
+ }
+ d.logger.Error("failed to create password", "err", err)
+ return nil, fmt.Errorf("create password: %v", err)
+ }
+
+ return &api.CreatePasswordResp{}, nil
+}
+
+func (d dexAPI) UpdatePassword(ctx context.Context, req *api.UpdatePasswordReq) (*api.UpdatePasswordResp, error) {
+ if req.Email == "" {
+ return nil, errors.New("no email supplied")
+ }
+ if req.NewHash == nil && req.NewUsername == "" {
+ return nil, errors.New("nothing to update")
+ }
+
+ if req.NewHash != nil {
+ if err := passwords.CheckCost(req.NewHash); err != nil {
+ return nil, err
+ }
+ }
+
+ updater := func(old storage.Password) (storage.Password, error) {
+ if req.NewHash != nil {
+ old.Hash = req.NewHash
+ }
+
+ if req.NewUsername != "" {
+ old.Username = req.NewUsername
+ }
+
+ return old, nil
+ }
+
+ if err := d.s.UpdatePassword(ctx, req.Email, updater); err != nil {
+ if err == storage.ErrNotFound {
+ return &api.UpdatePasswordResp{NotFound: true}, nil
+ }
+ d.logger.Error("failed to update password", "err", err)
+ return nil, fmt.Errorf("update password: %v", err)
+ }
+
+ return &api.UpdatePasswordResp{}, nil
+}
+
+func (d dexAPI) DeletePassword(ctx context.Context, req *api.DeletePasswordReq) (*api.DeletePasswordResp, error) {
+ if req.Email == "" {
+ return nil, errors.New("no email supplied")
+ }
+
+ err := d.s.DeletePassword(ctx, req.Email)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return &api.DeletePasswordResp{NotFound: true}, nil
+ }
+ d.logger.Error("failed to delete password", "err", err)
+ return nil, fmt.Errorf("delete password: %v", err)
+ }
+ return &api.DeletePasswordResp{}, nil
+}
+
+func (d dexAPI) ListPasswords(ctx context.Context, req *api.ListPasswordReq) (*api.ListPasswordResp, error) {
+ passwordList, err := d.s.ListPasswords(ctx)
+ if err != nil {
+ d.logger.Error("failed to list passwords", "err", err)
+ return nil, fmt.Errorf("list passwords: %v", err)
+ }
+
+ passwords := make([]*api.Password, 0, len(passwordList))
+ for _, password := range passwordList {
+ p := api.Password{
+ Email: password.Email,
+ Username: password.Username,
+ UserId: password.UserID,
+ }
+ passwords = append(passwords, &p)
+ }
+
+ return &api.ListPasswordResp{
+ Passwords: passwords,
+ }, nil
+}
+
+func (d dexAPI) VerifyPassword(ctx context.Context, req *api.VerifyPasswordReq) (*api.VerifyPasswordResp, error) {
+ if req.Email == "" {
+ return nil, errors.New("no email supplied")
+ }
+
+ if req.Password == "" {
+ return nil, errors.New("no password to verify supplied")
+ }
+
+ password, err := d.s.GetPassword(ctx, req.Email)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return &api.VerifyPasswordResp{
+ NotFound: true,
+ }, nil
+ }
+ d.logger.Error("there was an error retrieving the password", "err", err)
+ return nil, fmt.Errorf("verify password: %v", err)
+ }
+
+ if err := bcrypt.CompareHashAndPassword(password.Hash, []byte(req.Password)); err != nil {
+ d.logger.Info("password check failed", "err", err)
+ return &api.VerifyPasswordResp{
+ Verified: false,
+ }, nil
+ }
+ return &api.VerifyPasswordResp{
+ Verified: true,
+ }, nil
+}
diff --git a/server/apiserver/refresh.go b/server/apiserver/refresh.go
new file mode 100644
index 0000000000..d07edafa9a
--- /dev/null
+++ b/server/apiserver/refresh.go
@@ -0,0 +1,108 @@
+package apiserver
+
+import (
+ "context"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/storage"
+)
+
+func (d dexAPI) ListRefresh(ctx context.Context, req *api.ListRefreshReq) (*api.ListRefreshResp, error) {
+ id := new(internal.IDTokenSubject)
+ if err := internal.Unmarshal(req.UserId, id); err != nil {
+ d.logger.Error("failed to unmarshal ID Token subject", "err", err)
+ return nil, err
+ }
+
+ offlineSessions, err := d.s.GetOfflineSessions(ctx, id.UserId, id.ConnId)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ // This means that this user-client pair does not have a refresh token yet.
+ // An empty list should be returned instead of an error.
+ return &api.ListRefreshResp{}, nil
+ }
+ d.logger.Error("failed to list refresh tokens here", "err", err)
+ return nil, err
+ }
+
+ refreshTokenRefs := make([]*api.RefreshTokenRef, 0, len(offlineSessions.Refresh))
+ for _, session := range offlineSessions.Refresh {
+ r := api.RefreshTokenRef{
+ Id: session.ID,
+ ClientId: session.ClientID,
+ CreatedAt: session.CreatedAt.Unix(),
+ LastUsed: session.LastUsed.Unix(),
+ }
+ refreshTokenRefs = append(refreshTokenRefs, &r)
+ }
+
+ return &api.ListRefreshResp{
+ RefreshTokens: refreshTokenRefs,
+ }, nil
+}
+
+func (d dexAPI) RevokeRefresh(ctx context.Context, req *api.RevokeRefreshReq) (*api.RevokeRefreshResp, error) {
+ id := new(internal.IDTokenSubject)
+ if err := internal.Unmarshal(req.UserId, id); err != nil {
+ d.logger.Error("failed to unmarshal ID Token subject", "err", err)
+ return nil, err
+ }
+
+ var (
+ refreshID string
+ notFound bool
+ )
+ updater := func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
+ refreshRef := old.Refresh[req.ClientId]
+ if refreshRef == nil || refreshRef.ID == "" {
+ d.logger.Error("refresh token issued to client not found for deletion", "client_id", req.ClientId, "user_id", id.UserId)
+ notFound = true
+ return old, storage.ErrNotFound
+ }
+
+ refreshID = refreshRef.ID
+
+ // Remove entry from Refresh list of the OfflineSession object.
+ delete(old.Refresh, req.ClientId)
+
+ return old, nil
+ }
+
+ if err := d.s.UpdateOfflineSessions(ctx, id.UserId, id.ConnId, updater); err != nil {
+ if err == storage.ErrNotFound {
+ return &api.RevokeRefreshResp{NotFound: true}, nil
+ }
+ d.logger.Error("failed to update offline session object", "err", err)
+ return nil, err
+ }
+
+ if notFound {
+ return &api.RevokeRefreshResp{NotFound: true}, nil
+ }
+
+ // Delete the refresh token from the storage
+ //
+ // TODO(ericchiang): we don't have any good recourse if this call fails.
+ // Consider garbage collection of refresh tokens with no associated ref.
+ if err := d.s.DeleteRefresh(ctx, refreshID); err != nil {
+ d.logger.Error("failed to delete refresh token", "err", err)
+ return nil, err
+ }
+
+ return &api.RevokeRefreshResp{}, nil
+}
+
+// revokeUserRefreshTokens revokes all refresh tokens for a user/connector pair
+// and cleans up offline session references. Errors are logged but not returned
+// (best-effort).
+//
+// This is deliberately broader than what RP-initiated logout does. That flow revokes
+// only the client that asked for it, because it has a requesting client to scope to
+// and no mandate to touch anyone else's credentials (see revokeRequestingClient in
+// server/logout). An administrative call has neither: there is no client_id in the
+// request, and ending access is the entire point of the operation. Callers that want
+// one client's token gone use RevokeRefresh.
+func (d dexAPI) revokeUserRefreshTokens(ctx context.Context, userID, connectorID string) {
+ d.refresh.RevokeAll(ctx, userID, connectorID)
+}
diff --git a/server/apiserver/sessions.go b/server/apiserver/sessions.go
new file mode 100644
index 0000000000..acc96dfd3b
--- /dev/null
+++ b/server/apiserver/sessions.go
@@ -0,0 +1,230 @@
+package apiserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/storage"
+)
+
+func storageAuthSessionToAPI(s storage.AuthSession) *api.AuthSession {
+ clientStates := make([]*api.ClientAuthState, 0, len(s.ClientStates))
+ for clientID, state := range s.ClientStates {
+ if state == nil {
+ continue
+ }
+ clientStates = append(clientStates, &api.ClientAuthState{
+ ClientId: clientID,
+ AuthenticatedAt: unixOrZero(state.AuthenticatedAt),
+ LastActivity: unixOrZero(state.LastActivity),
+ LastTokenIssuedAt: unixOrZero(state.LastTokenIssuedAt),
+ ViaSso: state.ViaSSO,
+ })
+ }
+
+ return &api.AuthSession{
+ Id: s.ID,
+ UserId: s.UserID,
+ ConnectorId: s.ConnectorID,
+ ClientStates: clientStates,
+ CreatedAt: unixOrZero(s.CreatedAt),
+ LastActivity: unixOrZero(s.LastActivity),
+ IpAddress: s.IPAddress,
+ UserAgent: s.UserAgent,
+ AbsoluteExpiry: unixOrZero(s.AbsoluteExpiry),
+ IdleExpiry: unixOrZero(s.IdleExpiry),
+ }
+}
+
+func (d dexAPI) GetAuthSession(ctx context.Context, req *api.GetAuthSessionReq) (*api.GetAuthSessionResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.Id == "" {
+ return nil, errors.New("no id supplied")
+ }
+
+ session, err := d.s.GetAuthSession(ctx, req.Id)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return nil, storage.ErrNotFound
+ }
+ d.logger.Error("api: failed to get auth session", "err", err)
+ return nil, fmt.Errorf("get auth session: %v", err)
+ }
+
+ return &api.GetAuthSessionResp{
+ Session: storageAuthSessionToAPI(session),
+ }, nil
+}
+
+func (d dexAPI) ListAuthSessions(ctx context.Context, req *api.ListAuthSessionsReq) (*api.ListAuthSessionsResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ sessionList, err := d.s.ListAuthSessions(ctx)
+ if err != nil {
+ d.logger.Error("api: failed to list auth sessions", "err", err)
+ return nil, fmt.Errorf("list auth sessions: %v", err)
+ }
+
+ sessions := make([]*api.AuthSession, 0, len(sessionList))
+ for _, s := range sessionList {
+ if req.UserId != "" && s.UserID != req.UserId {
+ continue
+ }
+ if req.ConnectorId != "" && s.ConnectorID != req.ConnectorId {
+ continue
+ }
+ sessions = append(sessions, storageAuthSessionToAPI(s))
+ }
+
+ return &api.ListAuthSessionsResp{
+ Sessions: sessions,
+ }, nil
+}
+
+func (d dexAPI) DeleteAuthSession(ctx context.Context, req *api.DeleteAuthSessionReq) (*api.DeleteAuthSessionResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.Id == "" {
+ return nil, errors.New("no id supplied")
+ }
+
+ session, err := d.s.GetAuthSession(ctx, req.Id)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteAuthSessionResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to get auth session", "err", err)
+ return nil, fmt.Errorf("get auth session: %v", err)
+ }
+
+ if d.backchannel != nil {
+ d.backchannel.Notify(ctx, &session)
+ }
+
+ // Revoke every refresh token the user holds on this connector, not just one
+ // client's. See revokeUserRefreshTokens for why the administrative path is
+ // deliberately broader than RP-initiated logout.
+ d.revokeUserRefreshTokens(ctx, session.UserID, session.ConnectorID)
+
+ if err := d.s.DeleteAuthSession(ctx, req.Id); err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return &api.DeleteAuthSessionResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to delete auth session", "err", err)
+ return nil, fmt.Errorf("delete auth session: %v", err)
+ }
+
+ d.logger.Info("api: deleted auth session", "session_id", req.Id, "user_id", session.UserID)
+ return &api.DeleteAuthSessionResp{}, nil
+}
+
+func (d dexAPI) terminateSessions(ctx context.Context, match func(storage.AuthSession) bool) (int64, error) {
+ sessionList, err := d.s.ListAuthSessions(ctx)
+ if err != nil {
+ d.logger.Error("api: failed to list auth sessions", "err", err)
+ return 0, fmt.Errorf("list auth sessions: %v", err)
+ }
+
+ var terminated int64
+ for _, s := range sessionList {
+ if !match(s) {
+ continue
+ }
+
+ if d.backchannel != nil {
+ d.backchannel.Notify(ctx, &s)
+ }
+ d.revokeUserRefreshTokens(ctx, s.UserID, s.ConnectorID)
+
+ if err := d.s.DeleteAuthSession(ctx, s.ID); err != nil {
+ d.logger.Error("api: failed to delete auth session during batch terminate",
+ "session_id", s.ID, "user_id", s.UserID, "err", err)
+ continue
+ }
+ terminated++
+ }
+ return terminated, nil
+}
+
+func (d dexAPI) TerminateSessionsByConnector(ctx context.Context, req *api.TerminateSessionsByConnectorReq) (*api.TerminateSessionsByConnectorResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+
+ terminated, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool {
+ return s.ConnectorID == req.ConnectorId
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ d.logger.Info("api: terminated sessions by connector", "connector_id", req.ConnectorId, "count", terminated)
+ return &api.TerminateSessionsByConnectorResp{SessionsTerminated: terminated}, nil
+}
+
+func (d dexAPI) TerminateSessionsByUser(ctx context.Context, req *api.TerminateSessionsByUserReq) (*api.TerminateSessionsByUserResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+
+ terminated, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool {
+ return s.UserID == req.UserId
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ d.logger.Info("api: terminated sessions by user", "user_id", req.UserId, "count", terminated)
+ return &api.TerminateSessionsByUserResp{SessionsTerminated: terminated}, nil
+}
+
+func (d dexAPI) RevokeConsent(ctx context.Context, req *api.RevokeConsentReq) (*api.RevokeConsentResp, error) {
+ if !featureflags.APISessionsIdentitiesCRUD.Enabled() {
+ return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name)
+ }
+
+ if req.UserId == "" {
+ return nil, errors.New("no user_id supplied")
+ }
+ if req.ConnectorId == "" {
+ return nil, errors.New("no connector_id supplied")
+ }
+ if req.ClientId == "" {
+ return nil, errors.New("no client_id supplied")
+ }
+
+ if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ if _, ok := old.Consents[req.ClientId]; !ok {
+ return old, errIdentityUnchanged
+ }
+ delete(old.Consents, req.ClientId)
+ return old, nil
+ }); err != nil {
+ if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) {
+ return &api.RevokeConsentResp{NotFound: true}, nil
+ }
+ d.logger.Error("api: failed to revoke consent", "err", err)
+ return nil, fmt.Errorf("revoke consent: %v", err)
+ }
+
+ d.logger.Info("api: revoked consent", "user_id", req.UserId, "connector_id", req.ConnectorId, "client_id", req.ClientId)
+ return &api.RevokeConsentResp{}, nil
+}
diff --git a/server/apiserver/sessions_test.go b/server/apiserver/sessions_test.go
new file mode 100644
index 0000000000..62b3bc0192
--- /dev/null
+++ b/server/apiserver/sessions_test.go
@@ -0,0 +1,108 @@
+package apiserver
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/api/v2"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/server/backchannel"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+// TestTerminateSessionNotifiesRelyingParties: an operator ending a session leaves the
+// relying parties as the only thing keeping the user signed in, so they are told.
+func TestTerminateSessionNotifiesRelyingParties(t *testing.T) {
+ t.Setenv("DEX_"+strings.ToUpper(featureflags.APISessionsIdentitiesCRUD.Name), "true")
+
+ const userID, connectorID, clientID, sessionID = "u1", "mock", "web", "s1"
+
+ tests := []struct {
+ name string
+ call func(context.Context, api.DexServer) error
+ }{
+ {
+ name: "delete one session",
+ call: func(ctx context.Context, d api.DexServer) error {
+ _, err := d.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: sessionID})
+ return err
+ },
+ },
+ {
+ name: "terminate every session of a user",
+ call: func(ctx context.Context, d api.DexServer) error {
+ _, err := d.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{
+ UserId: userID,
+ })
+ return err
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := t.Context()
+ logger := newLogger(t)
+ s := memory.New(logger)
+
+ var mu sync.Mutex
+ var tokens []string
+ rp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.NoError(t, r.ParseForm())
+ mu.Lock()
+ tokens = append(tokens, r.PostForm.Get("logout_token"))
+ mu.Unlock()
+ }))
+ defer rp.Close()
+
+ require.NoError(t, s.CreateClient(ctx, storage.Client{
+ ID: clientID, Secret: "secret",
+ RedirectURIs: []string{"https://example.com/cb"},
+ BackchannelLogoutURI: rp.URL,
+ }))
+ require.NoError(t, s.CreateAuthSession(ctx, storage.AuthSession{
+ ID: sessionID, Secret: "session-secret",
+ UserID: userID, ConnectorID: connectorID,
+ CreatedAt: time.Now(), LastActivity: time.Now(),
+ ClientStates: map[string]*storage.ClientAuthState{clientID: {AuthenticatedAt: time.Now()}},
+ }))
+
+ sign, err := (&signer.MockConfig{}).Open(ctx)
+ require.NoError(t, err)
+
+ d := NewAPI(s, logger, "test", nil, nil, &backchannel.Notifier{
+ Storage: s, Signer: sign, IssuerURL: issuerURL(t), Logger: logger,
+ })
+
+ require.NoError(t, tc.call(ctx, d))
+
+ // Delivery is fire-and-forget, so the call returns before the POST lands.
+ require.Eventually(t, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(tokens) == 1
+ }, 2*time.Second, 10*time.Millisecond)
+
+ _, err = s.GetAuthSession(ctx, sessionID)
+ require.ErrorIs(t, err, storage.ErrNotFound)
+ })
+ }
+}
+
+func issuerURL(t *testing.T) oauth2.IssuerURL {
+ t.Helper()
+ u, err := url.Parse("https://dex.example.com")
+ require.NoError(t, err)
+ return oauth2.IssuerURL{URL: *u}
+}
diff --git a/server/authflow/authorize.go b/server/authflow/authorize.go
new file mode 100644
index 0000000000..259e12516e
--- /dev/null
+++ b/server/authflow/authorize.go
@@ -0,0 +1,186 @@
+package authflow
+
+// authorize.go handles the /auth authorization endpoint: parsing the request,
+// selecting a connector, and the browser-facing (HTML/redirect) error surface.
+
+import (
+ "context"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ conns "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/storage"
+)
+
+// grantTypeFromAuthRequest determines the grant type from the authorization request parameters.
+func (h *Handler) grantTypeFromAuthRequest(r *http.Request) string {
+ redirectURI := r.Form.Get("redirect_uri")
+ if redirectURI == oauth2.DeviceCallbackURI || strings.HasSuffix(redirectURI, oauth2.DeviceCallbackURI) {
+ return oauth2.GrantTypeDeviceCode
+ }
+ responseType := r.Form.Get("response_type")
+ for _, rt := range strings.Fields(responseType) {
+ if rt == "token" || rt == "id_token" {
+ return oauth2.GrantTypeImplicit
+ }
+ }
+ return oauth2.GrantTypeAuthorizationCode
+}
+
+// handleAuthorization handles the OAuth2 auth endpoint. It is both the entry and
+// the exit of the flow: a fresh request starts login, while a request carrying an
+// auth-request id (req) is the consent step returning to issue the response โ
+// issuance is the authorize endpoint's own job.
+func (h *Handler) handleAuthorization(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ // Extract the arguments
+ if err := r.ParseForm(); err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse arguments", "err", err)
+
+ h.renderError(r, w, http.StatusBadRequest, ErrMsgInvalidRequest)
+ return
+ }
+
+ // A request with an auth-request id is a step returning to the dispatcher.
+ if r.Form.Get("req") != "" {
+ h.handleContinue(w, r)
+ return
+ }
+
+ connectorID := r.Form.Get("connector_id")
+ allConnectors, err := h.Storage.ListConnectors(ctx)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to get list of connectors", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.")
+ return
+ }
+
+ // Determine the grant type from the authorization request to filter connectors.
+ grantType := h.grantTypeFromAuthRequest(r)
+ connectors := make([]storage.Connector, 0, len(allConnectors))
+ for _, c := range allConnectors {
+ if conns.GrantTypeAllowed(c.GrantTypes, grantType) {
+ connectors = append(connectors, c)
+ }
+ }
+
+ // Filter connectors based on the client's allowed connectors list.
+ // client_id is required per RFC 6749 ยง4.1.1.
+ client, authErr := h.getClientWithAuthError(ctx, r.Form.Get("client_id"))
+ if authErr != nil {
+ h.renderError(r, w, authErr.Status, authErr.Error())
+ return
+ }
+ connectors = conns.Filter(connectors, client.AllowedConnectors)
+
+ if len(connectors) == 0 {
+ h.renderError(r, w, http.StatusBadRequest, "No connectors available for this client.")
+ return
+ }
+
+ // We don't need connector_id any more
+ r.Form.Del("connector_id")
+
+ // Construct a URL with all of the arguments in its query
+ connURL := url.URL{
+ RawQuery: r.Form.Encode(),
+ }
+
+ // Redirect if a client chooses a specific connector_id
+ if connectorID != "" {
+ for _, c := range connectors {
+ if c.ID == connectorID {
+ connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(c.ID))
+ http.Redirect(w, r, connURL.String(), http.StatusFound)
+ return
+ }
+ }
+ h.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector")
+ return
+ }
+
+ if len(connectors) == 1 && !h.AlwaysShowLogin {
+ connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(connectors[0].ID))
+ http.Redirect(w, r, connURL.String(), http.StatusFound)
+ return
+ }
+
+ // Skip connector selection if a valid session exists, unless prompt=select_account or alwaysShowLogin.
+ if h.Sessions.Enabled() {
+ authReq, _, err := h.parseAuthorizationRequest(r)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err)
+
+ switch authErr := err.(type) {
+ case *redirectedAuthErr:
+ authErr.Handler().ServeHTTP(w, r)
+ case *displayedAuthErr:
+ h.renderError(r, w, authErr.Status, err.Error())
+ default:
+ panic("unsupported error type")
+ }
+ return
+ }
+ prompt, err := oauth2.ParsePrompt(authReq.Prompt)
+ if err != nil {
+ // Server error because authReq was validated before saving it to database.
+ h.redirectWithError(w, r, authReq, oauth2.ServerError, "Invalid authentication request")
+ return
+ }
+
+ // Invalid prompts will be validated and properly redirected later
+ if !h.AlwaysShowLogin && !prompt.SelectAccount() {
+ session := h.Sessions.ValidSession(ctx, w, r)
+ if session != nil {
+ for _, c := range connectors {
+ if c.ID != session.ConnectorID {
+ continue
+ }
+ connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(session.ConnectorID))
+ http.Redirect(w, r, connURL.String(), http.StatusFound)
+ return
+ }
+ }
+ }
+ if prompt.None() {
+ // Cannot authenticate silently with prompt=none.
+ h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "id_token_hint does not match authenticated user")
+ return
+ }
+ }
+
+ connectorInfos := make([]templates.ConnectorInfo, 0, len(connectors))
+ for _, conn := range connectors {
+ connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(conn.ID))
+ connectorInfos = append(connectorInfos, templates.ConnectorInfo{
+ ID: conn.ID,
+ Name: conn.Name,
+ Type: conn.Type,
+ URL: template.URL(connURL.String()),
+ })
+ }
+
+ if err := h.Templates.Login(r, w, connectorInfos); err != nil {
+ h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
+ }
+}
+
+// getClientWithAuthError retrieves a client by ID and returns a displayedAuthErr on failure.
+// Invalid client_id is not treated as a redirect error per RFC 6749 ยง4.1.2.1.
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
+func (h *Handler) getClientWithAuthError(ctx context.Context, clientID string) (storage.Client, *displayedAuthErr) {
+ client, err := h.Storage.GetClient(ctx, clientID)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID)
+ return storage.Client{}, newDisplayedErr(http.StatusBadRequest, "Invalid client_id provided.")
+ }
+ h.Logger.ErrorContext(ctx, "failed to get client", "client_id", clientID, "err", err)
+ return storage.Client{}, newDisplayedErr(http.StatusInternalServerError, "Database error.")
+ }
+ return client, nil
+}
diff --git a/server/authflow/callback.go b/server/authflow/callback.go
new file mode 100644
index 0000000000..bdc2ee6725
--- /dev/null
+++ b/server/authflow/callback.go
@@ -0,0 +1,114 @@
+package authflow
+
+// callback.go implements the connector callback mechanism: the return leg of
+// redirect-based connectors (OAuth2 callback and SAML POST binding).
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+
+ "github.com/gorilla/mux"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+func (h *Handler) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var authID string
+ switch r.Method {
+ case http.MethodGet: // OAuth2 callback
+ if authID = r.URL.Query().Get("state"); authID == "" {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ return
+ }
+ case http.MethodPost: // SAML POST binding
+ if authID = r.PostFormValue("RelayState"); authID == "" {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ return
+ }
+ default:
+ h.renderError(r, w, http.StatusBadRequest, "Method not supported")
+ return
+ }
+
+ authReq, err := h.Storage.GetAuthRequest(ctx, authID)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.Logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+ h.Logger.ErrorContext(r.Context(), "failed to get auth request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Database error.")
+ return
+ }
+
+ connID, err := url.PathUnescape(mux.Vars(r)["connector"])
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ } else if connID != "" && connID != authReq.ConnectorID {
+ h.Logger.ErrorContext(r.Context(), "connector mismatch: callback triggered for different connector than authentication start", "authentication_start_connector_id", authReq.ConnectorID, "connector_id", connID)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+
+ conn, err := h.Connectors.Get(ctx, authReq.ConnectorID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
+ return
+ }
+
+ var identity connector.Identity
+ switch conn := conn.Connector.(type) {
+ case connector.CallbackConnector:
+ if r.Method != http.MethodGet {
+ h.Logger.ErrorContext(r.Context(), "SAML request mapped to OAuth2 connector")
+ h.renderError(r, w, http.StatusBadRequest, "Invalid request")
+ return
+ }
+ identity, err = conn.HandleCallback(tokens.ParseScopes(authReq.Scopes), authReq.ConnectorData, r)
+ case connector.SAMLConnector:
+ if r.Method != http.MethodPost {
+ h.Logger.ErrorContext(r.Context(), "OAuth2 request mapped to SAML connector")
+ h.renderError(r, w, http.StatusBadRequest, "Invalid request")
+ return
+ }
+ identity, err = conn.HandlePOST(tokens.ParseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID)
+ default:
+ h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
+ return
+ }
+
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to authenticate", "err", err)
+ var groupsErr *connector.UserNotInRequiredGroupsError
+ if errors.As(err, &groupsErr) {
+ h.renderError(r, w, http.StatusForbidden, ErrMsgNotInRequiredGroups)
+ } else {
+ h.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed)
+ }
+ return
+ }
+
+ authReq, err = h.finalizeLogin(ctx, identity, authReq, conn.Connector)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to finalize login", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Login error.")
+ return
+ }
+
+ // Connector callbacks don't render the remember_me checkbox, so we use the server default.
+ // The password login handler reads r.FormValue("remember_me") from the submitted form instead.
+ rememberMe := h.Sessions.RememberMeDefault()
+ if err := h.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, rememberMe != nil && *rememberMe); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to create/update auth session", "err", err)
+ }
+
+ http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther)
+}
diff --git a/server/authflow/dispatch.go b/server/authflow/dispatch.go
new file mode 100644
index 0000000000..02d81d86d6
--- /dev/null
+++ b/server/authflow/dispatch.go
@@ -0,0 +1,113 @@
+package authflow
+
+// dispatch.go is the /auth flow dispatcher. After login and after every step,
+// the browser re-enters /auth carrying an HMAC verifier; the dispatcher inspects
+// the auth request and decides the next step โ an MFA factor, the consent screen,
+// or issuing the response. This mirrors hydra's authorize strategy, which routes
+// by which verifier (login/consent) is present. The steps only ever redirect
+// back here, never to one another.
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/dexidp/dex/server/consent"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/storage"
+)
+
+func (h *Handler) handleContinue(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ mac := r.FormValue("hmac")
+ if mac == "" {
+ h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
+ return
+ }
+ authReq, err := h.Storage.GetAuthRequest(ctx, r.FormValue("req"))
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ return
+ }
+ h.Logger.ErrorContext(ctx, "failed to get auth request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Database error.")
+ return
+ }
+ if !authReq.LoggedIn {
+ h.Logger.ErrorContext(ctx, "flow dispatcher reached for auth request without an identity")
+ h.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.")
+ return
+ }
+
+ // A step returns with the "continue" verifier (login, MFA) or the "approved"
+ // verifier (consent). The latter proves the user just approved, so consent is
+ // resolved for this request even under prompt=consent / ForceApprovalPrompt.
+ consentApproved := internal.VerifyStep(authReq, mac, internal.StepApproved)
+ if !consentApproved && !internal.VerifyStep(authReq, mac, internal.StepContinue) {
+ h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
+ return
+ }
+
+ h.dispatch(w, r, authReq, consentApproved)
+}
+
+// dispatch runs the auth request's next step. It is the single place the
+// post-identity decision lives: MFA, then consent, then issuance. Each
+// user-facing step is forbidden under prompt=none, which allows only a silent
+// issue.
+func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, consentApproved bool) {
+ ctx := r.Context()
+ prompt, _ := oauth2.ParsePrompt(authReq.Prompt)
+
+ // MFA: if the client requires it and it is not yet satisfied, hand off to the
+ // MFA entry, which resolves the effective chain and picks the factor. The
+ // dispatcher only decides that MFA applies โ the requested chain is client
+ // state (client.MFAChain, else the server default), not a query into MFA.
+ if h.MFAEnabled && !authReq.MFAValidated {
+ required, err := h.mfaRequired(ctx, authReq.ClientID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to determine MFA requirement", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, ErrMsgInternalServerError)
+ return
+ }
+ if required {
+ if prompt.None() {
+ h.redirectWithError(w, r, &authReq, oauth2.InteractionRequired, "User interaction required")
+ return
+ }
+ http.Redirect(w, r, h.buildMFAURL(authReq), http.StatusSeeOther)
+ return
+ }
+ }
+
+ // Consent: the "approved" verifier resolves it for this request; otherwise ask
+ // whether it can be skipped from persisted state.
+ if !consentApproved && !consent.Satisfied(ctx, h.Storage, h.SkipApproval, &authReq) {
+ if prompt.None() {
+ h.redirectWithError(w, r, &authReq, oauth2.InteractionRequired, "User interaction required")
+ return
+ }
+ http.Redirect(w, r, h.buildApprovalURL(authReq), http.StatusSeeOther)
+ return
+ }
+
+ // Fully authorized โ issue the response.
+ h.writeResponse(w, r, authReq)
+}
+
+// mfaRequired reports whether the client requests any MFA โ its own chain, or the
+// server default when the client sets none. This is only the dispatcher's cheap
+// gate; the MFA entry does the precise, provider-aware resolution and, when
+// nothing applies, records MFA as satisfied so control does not return here.
+func (h *Handler) mfaRequired(ctx context.Context, clientID string) (bool, error) {
+ client, err := h.Storage.GetClient(ctx, clientID)
+ if err != nil {
+ return false, err
+ }
+ chain := client.MFAChain
+ if chain == nil {
+ chain = h.DefaultMFAChain
+ }
+ return len(chain) > 0, nil
+}
diff --git a/server/authflow/doc.go b/server/authflow/doc.go
new file mode 100644
index 0000000000..012d906d51
--- /dev/null
+++ b/server/authflow/doc.go
@@ -0,0 +1,20 @@
+// Package authflow implements dex's interactive, browser-facing authorization
+// flow: the /auth authorization endpoint, connector and password login, the
+// session (SSO) shortcut, and the connector callback.
+//
+// The flow is a state machine over a storage.AuthRequest. /auth is the
+// dispatcher (dispatch.go): it parses the request, starts login, and on each
+// return decides the next step from persisted state โ hand off to MFA, to the
+// consent screen, or issue the response (response.go). Steps never route to one
+// another; each returns to /auth carrying an HMAC verifier that proves the
+// transition ("continue" after login or a factor, "approved" after consent).
+//
+// /auth parse the request; pick a connector, reuse a session, or dispatch the next step
+// /auth/{c}, .../login connector or password login -> finalizeLogin -> /auth
+// /callback connector callback -> finalizeLogin -> /auth
+//
+// The MFA, consent and logout steps live in sibling packages (server/mfa,
+// server/consent, server/logout); they mount their own routes (/mfa, /approval,
+// /logout), and the dispatcher sends users there and back. Shared session state
+// lives in server/session (cookie, SSO, auth-session CRUD).
+package authflow
diff --git a/server/authflow/errors.go b/server/authflow/errors.go
new file mode 100644
index 0000000000..05291c513e
--- /dev/null
+++ b/server/authflow/errors.go
@@ -0,0 +1,30 @@
+package authflow
+
+// Safe error messages for user-facing responses.
+// These messages are intentionally generic to avoid leaking internal details.
+// All actual error details should be logged server-side.
+
+const (
+ // ErrMsgLoginError is a generic login error message shown to users.
+ // Used when authentication fails due to internal server errors.
+ ErrMsgLoginError = "Login error. Please contact your administrator or try again later."
+
+ // ErrMsgAuthenticationFailed is shown when callback/SAML authentication fails.
+ ErrMsgAuthenticationFailed = "Authentication failed. Please contact your administrator or try again later."
+
+ // ErrMsgInternalServerError is a generic internal server error message.
+ ErrMsgInternalServerError = "Internal server error. Please contact your administrator or try again later."
+
+ // ErrMsgDatabaseError is shown when database operations fail.
+ ErrMsgDatabaseError = "A database error occurred. Please try again later."
+
+ // ErrMsgInvalidRequest is shown when request parsing fails.
+ ErrMsgInvalidRequest = "Invalid request. Please try again."
+
+ // ErrMsgMethodNotAllowed is shown when an unsupported HTTP method is used.
+ ErrMsgMethodNotAllowed = "Method not allowed."
+
+ // ErrMsgNotInRequiredGroups is shown when a user authenticates successfully
+ // but is not a member of any of the groups required by the connector.
+ ErrMsgNotInRequiredGroups = "You are not a member of any of the required groups to authenticate."
+)
diff --git a/server/authflow/errors_test.go b/server/authflow/errors_test.go
new file mode 100644
index 0000000000..1688900bd3
--- /dev/null
+++ b/server/authflow/errors_test.go
@@ -0,0 +1,69 @@
+package authflow
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestRenderErrorSafeMessages tests that renderError uses safe messages.
+func TestRenderErrorSafeMessages(t *testing.T) {
+ tests := []struct {
+ name string
+ statusCode int
+ message string
+ expectedInBody []string
+ notInBody []string
+ }{
+ {
+ name: "Login error message",
+ statusCode: http.StatusInternalServerError,
+ message: ErrMsgLoginError,
+ expectedInBody: []string{"Login error", "administrator"},
+ notInBody: []string{"stack", "panic", ".go:"},
+ },
+ {
+ name: "Authentication failed message",
+ statusCode: http.StatusInternalServerError,
+ message: ErrMsgAuthenticationFailed,
+ expectedInBody: []string{"Authentication failed", "administrator"},
+ notInBody: []string{"stack", "panic", ".go:"},
+ },
+ {
+ name: "Database error message",
+ statusCode: http.StatusInternalServerError,
+ message: ErrMsgDatabaseError,
+ expectedInBody: []string{"database error"},
+ notInBody: []string{"sql:", "connection", "timeout"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, s := newTestHandler(t, nil)
+
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/", nil)
+
+ s.renderError(req, rr, tc.statusCode, tc.message)
+
+ resp := rr.Result()
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ bodyStr := string(body)
+
+ require.Equal(t, tc.statusCode, resp.StatusCode)
+
+ for _, expected := range tc.expectedInBody {
+ require.Contains(t, bodyStr, expected, "Response should contain: %s", expected)
+ }
+ for _, notExpected := range tc.notInBody {
+ require.NotContains(t, bodyStr, notExpected, "Response should not contain: %s", notExpected)
+ }
+ })
+ }
+}
diff --git a/server/authflow/finalize.go b/server/authflow/finalize.go
new file mode 100644
index 0000000000..33c0bdbeba
--- /dev/null
+++ b/server/authflow/finalize.go
@@ -0,0 +1,152 @@
+package authflow
+
+// finalize.go implements the post-authentication step shared by every login
+// mechanism: it persists the identity onto the AuthRequest, records the offline
+// session and the user identity, then returns the finalized request.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// finalizeLogin associates the user's identity with the current AuthRequest, then returns
+// the approval page's path.
+func (h *Handler) finalizeLogin(ctx context.Context, identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (storage.AuthRequest, error) {
+ // Refuse to complete login for a locked account. BlockedUntil lives on the
+ // persisted UserIdentity, which only exists when the sessions feature is on;
+ // a first-time login (no stored identity yet) cannot be blocked.
+ if h.Sessions.Enabled() {
+ storedIdentity, err := h.Storage.GetUserIdentity(ctx, identity.UserID, authReq.ConnectorID)
+ switch {
+ case err == nil:
+ if !storedIdentity.BlockedUntil.IsZero() && h.Now().Before(storedIdentity.BlockedUntil) {
+ h.Logger.WarnContext(ctx, "login rejected for locked account",
+ "connector_id", authReq.ConnectorID, "user_id", identity.UserID, "blocked_until", storedIdentity.BlockedUntil)
+ return storage.AuthRequest{}, fmt.Errorf("account is locked until %s", storedIdentity.BlockedUntil.Format(time.RFC3339))
+ }
+ case !errors.Is(err, storage.ErrNotFound):
+ return storage.AuthRequest{}, fmt.Errorf("failed to look up user identity: %w", err)
+ }
+ }
+
+ claims := storage.Claims{
+ UserID: identity.UserID,
+ Username: identity.Username,
+ PreferredUsername: identity.PreferredUsername,
+ Email: identity.Email,
+ EmailVerified: identity.EmailVerified,
+ Groups: identity.Groups,
+ }
+
+ updater := func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.LoggedIn = true
+ a.Claims = claims
+ a.ConnectorData = identity.ConnectorData
+ a.AuthTime = h.Now()
+ return a, nil
+ }
+ if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, updater); err != nil {
+ return storage.AuthRequest{}, fmt.Errorf("failed to update auth request: %v", err)
+ }
+ // Keep the in-memory copy in sync with what was persisted so later reads
+ // (the next-step decision below) see the identity we just stored.
+ authReq, _ = updater(authReq)
+
+ email := claims.Email
+ if !claims.EmailVerified {
+ email += " (unverified)"
+ }
+
+ h.Logger.InfoContext(ctx, "login successful",
+ "connector_id", authReq.ConnectorID, "user_id", claims.UserID,
+ "username", claims.Username, "preferred_username", claims.PreferredUsername,
+ "email", email, "groups", claims.Groups)
+
+ offlineAccessRequested := false
+ for _, scope := range authReq.Scopes {
+ if scope == tokens.ScopeOfflineAccess {
+ offlineAccessRequested = true
+ break
+ }
+ }
+ _, canRefresh := conn.(connector.RefreshConnector)
+
+ if offlineAccessRequested && canRefresh {
+ // Try to retrieve an existing OfflineSession object for the corresponding user.
+ session, err := h.Storage.GetOfflineSessions(ctx, identity.UserID, authReq.ConnectorID)
+ switch {
+ case err != nil && err == storage.ErrNotFound:
+ offlineSessions := storage.OfflineSessions{
+ UserID: identity.UserID,
+ ConnID: authReq.ConnectorID,
+ Refresh: make(map[string]*storage.RefreshTokenRef),
+ ConnectorData: identity.ConnectorData,
+ }
+
+ // Create a new OfflineSession object for the user and add a reference object for
+ // the newly received refreshtoken.
+ if err := h.Storage.CreateOfflineSessions(ctx, offlineSessions); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to create offline session", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ case err == nil:
+ // Update existing OfflineSession obj with new RefreshTokenRef.
+ if err := h.Storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
+ if len(identity.ConnectorData) > 0 {
+ old.ConnectorData = identity.ConnectorData
+ }
+ return old, nil
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to update offline session", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ default:
+ h.Logger.ErrorContext(ctx, "failed to get offline session", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ }
+
+ // Create or update UserIdentity to persist user claims across sessions.
+ if h.Sessions.Enabled() {
+ now := h.Now()
+
+ _, err := h.Storage.GetUserIdentity(ctx, identity.UserID, authReq.ConnectorID)
+ switch {
+ case err != nil && errors.Is(err, storage.ErrNotFound):
+ ui := storage.UserIdentity{
+ UserID: identity.UserID,
+ ConnectorID: authReq.ConnectorID,
+ Claims: claims,
+ Consents: make(map[string][]string),
+ CreatedAt: now,
+ LastLogin: now,
+ }
+ if err := h.Storage.CreateUserIdentity(ctx, ui); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to create user identity", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ case err == nil:
+ if err := h.Storage.UpdateUserIdentity(ctx, identity.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ old.Claims = claims
+ old.LastLogin = now
+ return old, nil
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to update user identity", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ default:
+ h.Logger.ErrorContext(ctx, "failed to get user identity", "err", err)
+ return storage.AuthRequest{}, err
+ }
+ }
+
+ // The identity is persisted; return the finalized request so the caller can
+ // create the session and advance the flow.
+ return h.Storage.GetAuthRequest(ctx, authReq.ID)
+}
diff --git a/server/authflow/finalize_test.go b/server/authflow/finalize_test.go
new file mode 100644
index 0000000000..8dea85837c
--- /dev/null
+++ b/server/authflow/finalize_test.go
@@ -0,0 +1,60 @@
+package authflow
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/storage"
+)
+
+func TestFinalizeLoginBlockedAccount(t *testing.T) {
+ t.Setenv("DEX_SESSIONS_ENABLED", "true")
+
+ httpServer, server := newTestHandler(t, func(c *testFlowConfig) {
+ c.SessionConfig = &session.Config{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour}
+ })
+ defer httpServer.Close()
+
+ ctx := t.Context()
+
+ ident := connector.Identity{UserID: "user-1", Email: "user@example.com"}
+ authReq := storage.AuthRequest{
+ ID: "login-req",
+ ClientID: "example-app",
+ Expiry: time.Now().Add(time.Hour),
+ ConnectorID: "mock",
+ }
+ require.NoError(t, server.Storage.CreateAuthRequest(ctx, authReq))
+ require.NoError(t, server.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Email: "user@example.com"},
+ Consents: map[string][]string{},
+ MFASecrets: map[string]*storage.MFASecret{},
+ WebAuthnCredentials: map[string][]storage.WebAuthnCredential{},
+ CreatedAt: time.Now(),
+ LastLogin: time.Now(),
+ BlockedUntil: time.Now().Add(time.Hour),
+ }))
+
+ // Blocked: finalizeLogin must reject without marking the request logged in.
+ _, err := server.finalizeLogin(ctx, ident, authReq, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "locked")
+
+ updated, err := server.Storage.GetAuthRequest(ctx, authReq.ID)
+ require.NoError(t, err)
+ require.False(t, updated.LoggedIn, "blocked account must not be logged in")
+
+ // Clear the block: login should now proceed.
+ require.NoError(t, server.Storage.UpdateUserIdentity(ctx, "user-1", "mock", func(u storage.UserIdentity) (storage.UserIdentity, error) {
+ u.BlockedUntil = time.Time{}
+ return u, nil
+ }))
+ _, err = server.finalizeLogin(ctx, ident, authReq, nil)
+ require.NoError(t, err)
+}
diff --git a/server/authflow/handler.go b/server/authflow/handler.go
new file mode 100644
index 0000000000..c24731d6a9
--- /dev/null
+++ b/server/authflow/handler.go
@@ -0,0 +1,78 @@
+package authflow
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// Handler serves the interactive login flow (connector selection, connector and
+// password login, the callback) and the /auth dispatcher that decides each next
+// step and issues the response. The /auth endpoint is the flow dispatcher: it
+// starts login, then on each return decides the next step (MFA, consent) or
+// issues. It decides those from persisted state and config alone โ it holds no
+// reference to the MFA or consent handlers; each step only redirects back to
+// /auth, never to another step.
+type Handler struct {
+ IssuerURL oauth2.IssuerURL
+ Connectors *connectors.Cache
+ Storage storage.Storage
+ Templates *templates.Templates
+ Signer signer.Signer
+ Now func() time.Time
+ Logger *slog.Logger
+ AlwaysShowLogin bool
+ SupportedResponseTypes map[string]bool
+ PKCE PKCEConfig
+ AuthRequestsValidFor time.Duration
+
+ // Sessions owns the session cookie, SSO lookup and auth-session CRUD.
+ Sessions *session.Manager
+ // Issuer mints tokens for the authorization response (see response.go).
+ Issuer *tokens.Issuer
+
+ // MFAEnabled reports whether any authenticator is configured; DefaultMFAChain
+ // is the chain applied to clients that set none. Together they let the
+ // dispatcher gate MFA without the MFA handler โ see mfaRequired.
+ MFAEnabled bool
+ DefaultMFAChain []string
+ // SkipApproval disables the consent screen server-wide (see consent.Satisfied).
+ SkipApproval bool
+}
+
+// Mount registers the login routes. The /auth endpoint is both the entry
+// (login) and the exit (issuance, see response.go). The mfa, consent and logout
+// steps are mounted separately by the server.
+func (h *Handler) Mount(m router.Mux) {
+ m.HandleFunc("/auth", h.handleAuthorization)
+ m.HandleFunc("/auth/{connector}", h.handleConnectorLogin)
+ m.HandleFunc("/auth/{connector}/login", h.handlePasswordLogin)
+ // The bare /callback serves OAuth/OIDC redirects, where X-Remote-* never
+ // belongs, so strip it: a client must not spoof the authproxy connector here.
+ // The /callback/{connector} route is authproxy's own and passes them through.
+ m.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
+ stripRemoteHeaders(r)
+ h.handleConnectorCallback(w, r)
+ })
+ m.HandleFunc("/callback/{connector}", h.handleConnectorCallback)
+}
+
+// stripRemoteHeaders drops the X-Remote-* request headers the authproxy
+// connector trusts, so they cannot be forged on a route that does not set them.
+func stripRemoteHeaders(r *http.Request) {
+ for key := range r.Header {
+ if strings.HasPrefix(strings.ToLower(key), "x-remote-") {
+ r.Header.Del(key)
+ }
+ }
+}
diff --git a/server/authflow/handler_test.go b/server/authflow/handler_test.go
new file mode 100644
index 0000000000..ed7ccc2b90
--- /dev/null
+++ b/server/authflow/handler_test.go
@@ -0,0 +1,180 @@
+package authflow
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/connector/mock"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/consent"
+ "github.com/dexidp/dex/server/logout"
+ "github.com/dexidp/dex/server/mfa"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+ dexweb "github.com/dexidp/dex/web"
+)
+
+func newLogger(t *testing.T) *slog.Logger {
+ return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug}))
+}
+
+// testResolveConnector is the connector resolver used by the flow's unit tests.
+// They set connectors in the cache directly; the mock callback connector covers
+// the few paths that open one.
+func testResolveConnector(conn storage.Connector) (connector.Connector, error) {
+ return mock.NewCallbackConnector(nil), nil
+}
+
+// testMux adapts a gorilla router to router.Mux so a Handler can mount its
+// routes (the handlers read path variables with mux.Vars).
+type testMux struct{ r *mux.Router }
+
+func (m testMux) Handle(p string, h http.Handler) { m.r.Handle(p, h) }
+func (m testMux) HandleFunc(p string, h http.HandlerFunc) { m.r.HandleFunc(p, h) }
+func (m testMux) HandleCORS(p string, h http.HandlerFunc) { m.r.HandleFunc(p, h) }
+func (m testMux) HandlePrefix(p string, h http.Handler) {
+ m.r.PathPrefix(p).Handler(http.StripPrefix(p, h))
+}
+
+// testServer wraps a Handler with the router it is mounted on so tests can both
+// call flow methods directly (promoted from the embedded Handler) and drive it
+// over HTTP via ServeHTTP.
+type testServer struct {
+ *Handler
+ mux http.Handler
+}
+
+func (ts *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ ts.mux.ServeHTTP(w, r)
+}
+
+// testFlowConfig bundles the login Config with the raw inputs the server uses to
+// build the shared flow components, so a test can tweak either before assembly.
+type testFlowConfig struct {
+ Handler
+ SessionConfig *session.Config
+ MFAProviders map[string]mfa.Provider
+ DefaultMFAChain []string
+ SkipApproval bool
+}
+
+// newTestHandler builds the login flow and its shared components wired to an
+// httptest server, assembling them exactly as the server package does.
+// updateConfig may tweak the config before the components are built.
+func newTestHandler(t *testing.T, updateConfig func(c *testFlowConfig)) (*httptest.Server, *testServer) {
+ t.Helper()
+ logger := newLogger(t)
+ ctx := t.Context()
+
+ sig, err := signer.NewMockSigner(testKey)
+ require.NoError(t, err)
+
+ store := memory.New(logger)
+
+ var handler http.Handler
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler.ServeHTTP(w, r)
+ }))
+ t.Cleanup(srv.Close)
+
+ issuerURL, err := url.Parse(srv.URL)
+ require.NoError(t, err)
+
+ //nolint:dogsled // only the templates are needed here
+ _, _, _, tmpls, err := templates.LoadWebConfig(templates.Config{
+ WebFS: dexweb.FS(),
+ IssuerURL: srv.URL,
+ })
+ require.NoError(t, err)
+
+ now := func() time.Time { return time.Now() }
+ conns := connectors.NewCache(store, testResolveConnector)
+ issuer := tokens.NewIssuer(store, sig, *issuerURL, 24*time.Hour, now, logger)
+
+ tc := testFlowConfig{
+ Handler: Handler{
+ IssuerURL: oauth2.IssuerURL{URL: *issuerURL},
+ Connectors: conns,
+ Storage: store,
+ Templates: tmpls,
+ Signer: sig,
+ Now: now,
+ Logger: logger,
+ SupportedResponseTypes: map[string]bool{"code": true, "token": true, "id_token": true},
+ PKCE: PKCEConfig{CodeChallengeMethodsSupported: []string{"S256", "plain"}},
+ AuthRequestsValidFor: 24 * time.Hour,
+ },
+ SkipApproval: true,
+ }
+ if updateConfig != nil {
+ updateConfig(&tc)
+ }
+
+ // Assemble the flow the same way the server does: shared infrastructure plus
+ // independent step handlers that hand off by redirect.
+ sessions := &session.Manager{Storage: store, Config: tc.SessionConfig, Now: now, Logger: logger, IssuerURL: oauth2.IssuerURL{URL: *issuerURL}}
+ mfaManager := &mfa.Handler{IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, Storage: store, Templates: tmpls, Logger: logger, MFAProviders: tc.MFAProviders, DefaultMFAChain: tc.DefaultMFAChain, Now: now, Connectors: conns}
+ consentManager := &consent.Handler{IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, Storage: store, Templates: tmpls, Logger: logger, Sessions: sessions, SkipApproval: tc.SkipApproval}
+ logoutManager := &logout.Handler{Storage: store, Templates: tmpls, Logger: logger, Sessions: sessions, Connectors: conns, Issuer: issuer, Signer: sig, IssuerURL: oauth2.IssuerURL{URL: *issuerURL}}
+
+ tc.Sessions = sessions
+ tc.Issuer = issuer
+ tc.Handler.MFAEnabled = len(tc.MFAProviders) > 0
+ tc.Handler.DefaultMFAChain = tc.DefaultMFAChain
+ tc.Handler.SkipApproval = tc.SkipApproval
+
+ h := &tc.Handler
+
+ router := mux.NewRouter()
+ h.Mount(testMux{router})
+ mfaManager.Mount(testMux{router})
+ consentManager.Mount(testMux{router})
+ logoutManager.Mount(testMux{router})
+ handler = router
+
+ for _, id := range []string{"mock", "mock2"} {
+ require.NoError(t, store.CreateConnector(ctx, storage.Connector{
+ ID: id,
+ Type: "mockCallback",
+ Name: "Mock",
+ ResourceVersion: "1",
+ }))
+ }
+
+ return srv, &testServer{Handler: h, mux: router}
+}
+
+// testKey is a throwaway RSA key for the mock signer; the flow's unit tests
+// don't verify signatures, so a freshly generated key is enough.
+var testKey = func() *rsa.PrivateKey {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ panic(err)
+ }
+ return key
+}()
+
+// toResponseTypeSet converts a list of response types to the set form the
+// Handler expects.
+func toResponseTypeSet(types []string) map[string]bool {
+ m := make(map[string]bool, len(types))
+ for _, t := range types {
+ m[t] = true
+ }
+ return m
+}
diff --git a/server/authflow/login.go b/server/authflow/login.go
new file mode 100644
index 0000000000..e0584a7e5c
--- /dev/null
+++ b/server/authflow/login.go
@@ -0,0 +1,225 @@
+package authflow
+
+// login.go is the login entry point: it validates the chosen connector against
+// the client and OIDC prompt/session rules, then kicks off that connector's
+// mechanism (redirect for OAuth2/SAML, the password form for password
+// connectors).
+
+import (
+ "fmt"
+ "maps"
+ "net/http"
+ "net/url"
+
+ "github.com/gorilla/mux"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+func (h *Handler) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ authReq, hintSubject, err := h.parseAuthorizationRequest(r)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err)
+
+ switch authErr := err.(type) {
+ case *redirectedAuthErr:
+ authErr.Handler().ServeHTTP(w, r)
+ case *displayedAuthErr:
+ h.renderError(r, w, authErr.Status, err.Error())
+ default:
+ panic("unsupported error type")
+ }
+
+ return
+ }
+
+ connID, err := url.PathUnescape(mux.Vars(r)["connector"])
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist")
+ return
+ }
+
+ // Validate that the connector is allowed for this client.
+ client, authErr := h.getClientWithAuthError(ctx, authReq.ClientID)
+ if authErr != nil {
+ h.renderError(r, w, authErr.Status, authErr.Error())
+ return
+ }
+ if !connectors.ConnectorAllowed(client.AllowedConnectors, connID) {
+ h.Logger.ErrorContext(r.Context(), "connector not allowed for client",
+ "connector_id", connID, "client_id", authReq.ClientID)
+ h.renderError(r, w, http.StatusForbidden, "Connector not allowed for this client.")
+ return
+ }
+
+ conn, err := h.Connectors.Get(ctx, connID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "Failed to get connector", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Connector failed to initialize")
+ return
+ }
+
+ // Check if the connector allows the requested grant type.
+ grantType := h.grantTypeFromAuthRequest(r)
+ if !connectors.GrantTypeAllowed(conn.GrantTypes, grantType) {
+ h.Logger.ErrorContext(r.Context(), "connector does not allow requested grant type",
+ "connector_id", connID, "grant_type", grantType)
+ h.renderError(r, w, http.StatusBadRequest, "Requested connector does not support this grant type.")
+ return
+ }
+
+ // Set the connector being used for the login.
+ if authReq.ConnectorID != "" && authReq.ConnectorID != connID {
+ h.Logger.ErrorContext(r.Context(), "mismatched connector ID in auth request",
+ "auth_request_connector_id", authReq.ConnectorID, "connector_id", connID)
+ h.renderError(r, w, http.StatusBadRequest, "Bad connector ID")
+ return
+ }
+
+ authReq.ConnectorID = connID
+
+ // Actually create the auth request
+ authReq.Expiry = h.Now().Add(h.AuthRequestsValidFor)
+ if err := h.Storage.CreateAuthRequest(ctx, *authReq); err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to create authorization request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.")
+ return
+ }
+
+ // Handle OIDC prompt parameter and session-based login.
+ prompt, err := oauth2.ParsePrompt(authReq.Prompt)
+ if err != nil {
+ // Server error because authReq was validated before saving it to database.
+ h.redirectWithError(w, r, authReq, oauth2.ServerError, "Invalid authentication request")
+ return
+ }
+ // handle prompt only if sessions are enabled
+ if h.Sessions.Enabled() {
+ // Retrieve the session once for use in both hint and prompt logic.
+ session := h.Sessions.ValidAuthSession(ctx, w, r, authReq)
+
+ // id_token_hint logic (OIDC Core 1.0 3.1.2.1):
+ // When a hint is provided, verify that the session user matches.
+ if hintSubject != "" {
+ if !sessionMatchesHint(session, hintSubject) {
+ // Clear the session if the user is different from the hint.
+ session = nil
+ }
+ if session == nil && prompt.None() {
+ // Cannot authenticate silently with prompt=none.
+ h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "id_token_hint does not match authenticated user")
+ return
+ }
+ }
+
+ // prompt=none: no UI allowed.
+ if prompt.None() {
+ // prompt=none: no UI allowed. advance reports interaction_required if the
+ // session login can't complete silently; a missing session is login_required.
+ if !h.trySessionLoginWithSession(ctx, r, w, authReq, session) {
+ h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "User not authenticated")
+ }
+ return
+ }
+
+ if !prompt.Login() {
+ // Normal flow: try session-based login (skip if prompt=login forces re-auth).
+ if h.trySessionLoginWithSession(ctx, r, w, authReq, session) {
+ return
+ }
+ }
+ }
+
+ scopes := tokens.ParseScopes(authReq.Scopes)
+
+ // Work out where the "Select another login method" link should go.
+ // Include prompt=select_account so that handleAuthorization skips
+ // session-based connector reuse and shows the connector list.
+ backLink := ""
+ if h.Connectors.Len() > 1 {
+ backLinkParams := make(url.Values)
+ maps.Copy(backLinkParams, r.Form)
+ if h.Sessions.Enabled() {
+ backLinkParams.Set("prompt", "select_account")
+ }
+ backLinkURL := url.URL{
+ Path: h.IssuerURL.AbsPath("/auth"),
+ RawQuery: backLinkParams.Encode(),
+ }
+ backLink = backLinkURL.String()
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ switch conn := conn.Connector.(type) {
+ case connector.CallbackConnector:
+ // Use the auth request ID as the "state" token.
+ //
+ // TODO(ericchiang): Is this appropriate or should we also be using a nonce?
+ callbackURL, connData, err := conn.LoginURL(scopes, h.IssuerURL.AbsURL("/callback"), authReq.ID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "connector returned error when creating callback", "connector_id", connID, "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Login error.")
+ return
+ }
+ if len(connData) > 0 {
+ updater := func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.ConnectorData = connData
+ return a, nil
+ }
+ err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, updater)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "Failed to set connector data on auth request", "connector_id", connID, "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Database error.")
+ return
+ }
+ }
+ http.Redirect(w, r, callbackURL, http.StatusFound)
+ case connector.PasswordConnector:
+ loginURL := url.URL{
+ Path: h.IssuerURL.AbsPath("/auth", connID, "login"),
+ }
+ q := loginURL.Query()
+ q.Set("state", authReq.ID)
+ q.Set("back", backLink)
+ loginURL.RawQuery = q.Encode()
+
+ http.Redirect(w, r, loginURL.String(), http.StatusFound)
+ case connector.SAMLConnector:
+ action, value, err := conn.POSTData(scopes, authReq.ID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "creating SAML data", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Connector Login Error")
+ return
+ }
+
+ // TODO(ericchiang): Don't inline this.
+ fmt.Fprintf(w, `
+
+
+
+ SAML login
+
+
+
+
+
+ `, action, value, authReq.ID)
+ default:
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ }
+ default:
+ h.renderError(r, w, http.StatusBadRequest, "Unsupported request method.")
+ }
+}
diff --git a/server/authflow/password.go b/server/authflow/password.go
new file mode 100644
index 0000000000..e38d54d8a9
--- /dev/null
+++ b/server/authflow/password.go
@@ -0,0 +1,163 @@
+package authflow
+
+// password.go implements the password-credential login mechanism: the login
+// form and the credential check for password connectors.
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/gorilla/mux"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+func (h *Handler) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ authID := r.URL.Query().Get("state")
+ if authID == "" {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ return
+ }
+
+ backLink := sanitizeBackLink(r.URL.Query().Get("back"))
+
+ authReq, err := h.Storage.GetAuthRequest(ctx, authID)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.Logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+ h.Logger.ErrorContext(r.Context(), "failed to get auth request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Database error.")
+ return
+ }
+
+ connID, err := url.PathUnescape(mux.Vars(r)["connector"])
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist")
+ return
+ } else if connID != "" && connID != authReq.ConnectorID {
+ h.Logger.ErrorContext(r.Context(), "connector mismatch: password login triggered for different connector from authentication start", "start_connector_id", authReq.ConnectorID, "password_connector_id", connID)
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+
+ conn, err := h.Connectors.Get(ctx, authReq.ConnectorID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Connector failed to initialize.")
+ return
+ }
+
+ pwConn, ok := conn.Connector.(connector.PasswordConnector)
+ if !ok {
+ h.Logger.ErrorContext(r.Context(), "expected password connector in handlePasswordLogin()", "password_connector", pwConn)
+ h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
+ return
+ }
+
+ rememberMe := h.Sessions.RememberMeDefault()
+
+ switch r.Method {
+ case http.MethodGet:
+ // Before rendering the password form, allow connectors that support SPNEGO to try Kerberos auth.
+ if sp, ok := pwConn.(connector.SPNEGOAware); ok {
+ scopes := tokens.ParseScopes(authReq.Scopes)
+ if ident, handled, err := sp.TrySPNEGO(ctx, scopes, w, r); bool(handled) {
+ if err != nil {
+ // SPNEGO handled the request but reported an error (e.g., LDAP lookup failed
+ // after successful Kerberos auth). Log error details, show generic message to user.
+ h.Logger.ErrorContext(ctx, "SPNEGO authentication error", "err", err)
+ h.renderError(r, w, http.StatusUnauthorized, ErrMsgAuthenticationFailed)
+ return
+ }
+ if ident != nil {
+ authReq, err = h.finalizeLogin(ctx, *ident, authReq, conn.Connector)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to finalize login", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Login error.")
+ return
+ }
+ http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther)
+ return
+ }
+ // handled with no identity typically means the SPNEGO middleware
+ // wrote its own 401 (bare challenge, continuation, or reject); do
+ // not render the password form on top of it.
+ return
+ }
+ }
+ if err := h.Templates.Password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, rememberMe); err != nil {
+ h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
+ }
+ case http.MethodPost:
+ username := r.FormValue("login")
+ password := r.FormValue("password")
+ scopes := tokens.ParseScopes(authReq.Scopes)
+
+ identity, ok, err := pwConn.Login(r.Context(), scopes, username, password)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to login user", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, ErrMsgLoginError)
+ return
+ }
+ if !ok {
+ if err := h.Templates.Password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, rememberMe); err != nil {
+ h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
+ }
+ h.Logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username)
+ return
+ }
+ authReq, err = h.finalizeLogin(r.Context(), identity, authReq, conn.Connector)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to finalize login", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Login error.")
+ return
+ }
+
+ rememberMe := r.FormValue("remember_me") == "on"
+ if err := h.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, rememberMe); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to create/update auth session", "err", err)
+ }
+
+ http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther)
+ default:
+ h.renderError(r, w, http.StatusBadRequest, "Unsupported request method.")
+ }
+}
+
+// sanitizeBackLink permits only a same-origin absolute path as the "Select
+// another login method" target. The legitimate value is always a rooted path
+// built from the issuer path (see login.go), so anything that could redirect
+// off-origin โ an absolute URL, a scheme-relative "//host" or "/\host" that
+// browsers treat as protocol-relative, or a value that fails to parse โ is
+// dropped rather than rendered as a link (open-redirect prevention).
+func sanitizeBackLink(back string) string {
+ if back == "" {
+ return ""
+ }
+ u, err := url.Parse(back)
+ if err != nil || u.IsAbs() || u.Host != "" {
+ return ""
+ }
+ if back[0] != '/' {
+ return ""
+ }
+ if len(back) >= 2 && (back[1] == '/' || back[1] == '\\') {
+ return ""
+ }
+ return back
+}
+
+// Check for username prompt override from connector. Defaults to "Username".
+func usernamePrompt(conn connector.PasswordConnector) string {
+ if attr := conn.Prompt(); attr != "" {
+ return attr
+ }
+ return "Username"
+}
diff --git a/server/authflow/password_test.go b/server/authflow/password_test.go
new file mode 100644
index 0000000000..6be2192a06
--- /dev/null
+++ b/server/authflow/password_test.go
@@ -0,0 +1,23 @@
+package authflow
+
+import "testing"
+
+func TestSanitizeBackLink(t *testing.T) {
+ tests := map[string]string{
+ "": "",
+ "/auth?prompt=select_account": "/auth?prompt=select_account",
+ "/dex/auth?client_id=x": "/dex/auth?client_id=x",
+ "https://evil.example": "",
+ "http://evil.example/auth": "",
+ "//evil.example": "",
+ "/\\evil.example": "",
+ "javascript:alert(1)": "",
+ "relative/path": "", // not rooted
+ "/auth#frag": "/auth#frag",
+ }
+ for in, want := range tests {
+ if got := sanitizeBackLink(in); got != want {
+ t.Errorf("sanitizeBackLink(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/server/authflow/render.go b/server/authflow/render.go
new file mode 100644
index 0000000000..e8b31cf8c2
--- /dev/null
+++ b/server/authflow/render.go
@@ -0,0 +1,12 @@
+package authflow
+
+import (
+ "net/http"
+
+ "github.com/dexidp/dex/server/templates"
+)
+
+// renderError renders a user-facing HTML error page.
+func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
+ templates.RenderError(h.Templates, h.Logger, r, w, status, description)
+}
diff --git a/server/authflow/request.go b/server/authflow/request.go
new file mode 100644
index 0000000000..4b47887e6f
--- /dev/null
+++ b/server/authflow/request.go
@@ -0,0 +1,384 @@
+package authflow
+
+import (
+ "context"
+ "crypto"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+
+ conns "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// request.go parses and validates the OAuth2 /auth authorization request into a
+// storage.AuthRequest, and defines the request-error surface (displayed vs
+// redirected).
+
+// displayedAuthErr is an error that should be displayed to the user as a web page.
+// See RFC 6749 ยง4.1.2.1: an invalid client_id or redirect_uri is shown, not
+// redirected.
+type displayedAuthErr struct {
+ Status int
+ Description string
+}
+
+func (err *displayedAuthErr) Error() string { return err.Description }
+
+// newDisplayedErr builds a displayedAuthErr.
+func newDisplayedErr(status int, format string, a ...interface{}) *displayedAuthErr {
+ return &displayedAuthErr{status, fmt.Sprintf(format, a...)}
+}
+
+// redirectedAuthErr is an error reported back to the client by 302 redirect.
+type redirectedAuthErr struct {
+ State string
+ RedirectURI string
+ Type string
+ Description string
+}
+
+func (err *redirectedAuthErr) Error() string { return err.Description }
+
+// Handler returns an http.Handler that redirects to the client with the error.
+func (err *redirectedAuthErr) Handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ v := url.Values{}
+ v.Add("state", err.State)
+ v.Add("error", err.Type)
+ if err.Description != "" {
+ v.Add("error_description", err.Description)
+ }
+
+ // Parse the redirect URI to ensure it's valid before redirecting.
+ u, parseErr := url.Parse(err.RedirectURI)
+ if parseErr != nil {
+ http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
+ return
+ }
+
+ query := u.Query()
+ for key, values := range v {
+ for _, value := range values {
+ query.Add(key, value)
+ }
+ }
+ u.RawQuery = query.Encode()
+
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+ })
+}
+
+// redirectWithError redirects back to the client with an OAuth2 error response.
+// Used for prompt=none when login or consent is required.
+func (h *Handler) redirectWithError(w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest, errType, description string) {
+ err := &redirectedAuthErr{State: authReq.State, RedirectURI: authReq.RedirectURI, Type: errType, Description: description}
+ err.Handler().ServeHTTP(w, r)
+}
+
+func validateRedirectURI(client storage.Client, redirectURI string) bool {
+ // Allow named RedirectURIs for both public and non-public clients.
+ // This is required to make PKCE-enabled web apps work when configured as public clients.
+ for _, uri := range client.RedirectURIs {
+ if redirectURI == uri {
+ return true
+ }
+ }
+ // For non-public clients or when RedirectURIs is set, we allow only explicitly named RedirectURIs.
+ if !client.Public || len(client.RedirectURIs) > 0 {
+ return false
+ }
+
+ if redirectURI == oauth2.RedirectURIOOB || redirectURI == oauth2.DeviceCallbackURI {
+ return true
+ }
+
+ // Verify the host is a loopback form ("http://localhost:(port)(path)" etc).
+ u, err := url.Parse(redirectURI)
+ if err != nil {
+ return false
+ }
+ if u.Scheme != "http" {
+ return false
+ }
+ return isHostLocal(u.Host)
+}
+
+func isHostLocal(host string) bool {
+ if host == "localhost" || net.ParseIP(host).IsLoopback() {
+ return true
+ }
+
+ host, _, err := net.SplitHostPort(host)
+ if err != nil {
+ return false
+ }
+
+ return host == "localhost" || net.ParseIP(host).IsLoopback()
+}
+
+func validateConnectorID(connectors []storage.Connector, connectorID string) bool {
+ for _, c := range connectors {
+ if c.ID == connectorID {
+ return true
+ }
+ }
+ return false
+}
+
+// sessionMatchesHint checks whether the session's user identity matches the
+// subject from an id_token_hint by encoding the session's (userID, connectorID)
+// via GenSubject and doing a string comparison.
+func sessionMatchesHint(session *storage.AuthSession, hintSubject string) bool {
+ if session == nil {
+ return false
+ }
+ encoded, err := tokens.GenSubject(session.UserID, session.ConnectorID)
+ if err != nil {
+ return false
+ }
+ return encoded == hintSubject
+}
+
+// PKCEConfig holds PKCE (Proof Key for Code Exchange) settings.
+type PKCEConfig struct {
+ // If true, PKCE is required for all authorization code flows.
+ Enforce bool
+ // Supported code challenge methods. Defaults to ["S256", "plain"].
+ CodeChallengeMethodsSupported []string
+}
+
+// ValidateIDTokenHint verifies the signature and issuer of an id_token_hint.
+// Expired tokens are accepted per OIDC Core 1.0 ยง3.1.2.1. It returns the verified
+// token so callers can extract Subject, Audience, etc.
+func (h *Handler) validateIDTokenHint(ctx context.Context, hint string) (*oidc.IDToken, error) {
+ verifier := oidc.NewVerifier(h.IssuerURL.String(), &signer.KeySet{Signer: h.Signer}, &oidc.Config{
+ SkipExpiryCheck: true,
+ // SkipClientIDCheck is set because the hint may originate from any client that
+ // Dex issued a token to โ the caller does not know the expected audience in advance.
+ // The signature verification via signer.KeySet already guarantees the token was
+ // issued by this server. Dex does the client id check later during session validation.
+ SkipClientIDCheck: true,
+ })
+ return verifier.Verify(ctx, hint)
+}
+
+// Parse parses the initial request from the OAuth2 client. It returns the auth
+// request, the raw subject from id_token_hint (empty if not provided), and any
+// error (a *displayedAuthErr or *redirectedAuthErr).
+func (h *Handler) parseAuthorizationRequest(r *http.Request) (*storage.AuthRequest, string, error) {
+ ctx := r.Context()
+ if err := r.ParseForm(); err != nil {
+ return nil, "", newDisplayedErr(http.StatusBadRequest, "Failed to parse request.")
+ }
+ q := r.Form
+ // r.ParseForm already URL-decodes query values once; decoding redirect_uri a
+ // second time created a normalization differential with the token endpoint.
+ redirectURI := q.Get("redirect_uri")
+
+ clientID := q.Get("client_id")
+ state := q.Get("state")
+ nonce := q.Get("nonce")
+ connectorID := q.Get("connector_id")
+ // Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
+ scopes := strings.Fields(q.Get("scope"))
+ responseTypes := strings.Fields(q.Get("response_type"))
+
+ codeChallenge := q.Get("code_challenge")
+ codeChallengeMethod := q.Get("code_challenge_method")
+
+ if codeChallengeMethod == "" {
+ codeChallengeMethod = oauth2.PKCEMethodPlain
+ }
+
+ client, err := h.Storage.GetClient(ctx, clientID)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID)
+ return nil, "", newDisplayedErr(http.StatusNotFound, "Invalid client_id.")
+ }
+ h.Logger.ErrorContext(ctx, "failed to get client", "err", err)
+ return nil, "", newDisplayedErr(http.StatusInternalServerError, "Database error.")
+ }
+
+ if !validateRedirectURI(client, redirectURI) {
+ h.Logger.ErrorContext(ctx, "unregistered redirect_uri", "redirect_uri", redirectURI, "client_id", clientID)
+ return nil, "", newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.")
+ }
+ if redirectURI == oauth2.DeviceCallbackURI && client.Public {
+ redirectURI = h.IssuerURL.AbsPath(oauth2.DeviceCallbackURI)
+ }
+
+ // From here on out, we want to redirect back to the client with an error.
+ newredirectedAuthErr := func(typ, format string, a ...interface{}) *redirectedAuthErr {
+ return &redirectedAuthErr{state, redirectURI, typ, fmt.Sprintf(format, a...)}
+ }
+
+ if connectorID != "" {
+ connectors, err := h.Storage.ListConnectors(ctx)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to list connectors", "err", err)
+ return nil, "", newredirectedAuthErr(oauth2.ServerError, "Unable to retrieve connectors")
+ }
+ if !validateConnectorID(connectors, connectorID) {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid ConnectorID")
+ }
+ if !conns.ConnectorAllowed(client.AllowedConnectors, connectorID) {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Connector not allowed for this client")
+ }
+ }
+
+ // dex doesn't support the request parameter and must return request_not_supported.
+ // https://openid.net/specs/openid-connect-core-1_0.html#6.1
+ if q.Get("request") != "" {
+ return nil, "", newredirectedAuthErr(oauth2.RequestNotSupported, "Server does not support request parameter.")
+ }
+
+ if codeChallenge != "" && !slices.Contains(h.PKCE.CodeChallengeMethodsSupported, codeChallengeMethod) {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod)
+ }
+
+ // Enforce PKCE if configured.
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1
+ if h.PKCE.Enforce && codeChallenge == "" {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "PKCE is required. The code_challenge parameter must be provided.")
+ }
+
+ var (
+ unrecognized []string
+ invalidScopes []string
+ )
+ hasOpenIDScope := false
+ for _, scope := range scopes {
+ switch scope {
+ case tokens.ScopeOpenID:
+ hasOpenIDScope = true
+ case tokens.ScopeOfflineAccess, tokens.ScopeEmail, tokens.ScopeProfile, tokens.ScopeGroups, tokens.ScopeFederatedID:
+ default:
+ peerID, ok := tokens.ParseCrossClientScope(scope)
+ if !ok {
+ unrecognized = append(unrecognized, scope)
+ continue
+ }
+
+ isTrusted, err := tokens.CrossClientTrusted(ctx, h.Storage, clientID, peerID)
+ if err != nil {
+ return nil, "", newredirectedAuthErr(oauth2.ServerError, "Internal server error.")
+ }
+ if !isTrusted {
+ invalidScopes = append(invalidScopes, scope)
+ }
+ }
+ }
+ if !hasOpenIDScope {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidScope, `Missing required scope(s) ["openid"].`)
+ }
+ if len(unrecognized) > 0 {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidScope, "Unrecognized scope(s) %q", unrecognized)
+ }
+ if len(invalidScopes) > 0 {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidScope, "Client can't request scope(s) %q", invalidScopes)
+ }
+
+ var rt struct {
+ code bool
+ idToken bool
+ token bool
+ }
+
+ for _, responseType := range responseTypes {
+ switch responseType {
+ case oauth2.ResponseTypeCode:
+ rt.code = true
+ case oauth2.ResponseTypeIDToken:
+ rt.idToken = true
+ case oauth2.ResponseTypeToken:
+ rt.token = true
+ default:
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid response type %q", responseType)
+ }
+
+ if !h.SupportedResponseTypes[responseType] {
+ return nil, "", newredirectedAuthErr(oauth2.UnsupportedResponseType, "Unsupported response type %q", responseType)
+ }
+ }
+
+ if len(responseTypes) == 0 {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "No response_type provided")
+ }
+
+ if rt.token && !rt.code && !rt.idToken {
+ // "token" can't be provided on its own.
+ // https://openid.net/specs/openid-connect-core-1_0.html#Authentication
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Response type 'token' must be provided with type 'id_token' and/or 'code'")
+ }
+ if !rt.code {
+ // Either "id_token token" or "id_token" implies the implicit flow, which
+ // requires a nonce value.
+ // https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
+ if nonce == "" {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Response type 'token' requires a 'nonce' value.")
+ }
+ }
+ if rt.token {
+ if redirectURI == oauth2.RedirectURIOOB {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", oauth2.RedirectURIOOB)
+ }
+ }
+
+ prompt, err := oauth2.ParsePrompt(q.Get("prompt"))
+ if err != nil {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid prompt parameter: %v", err)
+ }
+
+ // Parse max_age: -1 means not specified.
+ maxAge := -1
+ if maxAgeStr := q.Get("max_age"); maxAgeStr != "" {
+ v, err := strconv.Atoi(maxAgeStr)
+ if err != nil || v < 0 {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid max_age value %q", maxAgeStr)
+ }
+ maxAge = v
+ }
+
+ // OIDC prompt=consent implies force approval.
+ forceApproval := q.Get("approval_prompt") == "force" || prompt.Consent()
+
+ // Validate id_token_hint if provided (OIDC Core 1.0 ยง3.1.2.1).
+ var idTokenHintSubject string
+ if hint := q.Get("id_token_hint"); hint != "" {
+ idToken, err := h.validateIDTokenHint(ctx, hint)
+ if err != nil {
+ return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid id_token_hint.")
+ }
+ idTokenHintSubject = idToken.Subject
+ }
+
+ return &storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: client.ID,
+ State: state,
+ Nonce: nonce,
+ ForceApprovalPrompt: forceApproval,
+ Prompt: prompt.String(),
+ MaxAge: maxAge,
+ Scopes: scopes,
+ RedirectURI: redirectURI,
+ ResponseTypes: responseTypes,
+ ConnectorID: connectorID,
+ PKCE: storage.PKCE{
+ CodeChallenge: codeChallenge,
+ CodeChallengeMethod: codeChallengeMethod,
+ },
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ }, idTokenHintSubject, nil
+}
diff --git a/server/oauth2_test.go b/server/authflow/request_test.go
similarity index 51%
rename from server/oauth2_test.go
rename to server/authflow/request_test.go
index 710382aa23..b874a9201e 100644
--- a/server/oauth2_test.go
+++ b/server/authflow/request_test.go
@@ -1,19 +1,25 @@
-package server
+package authflow
import (
- "context"
"crypto/rand"
"crypto/rsa"
+ "encoding/json"
+ "log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
+ "time"
- "gopkg.in/square/go-jose.v2"
+ "github.com/go-jose/go-jose/v4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/tokens"
"github.com/dexidp/dex/storage"
- "github.com/dexidp/dex/storage/memory"
)
func TestParseAuthorizationRequest(t *testing.T) {
@@ -21,6 +27,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
name string
clients []storage.Client
supportedResponseTypes []string
+ pkce PKCEConfig
usePOST bool
@@ -126,7 +133,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
"response_type": "code id_token",
"scope": "openid email profile",
},
- expectedError: &redirectedAuthErr{Type: errUnsupportedResponseType},
+ expectedError: &redirectedAuthErr{Type: oauth2.UnsupportedResponseType},
},
{
name: "only token response type",
@@ -143,7 +150,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
"response_type": "token",
"scope": "openid email profile",
},
- expectedError: &redirectedAuthErr{Type: errInvalidRequest},
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "choose connector_id",
@@ -195,7 +202,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
"response_type": "code id_token",
"scope": "openid email profile",
},
- expectedError: &redirectedAuthErr{Type: errInvalidRequest},
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "PKCE code_challenge_method plain",
@@ -267,7 +274,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
"code_challenge_method": "invalid_method",
"scope": "openid email profile",
},
- expectedError: &redirectedAuthErr{Type: errInvalidRequest},
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "No response type",
@@ -285,18 +292,104 @@ func TestParseAuthorizationRequest(t *testing.T) {
"code_challenge_method": "plain",
"scope": "openid email profile",
},
- expectedError: &redirectedAuthErr{Type: errInvalidRequest},
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
+ },
+ {
+ name: "PKCE enforced, no code_challenge provided",
+ clients: []storage.Client{
+ {
+ ID: "bar",
+ RedirectURIs: []string{"https://example.com/bar"},
+ },
+ },
+ supportedResponseTypes: []string{"code"},
+ pkce: PKCEConfig{
+ Enforce: true,
+ CodeChallengeMethodsSupported: []string{"S256", "plain"},
+ },
+ queryParams: map[string]string{
+ "client_id": "bar",
+ "redirect_uri": "https://example.com/bar",
+ "response_type": "code",
+ "scope": "openid email profile",
+ },
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
+ },
+ {
+ name: "PKCE enforced, code_challenge provided",
+ clients: []storage.Client{
+ {
+ ID: "bar",
+ RedirectURIs: []string{"https://example.com/bar"},
+ },
+ },
+ supportedResponseTypes: []string{"code"},
+ pkce: PKCEConfig{
+ Enforce: true,
+ CodeChallengeMethodsSupported: []string{"S256", "plain"},
+ },
+ queryParams: map[string]string{
+ "client_id": "bar",
+ "redirect_uri": "https://example.com/bar",
+ "response_type": "code",
+ "code_challenge": "123",
+ "code_challenge_method": "S256",
+ "scope": "openid email profile",
+ },
+ },
+ {
+ name: "PKCE only S256 allowed, plain rejected",
+ clients: []storage.Client{
+ {
+ ID: "bar",
+ RedirectURIs: []string{"https://example.com/bar"},
+ },
+ },
+ supportedResponseTypes: []string{"code"},
+ pkce: PKCEConfig{
+ CodeChallengeMethodsSupported: []string{"S256"},
+ },
+ queryParams: map[string]string{
+ "client_id": "bar",
+ "redirect_uri": "https://example.com/bar",
+ "response_type": "code",
+ "code_challenge": "123",
+ "code_challenge_method": "plain",
+ "scope": "openid email profile",
+ },
+ expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
+ },
+ {
+ name: "PKCE only S256 allowed, S256 accepted",
+ clients: []storage.Client{
+ {
+ ID: "bar",
+ RedirectURIs: []string{"https://example.com/bar"},
+ },
+ },
+ supportedResponseTypes: []string{"code"},
+ pkce: PKCEConfig{
+ CodeChallengeMethodsSupported: []string{"S256"},
+ },
+ queryParams: map[string]string{
+ "client_id": "bar",
+ "redirect_uri": "https://example.com/bar",
+ "response_type": "code",
+ "code_challenge": "123",
+ "code_challenge_method": "S256",
+ "scope": "openid email profile",
+ },
},
}
for _, tc := range tests {
- func() {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, server := newTestServerMultipleConnectors(ctx, t, func(c *Config) {
- c.SupportedResponseTypes = tc.supportedResponseTypes
+ t.Run(tc.name, func(t *testing.T) {
+ httpServer, server := newTestHandler(t, func(c *testFlowConfig) {
+ c.SupportedResponseTypes = toResponseTypeSet(tc.supportedResponseTypes)
c.Storage = storage.WithStaticClients(c.Storage, tc.clients)
+ if len(tc.pkce.CodeChallengeMethodsSupported) > 0 || tc.pkce.Enforce {
+ c.PKCE = tc.pkce
+ }
})
defer httpServer.Close()
@@ -313,7 +406,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
req = httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil)
}
- _, err := server.parseAuthorizationRequest(req)
+ _, _, err := server.parseAuthorizationRequest(req)
if tc.expectedError == nil {
if err != nil {
t.Errorf("%s: expected no error", tc.name)
@@ -343,24 +436,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
t.Fatalf("%s: unsupported error type", tc.name)
}
}
- }()
- }
-}
-
-const (
- // at_hash value and access_token returned by Google.
- googleAccessTokenHash = "piwt8oCH-K2D9pXlaS1Y-w"
- googleAccessToken = "ya29.CjHSA1l5WUn8xZ6HanHFzzdHdbXm-14rxnC7JHch9eFIsZkQEGoWzaYG4o7k5f6BnPLj"
- googleSigningAlg = jose.RS256
-)
-
-func TestAccessTokenHash(t *testing.T) {
- atHash, err := accessTokenHash(googleSigningAlg, googleAccessToken)
- if err != nil {
- t.Fatal(err)
- }
- if atHash != googleAccessTokenHash {
- t.Errorf("expected %q got %q", googleAccessTokenHash, atHash)
+ })
}
}
@@ -420,6 +496,27 @@ func TestValidRedirectURI(t *testing.T) {
redirectURI: "http://localhost",
wantValid: true,
},
+ {
+ client: storage.Client{
+ Public: true,
+ },
+ redirectURI: "http://127.0.0.1:8080/",
+ wantValid: true,
+ },
+ {
+ client: storage.Client{
+ Public: true,
+ },
+ redirectURI: "http://127.0.0.1:991/bar",
+ wantValid: true,
+ },
+ {
+ client: storage.Client{
+ Public: true,
+ },
+ redirectURI: "http://127.0.0.1",
+ wantValid: true,
+ },
// Both Public + RedirectURIs configured: Could e.g. be a PKCE-enabled web app.
{
client: storage.Client{
@@ -544,86 +641,294 @@ func TestValidRedirectURI(t *testing.T) {
}
}
-func TestStorageKeySet(t *testing.T) {
- s := memory.New(logger)
- if err := s.UpdateKeys(func(keys storage.Keys) (storage.Keys, error) {
- keys.SigningKey = &jose.JSONWebKey{
- Key: testKey,
- KeyID: "testkey",
- Algorithm: "RS256",
- Use: "sig",
- }
- keys.SigningKeyPub = &jose.JSONWebKey{
- Key: testKey.Public(),
- KeyID: "testkey",
- Algorithm: "RS256",
- Use: "sig",
- }
- return keys, nil
- }); err != nil {
- t.Fatal(err)
- }
-
+func TestRedirectedAuthErrHandler(t *testing.T) {
tests := []struct {
- name string
- tokenGenerator func() (jwt string, err error)
- wantErr bool
+ name string
+ redirectURI string
+ state string
+ errType string
+ description string
+ wantStatus int
+ wantErr bool
}{
{
- name: "valid token",
- tokenGenerator: func() (string, error) {
- signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testKey}, nil)
- if err != nil {
- return "", err
- }
+ name: "valid redirect uri with error parameters",
+ redirectURI: "https://example.com/callback",
+ state: "state123",
+ errType: oauth2.InvalidRequest,
+ description: "Invalid request parameter",
+ wantStatus: http.StatusSeeOther,
+ wantErr: false,
+ },
+ {
+ name: "valid redirect uri with query params",
+ redirectURI: "https://example.com/callback?existing=param&another=value",
+ state: "state456",
+ errType: oauth2.AccessDenied,
+ description: "User denied access",
+ wantStatus: http.StatusSeeOther,
+ wantErr: false,
+ },
+ {
+ name: "valid redirect uri without description",
+ redirectURI: "https://example.com/callback",
+ state: "state789",
+ errType: oauth2.ServerError,
+ description: "",
+ wantStatus: http.StatusSeeOther,
+ wantErr: false,
+ },
+ {
+ name: "invalid redirect uri",
+ redirectURI: "not a valid url ://",
+ state: "state",
+ errType: oauth2.InvalidRequest,
+ description: "Test error",
+ wantStatus: http.StatusBadRequest,
+ wantErr: true,
+ },
+ }
- jws, err := signer.Sign([]byte("payload"))
- if err != nil {
- return "", err
+ for _, tc := range tests {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ err := &redirectedAuthErr{
+ State: tc.state,
+ RedirectURI: tc.redirectURI,
+ Type: tc.errType,
+ Description: tc.description,
+ }
+
+ handler := err.Handler()
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/", nil)
+
+ handler.ServeHTTP(w, r)
+
+ if w.Code != tc.wantStatus {
+ t.Errorf("expected status %d, got %d", tc.wantStatus, w.Code)
+ }
+
+ if tc.wantStatus == http.StatusSeeOther {
+ // Verify the redirect location is a valid URL
+ location := w.Header().Get("Location")
+ if location == "" {
+ t.Fatalf("expected Location header, got empty string")
}
- return jws.CompactSerialize()
- },
- wantErr: false,
- },
- {
- name: "token signed by different key",
- tokenGenerator: func() (string, error) {
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- return "", err
+ // Parse the redirect URL to verify it's valid
+ redirectURL, parseErr := url.Parse(location)
+ if parseErr != nil {
+ t.Fatalf("invalid redirect URL: %v", parseErr)
}
- signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, nil)
- if err != nil {
- return "", err
+ // Verify error parameters are present in the query string
+ query := redirectURL.Query()
+ if query.Get("state") != tc.state {
+ t.Errorf("expected state %q, got %q", tc.state, query.Get("state"))
+ }
+ if query.Get("error") != tc.errType {
+ t.Errorf("expected error type %q, got %q", tc.errType, query.Get("error"))
+ }
+ if tc.description != "" && query.Get("error_description") != tc.description {
+ t.Errorf("expected error_description %q, got %q", tc.description, query.Get("error_description"))
}
- jws, err := signer.Sign([]byte("payload"))
- if err != nil {
- return "", err
+ // Verify that existing query parameters are preserved
+ if tc.name == "valid redirect uri with query params" {
+ if query.Get("existing") != "param" {
+ t.Errorf("expected existing parameter 'param', got %q", query.Get("existing"))
+ }
+ if query.Get("another") != "value" {
+ t.Errorf("expected another parameter 'value', got %q", query.Get("another"))
+ }
}
+ }
+ })
+ }
+}
- return jws.CompactSerialize()
- },
- wantErr: true,
- },
+// signTestIDToken creates a signed JWT with the given claims using the test key.
+func signTestIDToken(t *testing.T, claims interface{}) string {
+ t.Helper()
+ payload, err := json.Marshal(claims)
+ require.NoError(t, err)
+
+ joseSigner, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testKey}, nil)
+ require.NoError(t, err)
+
+ jws, err := joseSigner.Sign(payload)
+ require.NoError(t, err)
+
+ token, err := jws.CompactSerialize()
+ require.NoError(t, err)
+ return token
+}
+
+func TestValidateIDTokenHint(t *testing.T) {
+ sig, err := signer.NewMockSigner(testKey)
+ require.NoError(t, err)
+
+ issuerURL, err := url.Parse("https://issuer.example.com")
+ require.NoError(t, err)
+
+ s := &Handler{
+ Signer: sig,
+ IssuerURL: oauth2.IssuerURL{URL: *issuerURL},
+ Logger: slog.Default(),
}
- for _, tc := range tests {
- tc := tc
- t.Run(tc.name, func(t *testing.T) {
- jwt, err := tc.tokenGenerator()
- if err != nil {
- t.Fatal(err)
- }
+ now := time.Now()
- keySet := &storageKeySet{s}
+ t.Run("valid hint (not expired)", func(t *testing.T) {
+ token := signTestIDToken(t, tokens.IDTokenClaims{
+ Issuer: "https://issuer.example.com",
+ Subject: "CgNmb28SA2Jhcg",
+ Expiry: now.Add(1 * time.Hour).Unix(),
+ })
+ idToken, err := s.validateIDTokenHint(t.Context(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject)
+ })
- _, err = keySet.VerifySignature(context.Background(), jwt)
- if (err != nil && !tc.wantErr) || (err == nil && tc.wantErr) {
- t.Fatalf("wantErr = %v, but got err = %v", tc.wantErr, err)
- }
+ t.Run("valid hint (expired)", func(t *testing.T) {
+ token := signTestIDToken(t, tokens.IDTokenClaims{
+ Issuer: "https://issuer.example.com",
+ Subject: "CgNmb28SA2Jhcg",
+ Expiry: now.Add(-1 * time.Hour).Unix(),
})
- }
+ idToken, err := s.validateIDTokenHint(t.Context(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject)
+ })
+
+ t.Run("invalid signature", func(t *testing.T) {
+ otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ payload, err := json.Marshal(tokens.IDTokenClaims{
+ Issuer: "https://issuer.example.com",
+ Subject: "CgNmb28SA2Jhcg",
+ Expiry: now.Add(1 * time.Hour).Unix(),
+ })
+ require.NoError(t, err)
+
+ joseSigner, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: otherKey}, nil)
+ require.NoError(t, err)
+ jws, err := joseSigner.Sign(payload)
+ require.NoError(t, err)
+ token, err := jws.CompactSerialize()
+ require.NoError(t, err)
+
+ _, err = s.validateIDTokenHint(t.Context(), token)
+ assert.Error(t, err)
+ })
+
+ t.Run("wrong issuer", func(t *testing.T) {
+ token := signTestIDToken(t, tokens.IDTokenClaims{
+ Issuer: "https://wrong-issuer.example.com",
+ Subject: "CgNmb28SA2Jhcg",
+ Expiry: now.Add(1 * time.Hour).Unix(),
+ })
+ _, err := s.validateIDTokenHint(t.Context(), token)
+ assert.Error(t, err)
+ })
+
+ t.Run("malformed token", func(t *testing.T) {
+ _, err := s.validateIDTokenHint(t.Context(), "not-a-valid-jwt")
+ assert.Error(t, err)
+ })
+}
+
+func TestSessionMatchesHint(t *testing.T) {
+ // tokens.GenSubject("foo", "bar") == "CgNmb28SA2Jhcg" (from TestGetSubject)
+ assert.True(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "bar"}, "CgNmb28SA2Jhcg"))
+ assert.False(t, sessionMatchesHint(&storage.AuthSession{UserID: "other", ConnectorID: "bar"}, "CgNmb28SA2Jhcg"))
+ assert.False(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "other"}, "CgNmb28SA2Jhcg"))
+ assert.False(t, sessionMatchesHint(nil, "CgNmb28SA2Jhcg"))
+}
+
+func TestParseAuthorizationRequest_IDTokenHint(t *testing.T) {
+ sig, err := signer.NewMockSigner(testKey)
+ require.NoError(t, err)
+
+ now := time.Now()
+
+ t.Run("valid id_token_hint populates subject", func(t *testing.T) {
+ httpServer, server := newTestHandler(t, func(c *testFlowConfig) {
+ c.SupportedResponseTypes = map[string]bool{"code": true}
+ c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{
+ {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}},
+ })
+ c.Signer = sig
+ })
+ defer httpServer.Close()
+
+ token := signTestIDToken(t, tokens.IDTokenClaims{
+ Issuer: httpServer.URL,
+ Subject: "CgNmb28SA2Jhcg",
+ Expiry: now.Add(1 * time.Hour).Unix(),
+ })
+
+ params := url.Values{
+ "client_id": {"foo"},
+ "redirect_uri": {"https://example.com/foo"},
+ "response_type": {"code"},
+ "scope": {"openid"},
+ "id_token_hint": {token},
+ }
+ req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil)
+
+ _, hintSubject, err := server.parseAuthorizationRequest(req)
+ require.NoError(t, err)
+ assert.Equal(t, "CgNmb28SA2Jhcg", hintSubject)
+ })
+
+ t.Run("invalid id_token_hint returns error", func(t *testing.T) {
+ httpServer, server := newTestHandler(t, func(c *testFlowConfig) {
+ c.SupportedResponseTypes = map[string]bool{"code": true}
+ c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{
+ {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}},
+ })
+ c.Signer = sig
+ })
+ defer httpServer.Close()
+
+ params := url.Values{
+ "client_id": {"foo"},
+ "redirect_uri": {"https://example.com/foo"},
+ "response_type": {"code"},
+ "scope": {"openid"},
+ "id_token_hint": {"invalid-token"},
+ }
+ req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil)
+
+ _, _, err := server.parseAuthorizationRequest(req)
+ require.Error(t, err)
+ redirectErr, ok := err.(*redirectedAuthErr)
+ require.True(t, ok)
+ assert.Equal(t, oauth2.InvalidRequest, redirectErr.Type)
+ })
+
+ t.Run("no id_token_hint leaves subject empty", func(t *testing.T) {
+ httpServer, server := newTestHandler(t, func(c *testFlowConfig) {
+ c.SupportedResponseTypes = map[string]bool{"code": true}
+ c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{
+ {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}},
+ })
+ })
+ defer httpServer.Close()
+
+ params := url.Values{
+ "client_id": {"foo"},
+ "redirect_uri": {"https://example.com/foo"},
+ "response_type": {"code"},
+ "scope": {"openid"},
+ }
+ req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil)
+
+ _, hintSubject, err := server.parseAuthorizationRequest(req)
+ require.NoError(t, err)
+ assert.Equal(t, "", hintSubject)
+ })
}
diff --git a/server/authflow/response.go b/server/authflow/response.go
new file mode 100644
index 0000000000..3a63e25273
--- /dev/null
+++ b/server/authflow/response.go
@@ -0,0 +1,241 @@
+package authflow
+
+// response.go writes the authorization response once the dispatcher determines
+// the request is fully authorized: it mints the auth code and, for
+// implicit/hybrid flows, the access and ID tokens, then redirects the browser
+// back to the client (or renders the out-of-band page). This is the issuance
+// half of the authorize endpoint โ fosite's WriteAuthorizeResponse.
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// writeResponse issues the authorization response for a completed auth request:
+// it mints the code (and, for implicit/hybrid flows, the tokens) and redirects
+// the browser back to the client, or renders the out-of-band page.
+func (h *Handler) writeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) {
+ h.Sessions.UpdateTokenIssuedAt(r, authReq.ClientID)
+
+ ctx := r.Context()
+ if h.Now().After(authReq.Expiry) {
+ h.renderError(r, w, http.StatusBadRequest, "User session has expired.")
+ return
+ }
+
+ if err := h.Storage.DeleteAuthRequest(ctx, authReq.ID); err != nil {
+ if err != storage.ErrNotFound {
+ h.Logger.ErrorContext(r.Context(), "Failed to delete authorization request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ } else {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ }
+ return
+ }
+ u, err := url.Parse(authReq.RedirectURI)
+ if err != nil {
+ h.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.")
+ return
+ }
+
+ // Resolved once for the whole response. Every artifact below has to name the
+ // same session, and resolving it is a storage read that can clear a stale
+ // cookie โ not something to repeat two or three times while writing one
+ // response.
+ resp := &authResponse{sessionID: h.sessionID(ctx, w, r)}
+ for _, handle := range []responseTypeHandler{
+ h.issueCode,
+ h.issueAccessToken,
+ h.issueIDToken,
+ } {
+ if !handle(ctx, w, r, authReq, resp) {
+ return // the handler already wrote the response (error or OOB)
+ }
+ }
+
+ if resp.implicitOrHybrid {
+ v := url.Values{}
+ if resp.accessToken != "" {
+ v.Set("access_token", resp.accessToken)
+ v.Set("token_type", "bearer")
+ // The hybrid flow with "code token" or "code id_token token" doesn't return an
+ // "expires_in" value. If "code" wasn't provided, indicating the implicit flow,
+ // don't add it.
+ //
+ // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse
+ if resp.code.ID == "" {
+ v.Set("expires_in", strconv.Itoa(int(resp.idTokenExpiry.Sub(h.Now()).Seconds())))
+ }
+ }
+ v.Set("state", authReq.State)
+ if resp.idToken != "" {
+ v.Set("id_token", resp.idToken)
+ }
+ if resp.code.ID != "" {
+ v.Set("code", resp.code.ID)
+ }
+
+ // Implicit and hybrid flows return their values as part of the fragment.
+ //
+ // HTTP/1.1 303 See Other
+ // Location: https://client.example.org/cb#
+ // access_token=SlAV32hkKG
+ // &token_type=bearer
+ // &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso
+ // &expires_in=3600
+ // &state=af0ifjsldkj
+ //
+ u.Fragment = v.Encode()
+ } else {
+ // The code flow add values to the URL query.
+ //
+ // HTTP/1.1 303 See Other
+ // Location: https://client.example.org/cb?
+ // code=SplxlOBeZQQYbYS6WxSbIA
+ // &state=af0ifjsldkj
+ //
+ q := u.Query()
+ q.Set("code", resp.code.ID)
+ q.Set("state", authReq.State)
+ u.RawQuery = q.Encode()
+ }
+
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+}
+
+// authResponse accumulates the artifacts each response-type handler produces
+// for the authorization response.
+type authResponse struct {
+ // Was the initial request using the implicit or hybrid flow instead of the
+ // "normal" code flow?
+ implicitOrHybrid bool
+
+ // Only present in hybrid or code flow. code.ID == "" if this is not set.
+ code storage.AuthCode
+
+ // Access token, present when response_type includes "token".
+ accessToken string
+
+ // ID token, present when response_type includes "id_token". Only valid for
+ // implicit and hybrid flows.
+ idToken string
+ idTokenExpiry time.Time
+
+ // sessionID is the browser session everything in this response comes from.
+ sessionID string
+}
+
+// responseTypeHandler produces the response for a single OAuth2 response_type.
+// It self-selects on authReq.ResponseTypes, populates resp, and returns false
+// (after writing an error or OOB page itself) to abort the response.
+type responseTypeHandler func(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool
+
+// sessionID names the browser session this response is issued from, or "" when there
+// is none. The browser is on the other end of this request, so its cookie is the
+// answer โ a lookup by user would pick whichever session that user has open, which on
+// a second device is somebody else's.
+func (h *Handler) sessionID(ctx context.Context, w http.ResponseWriter, r *http.Request) string {
+ if s := h.Sessions.ValidSession(ctx, w, r); s != nil {
+ return s.ID
+ }
+ return ""
+}
+
+// issueCode handles the "code" response_type: it mints and stores an auth code.
+func (h *Handler) issueCode(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool {
+ if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeCode) {
+ return true
+ }
+ resp.code = storage.AuthCode{
+ ID: storage.NewID(),
+ SessionID: resp.sessionID,
+ ClientID: authReq.ClientID,
+ ConnectorID: authReq.ConnectorID,
+ Nonce: authReq.Nonce,
+ Scopes: authReq.Scopes,
+ Claims: authReq.Claims,
+ Expiry: h.Now().Add(time.Minute * 30),
+ RedirectURI: authReq.RedirectURI,
+ ConnectorData: authReq.ConnectorData,
+ PKCE: authReq.PKCE,
+ AuthTime: authReq.AuthTime,
+ }
+ if err := h.Storage.CreateAuthCode(ctx, resp.code); err != nil {
+ h.Logger.ErrorContext(r.Context(), "Failed to create auth code", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ return false
+ }
+
+ // Implicit and hybrid flows that try to use the OOB redirect URI are
+ // rejected earlier. If we got here we're using the code flow.
+ if authReq.RedirectURI == oauth2.RedirectURIOOB {
+ if err := h.Templates.OOB(r, w, resp.code.ID); err != nil {
+ h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
+ }
+ return false // OOB fully rendered the response
+ }
+ return true
+}
+
+// issueAccessToken handles the "token" response_type: it signs an access token.
+func (h *Handler) issueAccessToken(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool {
+ if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeToken) {
+ return true
+ }
+ resp.implicitOrHybrid = true
+ accessToken, _, err := h.Issuer.SignAccessToken(ctx, tokens.Authorization{
+ Client: storage.Client{ID: authReq.ClientID},
+ Claims: authReq.Claims,
+ Scopes: authReq.Scopes,
+ ConnectorID: authReq.ConnectorID,
+ Nonce: authReq.Nonce,
+ AuthTime: authReq.AuthTime,
+ SessionID: resp.sessionID,
+ })
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to create new access token", "err", err)
+ h.writeError(w, oauth2.ServerError, "", http.StatusInternalServerError)
+ return false
+ }
+ resp.accessToken = accessToken
+ return true
+}
+
+// issueIDToken handles the "id_token" response_type. It runs after issueCode and
+// issueAccessToken because the id_token signature binds the code and access token.
+func (h *Handler) issueIDToken(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool {
+ if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeIDToken) {
+ return true
+ }
+ resp.implicitOrHybrid = true
+ idToken, idTokenExpiry, err := h.Issuer.SignIDToken(ctx, tokens.Authorization{
+ Client: storage.Client{ID: authReq.ClientID},
+ Claims: authReq.Claims,
+ Scopes: authReq.Scopes,
+ ConnectorID: authReq.ConnectorID,
+ Nonce: authReq.Nonce,
+ AuthTime: authReq.AuthTime,
+ SessionID: resp.sessionID,
+ }, resp.accessToken, resp.code.ID)
+ if err != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to create ID token", "err", err)
+ h.writeError(w, oauth2.ServerError, "", http.StatusInternalServerError)
+ return false
+ }
+ resp.idToken = idToken
+ resp.idTokenExpiry = idTokenExpiry
+ return true
+}
+
+// writeError writes an OAuth2 error response for the token-bearing flows.
+func (h *Handler) writeError(w http.ResponseWriter, typ string, description string, statusCode int) {
+ oauth2.WriteErrorResponse(h.Logger, w, typ, description, statusCode)
+}
diff --git a/server/authflow/sessionlogin.go b/server/authflow/sessionlogin.go
new file mode 100644
index 0000000000..bd0200a7cf
--- /dev/null
+++ b/server/authflow/sessionlogin.go
@@ -0,0 +1,123 @@
+package authflow
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/dexidp/dex/storage"
+)
+
+func (h *Handler) trySessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest) bool {
+ session := h.Sessions.ValidAuthSession(ctx, w, r, authReq)
+ return h.trySessionLoginWithSession(ctx, r, w, authReq, session)
+}
+
+// trySessionLoginWithSession completes the login from an existing session: a
+// direct session for the client, or, failing that, an SSO session shared by
+// another client. SSO sharing is unidirectional โ a source sharing with a target
+// does not mean the target shares back. Returns false when no session applies.
+func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession) bool {
+ if session == nil {
+ return false
+ }
+
+ now := h.Now()
+
+ _, directLogin := session.ClientStates[authReq.ClientID]
+ if !directLogin {
+ // No direct session for this client โ try SSO from a sharing client.
+ sourceState := h.Sessions.FindSSO(ctx, session, authReq.ClientID)
+ if sourceState == nil {
+ return false
+ }
+
+ // Create a new client state for the target client via SSO. It carries the
+ // source's authentication time: the user did not authenticate again here.
+ if err := h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) {
+ if old.ClientStates == nil {
+ old.ClientStates = make(map[string]*storage.ClientAuthState)
+ }
+ old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{
+ AuthenticatedAt: sourceState.AuthenticatedAt,
+ LastActivity: now,
+ ViaSSO: true,
+ }
+ old.LastActivity = now
+ old.IdleExpiry = h.Sessions.IdleExpiry(now)
+ return old, nil
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err)
+ return false
+ }
+
+ h.Logger.DebugContext(ctx, "session: SSO login from sharing client",
+ "user_id", session.UserID, "connector_id", session.ConnectorID, "client_id", authReq.ClientID)
+ }
+
+ // Load identity from storage (same path for direct and SSO login).
+ ui, err := h.Storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "session: failed to get user identity", "err", err)
+ return false
+ }
+
+ // Check max_age: if the user's last authentication is too old, force re-auth.
+ if authReq.MaxAge >= 0 {
+ if now.Sub(ui.LastLogin) > time.Duration(authReq.MaxAge)*time.Second {
+ return false
+ }
+ }
+
+ if directLogin {
+ h.Logger.DebugContext(ctx, "session: re-authenticated from session",
+ "session_id", session.ID, "user_id", session.UserID)
+ }
+
+ return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, now)
+}
+
+// finishSessionLogin completes a session-based login (direct or SSO) by updating the auth request
+// with the user's identity, refreshing session activity, and returning the appropriate redirect URL.
+func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, now time.Time) bool {
+ claims := storage.Claims{
+ UserID: ui.Claims.UserID,
+ Username: ui.Claims.Username,
+ PreferredUsername: ui.Claims.PreferredUsername,
+ Email: ui.Claims.Email,
+ EmailVerified: ui.Claims.EmailVerified,
+ Groups: ui.Claims.Groups,
+ }
+
+ // Update AuthRequest with stored identity and auth_time from last login.
+ if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.LoggedIn = true
+ a.Claims = claims
+ a.ConnectorID = session.ConnectorID
+ a.AuthTime = ui.LastLogin
+ return a, nil
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "session: failed to update auth request", "err", err)
+ return false
+ }
+
+ // Update session activity.
+ _ = h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) {
+ old.LastActivity = now
+ old.IdleExpiry = h.Sessions.IdleExpiry(now)
+ if cs, ok := old.ClientStates[authReq.ClientID]; ok {
+ cs.LastActivity = now
+ }
+ return old, nil
+ })
+
+ // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above),
+ // then let the shared decision pick the next step.
+ updated, err := h.Storage.GetAuthRequest(ctx, authReq.ID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "session: failed to get auth request", "err", err)
+ return false
+ }
+ http.Redirect(w, r, h.buildContinueURL(updated), http.StatusSeeOther)
+ return true
+}
diff --git a/server/authflow/sessionlogin_test.go b/server/authflow/sessionlogin_test.go
new file mode 100644
index 0000000000..39da51aa11
--- /dev/null
+++ b/server/authflow/sessionlogin_test.go
@@ -0,0 +1,2049 @@
+package authflow
+
+import (
+ "crypto"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/mfa"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+// sessionTestServer wraps the login Handler together with the standalone consent
+// component. trySessionLogin now hands off to the dispatcher by redirect, so the
+// consent decision happens downstream; the wrapper keeps consent reachable for
+// the few tests that toggle SkipApproval.
+type sessionTestServer struct {
+ *Handler
+}
+
+func newTestSessionServer(t *testing.T) *sessionTestServer {
+ t.Helper()
+
+ now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
+ issuerURL, err := url.Parse("https://example.com/dex")
+ require.NoError(t, err)
+
+ sessionCfg := &session.Config{
+ CookieName: "dex_session",
+ AbsoluteLifetime: 24 * time.Hour,
+ ValidIfNotUsedFor: 1 * time.Hour,
+ }
+ h := &Handler{
+ Storage: memory.New(nil),
+ Now: func() time.Time { return now },
+ Logger: slog.Default(),
+ IssuerURL: oauth2.IssuerURL{URL: *issuerURL},
+ }
+ h.Connectors = connectors.NewCache(h.Storage, testResolveConnector)
+ h.Sessions = &session.Manager{Storage: h.Storage, Config: sessionCfg, Now: h.Now, Logger: slog.Default(), IssuerURL: oauth2.IssuerURL{URL: *issuerURL}}
+ return &sessionTestServer{Handler: h}
+}
+
+func TestSetSessionCookie(t *testing.T) {
+ s := newTestSessionServer(t)
+ w := httptest.NewRecorder()
+
+ s.Sessions.SetCookie(w, "session1", "secret1", false)
+
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+
+ c := cookies[0]
+ assert.Equal(t, "dex_session", c.Name)
+ assert.Equal(t, internal.SessionCookieValue("session1", "secret1", nil), c.Value)
+ assert.Equal(t, "/dex", c.Path)
+ assert.True(t, c.HttpOnly)
+ assert.True(t, c.Secure)
+ assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
+}
+
+func TestSetSessionCookie_HTTP(t *testing.T) {
+ s := newTestSessionServer(t)
+ u, _ := url.Parse("http://localhost:5556/dex")
+ resetSessions(s, &session.Config{CookieName: "dex_session"}, *u)
+ w := httptest.NewRecorder()
+
+ s.Sessions.SetCookie(w, "session1", "secret1", false)
+
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ assert.False(t, cookies[0].Secure)
+}
+
+func TestClearSessionCookie(t *testing.T) {
+ s := newTestSessionServer(t)
+ w := httptest.NewRecorder()
+
+ s.Sessions.ClearCookie(w)
+
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ assert.Equal(t, -1, cookies[0].MaxAge)
+ assert.Equal(t, "", cookies[0].Value)
+}
+
+func TestSessionCookieValueRoundtrip(t *testing.T) {
+ tests := []struct {
+ name string
+ sessionID string
+ secret string
+ }{
+ {"simple", "session1", "abc123"},
+ {"with special chars", "session@1", "xyz789"},
+ {"unicode", "ัะตััะธั", "ัะตะบัะตั"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ value := internal.SessionCookieValue(tt.sessionID, tt.secret, nil)
+ gotID, gotSecret, err := internal.ParseSessionCookie(value, nil)
+ require.NoError(t, err)
+ assert.Equal(t, tt.sessionID, gotID)
+ assert.Equal(t, tt.secret, gotSecret)
+ })
+ }
+}
+
+func TestSessionCookieValueEncryptedRoundtrip(t *testing.T) {
+ key := []byte("0123456789abcdef") // 16 bytes = AES-128
+
+ value := internal.SessionCookieValue("session1", "secret1", key)
+ // Encrypted value must differ from unencrypted.
+ unencrypted := internal.SessionCookieValue("session1", "secret1", nil)
+ assert.NotEqual(t, unencrypted, value)
+
+ // Must decrypt correctly.
+ gotID, gotSecret, err := internal.ParseSessionCookie(value, key)
+ require.NoError(t, err)
+ assert.Equal(t, "session1", gotID)
+ assert.Equal(t, "secret1", gotSecret)
+
+ // Wrong key must fail.
+ wrongKey := []byte("abcdef0123456789")
+ _, _, err = internal.ParseSessionCookie(value, wrongKey)
+ assert.Error(t, err)
+
+ // No key must fail (encrypted value isn't valid protobuf).
+ _, _, err = internal.ParseSessionCookie(value, nil)
+ assert.Error(t, err)
+}
+
+func TestParseSessionCookie_Invalid(t *testing.T) {
+ _, _, err := internal.ParseSessionCookie("invalid", nil)
+ assert.Error(t, err)
+ _, _, err = internal.ParseSessionCookie("a.b", nil)
+ assert.Error(t, err)
+}
+
+func TestGetValidAuthSession(t *testing.T) {
+ ctx := t.Context()
+ authReq := &storage.AuthRequest{ConnectorID: "conn1"}
+
+ t.Run("no session config", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ resetSessions(s, nil, url.URL{})
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq))
+ })
+
+ t.Run("no cookie", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq))
+ })
+
+ t.Run("invalid cookie format", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: "invalid-format"})
+ w := httptest.NewRecorder()
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, authReq))
+ // Cookie should be cleared.
+ assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
+ })
+
+ t.Run("session not found", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("nonce", "nonce", nil)})
+ w := httptest.NewRecorder()
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, authReq))
+ // Cookie should be cleared.
+ assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
+ })
+
+ t.Run("valid session", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+ nonce := "test-nonce"
+
+ session := storage.AuthSession{
+ UserID: "user1",
+ ConnectorID: "conn1",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(1 * time.Hour),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)})
+
+ result := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)
+ require.NotNil(t, result)
+ assert.Equal(t, "user1", result.UserID)
+ assert.Equal(t, "conn1", result.ConnectorID)
+ })
+
+ t.Run("connector mismatch", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+ nonce := "test-nonce-conn"
+
+ session := storage.AuthSession{
+ UserID: "user1",
+ ConnectorID: "ldap",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(1 * time.Hour),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)})
+
+ githubReq := &storage.AuthRequest{ConnectorID: "github"}
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq))
+ })
+
+ t.Run("nonce mismatch", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ session := storage.AuthSession{
+ UserID: "user2",
+ ConnectorID: "conn2",
+ ID: "correct-nonce", Secret: "correct-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(1 * time.Hour),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("wrong-nonce", "wrong-nonce", nil)})
+
+ conn2Req := &storage.AuthRequest{ConnectorID: "conn2"}
+ w := httptest.NewRecorder()
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn2Req))
+ assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
+ })
+
+ t.Run("expired absolute lifetime", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+ nonce := "expired-nonce"
+
+ session := storage.AuthSession{
+ UserID: "user3",
+ ConnectorID: "conn3",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-25 * time.Hour),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(-1 * time.Hour),
+ IdleExpiry: now.Add(1 * time.Hour),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)})
+
+ conn3Req := &storage.AuthRequest{ConnectorID: "conn3"}
+ w := httptest.NewRecorder()
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn3Req))
+ assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
+
+ // Session should be deleted.
+ _, err := s.Storage.GetAuthSession(ctx, nonce)
+ assert.ErrorIs(t, err, storage.ErrNotFound)
+ })
+
+ t.Run("expired idle timeout", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+ nonce := "idle-nonce"
+
+ session := storage.AuthSession{
+ UserID: "user4",
+ ConnectorID: "conn4",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-2 * time.Hour),
+ LastActivity: now.Add(-2 * time.Hour),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(22 * time.Hour),
+ IdleExpiry: now.Add(-1 * time.Hour),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)})
+
+ conn4Req := &storage.AuthRequest{ConnectorID: "conn4"}
+ w := httptest.NewRecorder()
+ assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn4Req))
+ assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
+
+ // Session should be deleted.
+ _, err := s.Storage.GetAuthSession(ctx, nonce)
+ assert.ErrorIs(t, err, storage.ErrNotFound)
+ })
+}
+
+func TestCreateOrUpdateAuthSession(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("create new session", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+
+ authReq := storage.AuthRequest{
+ ID: "auth-1",
+ ClientID: "client-1",
+ Claims: storage.Claims{UserID: "user-1"},
+ ConnectorID: "mock",
+ }
+
+ err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false)
+ require.NoError(t, err)
+
+ // Cookie should be set.
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+
+ sessionID, secret, err := internal.ParseSessionCookie(cookies[0].Value, nil)
+ require.NoError(t, err)
+ assert.NotEmpty(t, sessionID)
+ assert.NotEmpty(t, secret)
+ assert.NotEqual(t, sessionID, secret, "the published id must not be the proof")
+
+ // Session should exist in storage.
+ session, err := s.Storage.GetAuthSession(ctx, sessionID)
+ require.NoError(t, err)
+ assert.Equal(t, "user-1", session.UserID)
+ assert.Equal(t, "mock", session.ConnectorID)
+ require.Contains(t, session.ClientStates, "client-1")
+ assert.False(t, session.ClientStates["client-1"].AuthenticatedAt.IsZero())
+ })
+
+ t.Run("update existing session", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+ nonce := "existing-nonce"
+
+ existingSession := storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {
+ AuthenticatedAt: now.Add(-10 * time.Minute),
+ LastActivity: now.Add(-10 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-10 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(50 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, existingSession))
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ // Same browser: it presents the cookie of the session it already has.
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)})
+
+ authReq := storage.AuthRequest{
+ ID: "auth-2",
+ ClientID: "client-2",
+ Claims: storage.Claims{UserID: "user-1"},
+ ConnectorID: "mock",
+ }
+
+ err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false)
+ require.NoError(t, err)
+
+ // Cookie should name the same session.
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ gotID, _, err := internal.ParseSessionCookie(cookies[0].Value, nil)
+ require.NoError(t, err)
+ assert.Equal(t, nonce, gotID)
+
+ // Session should have both clients.
+ session, err := s.Storage.GetAuthSession(ctx, nonce)
+ require.NoError(t, err)
+ assert.Len(t, session.ClientStates, 2)
+ assert.Contains(t, session.ClientStates, "client-1")
+ assert.Contains(t, session.ClientStates, "client-2")
+ })
+
+ // The whole point of keying a session by its own id: the cookie decides whether
+ // there is a session to continue, so a second browser starts its own instead of
+ // joining the first and taking its cookie.
+ t.Run("a second browser gets its own session", func(t *testing.T) {
+ s := newTestSessionServer(t)
+
+ authReq := storage.AuthRequest{
+ ID: "auth-1",
+ ClientID: "client-1",
+ Claims: storage.Claims{UserID: "user-1"},
+ ConnectorID: "mock",
+ }
+
+ signIn := func(t *testing.T, r *http.Request) (id, secret string) {
+ t.Helper()
+ w := httptest.NewRecorder()
+ require.NoError(t, s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false))
+
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ id, secret, err := internal.ParseSessionCookie(cookies[0].Value, nil)
+ require.NoError(t, err)
+ return id, secret
+ }
+
+ // Same user, same connector, two browsers โ neither carrying a cookie.
+ firstID, firstSecret := signIn(t, httptest.NewRequest(http.MethodGet, "/", nil))
+ secondID, secondSecret := signIn(t, httptest.NewRequest(http.MethodGet, "/", nil))
+
+ assert.NotEqual(t, firstID, secondID, "a browser without a cookie must not join an existing session")
+ assert.NotEqual(t, firstSecret, secondSecret)
+
+ // Both are stored, and neither took the other's cookie.
+ first, err := s.Storage.GetAuthSession(ctx, firstID)
+ require.NoError(t, err, "the first browser's session must survive the second signing in")
+ assert.Equal(t, firstSecret, first.Secret)
+
+ second, err := s.Storage.GetAuthSession(ctx, secondID)
+ require.NoError(t, err)
+ assert.Equal(t, secondSecret, second.Secret)
+
+ // The same browser signing in again continues the session it already has.
+ againID, _ := signIn(t, sessionCookieRequest2(firstID, firstSecret))
+ assert.Equal(t, firstID, againID, "a browser with its cookie must continue its own session")
+
+ sessions, err := s.Storage.ListAuthSessions(ctx)
+ require.NoError(t, err)
+ assert.Len(t, sessions, 2)
+ })
+
+ t.Run("nil session config", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ resetSessions(s, nil, url.URL{})
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+
+ err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, storage.AuthRequest{}, false)
+ assert.NoError(t, err)
+ assert.Empty(t, w.Result().Cookies())
+ })
+}
+
+// setupSessionLoginFixture creates the necessary storage objects for trySessionLogin tests.
+func setupSessionLoginFixture(t *testing.T, s *sessionTestServer) storage.AuthRequest {
+ t.Helper()
+ ctx := t.Context()
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {
+ AuthenticatedAt: now.Add(-1 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{
+ UserID: "user-1",
+ Username: "testuser",
+ Email: "test@example.com",
+ },
+ Consents: map[string][]string{"client-1": {"openid", "email"}},
+ CreatedAt: now.Add(-1 * time.Hour),
+ LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-1",
+ ConnectorID: "mock",
+ Scopes: []string{"openid", "email"},
+ RedirectURI: "http://localhost/callback",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+ return authReq
+}
+
+// sessionCookieRequest2 is sessionCookieRequest for the tests that care that the
+// two halves of the cookie differ.
+func sessionCookieRequest2(sessionID, secret string) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(sessionID, secret, nil)})
+ return r
+}
+
+func sessionCookieRequest(sessionID string) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(sessionID, sessionID, nil)})
+ return r
+}
+
+func TestTrySessionLogin(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("no session", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ authReq := storage.AuthRequest{ConnectorID: "mock"}
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok)
+ })
+
+ t.Run("successful login with skipApproval", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.True(t, ok)
+ })
+
+ t.Run("successful login redirects to approval", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSessionLoginFixture(t, s)
+ authReq.ForceApprovalPrompt = true
+
+ require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.ForceApprovalPrompt = true
+ return a, nil
+ }))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ assert.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ assert.Contains(t, redirectURL, "req="+authReq.ID)
+ })
+
+ t.Run("skips approval when consent already given", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSessionLoginFixture(t, s)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.True(t, ok)
+ })
+
+ t.Run("connector mismatch returns false", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ authReq := setupSessionLoginFixture(t, s)
+ authReq.ConnectorID = "github"
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok)
+ })
+
+ t.Run("no client state for requested client", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ authReq := setupSessionLoginFixture(t, s)
+ authReq.ClientID = "unknown-client"
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok)
+ })
+
+ t.Run("updates session activity", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ require.True(t, ok)
+
+ session, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ assert.Equal(t, s.Now(), session.LastActivity)
+ })
+}
+
+// setupSessionWithIdentity creates an AuthSession, UserIdentity, and AuthRequest in storage
+// for use in trySessionLogin tests. Returns the authReq.
+func setupSessionWithIdentity(t *testing.T, s *sessionTestServer, now time.Time, lastLogin time.Time) storage.AuthRequest {
+ t.Helper()
+ ctx := t.Context()
+ nonce := "test-nonce"
+
+ session := storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: nonce, Secret: nonce,
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {
+ AuthenticatedAt: now.Add(-1 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ }
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, session))
+
+ ui := storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{
+ UserID: "user-1",
+ Username: "testuser",
+ Email: "test@example.com",
+ },
+ Consents: make(map[string][]string),
+ CreatedAt: now.Add(-1 * time.Hour),
+ LastLogin: lastLogin,
+ }
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, ui))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-1",
+ ConnectorID: "mock",
+ Scopes: []string{"openid"},
+ RedirectURI: "http://localhost/callback",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ return authReq
+}
+
+func TestTrySessionLogin_MaxAge(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("max_age not specified, session reused", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour))
+ authReq.MaxAge = -1 // not specified
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.True(t, ok, "session should be reused when max_age is not specified")
+ })
+
+ t.Run("max_age satisfied, session reused", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ // User logged in 10 minutes ago, max_age=3600 (1 hour)
+ authReq := setupSessionWithIdentity(t, s, now, now.Add(-10*time.Minute))
+ authReq.MaxAge = 3600
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.True(t, ok, "session should be reused when max_age is satisfied")
+ })
+
+ t.Run("max_age exceeded, force re-auth", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ // User logged in 2 hours ago, max_age=3600 (1 hour)
+ authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour))
+ authReq.MaxAge = 3600
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok, "session should NOT be reused when max_age is exceeded")
+ })
+
+ t.Run("max_age=0, always force re-auth", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ // User logged in 1 second ago, max_age=0
+ authReq := setupSessionWithIdentity(t, s, now, now.Add(-1*time.Second))
+ authReq.MaxAge = 0
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok, "max_age=0 should always force re-authentication")
+ })
+
+ t.Run("auth_time is set from UserIdentity.LastLogin", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ now := s.Now()
+ lastLogin := now.Add(-10 * time.Minute)
+
+ authReq := setupSessionWithIdentity(t, s, now, lastLogin)
+ authReq.ForceApprovalPrompt = true // force approval so AuthRequest is not deleted
+
+ require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.ForceApprovalPrompt = true
+ return a, nil
+ }))
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+
+ // Verify AuthTime was set on the auth request.
+ updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID)
+ require.NoError(t, err)
+ assert.Equal(t, lastLogin.Unix(), updated.AuthTime.Unix())
+ })
+}
+
+func TestTrySessionLoginWithSession_IDTokenHint(t *testing.T) {
+ ctx := t.Context()
+
+ // tokens.GenSubject("user-1", "mock") produces a deterministic subject string.
+ hintSubjectForUser1Mock, err := tokens.GenSubject("user-1", "mock")
+ require.NoError(t, err)
+
+ hintSubjectOther, err := tokens.GenSubject("other-user", "mock")
+ require.NoError(t, err)
+
+ t.Run("hint matches session user - session login succeeds", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq)
+ require.NotNil(t, session)
+
+ // Verify hint matches.
+ assert.True(t, sessionMatchesHint(session, hintSubjectForUser1Mock))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.True(t, ok)
+ })
+
+ t.Run("hint does not match session user - session invalidated", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq)
+ require.NotNil(t, session)
+
+ // Verify hint does NOT match.
+ assert.False(t, sessionMatchesHint(session, hintSubjectOther))
+
+ // Simulating the hint mismatch logic from handleConnectorLogin:
+ // when hint doesn't match and prompt is not none, session is set to nil.
+ var nilSession *storage.AuthSession
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nilSession)
+ assert.False(t, ok, "session login should fail when session is invalidated due to hint mismatch")
+ })
+
+ t.Run("hint with no session - trySessionLoginWithSession returns false", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nil)
+ assert.False(t, ok)
+ })
+
+ t.Run("no hint - unchanged behavior", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ authReq := setupSessionLoginFixture(t, s)
+
+ session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq)
+ require.NotNil(t, session)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.True(t, ok)
+ })
+}
+
+func TestParseAuthRequest_PromptAndMaxAge(t *testing.T) {
+ t.Run("prompt=consent sets ForceApprovalPrompt", func(t *testing.T) {
+ authReq := storage.AuthRequest{
+ Prompt: "consent",
+ ForceApprovalPrompt: true,
+ }
+ assert.True(t, authReq.ForceApprovalPrompt)
+ assert.Equal(t, "consent", authReq.Prompt)
+ })
+
+ t.Run("max_age default is -1", func(t *testing.T) {
+ authReq := storage.AuthRequest{
+ MaxAge: -1,
+ }
+ assert.Equal(t, -1, authReq.MaxAge)
+ })
+}
+
+func TestClientSharesSessionWith(t *testing.T) {
+ tests := []struct {
+ name string
+ ssoSharedWith []string
+ defaultPolicy string
+ targetClientID string
+ want bool
+ }{
+ {
+ name: "nil uses default none",
+ ssoSharedWith: nil,
+ defaultPolicy: "none",
+ targetClientID: "client-b",
+ want: false,
+ },
+ {
+ name: "nil uses default all",
+ ssoSharedWith: nil,
+ defaultPolicy: "all",
+ targetClientID: "client-b",
+ want: true,
+ },
+ {
+ name: "nil with empty default",
+ ssoSharedWith: nil,
+ defaultPolicy: "",
+ targetClientID: "client-b",
+ want: false,
+ },
+ {
+ name: "empty slice means no sharing",
+ ssoSharedWith: []string{},
+ defaultPolicy: "all",
+ targetClientID: "client-b",
+ want: false,
+ },
+ {
+ name: "wildcard shares with everyone",
+ ssoSharedWith: []string{"*"},
+ defaultPolicy: "none",
+ targetClientID: "any-client",
+ want: true,
+ },
+ {
+ name: "explicit match",
+ ssoSharedWith: []string{"client-b", "client-c"},
+ defaultPolicy: "none",
+ targetClientID: "client-b",
+ want: true,
+ },
+ {
+ name: "no match in list",
+ ssoSharedWith: []string{"client-b", "client-c"},
+ defaultPolicy: "none",
+ targetClientID: "client-d",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newTestSessionServer(t)
+ resetSessions(s, &session.Config{CookieName: "dex_session", AbsoluteLifetime: 24 * time.Hour, ValidIfNotUsedFor: time.Hour, SSOSharedWithDefault: tt.defaultPolicy}, url.URL{})
+
+ client := storage.Client{
+ ID: "source-client",
+ SSOSharedWith: tt.ssoSharedWith,
+ }
+ got := s.Sessions.ClientSharesWith(client, tt.targetClientID)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestFindSSOSession(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("finds SSO session from sharing client", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ SSOSharedWith: []string{"client-b"},
+ }))
+
+ session := &storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now.Add(-5 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ },
+ },
+ }
+
+ assert.NotNil(t, s.Sessions.FindSSO(ctx, session, "client-b"))
+ })
+
+ t.Run("no SSO when client does not share", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ SSOSharedWith: []string{"client-c"}, // Does not share with client-b
+ }))
+
+ session := &storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now.Add(-5 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ },
+ },
+ }
+
+ assert.Nil(t, s.Sessions.FindSSO(ctx, session, "client-b"))
+ })
+
+ t.Run("wildcard SSO with default all", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ resetSessions(s, &session.Config{CookieName: "dex_session", AbsoluteLifetime: 24 * time.Hour, ValidIfNotUsedFor: time.Hour, SSOSharedWithDefault: "all"}, url.URL{})
+ now := s.Now()
+
+ // Client with nil SSOSharedWith โ uses default "all"
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ // SSOSharedWith is nil โ uses ssoSharedWithDefault="all"
+ }))
+
+ session := &storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now.Add(-5 * time.Minute),
+ LastActivity: now.Add(-5 * time.Minute),
+ },
+ },
+ }
+
+ assert.NotNil(t, s.Sessions.FindSSO(ctx, session, "client-b"))
+ })
+}
+
+func TestTrySessionLogin_SSO(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("SSO login from sharing client", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ now := s.Now()
+
+ // Create source client that shares with target
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ SSOSharedWith: []string{"client-b"},
+ }))
+
+ // Create session with client-a authenticated
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now.Add(-1 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{
+ UserID: "user-1",
+ Username: "testuser",
+ Email: "test@example.com",
+ },
+ Consents: map[string][]string{"client-b": {"openid", "email"}},
+ CreatedAt: now.Add(-1 * time.Hour),
+ LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ // Auth request for client-b (not directly in session)
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-b",
+ ConnectorID: "mock",
+ Scopes: []string{"openid", "email"},
+ RedirectURI: "http://localhost/callback",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.True(t, ok, "SSO login should succeed")
+
+ // Verify client-b state was created in session
+ updated, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ assert.Contains(t, updated.ClientStates, "client-b")
+ assert.False(t, updated.ClientStates["client-b"].AuthenticatedAt.IsZero())
+ })
+
+ t.Run("SSO derived state inherits the source authentication time", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ SSOSharedWith: []string{"client-b"},
+ }))
+
+ // When the source client authenticated. The derived state must say the same:
+ // the user did not authenticate again to reach the target client.
+ sourceAuthTime := now.Add(-1 * time.Minute)
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: sourceAuthTime,
+ LastActivity: now.Add(-1 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{
+ UserID: "user-1",
+ Username: "testuser",
+ Email: "test@example.com",
+ },
+ Consents: map[string][]string{"client-b": {"openid", "email"}},
+ CreatedAt: now.Add(-1 * time.Hour),
+ LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-b",
+ ConnectorID: "mock",
+ Scopes: []string{"openid", "email"},
+ RedirectURI: "http://localhost/callback",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.True(t, ok, "SSO login should succeed")
+
+ updated, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ require.Contains(t, updated.ClientStates, "client-b")
+ assert.Equal(t, sourceAuthTime, updated.ClientStates["client-b"].AuthenticatedAt,
+ "derived state should carry the source's authentication time")
+ assert.True(t, updated.ClientStates["client-b"].ViaSSO)
+ })
+
+ t.Run("no SSO when client does not share", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a",
+ Secret: "secret",
+ Name: "Client A",
+ SSOSharedWith: []string{}, // Shares with nobody
+ }))
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {
+ AuthenticatedAt: now.Add(-1 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ },
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ IPAddress: "127.0.0.1",
+ UserAgent: "test",
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-b",
+ ConnectorID: "mock",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.False(t, ok, "SSO login should fail when client does not share")
+ })
+}
+
+func TestFinishSessionLogin_MFA(t *testing.T) {
+ ctx := t.Context()
+
+ setupMFAFixture := func(t *testing.T, mfaProviders map[string]mfa.Provider, clientMFAChain []string) (*sessionTestServer, storage.AuthRequest) {
+ t.Helper()
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ s.MFAEnabled = len(mfaProviders) > 0
+
+ // Create connector in storage and register it in the connectors map.
+ require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{
+ ID: "mock",
+ Type: "ldap",
+ Name: "Mock LDAP",
+ ResourceVersion: "1",
+ }))
+ s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"})
+
+ // Create client with MFA chain.
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-1",
+ Secret: "secret",
+ Name: "Test Client",
+ MFAChain: clientMFAChain,
+ }))
+
+ authReq := setupSessionLoginFixture(t, s)
+ return s, authReq
+ }
+
+ t.Run("MFA required redirects to MFA page", func(t *testing.T) {
+ s, authReq := setupMFAFixture(t, map[string]mfa.Provider{
+ "totp": mfa.NewTOTPProvider("test-issuer", nil), // nil connectorTypes = enabled for all
+ }, []string{"totp"})
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "should redirect to MFA page")
+ assert.Contains(t, redirectURL, "req="+authReq.ID, "redirect should include auth request ID")
+
+ // MFAValidated should NOT be set.
+ updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID)
+ require.NoError(t, err)
+ assert.False(t, updated.MFAValidated, "MFAValidated should be false when MFA is required")
+ // LoggedIn should still be set even though MFA is pending.
+ assert.True(t, updated.LoggedIn, "LoggedIn should be true even when MFA is pending")
+ })
+
+ t.Run("MFA provider not enabled for connector type skips MFA", func(t *testing.T) {
+ // TOTP provider only enabled for "oidc" connectors, but our connector is "ldap".
+ s, authReq := setupMFAFixture(t, map[string]mfa.Provider{
+ "totp": mfa.NewTOTPProvider("test-issuer", []string{"oidc"}),
+ }, []string{"totp"})
+ require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.ForceApprovalPrompt = true
+ return a, nil
+ }))
+ authReq.ForceApprovalPrompt = true
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+}
+
+// TestNonceVerificationRejectsForgedCookie verifies that a session cookie
+// with a valid (userID, connectorID) but wrong nonce is rejected.
+// The nonce comparison uses constant-time comparison to prevent timing attacks.
+// TestSecretVerificationRejectsForgedCookie: the session id travels in every id
+// token as the sid claim, so a cookie naming a real session proves nothing on its
+// own. Only the secret does, and these cases are the ones that reach that check.
+func TestSecretVerificationRejectsForgedCookie(t *testing.T) {
+ ctx := t.Context()
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "real-session", Secret: "real-secret",
+ CreatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ tests := []struct {
+ name string
+ sessionID string
+ secret string
+ }{
+ // The published id with every secret an attacker could try from it.
+ {"right id, wrong secret", "real-session", "wrong-secret"},
+ {"right id, no secret", "real-session", ""},
+ {"right id, secret is the id", "real-session", "real-session"},
+ {"right id, prefix of the secret", "real-session", "real"},
+ {"right id, secret with suffix", "real-session", "real-secret-extra"},
+ // And an id that names nothing, which never reaches the comparison.
+ {"unknown id", "other-session", "real-secret"},
+ {"empty id", "", "real-secret"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{
+ Name: "dex_session",
+ Value: internal.SessionCookieValue(tc.sessionID, tc.secret, nil),
+ })
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidSession(ctx, w, r)
+ assert.Nil(t, session, "forged cookie (%q, %q) should be rejected", tc.sessionID, tc.secret)
+
+ // Cookie should be cleared on nonce mismatch.
+ for _, c := range w.Result().Cookies() {
+ if c.Name == "dex_session" {
+ assert.Equal(t, -1, c.MaxAge, "cookie should be cleared")
+ }
+ }
+ })
+ }
+
+ t.Run("right id and secret accepted", func(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{
+ Name: "dex_session",
+ Value: internal.SessionCookieValue("real-session", "real-secret", nil),
+ })
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidSession(ctx, w, r)
+ require.NotNil(t, session)
+ assert.Equal(t, "user-1", session.UserID)
+ })
+}
+
+// TestPromptNone tests the prompt=none silent authentication scenarios.
+// These verify the code paths in handleConnectorLogin (handlers.go:444-457)
+// where prompt=none requires session-based login without any UI.
+func TestPromptNone(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("valid session with consent issues code silently", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSessionLoginFixture(t, s)
+ // Fixture already sets up Consents: {"client-1": {"openid", "email"}}
+ // and authReq.Scopes = {"openid", "email"} โ consent is satisfied.
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok, "session login should succeed")
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+
+ t.Run("valid session without consent returns approval URL", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute),
+ LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(59 * time.Minute),
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1",
+ ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{}, // No consent for any client.
+ CreatedAt: now.Add(-1 * time.Hour),
+ LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(),
+ ClientID: "client-1",
+ ConnectorID: "mock",
+ Scopes: []string{"openid", "email"},
+ RedirectURI: "http://localhost/callback",
+ MaxAge: -1,
+ HMACKey: storage.NewHMACKey(crypto.SHA256),
+ Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ // In handleConnectorLogin, a non-empty redirectURL with prompt=none
+ // triggers oauth2.InteractionRequired ("Consent required").
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok, "session login should succeed (user is authenticated)")
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+
+ t.Run("no session returns false", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ authReq := storage.AuthRequest{ConnectorID: "mock"}
+ r := httptest.NewRequest(http.MethodGet, "/", nil) // No cookie.
+ w := httptest.NewRecorder()
+
+ // In handleConnectorLogin, this triggers oauth2.LoginRequired.
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ assert.False(t, ok, "should fail without session")
+ })
+
+ t.Run("SSO available issues code silently", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
+ }))
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{},
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
+ Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok, "SSO silent login should succeed")
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+
+ // Verify SSO created a new client state.
+ updated, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ assert.Contains(t, updated.ClientStates, "client-b", "SSO should create client state for target")
+ })
+
+ t.Run("MFA required returns redirect not silent", func(t *testing.T) {
+ // This is the prompt=none + MFA case: finishSessionLogin returns MFA redirect URL.
+ // In handleConnectorLogin, this is a successful (ok=true) redirect, not oauth2.LoginRequired.
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ s.MFAEnabled = true
+
+ require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{
+ ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
+ }))
+ s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"})
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-1", Secret: "secret", Name: "Test", MFAChain: []string{"totp"},
+ }))
+
+ authReq := setupSessionLoginFixture(t, s)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "prompt=none with MFA should redirect to MFA page")
+ })
+}
+
+// TestPromptConsent tests that prompt=consent forces the approval screen
+// even when consent is already given.
+func TestPromptConsent(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("ForceApprovalPrompt overrides existing consent in session login", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSessionLoginFixture(t, s)
+
+ // Set ForceApprovalPrompt (set by prompt=consent in parseAuthorizationRequest).
+ require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
+ a.ForceApprovalPrompt = true
+ return a, nil
+ }))
+ authReq.ForceApprovalPrompt = true
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ ok := s.trySessionLogin(ctx, r, w, &authReq)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+
+ t.Run("login+consent parsed correctly", func(t *testing.T) {
+ prompt, err := oauth2.ParsePrompt("login consent")
+ require.NoError(t, err)
+ assert.True(t, prompt.Login(), "login flag should be set")
+ assert.True(t, prompt.Consent(), "consent flag should be set")
+ })
+}
+
+// TestSSO_ConsentAndMFA tests SSO interactions with consent and MFA.
+func TestSSO_ConsentAndMFA(t *testing.T) {
+ ctx := t.Context()
+
+ // setupSSOFixture creates a two-client SSO scenario where client-a shares with client-b.
+ setupSSOFixture := func(t *testing.T, s *sessionTestServer, consentsForB []string) storage.AuthRequest {
+ t.Helper()
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
+ }))
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+
+ consents := map[string][]string{}
+ if len(consentsForB) > 0 {
+ consents["client-b"] = consentsForB
+ }
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: consents,
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
+ Scopes: []string{"openid", "email"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+ return authReq
+ }
+
+ t.Run("SSO without consent for target shows approval", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSSOFixture(t, s, nil) // No consent for client-b.
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok, "SSO login should succeed")
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+
+ t.Run("SSO with consent for target skips approval", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = false
+ authReq := setupSSOFixture(t, s, []string{"openid", "email"})
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok, "SSO login should succeed")
+ assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
+ })
+
+ t.Run("SSO with MFA required on target client redirects to MFA", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ s.MFAEnabled = true
+
+ require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{
+ ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
+ }))
+ s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"})
+
+ // client-b requires MFA.
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-b", Secret: "secret", Name: "B", MFAChain: []string{"totp"},
+ }))
+
+ authReq := setupSSOFixture(t, s, []string{"openid", "email"})
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?", "SSO to MFA-requiring client should redirect to MFA")
+ })
+
+ t.Run("SSO source without MFA target with MFA enforces MFA", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ s.MFAEnabled = true
+
+ require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{
+ ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
+ }))
+ s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"})
+
+ // client-a has NO MFA, client-b requires MFA.
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
+ MFAChain: []string{}, // Explicitly no MFA.
+ }))
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-b", Secret: "secret", Name: "B",
+ MFAChain: []string{"totp"},
+ }))
+
+ now := s.Now()
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{},
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
+ Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ redirectURL := w.Header().Get("Location")
+ require.True(t, ok)
+ assert.Contains(t, redirectURL, "/auth?",
+ "SSO from no-MFA source to MFA-requiring target must enforce MFA")
+ })
+}
+
+// TestUpdateSessionTokenIssuedAt tests session activity tracking
+// when tokens are issued via sendCodeResponse (handlers.go:1016).
+func TestUpdateSessionTokenIssuedAt(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("updates session fields for correct client", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {AuthenticatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-10 * time.Minute)},
+ "client-2": {AuthenticatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-10 * time.Minute)},
+ },
+ CreatedAt: now.Add(-1 * time.Hour), LastActivity: now.Add(-10 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(50 * time.Minute),
+ }))
+
+ r := sessionCookieRequest("test-nonce")
+ s.Sessions.UpdateTokenIssuedAt(r, "client-1")
+
+ session, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+
+ assert.Equal(t, now, session.LastActivity, "session LastActivity should be updated")
+ assert.Equal(t, s.Sessions.IdleExpiry(now), session.IdleExpiry, "IdleExpiry should be extended")
+ assert.Equal(t, now, session.ClientStates["client-1"].LastTokenIssuedAt, "client-1 LastTokenIssuedAt should be set")
+ assert.Equal(t, now, session.ClientStates["client-1"].LastActivity, "client-1 LastActivity should be updated")
+ // client-2 should be untouched.
+ assert.Equal(t, now.Add(-10*time.Minute), session.ClientStates["client-2"].LastActivity,
+ "client-2 should not be affected")
+ })
+
+ t.Run("noop when sessions disabled", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ resetSessions(s, nil, url.URL{})
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ // Should not panic.
+ s.Sessions.UpdateTokenIssuedAt(r, "any-client")
+ })
+}
+
+// TestIdleExpiryExtension verifies that session activity pushes
+// IdleExpiry forward, preventing premature session expiration.
+func TestIdleExpiryExtension(t *testing.T) {
+ ctx := t.Context()
+
+ t.Run("createOrUpdateAuthSession extends IdleExpiry", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ now := s.Now()
+
+ // Create an existing session with IdleExpiry close to now.
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{},
+ CreatedAt: now.Add(-50 * time.Minute),
+ LastActivity: now.Add(-50 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(10 * time.Minute), // Only 10 minutes left.
+ }))
+
+ // The browser presents its cookie, so this is the same session continuing โ
+ // without one it would be a new device and a new session.
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+ authReq := storage.AuthRequest{
+ ClientID: "client-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1"},
+ }
+
+ err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false)
+ require.NoError(t, err)
+
+ session, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ assert.Equal(t, s.Sessions.IdleExpiry(now), session.IdleExpiry,
+ "IdleExpiry should be reset to now + ValidIfNotUsedFor")
+ })
+
+ t.Run("finishSessionLogin extends IdleExpiry", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-1": {AuthenticatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute)},
+ },
+ CreatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour),
+ IdleExpiry: now.Add(10 * time.Minute), // About to expire.
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{},
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-50 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: "client-1", ConnectorID: "mock",
+ Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ require.NotNil(t, session)
+
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ require.True(t, ok)
+
+ updated, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ assert.Equal(t, s.Sessions.IdleExpiry(now), updated.IdleExpiry,
+ "IdleExpiry should be extended after session login")
+ })
+}
+
+// TestSSO_Unidirectional verifies that SSO sharing is one-way:
+// A sharing with B does NOT mean B shares with A.
+func TestSSO_Unidirectional(t *testing.T) {
+ ctx := t.Context()
+
+ setup := func(t *testing.T, s *sessionTestServer, loginClient, targetClient string) (storage.AuthRequest, *storage.AuthSession) {
+ t.Helper()
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ loginClient: {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{},
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ authReq := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: targetClient, ConnectorID: "mock",
+ Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq))
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq)
+ return authReq, session
+ }
+
+ t.Run("A shares with B, login A request B succeeds", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"},
+ }))
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share back.
+ }))
+
+ authReq, session := setup(t, s, "client-a", "client-b")
+ require.NotNil(t, session)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.True(t, ok, "AโB SSO should succeed")
+ })
+
+ t.Run("B does not share with A, login B request A fails", func(t *testing.T) {
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"},
+ }))
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share.
+ }))
+
+ authReq, session := setup(t, s, "client-b", "client-a")
+ require.NotNil(t, session)
+
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+ ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
+ assert.False(t, ok, "BโA SSO should fail because B does not share with A")
+ })
+}
+
+// TestSSO_TransitiveTrustChain verifies SSO sharing does not chain: A shares
+// only with B and B shares only with C (A never shares with C), so a user
+// authenticated only to A must not be SSO'd into C via B.
+func TestSSO_TransitiveTrustChain(t *testing.T) {
+ ctx := t.Context()
+
+ s := newTestSessionServer(t)
+ s.SkipApproval = true
+ now := s.Now()
+
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"},
+ }))
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{"client-c"},
+ }))
+ require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{
+ ID: "client-c", Secret: "s", Name: "C", SSOSharedWith: []string{},
+ }))
+
+ // User authenticated only to A.
+ require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{
+ UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce",
+ ClientStates: map[string]*storage.ClientAuthState{
+ "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)},
+ },
+ CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
+ AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
+ }))
+ require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{
+ UserID: "user-1", ConnectorID: "mock",
+ Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
+ Consents: map[string][]string{},
+ CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
+ }))
+
+ tryLogin := func(target string) bool {
+ req := storage.AuthRequest{
+ ID: storage.NewID(), ClientID: target, ConnectorID: "mock",
+ Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
+ MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
+ }
+ require.NoError(t, s.Storage.CreateAuthRequest(ctx, req))
+ r := sessionCookieRequest("test-nonce")
+ w := httptest.NewRecorder()
+ session := s.Sessions.ValidAuthSession(ctx, w, r, &req)
+ require.NotNil(t, session)
+ ok := s.trySessionLoginWithSession(ctx, r, w, &req, session)
+ return ok
+ }
+
+ // Hop 1: AโB succeeds and records an SSO-derived state for B.
+ require.True(t, tryLogin("client-b"), "AโB SSO should succeed")
+
+ // The derived B state must be marked ViaSSO, which is what makes it ineligible
+ // as a source below โ assert it directly so a regression localizes here.
+ sess, err := s.Storage.GetAuthSession(ctx, "test-nonce")
+ require.NoError(t, err)
+ require.NotNil(t, sess.ClientStates["client-b"])
+ assert.True(t, sess.ClientStates["client-b"].ViaSSO, "B's SSO-derived state must be marked ViaSSO")
+
+ // Hop 2: C has no eligible SSO source โ A does not share with C and B's state
+ // is SSO-derived. Pin the exact invariant, then the login outcome.
+ assert.Nil(t, s.Sessions.FindSSO(ctx, &sess, "client-c"), "no eligible SSO source for C")
+ assert.False(t, tryLogin("client-c"), "transitive AโBโC SSO must be denied")
+}
+
+// TestRememberMeDefault tests that the rememberMeDefault helper
+// returns the correct value based on session configuration.
+func TestRememberMeDefault(t *testing.T) {
+ t.Run("sessions disabled returns nil", func(t *testing.T) {
+ s := &session.Manager{}
+ assert.Nil(t, s.RememberMeDefault())
+ })
+
+ t.Run("default false", func(t *testing.T) {
+ s := &session.Manager{Config: &session.Config{RememberMeCheckedByDefault: false}}
+ v := s.RememberMeDefault()
+ require.NotNil(t, v)
+ assert.False(t, *v)
+ })
+
+ t.Run("default true", func(t *testing.T) {
+ s := &session.Manager{Config: &session.Config{RememberMeCheckedByDefault: true}}
+ v := s.RememberMeDefault()
+ require.NotNil(t, v)
+ assert.True(t, *v)
+ })
+}
+
+// resetSessions rebuilds the Handler's session manager with the given config and
+// issuer, for tests that exercise Manager behavior under a different config.
+func resetSessions(s *sessionTestServer, cfg *session.Config, issuer url.URL) {
+ s.Sessions = &session.Manager{Storage: s.Storage, Config: cfg, Now: s.Now, Logger: slog.Default(), IssuerURL: oauth2.IssuerURL{URL: issuer}}
+}
diff --git a/server/authflow/urls.go b/server/authflow/urls.go
new file mode 100644
index 0000000000..78278ad0ce
--- /dev/null
+++ b/server/authflow/urls.go
@@ -0,0 +1,26 @@
+package authflow
+
+import (
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/storage"
+)
+
+// buildContinueURL builds the HMAC-protected URL that returns to the /auth
+// dispatcher, used once login completes so the dispatcher can pick the next step.
+func (h *Handler) buildContinueURL(authReq storage.AuthRequest) string {
+ return internal.StepURL(h.IssuerURL.AbsPath("/auth"), authReq, internal.StepContinue, nil)
+}
+
+// buildMFAURL builds the HMAC-protected URL of the MFA entry, where the
+// dispatcher sends the user when the client requires MFA. MFA resolves the
+// effective chain and picks the factor; the dispatcher only decides that MFA
+// applies.
+func (h *Handler) buildMFAURL(authReq storage.AuthRequest) string {
+ return internal.StepURL(h.IssuerURL.AbsPath("/mfa"), authReq, internal.StepMFA, nil)
+}
+
+// buildApprovalURL builds the HMAC-protected URL of the consent screen, where the
+// dispatcher sends the user when consent is required.
+func (h *Handler) buildApprovalURL(authReq storage.AuthRequest) string {
+ return internal.StepURL(h.IssuerURL.AbsPath("/approval"), authReq, internal.StepApproval, nil)
+}
diff --git a/server/backchannel/backchannel.go b/server/backchannel/backchannel.go
new file mode 100644
index 0000000000..d1ed698fa0
--- /dev/null
+++ b/server/backchannel/backchannel.go
@@ -0,0 +1,203 @@
+package backchannel
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/storage"
+)
+
+const (
+ // backchannelLogoutEvent is the event identifier a logout token must carry, per
+ // OIDC Back-Channel Logout 1.0 ยง2.4.
+ backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
+
+ // backchannelTokenLifetime bounds the replay window for a logout token. The spec
+ // recommends no more than two minutes.
+ backchannelTokenLifetime = 2 * time.Minute
+
+ // backchannelTimeout caps how long dex waits on one RP. Logout must not hang on a
+ // wedged relying party.
+ backchannelTimeout = 5 * time.Second
+)
+
+// Notifier posts logout tokens to the relying parties of a session that has ended.
+//
+// It lives outside server/logout because a session also ends by an operator's hand
+// over the gRPC API, and every path that ends one goes through here.
+type Notifier struct {
+ Storage storage.Storage
+ Signer signer.Signer
+ IssuerURL oauth2.IssuerURL
+ Logger *slog.Logger
+
+ // Now is the clock, for tests. Defaults to time.Now.
+ Now func() time.Time
+
+ // HTTPClient delivers the logout tokens. Defaults to one that refuses redirects.
+ HTTPClient *http.Client
+}
+
+// logoutTokenClaims is the JWT dex POSTs to a relying party's backchannel_logout_uri.
+//
+// Note the absences: there is no "nonce" (the spec forbids it, to keep a logout token
+// from being mistaken for an ID token) and no "events" payload beyond an empty object.
+type logoutTokenClaims struct {
+ Issuer string `json:"iss"`
+ Subject string `json:"sub"`
+ Audience string `json:"aud"`
+ IssuedAt int64 `json:"iat"`
+ Expiry int64 `json:"exp"`
+ JWTID string `json:"jti"`
+ SessionID string `json:"sid"`
+ Events map[string]json.RawMessage `json:"events"`
+}
+
+// Notify tells every relying party in the session that it is over.
+//
+// Delivery is best-effort and fire-and-forget: RP-Initiated Logout treats notifying
+// other RPs as a courtesy, and a relying party that is down must not be able to block
+// or fail the user's logout. Failures are logged and dropped.
+//
+// ponytail: no retries and no durable queue. An RP that is unreachable for these few
+// seconds keeps its session until it expires on its own. If that becomes a real
+// problem, the upgrade path is to persist pending notifications and drain them from
+// the garbage collector, not to make the user wait here.
+func (n *Notifier) Notify(ctx context.Context, authSession *storage.AuthSession) {
+ if len(authSession.ClientStates) == 0 {
+ return
+ }
+
+ subject, err := internal.Marshal(&internal.IDTokenSubject{
+ UserId: authSession.UserID,
+ ConnId: authSession.ConnectorID,
+ })
+ if err != nil {
+ n.Logger.ErrorContext(ctx, "logout: failed to marshal backchannel subject", "err", err)
+ return
+ }
+
+ sid := authSession.ID
+ clientIDs := slices.Sorted(maps.Keys(authSession.ClientStates))
+
+ // Read above while the session is still in hand: the caller deletes it the moment
+ // this returns. Delivery runs off the request โ one wedged relying party would
+ // otherwise hold the user's redirect for the whole timeout โ so it gets a context
+ // that outlives the one dying at that redirect.
+ ctx = context.WithoutCancel(ctx)
+ go func() {
+ ctx, cancel := context.WithTimeout(ctx, backchannelTimeout)
+ defer cancel()
+
+ var wg sync.WaitGroup
+ for _, clientID := range clientIDs {
+ wg.Go(func() {
+ client, err := n.Storage.GetClient(ctx, clientID)
+ if err != nil {
+ n.Logger.DebugContext(ctx, "logout: backchannel skipped, client not found",
+ "client_id", clientID, "err", err)
+ return
+ }
+ if client.BackchannelLogoutURI == "" {
+ return
+ }
+ n.deliverLogoutToken(ctx, client, subject, sid)
+ })
+ }
+ wg.Wait()
+ }()
+}
+
+// deliverLogoutToken mints a logout token for one client and POSTs it.
+func (n *Notifier) deliverLogoutToken(ctx context.Context, client storage.Client, subject, sid string) {
+ token, err := n.signLogoutToken(ctx, client.ID, subject, sid)
+ if err != nil {
+ n.Logger.ErrorContext(ctx, "logout: failed to sign logout token",
+ "client_id", client.ID, "err", err)
+ return
+ }
+
+ body := url.Values{"logout_token": {token}}.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BackchannelLogoutURI, strings.NewReader(body))
+ if err != nil {
+ n.Logger.ErrorContext(ctx, "logout: failed to build backchannel request",
+ "client_id", client.ID, "err", err)
+ return
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cache-Control", "no-cache, no-store")
+
+ resp, err := n.client().Do(req)
+ if err != nil {
+ n.Logger.WarnContext(ctx, "logout: backchannel delivery failed",
+ "client_id", client.ID, "uri", client.BackchannelLogoutURI, "err", err)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ n.Logger.WarnContext(ctx, "logout: backchannel delivery rejected",
+ "client_id", client.ID, "uri", client.BackchannelLogoutURI, "status", resp.StatusCode)
+ return
+ }
+
+ n.Logger.DebugContext(ctx, "logout: backchannel delivered", "client_id", client.ID)
+}
+
+// signLogoutToken builds and signs the logout token for one audience.
+func (n *Notifier) signLogoutToken(ctx context.Context, clientID, subject, sid string) (string, error) {
+ now := time.Now()
+ if n.Now != nil {
+ now = n.Now()
+ }
+
+ claims := logoutTokenClaims{
+ Issuer: n.IssuerURL.String(),
+ Subject: subject,
+ Audience: clientID,
+ IssuedAt: now.Unix(),
+ Expiry: now.Add(backchannelTokenLifetime).Unix(),
+ JWTID: uuid.New().String(),
+ SessionID: sid,
+ Events: map[string]json.RawMessage{backchannelLogoutEvent: json.RawMessage(`{}`)},
+ }
+
+ payload, err := json.Marshal(claims)
+ if err != nil {
+ return "", fmt.Errorf("marshal logout token: %w", err)
+ }
+
+ token, err := n.Signer.Sign(ctx, payload)
+ if err != nil {
+ return "", fmt.Errorf("sign logout token: %w", err)
+ }
+ return token, nil
+}
+
+// client returns the HTTP client used for delivery, defaulting to one with
+// no redirect following: a logout token must reach the URI the client registered, not
+// wherever that URI happens to point today.
+func (n *Notifier) client() *http.Client {
+ if n.HTTPClient != nil {
+ return n.HTTPClient
+ }
+ return &http.Client{
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+}
diff --git a/server/config.go b/server/config.go
new file mode 100644
index 0000000000..554d37ca07
--- /dev/null
+++ b/server/config.go
@@ -0,0 +1,293 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "os"
+ "sort"
+ "time"
+
+ gosundheit "github.com/AppsFlyer/go-sundheit"
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/dexidp/dex/server/authflow"
+ "github.com/dexidp/dex/server/mfa"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/web"
+)
+
+// Config holds the server's configuration options.
+//
+// Multiple servers using the same storage are expected to be configured identically.
+type Config struct {
+ Issuer string
+
+ // The backing persistence layer.
+ Storage storage.Storage
+
+ AllowedGrantTypes []string
+
+ // Valid values are "code" to enable the code flow and "token" to enable the implicit
+ // flow. If no response types are supplied this value defaults to "code".
+ SupportedResponseTypes []string
+
+ // Headers is a map of headers to be added to the all responses.
+ Headers http.Header
+
+ // Header to extract real ip from.
+ RealIPHeader string
+ TrustedRealIPCIDRs []netip.Prefix
+
+ // List of allowed origins for CORS requests on discovery, token and keys endpoint.
+ // If none are indicated, CORS requests are disabled. Passing in "*" will allow any
+ // domain.
+ AllowedOrigins []string
+
+ // List of allowed headers for CORS requests on discovery, token, and keys endpoint.
+ AllowedHeaders []string
+
+ // If enabled, the server won't prompt the user to approve authorization requests.
+ // Logging in implies approval.
+ SkipApprovalScreen bool
+
+ // If enabled, the connectors selection page will always be shown even if there's only one
+ AlwaysShowLoginScreen bool
+
+ IDTokensValidFor time.Duration // Defaults to 24 hours
+ AuthRequestsValidFor time.Duration // Defaults to 24 hours
+ DeviceRequestsValidFor time.Duration // Defaults to 5 minutes
+
+ // Refresh token expiration settings
+ RefreshTokenPolicy *tokens.RefreshStrategy
+
+ // If set, the server will use this connector to handle password grants
+ PasswordConnector string
+
+ // PKCE configuration
+ PKCE authflow.PKCEConfig
+
+ GCFrequency time.Duration // Defaults to 5 minutes
+
+ // If specified, the server will use this function for determining time.
+ Now func() time.Time
+
+ Web WebConfig
+
+ Logger *slog.Logger
+
+ // Signer is used to sign tokens.
+ Signer signer.Signer
+
+ PrometheusRegistry *prometheus.Registry
+
+ HealthChecker gosundheit.Health
+
+ // If enabled, the server will continue starting even if some connectors fail to initialize.
+ // This allows the server to operate with a subset of connectors if some are misconfigured.
+ ContinueOnConnectorFailure bool
+
+ // SessionConfig holds session settings. Nil when sessions are disabled.
+ SessionConfig *session.Config
+
+ // MFAProviders maps authenticator IDs to their provider implementations.
+ MFAProviders map[string]mfa.Provider
+
+ // DefaultMFAChain is applied to clients that don't specify their own mfaChain.
+ DefaultMFAChain []string
+}
+
+// WebConfig holds the server's frontend templates and asset configuration.
+type WebConfig struct {
+ // A file path to static web assets.
+ //
+ // It is expected to contain the following directories:
+ //
+ // * static - Static static served at "( issuer URL )/static".
+ // * templates - HTML templates controlled by dex.
+ // * themes/(theme) - Static static served at "( issuer URL )/theme".
+ Dir string
+
+ // Alternative way to programmatically configure static web assets.
+ // If Dir is specified, WebFS is ignored.
+ // It's expected to contain the same files and directories as mentioned above.
+ //
+ // Note: this is experimental. Might get removed without notice!
+ WebFS fs.FS
+
+ // Defaults to "( issuer URL )/theme/logo.png"
+ LogoURL string
+
+ // Defaults to "dex"
+ Issuer string
+
+ // Defaults to "light"
+ Theme string
+
+ // Map of extra values passed into the templates
+ Extra map[string]string
+}
+
+func value(val, defaultValue time.Duration) time.Duration {
+ if val == 0 {
+ return defaultValue
+ }
+ return val
+}
+
+// resolvedConfig is Config after defaults are filled in and values validated:
+// everything the handlers are wired from, derived once so newServer only has to
+// hand the pieces out.
+type resolvedConfig struct {
+ issuerURL oauth2.IssuerURL
+ now func() time.Time
+
+ // responseTypes and grantTypes are what the server advertises and accepts,
+ // narrowed to the configured subset.
+ responseTypes map[string]bool
+ grantTypes []string
+
+ authRequestsValidFor time.Duration
+ deviceRequestsValidFor time.Duration
+ idTokensValidFor time.Duration
+
+ templates *templates.Templates
+ static http.Handler
+ theme http.Handler
+ robots http.HandlerFunc
+}
+
+// normalizeConfig validates c and derives everything the server is built from.
+// It fills c's own defaults in place (response types, allowed headers, PKCE
+// methods), because the handlers read those fields directly.
+func normalizeConfig(c *Config) (resolvedConfig, error) {
+ if c.Storage == nil {
+ return resolvedConfig{}, errors.New("server: storage cannot be nil")
+ }
+
+ issuerURL, err := url.Parse(c.Issuer)
+ if err != nil {
+ return resolvedConfig{}, fmt.Errorf("server: can't parse issuer URL")
+ }
+
+ if len(c.SupportedResponseTypes) == 0 {
+ c.SupportedResponseTypes = []string{oauth2.ResponseTypeCode}
+ }
+ if len(c.AllowedHeaders) == 0 {
+ c.AllowedHeaders = []string{"Authorization"}
+ }
+ if len(c.PKCE.CodeChallengeMethodsSupported) == 0 {
+ c.PKCE.CodeChallengeMethodsSupported = []string{oauth2.PKCEMethodS256, oauth2.PKCEMethodPlain}
+ }
+ for _, m := range c.PKCE.CodeChallengeMethodsSupported {
+ if m != oauth2.PKCEMethodS256 && m != oauth2.PKCEMethodPlain {
+ return resolvedConfig{}, fmt.Errorf("unsupported PKCE challenge method %q", m)
+ }
+ }
+
+ responseTypes, grantTypes, err := supportedTypes(c)
+ if err != nil {
+ return resolvedConfig{}, err
+ }
+
+ static, theme, robots, tmpls, err := templates.LoadWebConfig(webConfig(c))
+ if err != nil {
+ return resolvedConfig{}, fmt.Errorf("server: failed to load web static: %v", err)
+ }
+
+ now := c.Now
+ if now == nil {
+ now = time.Now
+ }
+
+ return resolvedConfig{
+ issuerURL: oauth2.IssuerURL{URL: *issuerURL},
+ now: now,
+ responseTypes: responseTypes,
+ grantTypes: grantTypes,
+ authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour),
+ deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute),
+ idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour),
+ templates: tmpls,
+ static: static,
+ theme: theme,
+ robots: robots,
+ }, nil
+}
+
+// supportedTypes resolves the response types the server accepts and the grant
+// types it advertises. A response type enabling the implicit flow adds the
+// implicit grant; AllowedGrantTypes, when set, narrows the result to it.
+func supportedTypes(c *Config) (map[string]bool, []string, error) {
+ allGrants := map[string]bool{
+ oauth2.GrantTypeAuthorizationCode: true,
+ oauth2.GrantTypeRefreshToken: true,
+ oauth2.GrantTypeDeviceCode: true,
+ oauth2.GrantTypeTokenExchange: true,
+ oauth2.GrantTypeClientCredentials: true,
+ }
+ responseTypes := make(map[string]bool)
+
+ for _, respType := range c.SupportedResponseTypes {
+ switch respType {
+ case oauth2.ResponseTypeCode, oauth2.ResponseTypeIDToken, oauth2.ResponseTypeCodeIDToken:
+ // continue
+ case oauth2.ResponseTypeToken, oauth2.ResponseTypeCodeToken, oauth2.ResponseTypeIDTokenToken, oauth2.ResponseTypeCodeIDTokenToken:
+ // response_type=token is an implicit flow, let's add it to the discovery info
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.1
+ allGrants[oauth2.GrantTypeImplicit] = true
+ default:
+ return nil, nil, fmt.Errorf("unsupported response_type %q", respType)
+ }
+ responseTypes[respType] = true
+ }
+
+ if c.PasswordConnector != "" {
+ allGrants[oauth2.GrantTypePassword] = true
+ }
+
+ var grantTypes []string
+ if len(c.AllowedGrantTypes) > 0 {
+ for _, grant := range c.AllowedGrantTypes {
+ if allGrants[grant] {
+ grantTypes = append(grantTypes, grant)
+ }
+ }
+ } else {
+ for grant := range allGrants {
+ grantTypes = append(grantTypes, grant)
+ }
+ }
+ sort.Strings(grantTypes)
+
+ return responseTypes, grantTypes, nil
+}
+
+// webConfig resolves where the frontend assets are loaded from: an explicit
+// directory, a caller-supplied filesystem, or the assets embedded in dex.
+func webConfig(c *Config) templates.Config {
+ webFS := web.FS()
+ if c.Web.Dir != "" {
+ webFS = os.DirFS(c.Web.Dir)
+ } else if c.Web.WebFS != nil {
+ webFS = c.Web.WebFS
+ }
+
+ return templates.Config{
+ WebFS: webFS,
+ LogoURL: c.Web.LogoURL,
+ IssuerURL: c.Issuer,
+ Issuer: c.Web.Issuer,
+ Theme: c.Web.Theme,
+ Extra: c.Web.Extra,
+ }
+}
diff --git a/server/config_test.go b/server/config_test.go
new file mode 100644
index 0000000000..b62df7615a
--- /dev/null
+++ b/server/config_test.go
@@ -0,0 +1,117 @@
+package server
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+// baseConfig is the minimum normalizeConfig accepts: storage and an issuer.
+// Building it needs no server, so the config rules can be tested on their own.
+func baseConfig(t *testing.T) Config {
+ return Config{
+ Issuer: "https://dex.example.com",
+ Storage: memory.New(newLogger(t)),
+ Web: WebConfig{Dir: "../web"},
+ }
+}
+
+func TestNormalizeConfigDefaults(t *testing.T) {
+ c := baseConfig(t)
+
+ rc, err := normalizeConfig(&c)
+ require.NoError(t, err)
+
+ require.Equal(t, []string{oauth2.ResponseTypeCode}, c.SupportedResponseTypes)
+ require.Equal(t, []string{"Authorization"}, c.AllowedHeaders)
+ require.Equal(t, []string{oauth2.PKCEMethodS256, oauth2.PKCEMethodPlain}, c.PKCE.CodeChallengeMethodsSupported)
+
+ require.Equal(t, "https://dex.example.com", rc.issuerURL.String())
+ require.NotNil(t, rc.now)
+ require.NotNil(t, rc.templates)
+ require.Equal(t, map[string]bool{oauth2.ResponseTypeCode: true}, rc.responseTypes)
+
+ // Without AllowedGrantTypes every implemented grant is advertised, sorted,
+ // and the implicit grant is absent because no implicit response type is set.
+ // Sorted by value, so the two grant-type URNs come last.
+ require.Equal(t, []string{
+ oauth2.GrantTypeAuthorizationCode,
+ oauth2.GrantTypeClientCredentials,
+ oauth2.GrantTypeRefreshToken,
+ oauth2.GrantTypeDeviceCode,
+ oauth2.GrantTypeTokenExchange,
+ }, rc.grantTypes)
+}
+
+func TestNormalizeConfigGrantTypes(t *testing.T) {
+ t.Run("an implicit response type adds the implicit grant", func(t *testing.T) {
+ c := baseConfig(t)
+ c.SupportedResponseTypes = []string{oauth2.ResponseTypeCode, oauth2.ResponseTypeToken}
+
+ rc, err := normalizeConfig(&c)
+ require.NoError(t, err)
+ require.Contains(t, rc.grantTypes, oauth2.GrantTypeImplicit)
+ })
+
+ t.Run("a password connector adds the password grant", func(t *testing.T) {
+ c := baseConfig(t)
+ c.PasswordConnector = "local"
+
+ rc, err := normalizeConfig(&c)
+ require.NoError(t, err)
+ require.Contains(t, rc.grantTypes, oauth2.GrantTypePassword)
+ })
+
+ t.Run("AllowedGrantTypes narrows the set", func(t *testing.T) {
+ c := baseConfig(t)
+ c.AllowedGrantTypes = []string{oauth2.GrantTypeRefreshToken, "not-a-grant"}
+
+ rc, err := normalizeConfig(&c)
+ require.NoError(t, err)
+ require.Equal(t, []string{oauth2.GrantTypeRefreshToken}, rc.grantTypes)
+ })
+}
+
+func TestNormalizeConfigRejects(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*Config)
+ errMsg string
+ }{
+ {
+ name: "nil storage",
+ mutate: func(c *Config) { c.Storage = nil },
+ errMsg: "storage cannot be nil",
+ },
+ {
+ name: "unparseable issuer",
+ mutate: func(c *Config) { c.Issuer = "://" },
+ errMsg: "can't parse issuer URL",
+ },
+ {
+ name: "unknown response type",
+ mutate: func(c *Config) { c.SupportedResponseTypes = []string{"nonsense"} },
+ errMsg: `unsupported response_type "nonsense"`,
+ },
+ {
+ name: "unknown PKCE method",
+ mutate: func(c *Config) {
+ c.PKCE.CodeChallengeMethodsSupported = []string{"S512"}
+ },
+ errMsg: `unsupported PKCE challenge method "S512"`,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ c := baseConfig(t)
+ tc.mutate(&c)
+
+ _, err := normalizeConfig(&c)
+ require.ErrorContains(t, err, tc.errMsg)
+ })
+ }
+}
diff --git a/server/connector.go b/server/connector.go
new file mode 100644
index 0000000000..172031c68b
--- /dev/null
+++ b/server/connector.go
@@ -0,0 +1,46 @@
+package server
+
+import (
+ "github.com/dexidp/dex/connector/atlassiancrowd"
+ "github.com/dexidp/dex/connector/authproxy"
+ "github.com/dexidp/dex/connector/bitbucketcloud"
+ "github.com/dexidp/dex/connector/gitea"
+ "github.com/dexidp/dex/connector/github"
+ "github.com/dexidp/dex/connector/gitlab"
+ "github.com/dexidp/dex/connector/google"
+ "github.com/dexidp/dex/connector/keystone"
+ "github.com/dexidp/dex/connector/ldap"
+ "github.com/dexidp/dex/connector/linkedin"
+ "github.com/dexidp/dex/connector/microsoft"
+ "github.com/dexidp/dex/connector/mock"
+ "github.com/dexidp/dex/connector/oauth"
+ "github.com/dexidp/dex/connector/oidc"
+ "github.com/dexidp/dex/connector/openshift"
+ "github.com/dexidp/dex/connector/saml"
+ "github.com/dexidp/dex/server/connectors"
+)
+
+// ConnectorsConfig maps each built-in connector type to its config factory. It
+// is handed to connectors.Resolver so the connectors package itself imports no
+// connector implementation; a library consumer can pass a different map.
+var ConnectorsConfig = map[string]func() connectors.ConnectorConfig{
+ "keystone": func() connectors.ConnectorConfig { return new(keystone.Config) },
+ "mockCallback": func() connectors.ConnectorConfig { return new(mock.CallbackConfig) },
+ "mockPassword": func() connectors.ConnectorConfig { return new(mock.PasswordConfig) },
+ "ldap": func() connectors.ConnectorConfig { return new(ldap.Config) },
+ "gitea": func() connectors.ConnectorConfig { return new(gitea.Config) },
+ "github": func() connectors.ConnectorConfig { return new(github.Config) },
+ "gitlab": func() connectors.ConnectorConfig { return new(gitlab.Config) },
+ "google": func() connectors.ConnectorConfig { return new(google.Config) },
+ "oidc": func() connectors.ConnectorConfig { return new(oidc.Config) },
+ "oauth": func() connectors.ConnectorConfig { return new(oauth.Config) },
+ "saml": func() connectors.ConnectorConfig { return new(saml.Config) },
+ "authproxy": func() connectors.ConnectorConfig { return new(authproxy.Config) },
+ "linkedin": func() connectors.ConnectorConfig { return new(linkedin.Config) },
+ "microsoft": func() connectors.ConnectorConfig { return new(microsoft.Config) },
+ "bitbucket-cloud": func() connectors.ConnectorConfig { return new(bitbucketcloud.Config) },
+ "openshift": func() connectors.ConnectorConfig { return new(openshift.Config) },
+ "atlassian-crowd": func() connectors.ConnectorConfig { return new(atlassiancrowd.Config) },
+ // Keep around for backwards compatibility.
+ "samlExperimental": func() connectors.ConnectorConfig { return new(saml.Config) },
+}
diff --git a/server/connectors/connectors.go b/server/connectors/connectors.go
new file mode 100644
index 0000000000..a028d54945
--- /dev/null
+++ b/server/connectors/connectors.go
@@ -0,0 +1,115 @@
+package connectors
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/storage"
+)
+
+// Connector is a connector with resource version metadata.
+type Connector struct {
+ Type string
+ ResourceVersion string
+ Connector connector.Connector
+ GrantTypes []string
+}
+
+// ResolveFunc builds the underlying connector implementation for a stored
+// connector. The server injects it so that connector construction (the local
+// password DB and the connector-config registry) stays in the server package.
+type ResolveFunc func(storage.Connector) (connector.Connector, error)
+
+// Cache resolves connectors from storage and keeps the opened instances in
+// memory, refreshing an entry when its stored resource version changes. It is
+// the sole owner of the connector map and its mutex.
+type Cache struct {
+ mu sync.Mutex
+ conns map[string]Connector
+ storage storage.Storage
+ resolve ResolveFunc
+}
+
+// NewCache returns an empty cache backed by the given storage and resolver.
+func NewCache(storage storage.Storage, resolve ResolveFunc) *Cache {
+ return &Cache{
+ conns: make(map[string]Connector),
+ storage: storage,
+ resolve: resolve,
+ }
+}
+
+// Open builds the connector for the given stored connector and records it in the
+// cache, replacing any existing entry for the same ID.
+func (c *Cache) Open(conn storage.Connector) (Connector, error) {
+ impl, err := c.resolve(conn)
+ if err != nil {
+ return Connector{}, fmt.Errorf("failed to open connector: %v", err)
+ }
+
+ opened := Connector{
+ Type: conn.Type,
+ ResourceVersion: conn.ResourceVersion,
+ Connector: impl,
+ GrantTypes: conn.GrantTypes,
+ }
+
+ c.mu.Lock()
+ c.conns[conn.ID] = opened
+ c.mu.Unlock()
+
+ return opened, nil
+}
+
+// Set records an already-opened connector under the given id, replacing any
+// existing entry. It is used to inject connectors that are not built from stored
+// config (for example the built-in local connector, or mocks in tests).
+func (c *Cache) Set(id string, conn Connector) {
+ c.mu.Lock()
+ c.conns[id] = conn
+ c.mu.Unlock()
+}
+
+// Cached returns the connector currently held for id without consulting storage.
+func (c *Cache) Cached(id string) (Connector, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ conn, ok := c.conns[id]
+ return conn, ok
+}
+
+// Len reports the number of cached connectors.
+func (c *Cache) Len() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return len(c.conns)
+}
+
+// Close removes the connector from the in-memory cache.
+func (c *Cache) Close(id string) {
+ c.mu.Lock()
+ delete(c.conns, id)
+ c.mu.Unlock()
+}
+
+// Get returns the connector with the given id, opening (or reopening) it when it
+// is missing from the cache or its stored resource version has changed.
+func (c *Cache) Get(ctx context.Context, id string) (Connector, error) {
+ storageConnector, err := c.storage.GetConnector(ctx, id)
+ if err != nil {
+ return Connector{}, fmt.Errorf("failed to get connector object from storage: %v", err)
+ }
+
+ c.mu.Lock()
+ conn, ok := c.conns[id]
+ c.mu.Unlock()
+
+ if !ok || storageConnector.ResourceVersion != conn.ResourceVersion {
+ // Not cached, or updated in storage since we last opened it.
+ return c.Open(storageConnector)
+ }
+
+ return conn, nil
+}
diff --git a/server/connectors/connectors_test.go b/server/connectors/connectors_test.go
new file mode 100644
index 0000000000..c035684e70
--- /dev/null
+++ b/server/connectors/connectors_test.go
@@ -0,0 +1,122 @@
+package connectors
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+// stubConn is a trivial connector.Connector (which is interface{}) tagged with
+// the version it was resolved from, so tests can tell reopens apart.
+type stubConn struct{ version string }
+
+func newTestCache(t *testing.T) (*Cache, storage.Storage, *int) {
+ t.Helper()
+ store := memory.New(slog.New(slog.DiscardHandler))
+ calls := 0
+ resolve := func(c storage.Connector) (connector.Connector, error) {
+ calls++
+ return stubConn{version: c.ResourceVersion}, nil
+ }
+ return NewCache(store, resolve), store, &calls
+}
+
+func TestGetOpensAndCaches(t *testing.T) {
+ ctx := context.Background()
+ cache, store, calls := newTestCache(t)
+
+ require.NoError(t, store.CreateConnector(ctx, storage.Connector{
+ ID: "c1", Type: "mock", ResourceVersion: "1", GrantTypes: []string{"authorization_code"},
+ }))
+
+ got, err := cache.Get(ctx, "c1")
+ require.NoError(t, err)
+ require.Equal(t, 1, *calls)
+ require.Equal(t, "mock", got.Type)
+ require.Equal(t, "1", got.ResourceVersion)
+ require.Equal(t, []string{"authorization_code"}, got.GrantTypes)
+ require.Equal(t, stubConn{version: "1"}, got.Connector)
+
+ // Second Get hits the cache; the connector is not resolved again.
+ got2, err := cache.Get(ctx, "c1")
+ require.NoError(t, err)
+ require.Equal(t, 1, *calls)
+ require.Equal(t, got, got2)
+}
+
+func TestGetReopensOnVersionChange(t *testing.T) {
+ ctx := context.Background()
+ cache, store, calls := newTestCache(t)
+
+ require.NoError(t, store.CreateConnector(ctx, storage.Connector{ID: "c1", Type: "mock", ResourceVersion: "1"}))
+ _, err := cache.Get(ctx, "c1")
+ require.NoError(t, err)
+ require.Equal(t, 1, *calls)
+
+ // A stored resource-version bump must invalidate the cached entry.
+ require.NoError(t, store.UpdateConnector(ctx, "c1", func(old storage.Connector) (storage.Connector, error) {
+ old.ResourceVersion = "2"
+ return old, nil
+ }))
+
+ got, err := cache.Get(ctx, "c1")
+ require.NoError(t, err)
+ require.Equal(t, 2, *calls)
+ require.Equal(t, "2", got.ResourceVersion)
+ require.Equal(t, stubConn{version: "2"}, got.Connector)
+}
+
+func TestGetNotFound(t *testing.T) {
+ ctx := context.Background()
+ cache, _, calls := newTestCache(t)
+
+ _, err := cache.Get(ctx, "missing")
+ require.Error(t, err)
+ require.Equal(t, 0, *calls)
+}
+
+func TestOpenResolveErrorNotCached(t *testing.T) {
+ store := memory.New(slog.New(slog.DiscardHandler))
+ cache := NewCache(store, func(storage.Connector) (connector.Connector, error) {
+ return nil, errors.New("boom")
+ })
+
+ _, err := cache.Open(storage.Connector{ID: "c1"})
+ require.Error(t, err)
+
+ _, ok := cache.Cached("c1")
+ require.False(t, ok)
+ require.Equal(t, 0, cache.Len())
+}
+
+func TestSetCachedCloseLen(t *testing.T) {
+ cache, _, _ := newTestCache(t)
+
+ require.Equal(t, 0, cache.Len())
+ _, ok := cache.Cached("c1")
+ require.False(t, ok)
+
+ cache.Set("c1", Connector{Type: "mock", ResourceVersion: "1", Connector: stubConn{version: "1"}})
+ cache.Set("c2", Connector{Type: "ldap"})
+ require.Equal(t, 2, cache.Len())
+
+ got, ok := cache.Cached("c1")
+ require.True(t, ok)
+ require.Equal(t, "mock", got.Type)
+
+ cache.Close("c1")
+ _, ok = cache.Cached("c1")
+ require.False(t, ok)
+ require.Equal(t, 1, cache.Len())
+
+ // Closing an unknown id is a no-op.
+ cache.Close("nope")
+ require.Equal(t, 1, cache.Len())
+}
diff --git a/server/connectors/doc.go b/server/connectors/doc.go
new file mode 100644
index 0000000000..942135c257
--- /dev/null
+++ b/server/connectors/doc.go
@@ -0,0 +1,2 @@
+// Package connectors holds the server's in-memory cache of opened connectors.
+package connectors
diff --git a/server/connectors/filter.go b/server/connectors/filter.go
new file mode 100644
index 0000000000..df23341715
--- /dev/null
+++ b/server/connectors/filter.go
@@ -0,0 +1,25 @@
+package connectors
+
+import "github.com/dexidp/dex/storage"
+
+// Filter returns the connectors allowed for a client. When allowedConnectors is
+// empty the list is returned unfiltered. It is the browser auth flow's counterpart
+// to ConnectorAllowed (which checks a single id).
+func Filter(conns []storage.Connector, allowedConnectors []string) []storage.Connector {
+ if len(allowedConnectors) == 0 {
+ return conns
+ }
+
+ allowed := make(map[string]bool, len(allowedConnectors))
+ for _, id := range allowedConnectors {
+ allowed[id] = true
+ }
+
+ filtered := make([]storage.Connector, 0, len(conns))
+ for _, c := range conns {
+ if allowed[c.ID] {
+ filtered = append(filtered, c)
+ }
+ }
+ return filtered
+}
diff --git a/server/connectors/filter_test.go b/server/connectors/filter_test.go
new file mode 100644
index 0000000000..0cc37950aa
--- /dev/null
+++ b/server/connectors/filter_test.go
@@ -0,0 +1,106 @@
+package connectors
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/storage"
+)
+
+func TestFilterConnectors(t *testing.T) {
+ connectors := []storage.Connector{
+ {ID: "github", Type: "github", Name: "GitHub"},
+ {ID: "google", Type: "oidc", Name: "Google"},
+ {ID: "ldap", Type: "ldap", Name: "LDAP"},
+ }
+
+ tests := []struct {
+ name string
+ allowedConnectors []string
+ wantIDs []string
+ }{
+ {
+ name: "No filter - all connectors returned",
+ allowedConnectors: nil,
+ wantIDs: []string{"github", "google", "ldap"},
+ },
+ {
+ name: "Empty filter - all connectors returned",
+ allowedConnectors: []string{},
+ wantIDs: []string{"github", "google", "ldap"},
+ },
+ {
+ name: "Filter to one connector",
+ allowedConnectors: []string{"github"},
+ wantIDs: []string{"github"},
+ },
+ {
+ name: "Filter to two connectors",
+ allowedConnectors: []string{"github", "ldap"},
+ wantIDs: []string{"github", "ldap"},
+ },
+ {
+ name: "Filter with non-existent connector ID",
+ allowedConnectors: []string{"nonexistent"},
+ wantIDs: []string{},
+ },
+ {
+ name: "Filter with mix of valid and invalid IDs",
+ allowedConnectors: []string{"google", "nonexistent"},
+ wantIDs: []string{"google"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := Filter(connectors, tc.allowedConnectors)
+ gotIDs := make([]string, len(result))
+ for i, c := range result {
+ gotIDs[i] = c.ID
+ }
+ require.Equal(t, tc.wantIDs, gotIDs)
+ })
+ }
+}
+
+func TestIsConnectorAllowed(t *testing.T) {
+ tests := []struct {
+ name string
+ allowedConnectors []string
+ connectorID string
+ want bool
+ }{
+ {
+ name: "No restrictions - all allowed",
+ allowedConnectors: nil,
+ connectorID: "any",
+ want: true,
+ },
+ {
+ name: "Empty list - all allowed",
+ allowedConnectors: []string{},
+ connectorID: "any",
+ want: true,
+ },
+ {
+ name: "Connector in allowed list",
+ allowedConnectors: []string{"github", "google"},
+ connectorID: "github",
+ want: true,
+ },
+ {
+ name: "Connector not in allowed list",
+ allowedConnectors: []string{"github", "google"},
+ connectorID: "ldap",
+ want: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ConnectorAllowed(tc.allowedConnectors, tc.connectorID)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/server/connectors/password.go b/server/connectors/password.go
new file mode 100644
index 0000000000..8b5fb8ca1d
--- /dev/null
+++ b/server/connectors/password.go
@@ -0,0 +1,99 @@
+package connectors
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/passwords"
+ "github.com/dexidp/dex/storage"
+)
+
+// NewPasswordDB returns the built-in local password connector backed by the
+// password store. Resolver uses it for LocalConnector; it is exported so a
+// custom ResolveFunc can reuse it.
+func NewPasswordDB(s storage.Storage) interface {
+ connector.Connector
+ connector.PasswordConnector
+} {
+ return passwordDB{s}
+}
+
+type passwordDB struct {
+ s storage.Storage
+}
+
+func resolvePasswordName(p storage.Password) string {
+ if p.Name != "" {
+ return p.Name
+ }
+ return p.Username
+}
+
+func resolvePasswordEmailVerified(p storage.Password) bool {
+ if p.EmailVerified != nil {
+ return *p.EmailVerified
+ }
+ return true
+}
+
+func (db passwordDB) Login(ctx context.Context, s connector.Scopes, email, password string) (connector.Identity, bool, error) {
+ p, err := db.s.GetPassword(ctx, email)
+ if err != nil {
+ if err != storage.ErrNotFound {
+ return connector.Identity{}, false, fmt.Errorf("get password: %v", err)
+ }
+ return connector.Identity{}, false, nil
+ }
+ // This check prevents dex users from logging in using static passwords
+ // configured with hash costs that are too high or low.
+ if err := passwords.CheckCost(p.Hash); err != nil {
+ return connector.Identity{}, false, err
+ }
+ if err := bcrypt.CompareHashAndPassword(p.Hash, []byte(password)); err != nil {
+ return connector.Identity{}, false, nil
+ }
+ return connector.Identity{
+ UserID: p.UserID,
+ Username: resolvePasswordName(p),
+ PreferredUsername: p.PreferredUsername,
+ Email: p.Email,
+ EmailVerified: resolvePasswordEmailVerified(p),
+ Groups: p.Groups,
+ }, true, nil
+}
+
+func (db passwordDB) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
+ // If the user has been deleted, the refresh token will be rejected.
+ p, err := db.s.GetPassword(ctx, identity.Email)
+ if err != nil {
+ if err == storage.ErrNotFound {
+ return connector.Identity{}, errors.New("user not found")
+ }
+ return connector.Identity{}, fmt.Errorf("get password: %v", err)
+ }
+
+ // User removed but a new user with the same email exists.
+ if p.UserID != identity.UserID {
+ return connector.Identity{}, errors.New("user not found")
+ }
+
+ // If a user has updated their username, that will be reflected in the
+ // refreshed token.
+ //
+ // No other fields are expected to be refreshable as email is effectively used
+ // as an ID.
+ identity.Username = resolvePasswordName(p)
+ identity.PreferredUsername = p.PreferredUsername
+ identity.EmailVerified = resolvePasswordEmailVerified(p)
+ identity.Groups = p.Groups
+
+ return identity, nil
+}
+
+func (db passwordDB) Prompt() string {
+ return "Email Address"
+}
diff --git a/server/connectors/policy.go b/server/connectors/policy.go
new file mode 100644
index 0000000000..e70fcb4c32
--- /dev/null
+++ b/server/connectors/policy.go
@@ -0,0 +1,32 @@
+package connectors
+
+import (
+ "slices"
+
+ "github.com/dexidp/dex/server/oauth2"
+)
+
+// ConnectorGrantTypes is the set of grant types that can be restricted per connector.
+var ConnectorGrantTypes = map[string]bool{
+ oauth2.GrantTypeAuthorizationCode: true,
+ oauth2.GrantTypeRefreshToken: true,
+ oauth2.GrantTypeImplicit: true,
+ oauth2.GrantTypePassword: true,
+ oauth2.GrantTypeDeviceCode: true,
+ oauth2.GrantTypeTokenExchange: true,
+}
+
+// GrantTypeAllowed reports whether grantType is allowed for a connector with the
+// given configured grant types. If none are configured, all are allowed.
+func GrantTypeAllowed(configuredTypes []string, grantType string) bool {
+ return len(configuredTypes) == 0 || slices.Contains(configuredTypes, grantType)
+}
+
+// ConnectorAllowed reports whether connectorID is in a client's allowed
+// connectors list. If the list is empty, all connectors are allowed.
+func ConnectorAllowed(allowedConnectors []string, connectorID string) bool {
+ if len(allowedConnectors) == 0 {
+ return true
+ }
+ return slices.Contains(allowedConnectors, connectorID)
+}
diff --git a/server/connectors/resolve.go b/server/connectors/resolve.go
new file mode 100644
index 0000000000..c9ee160fc0
--- /dev/null
+++ b/server/connectors/resolve.go
@@ -0,0 +1,57 @@
+package connectors
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/storage"
+)
+
+// LocalConnector is the local passwordDB connector: an internal connector,
+// backed by the password store, that is not part of the injected config map.
+const LocalConnector = "local"
+
+// ConnectorConfig is a configuration that can open a connector.
+type ConnectorConfig interface {
+ Open(id string, logger *slog.Logger) (connector.Connector, error)
+}
+
+// Resolver returns a ResolveFunc that builds the underlying implementation for a
+// stored connector: the built-in local password DB (backed by storage), or a
+// connector from the given config map. The map is injected by the caller so this
+// package need not import any connector implementation โ a library consumer can
+// pass its own set of connectors.
+func Resolver(store storage.Storage, logger *slog.Logger, configs map[string]func() ConnectorConfig) ResolveFunc {
+ return func(conn storage.Connector) (connector.Connector, error) {
+ if conn.Type == LocalConnector {
+ return NewPasswordDB(store), nil
+ }
+ return openConnector(logger, configs, conn)
+ }
+}
+
+// openConnector parses the stored config and opens the connector named by its type.
+func openConnector(logger *slog.Logger, configs map[string]func() ConnectorConfig, conn storage.Connector) (connector.Connector, error) {
+ var c connector.Connector
+
+ f, ok := configs[conn.Type]
+ if !ok {
+ return c, fmt.Errorf("unknown connector type %q", conn.Type)
+ }
+
+ connConfig := f()
+ if len(conn.Config) != 0 {
+ if err := json.Unmarshal(conn.Config, connConfig); err != nil {
+ return c, fmt.Errorf("parse connector config: %v", err)
+ }
+ }
+
+ c, err := connConfig.Open(conn.ID, logger)
+ if err != nil {
+ return c, fmt.Errorf("failed to create connector %s: %v", conn.ID, err)
+ }
+
+ return c, nil
+}
diff --git a/server/consent/consent.go b/server/consent/consent.go
new file mode 100644
index 0000000000..e69d5da3ec
--- /dev/null
+++ b/server/consent/consent.go
@@ -0,0 +1,145 @@
+package consent
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// Handler owns the consent step. The /auth dispatcher decides whether consent is
+// needed (via Satisfied) and, if so, routes to the approval screen here; on
+// approve it records the granted scopes and returns to the dispatcher with the
+// "approved" verifier. It holds no reference to the other flow steps.
+type Handler struct {
+ Storage storage.Storage
+ Templates *templates.Templates
+ Logger *slog.Logger
+ IssuerURL oauth2.IssuerURL
+ Sessions *session.Manager
+ SkipApproval bool
+}
+
+// renderError renders a user-facing HTML error page.
+func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
+ templates.RenderError(h.Templates, h.Logger, r, w, status, description)
+}
+
+// Mount registers the consent endpoint.
+func (h *Handler) Mount(mux router.Mux) {
+ mux.HandleFunc("/approval", h.handleApproval)
+}
+
+// buildApprovedURL builds the HMAC-protected URL that returns to the authorize
+// dispatcher (/auth) with the "approved" verifier, so the dispatcher knows the
+// user consented and can issue.
+func (h *Handler) buildApprovedURL(authReq storage.AuthRequest) string {
+ return internal.StepURL(h.IssuerURL.AbsPath("/auth"), authReq, internal.StepApproved, nil)
+}
+
+// Satisfied reports whether the approval screen can be skipped: the client did
+// not force it, and either approval is disabled server-wide or the user has
+// already consented to the requested scopes for this client. It is a package
+// function so the /auth dispatcher can decide consent from state without holding
+// the consent Handler.
+func Satisfied(ctx context.Context, store storage.Storage, skipApproval bool, authReq *storage.AuthRequest) bool {
+ if authReq.ForceApprovalPrompt {
+ return false
+ }
+ if skipApproval {
+ return true
+ }
+ ui, err := store.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID)
+ return err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes)
+}
+
+func (h *Handler) handleApproval(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ macEncoded := r.FormValue("hmac")
+ if macEncoded == "" {
+ h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
+ return
+ }
+ authReq, err := h.Storage.GetAuthRequest(ctx, r.FormValue("req"))
+ if err != nil {
+ if err == storage.ErrNotFound {
+ h.renderError(r, w, http.StatusBadRequest, "User session error.")
+ return
+ }
+ h.Logger.ErrorContext(ctx, "failed to get auth request", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Database error.")
+ return
+ }
+ if !authReq.LoggedIn {
+ h.Logger.ErrorContext(ctx, "auth request does not have an identity for approval")
+ h.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.")
+ return
+ }
+
+ if !internal.VerifyStep(authReq, macEncoded, internal.StepApproval) {
+ h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request")
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ // The dispatcher routes here only when consent is required, so just show
+ // the approval screen.
+ client, err := h.Storage.GetClient(ctx, authReq.ClientID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "Failed to get client", "client_id", authReq.ClientID, "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.")
+ return
+ }
+ if err := h.Templates.Approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil {
+ h.Logger.ErrorContext(ctx, "server template error", "err", err)
+ }
+ case http.MethodPost:
+ if r.FormValue("approval") != "approve" {
+ h.renderError(r, w, http.StatusInternalServerError, "Approval rejected.")
+ return
+ }
+ // Persist the approved scopes so a future request skips consent, then return
+ // to the dispatcher with the "approved" verifier.
+ if h.Sessions.Enabled() {
+ if err := h.Storage.UpdateUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
+ if old.Consents == nil {
+ old.Consents = make(map[string][]string)
+ }
+ old.Consents[authReq.ClientID] = authReq.Scopes
+ return old, nil
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to update user identity consents", "err", err)
+ }
+ }
+ http.Redirect(w, r, h.buildApprovedURL(authReq), http.StatusSeeOther)
+ }
+}
+
+// scopesCoveredByConsent checks whether the approved scopes cover all requested
+// scopes. The openid scope is excluded from the comparison as it is a technical
+// scope that does not require user consent.
+func scopesCoveredByConsent(approved, requested []string) bool {
+ approvedSet := make(map[string]struct{}, len(approved))
+ for _, s := range approved {
+ approvedSet[s] = struct{}{}
+ }
+
+ for _, scope := range requested {
+ if scope == tokens.ScopeOpenID {
+ continue
+ }
+ if _, ok := approvedSet[scope]; !ok {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/server/consent/consent_test.go b/server/consent/consent_test.go
new file mode 100644
index 0000000000..4ac8a8a542
--- /dev/null
+++ b/server/consent/consent_test.go
@@ -0,0 +1,80 @@
+package consent
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestScopesCoveredByConsent(t *testing.T) {
+ tests := []struct {
+ name string
+ approved []string
+ requested []string
+ want bool
+ }{
+ {
+ name: "All scopes covered",
+ approved: []string{"email", "profile"},
+ requested: []string{"openid", "email", "profile"},
+ want: true,
+ },
+ {
+ name: "Missing scope",
+ approved: []string{"email"},
+ requested: []string{"openid", "email", "groups"},
+ want: false,
+ },
+ {
+ name: "Only openid scope skipped",
+ approved: []string{},
+ requested: []string{"openid"},
+ want: true,
+ },
+ {
+ name: "offline_access requires consent",
+ approved: []string{},
+ requested: []string{"openid", "offline_access"},
+ want: false,
+ },
+ {
+ name: "offline_access covered by consent",
+ approved: []string{"offline_access"},
+ requested: []string{"openid", "offline_access"},
+ want: true,
+ },
+ {
+ name: "Nil approved",
+ approved: nil,
+ requested: []string{"email"},
+ want: false,
+ },
+ {
+ name: "Empty requested",
+ approved: []string{"email"},
+ requested: []string{},
+ want: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := scopesCoveredByConsent(tc.approved, tc.requested)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
+
+// TestConsentIsolatedBetweenClients verifies that consent given for
+// client-A does not satisfy scope check for client-B.
+func TestConsentIsolatedBetweenClients(t *testing.T) {
+ approvedForA := map[string][]string{"client-a": {"openid", "email"}}
+
+ // client-b should not have consent.
+ require.False(t, scopesCoveredByConsent(approvedForA["client-b"], []string{"openid", "email"}),
+ "consent for client-a should not cover client-b")
+
+ // client-a should have consent.
+ require.True(t, scopesCoveredByConsent(approvedForA["client-a"], []string{"openid", "email"}),
+ "consent for client-a should cover client-a's requested scopes")
+}
diff --git a/server/consent/doc.go b/server/consent/doc.go
new file mode 100644
index 0000000000..94dc27a3bd
--- /dev/null
+++ b/server/consent/doc.go
@@ -0,0 +1,9 @@
+// Package consent owns the approval (consent) step of the authorization flow:
+// the /approval endpoint, the consent screen, recording the user's consent, and
+// the decision of whether consent can be skipped.
+//
+// It is one of the shared flow steps (alongside mfa and issue): the browser
+// login flow and, conceptually, any other front-channel flow reach it once the
+// user is authenticated. When consent is granted (or already covered) it hands
+// off to the issue component to complete the authorization response.
+package consent
diff --git a/server/device/device.go b/server/device/device.go
new file mode 100644
index 0000000000..486d3297ae
--- /dev/null
+++ b/server/device/device.go
@@ -0,0 +1,432 @@
+package device
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/grants"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// DeviceCodeResponse is the device authorization response (RFC 8628 ยง3.2).
+type DeviceCodeResponse struct {
+ // The unique device code for device authentication
+ DeviceCode string `json:"device_code"`
+ // The code the user will exchange via a browser and log in
+ UserCode string `json:"user_code"`
+ // The url to verify the user code.
+ VerificationURI string `json:"verification_uri"`
+ // The verification uri with the user code appended for pre-filling form
+ VerificationURIComplete string `json:"verification_uri_complete"`
+ // The lifetime of the device code
+ ExpireTime int `json:"expires_in"`
+ // How often the device is allowed to poll to verify that the user login occurred
+ PollInterval int `json:"interval"`
+}
+
+// Handler serves the browser side of the device authorization grant.
+type Handler struct {
+ IssuerURL oauth2.IssuerURL
+ Storage storage.Storage
+ Templates *templates.Templates
+ Now func() time.Time
+ RequestsValidFor time.Duration
+ Logger *slog.Logger
+
+ // Issuer mints the tokens, and Connectors resolves the connector, for the
+ // auth-code exchange the device flow shares with the authorization_code grant
+ // via grants.ExchangeAuthCode.
+ Issuer *tokens.Issuer
+ Connectors *connectors.Cache
+}
+
+// Mount registers the device authorization routes.
+func (h *Handler) Mount(m router.Mux) {
+ m.HandleFunc("/device", h.handleDeviceExchange)
+ m.HandleFunc("/device/auth/verify_code", h.verifyUserCode)
+ m.HandleFunc("/device/code", h.handleDeviceCode)
+ m.HandleFunc(oauth2.DeviceCallbackURI, h.handleDeviceCallback)
+}
+
+// deviceFlowError is a failed step in the flow. A non-empty OAuth2 code makes the
+// handler write a JSON error response; otherwise the message is rendered as an
+// HTML error page.
+type deviceFlowError struct {
+ status int
+ code string
+ message string
+}
+
+func (h *Handler) writeFlowError(r *http.Request, w http.ResponseWriter, e *deviceFlowError) {
+ if e.code != "" {
+ h.writeError(w, e.code, e.message, e.status)
+ return
+ }
+ h.renderError(r, w, e.status, e.message)
+}
+
+// writeError writes a JSON OAuth2 error response.
+func (h *Handler) writeError(w http.ResponseWriter, typ, description string, statusCode int) {
+ oauth2.WriteErrorResponse(h.Logger, w, typ, description, statusCode)
+}
+
+// renderError renders an HTML error page.
+func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
+ templates.RenderError(h.Templates, h.Logger, r, w, status, description)
+}
+
+func (h *Handler) getDeviceVerificationURI() string {
+ return h.IssuerURL.AbsPath("/device/auth/verify_code")
+}
+
+// handleDeviceExchange serves the /device user-code entry page.
+func (h *Handler) handleDeviceExchange(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+
+ // If "user_code" is set, pre-populate the user code field. If "invalid" is
+ // set, show a message that the code was invalid or expired.
+ userCode := r.URL.Query().Get("user_code")
+ invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid"))
+ if err != nil {
+ invalidAttempt = false
+ }
+ if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil {
+ h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
+ h.renderError(r, w, http.StatusNotFound, "Page not found")
+ }
+}
+
+// deviceCodeRequest is a parsed /device/code authorization request.
+type deviceCodeRequest struct {
+ clientID string
+ clientSecret string
+ scopes []string
+ pkce storage.PKCE
+}
+
+// parseDeviceCodeRequest parses and validates the /device/code form.
+func (h *Handler) parseDeviceCodeRequest(r *http.Request) (deviceCodeRequest, *deviceFlowError) {
+ if err := r.ParseForm(); err != nil {
+ h.Logger.ErrorContext(r.Context(), "could not parse Device Request body", "err", err)
+ return deviceCodeRequest{}, &deviceFlowError{status: http.StatusNotFound, code: oauth2.InvalidRequest}
+ }
+
+ method := r.Form.Get("code_challenge_method")
+ if method == "" {
+ method = oauth2.PKCEMethodPlain
+ }
+ if method != oauth2.PKCEMethodS256 && method != oauth2.PKCEMethodPlain {
+ return deviceCodeRequest{}, &deviceFlowError{
+ status: http.StatusBadRequest,
+ code: oauth2.InvalidRequest,
+ message: fmt.Sprintf("Unsupported PKCE challenge method (%q).", method),
+ }
+ }
+
+ scopes := strings.Fields(r.Form.Get("scope"))
+ if len(scopes) == 0 {
+ // per RFC 8628 ยง3.1 scope is optional, but dex requires at least 'openid'.
+ scopes = []string{"openid"}
+ }
+
+ return deviceCodeRequest{
+ clientID: r.Form.Get("client_id"),
+ clientSecret: r.Form.Get("client_secret"),
+ scopes: scopes,
+ pkce: storage.PKCE{
+ CodeChallenge: r.Form.Get("code_challenge"),
+ CodeChallengeMethod: method,
+ },
+ }, nil
+}
+
+// createDeviceAuthorization mints and stores the device and user codes and builds
+// the authorization response the device polls against.
+func (h *Handler) createDeviceAuthorization(ctx context.Context, req deviceCodeRequest) (*DeviceCodeResponse, *deviceFlowError) {
+ h.Logger.InfoContext(ctx, "received device request", "client_id", req.clientID, "scoped", req.scopes)
+
+ deviceCode := storage.NewDeviceCode()
+ userCode := storage.NewUserCode()
+ expireTime := h.Now().Add(h.RequestsValidFor)
+
+ if err := h.Storage.CreateDeviceRequest(ctx, storage.DeviceRequest{
+ UserCode: userCode,
+ DeviceCode: deviceCode,
+ ClientID: req.clientID,
+ ClientSecret: req.clientSecret,
+ Scopes: req.scopes,
+ Expiry: expireTime,
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to store device request", "err", err)
+ return nil, &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.InvalidRequest}
+ }
+
+ if err := h.Storage.CreateDeviceToken(ctx, storage.DeviceToken{
+ DeviceCode: deviceCode,
+ Status: oauth2.DeviceTokenPending,
+ Expiry: expireTime,
+ LastRequestTime: h.Now(),
+ PollIntervalSeconds: 0,
+ PKCE: req.pkce,
+ }); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to store device token", "err", err)
+ return nil, &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.InvalidRequest}
+ }
+
+ u := h.IssuerURL
+ u.Path = path.Join(u.Path, "device")
+ vURI := u.String()
+
+ q := u.Query()
+ q.Set("user_code", userCode)
+ u.RawQuery = q.Encode()
+ vURIComplete := u.String()
+
+ return &DeviceCodeResponse{
+ DeviceCode: deviceCode,
+ UserCode: userCode,
+ VerificationURI: vURI,
+ VerificationURIComplete: vURIComplete,
+ ExpireTime: int(h.RequestsValidFor.Seconds()),
+ PollInterval: 5,
+ }, nil
+}
+
+func (h *Handler) handleDeviceCode(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ h.renderError(r, w, http.StatusBadRequest, "Invalid device code request type")
+ h.writeError(w, oauth2.InvalidRequest, "", http.StatusBadRequest)
+ return
+ }
+
+ req, ferr := h.parseDeviceCodeRequest(r)
+ if ferr != nil {
+ h.writeFlowError(r, w, ferr)
+ return
+ }
+
+ resp, ferr := h.createDeviceAuthorization(r.Context(), req)
+ if ferr != nil {
+ h.writeFlowError(r, w, ferr)
+ return
+ }
+
+ writeDeviceCodeResponse(w, resp)
+}
+
+// writeDeviceCodeResponse writes the device authorization response: it can carry
+// a cache-control header (RFC 8628 ยง3.2) and is JSON (RFC 6749 ยง5.1).
+func writeDeviceCodeResponse(w http.ResponseWriter, resp *DeviceCodeResponse) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+}
+
+func (h *Handler) verifyUserCode(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
+ return
+ }
+ ctx := r.Context()
+
+ if err := r.ParseForm(); err != nil {
+ h.Logger.Warn("could not parse user code verification request body", "err", err)
+ h.renderError(r, w, http.StatusBadRequest, "")
+ return
+ }
+
+ userCode := r.Form.Get("user_code")
+ if userCode == "" {
+ h.renderError(r, w, http.StatusBadRequest, "No user code received")
+ return
+ }
+ userCode = strings.ToUpper(userCode)
+
+ // Find the user code among the outstanding requests.
+ deviceRequest, err := h.Storage.GetDeviceRequest(ctx, userCode)
+ if err != nil || h.Now().After(deviceRequest.Expiry) {
+ if err != nil && err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get device request", "err", err)
+ }
+ if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, true); err != nil {
+ h.Logger.ErrorContext(ctx, "Server template error", "err", err)
+ h.renderError(r, w, http.StatusNotFound, "Page not found")
+ }
+ return
+ }
+
+ // Redirect to the dex auth endpoint, which sends the user back to the device
+ // callback once they authenticate.
+ u := h.IssuerURL
+ u.Path = path.Join(u.Path, "/auth")
+ q := u.Query()
+ q.Set("client_id", deviceRequest.ClientID)
+ // Do not put client_secret in this browser redirect: /auth is the
+ // authorization endpoint and never consumes it, so it would only leak the
+ // confidential secret into browser history, Referer, and access logs. The
+ // client is authenticated later in completeDeviceAuthorization against the
+ // stored device request.
+ q.Set("state", deviceRequest.UserCode)
+ q.Set("response_type", "code")
+ q.Set("redirect_uri", h.IssuerURL.AbsPath(oauth2.DeviceCallbackURI))
+ q.Set("scope", strings.Join(deviceRequest.Scopes, " "))
+ u.RawQuery = q.Encode()
+
+ http.Redirect(w, r, u.String(), http.StatusFound)
+}
+
+func (h *Handler) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ h.Logger.ErrorContext(r.Context(), "unsupported method in device callback", "method", r.Method)
+ h.renderError(r, w, http.StatusBadRequest, "Method not allowed.")
+ return
+ }
+
+ clientName, ferr := h.completeDeviceAuthorization(w, r)
+ if ferr != nil {
+ h.writeFlowError(r, w, ferr)
+ return
+ }
+
+ if err := h.Templates.DeviceSuccess(r, w, clientName); err != nil {
+ h.Logger.ErrorContext(r.Context(), "Server template error", "err", err)
+ h.renderError(r, w, http.StatusNotFound, "Page not found")
+ }
+}
+
+// completeDeviceAuthorization handles the browser callback: it exchanges the
+// authorization code for tokens and stores them against the device code so the
+// polling device_code grant can return them. It returns the client name for the
+// success page.
+func (h *Handler) completeDeviceAuthorization(w http.ResponseWriter, r *http.Request) (string, *deviceFlowError) {
+ ctx := r.Context()
+
+ userCode := r.FormValue("state")
+ code := r.FormValue("code")
+ if userCode == "" || code == "" {
+ return "", &deviceFlowError{status: http.StatusBadRequest, message: "Request was missing parameters"}
+ }
+
+ // Authorization redirect callback from the OAuth2 auth flow.
+ if errMsg := r.FormValue("error"); errMsg != "" {
+ // Log the error details but don't expose them to the user.
+ h.Logger.ErrorContext(ctx, "OAuth2 authorization error",
+ "error", errMsg,
+ "error_description", r.FormValue("error_description"))
+ return "", &deviceFlowError{status: http.StatusBadRequest, message: "Authorization failed. Please try again."}
+ }
+
+ authCode, err := h.Storage.GetAuthCode(ctx, code)
+ if err != nil || h.Now().After(authCode.Expiry) {
+ status := http.StatusBadRequest
+ if err != nil && err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get auth code", "err", err)
+ status = http.StatusInternalServerError
+ }
+ return "", &deviceFlowError{status: status, message: "Invalid or expired auth code."}
+ }
+
+ deviceReq, err := h.Storage.GetDeviceRequest(ctx, userCode)
+ if err != nil || h.Now().After(deviceReq.Expiry) {
+ status := http.StatusBadRequest
+ if err != nil && err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get device code", "err", err)
+ status = http.StatusInternalServerError
+ }
+ return "", &deviceFlowError{status: status, message: "Invalid or expired user code."}
+ }
+
+ // Bind the auth code to this device request: it must have been minted for the
+ // same client and issued to the device callback redirect. The authorization_code
+ // grant enforces the same client/redirect binding (see grants/authcode.go); the
+ // device callback must not skip it, or a code minted for one client could be
+ // redeemed against another client's device request (cross-client token theft).
+ // The redirect is matched on its parsed path suffix, mirroring how the auth flow
+ // recognizes the device callback: the issuer path prefix does not matter, and a
+ // "/device/callback" in the query string can not spoof it. A value that fails to
+ // parse is not a valid device redirect.
+ redirectURL, err := url.Parse(authCode.RedirectURI)
+ validRedirect := err == nil && strings.HasSuffix(redirectURL.Path, oauth2.DeviceCallbackURI)
+ if authCode.ClientID != deviceReq.ClientID || !validRedirect {
+ h.Logger.ErrorContext(ctx, "device callback: auth code does not match the device request",
+ "auth_code_client_id", authCode.ClientID, "device_client_id", deviceReq.ClientID)
+ return "", &deviceFlowError{status: http.StatusBadRequest, message: "Invalid or expired auth code."}
+ }
+
+ client, err := h.Storage.GetClient(ctx, deviceReq.ClientID)
+ if err != nil {
+ if err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get client", "err", err)
+ return "", &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.ServerError}
+ }
+ return "", &deviceFlowError{status: http.StatusUnauthorized, code: oauth2.InvalidClient, message: "Invalid client credentials."}
+ }
+ // Constant-time comparison of the client secret, matching grants.go's client
+ // authentication, so the compare does not leak the secret via timing.
+ if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(deviceReq.ClientSecret)) != 1 {
+ return "", &deviceFlowError{status: http.StatusUnauthorized, code: oauth2.InvalidClient, message: "Invalid client credentials."}
+ }
+
+ // ExchangeAuthCode consumes the code (its atomic single-use gate) and returns
+ // what to issue; the tokens are minted here.
+ auth, withRefresh, err := grants.ExchangeAuthCode(ctx, h.Storage, h.Connectors, h.Logger, authCode, client)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "could not exchange auth code for client", "client_id", deviceReq.ClientID, "err", err)
+ return "", &deviceFlowError{status: http.StatusInternalServerError, message: "Failed to exchange auth code."}
+ }
+ resp, err := h.Issuer.IssueResponse(ctx, auth, authCode.ID, withRefresh)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "could not issue tokens for device flow", "client_id", deviceReq.ClientID, "err", err)
+ return "", &deviceFlowError{status: http.StatusInternalServerError, message: "Failed to exchange auth code."}
+ }
+
+ old, err := h.Storage.GetDeviceToken(ctx, deviceReq.DeviceCode)
+ if err != nil || h.Now().After(old.Expiry) {
+ status := http.StatusBadRequest
+ if err != nil && err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get device token", "err", err)
+ status = http.StatusInternalServerError
+ }
+ return "", &deviceFlowError{status: status, message: "Invalid or expired device code."}
+ }
+
+ // Store the token against the device code and mark it complete.
+ updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
+ if old.Status == oauth2.DeviceTokenComplete {
+ return old, errors.New("device token already complete")
+ }
+ respStr, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to marshal device token response", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "")
+ return old, err
+ }
+ old.Token = string(respStr)
+ old.Status = oauth2.DeviceTokenComplete
+ return old, nil
+ }
+ if err := h.Storage.UpdateDeviceToken(ctx, deviceReq.DeviceCode, updater); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to update device token", "err", err)
+ return "", &deviceFlowError{status: http.StatusBadRequest, message: ""}
+ }
+
+ return client.Name, nil
+}
diff --git a/server/device/device_test.go b/server/device/device_test.go
new file mode 100644
index 0000000000..cdadbffead
--- /dev/null
+++ b/server/device/device_test.go
@@ -0,0 +1,59 @@
+package device
+
+import (
+ "io"
+ "log/slog"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+func TestGetDeviceVerificationURI(t *testing.T) {
+ u, err := url.Parse("https://dex.example.com/non-root-path")
+ require.NoError(t, err)
+
+ h := &Handler{IssuerURL: oauth2.IssuerURL{URL: *u}}
+ require.Equal(t, "/non-root-path/device/auth/verify_code", h.getDeviceVerificationURI())
+}
+
+// TestCreateDeviceAuthorizationUsesInjectedClock pins the device request and
+// token expiry to the handler's clock. Both were minted from a mix of
+// time.Now() and h.Now(), which made the two expiries drift apart under a
+// fixed test clock.
+func TestCreateDeviceAuthorizationUsesInjectedClock(t *testing.T) {
+ u, err := url.Parse("https://dex.example.com")
+ require.NoError(t, err)
+
+ fixed := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ h := &Handler{
+ IssuerURL: oauth2.IssuerURL{URL: *u},
+ Storage: memory.New(logger),
+ Logger: logger,
+ Now: func() time.Time { return fixed },
+ RequestsValidFor: 5 * time.Minute,
+ }
+
+ resp, ferr := h.createDeviceAuthorization(t.Context(), deviceCodeRequest{
+ clientID: "test",
+ scopes: []string{"openid"},
+ })
+ require.Nil(t, ferr)
+
+ want := fixed.Add(5 * time.Minute)
+
+ req, err := h.Storage.GetDeviceRequest(t.Context(), resp.UserCode)
+ require.NoError(t, err)
+ require.WithinDuration(t, want, req.Expiry, 0)
+
+ token, err := h.Storage.GetDeviceToken(t.Context(), resp.DeviceCode)
+ require.NoError(t, err)
+ require.WithinDuration(t, want, token.Expiry, 0)
+ require.WithinDuration(t, fixed, token.LastRequestTime, 0)
+}
diff --git a/server/device/doc.go b/server/device/doc.go
new file mode 100644
index 0000000000..54749ff9ac
--- /dev/null
+++ b/server/device/doc.go
@@ -0,0 +1,6 @@
+// Package device implements the browser-facing side of the OAuth2 device
+// authorization grant (RFC 8628): the /device user-code entry page, the
+// /device/code authorization request, user-code verification, and the callback
+// that completes the flow. The device_code token grant that the device polls for
+// lives with the token endpoint.
+package device
diff --git a/server/deviceflowhandlers.go b/server/deviceflowhandlers.go
deleted file mode 100644
index 95fed3b3c3..0000000000
--- a/server/deviceflowhandlers.go
+++ /dev/null
@@ -1,444 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "path"
- "strconv"
- "strings"
- "time"
-
- "golang.org/x/net/html"
-
- "github.com/dexidp/dex/pkg/log"
- "github.com/dexidp/dex/storage"
-)
-
-type deviceCodeResponse struct {
- // The unique device code for device authentication
- DeviceCode string `json:"device_code"`
- // The code the user will exchange via a browser and log in
- UserCode string `json:"user_code"`
- // The url to verify the user code.
- VerificationURI string `json:"verification_uri"`
- // The verification uri with the user code appended for pre-filling form
- VerificationURIComplete string `json:"verification_uri_complete"`
- // The lifetime of the device code
- ExpireTime int `json:"expires_in"`
- // How often the device is allowed to poll to verify that the user login occurred
- PollInterval int `json:"interval"`
-}
-
-func (s *Server) getDeviceVerificationURI() string {
- return path.Join(s.issuerURL.Path, "/device/auth/verify_code")
-}
-
-func (s *Server) handleDeviceExchange(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- // Grab the parameter(s) from the query.
- // If "user_code" is set, pre-populate the user code text field.
- // If "invalid" is set, set the invalidAttempt boolean, which will display a message to the user that they
- // attempted to redeem an invalid or expired user code.
- userCode := r.URL.Query().Get("user_code")
- invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid"))
- if err != nil {
- invalidAttempt = false
- }
- if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- s.renderError(r, w, http.StatusNotFound, "Page not found")
- }
- default:
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- }
-}
-
-func (s *Server) handleDeviceCode(w http.ResponseWriter, r *http.Request) {
- pollIntervalSeconds := 5
-
- switch r.Method {
- case http.MethodPost:
- err := r.ParseForm()
- if err != nil {
- s.logger.Errorf("Could not parse Device Request body: %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusNotFound)
- return
- }
-
- // Get the client id and scopes from the post
- clientID := r.Form.Get("client_id")
- clientSecret := r.Form.Get("client_secret")
- scopes := strings.Fields(r.Form.Get("scope"))
- codeChallenge := r.Form.Get("code_challenge")
- codeChallengeMethod := r.Form.Get("code_challenge_method")
-
- if codeChallengeMethod == "" {
- codeChallengeMethod = codeChallengeMethodPlain
- }
- if codeChallengeMethod != codeChallengeMethodS256 && codeChallengeMethod != codeChallengeMethodPlain {
- description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod)
- s.tokenErrHelper(w, errInvalidRequest, description, http.StatusBadRequest)
- return
- }
-
- s.logger.Infof("Received device request for client %v with scopes %v", clientID, scopes)
-
- // Make device code
- deviceCode := storage.NewDeviceCode()
-
- // make user code
- userCode := storage.NewUserCode()
-
- // Generate the expire time
- expireTime := time.Now().Add(s.deviceRequestsValidFor)
-
- // Store the Device Request
- deviceReq := storage.DeviceRequest{
- UserCode: userCode,
- DeviceCode: deviceCode,
- ClientID: clientID,
- ClientSecret: clientSecret,
- Scopes: scopes,
- Expiry: expireTime,
- }
-
- if err := s.storage.CreateDeviceRequest(deviceReq); err != nil {
- s.logger.Errorf("Failed to store device request; %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
- return
- }
-
- // Store the device token
- deviceToken := storage.DeviceToken{
- DeviceCode: deviceCode,
- Status: deviceTokenPending,
- Expiry: expireTime,
- LastRequestTime: s.now(),
- PollIntervalSeconds: 0,
- PKCE: storage.PKCE{
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- },
- }
-
- if err := s.storage.CreateDeviceToken(deviceToken); err != nil {
- s.logger.Errorf("Failed to store device token %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
- return
- }
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- s.logger.Errorf("Could not parse issuer URL %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
- return
- }
- u.Path = path.Join(u.Path, "device")
- vURI := u.String()
-
- q := u.Query()
- q.Set("user_code", userCode)
- u.RawQuery = q.Encode()
- vURIComplete := u.String()
-
- code := deviceCodeResponse{
- DeviceCode: deviceCode,
- UserCode: userCode,
- VerificationURI: vURI,
- VerificationURIComplete: vURIComplete,
- ExpireTime: int(s.deviceRequestsValidFor.Seconds()),
- PollInterval: pollIntervalSeconds,
- }
-
- // Device Authorization Response can contain cache control header according to
- // https://tools.ietf.org/html/rfc8628#section-3.2
- w.Header().Set("Cache-Control", "no-store")
-
- // Response type should be application/json according to
- // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
- w.Header().Set("Content-Type", "application/json")
-
- enc := json.NewEncoder(w)
- enc.SetEscapeHTML(false)
- enc.SetIndent("", " ")
- enc.Encode(code)
-
- default:
- s.renderError(r, w, http.StatusBadRequest, "Invalid device code request type")
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
- }
-}
-
-func (s *Server) handleDeviceTokenDeprecated(w http.ResponseWriter, r *http.Request) {
- log.Deprecated(s.logger, `The /device/token endpoint was called. It will be removed, use /token instead.`)
-
- w.Header().Set("Content-Type", "application/json")
- switch r.Method {
- case http.MethodPost:
- err := r.ParseForm()
- if err != nil {
- s.logger.Warnf("Could not parse Device Token Request body: %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
- return
- }
-
- grantType := r.PostFormValue("grant_type")
- if grantType != grantTypeDeviceCode {
- s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
- return
- }
-
- s.handleDeviceToken(w, r)
- default:
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- }
-}
-
-func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
- deviceCode := r.Form.Get("device_code")
- if deviceCode == "" {
- s.tokenErrHelper(w, errInvalidRequest, "No device code received", http.StatusBadRequest)
- return
- }
-
- now := s.now()
-
- // Grab the device token, check validity
- deviceToken, err := s.storage.GetDeviceToken(deviceCode)
- if err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get device code: %v", err)
- }
- s.tokenErrHelper(w, errInvalidRequest, "Invalid Device code.", http.StatusBadRequest)
- return
- } else if now.After(deviceToken.Expiry) {
- s.tokenErrHelper(w, deviceTokenExpired, "", http.StatusBadRequest)
- return
- }
-
- // Rate Limiting check
- slowDown := false
- pollInterval := deviceToken.PollIntervalSeconds
- minRequestTime := deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval))
- if now.Before(minRequestTime) {
- slowDown = true
- // Continually increase the poll interval until the user waits the proper time
- pollInterval += 5
- } else {
- pollInterval = 5
- }
-
- switch deviceToken.Status {
- case deviceTokenPending:
- updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
- old.PollIntervalSeconds = pollInterval
- old.LastRequestTime = now
- return old, nil
- }
- // Update device token last request time in storage
- if err := s.storage.UpdateDeviceToken(deviceCode, updater); err != nil {
- s.logger.Errorf("failed to update device token: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "")
- return
- }
- if slowDown {
- s.tokenErrHelper(w, deviceTokenSlowDown, "", http.StatusBadRequest)
- } else {
- s.tokenErrHelper(w, deviceTokenPending, "", http.StatusUnauthorized)
- }
- case deviceTokenComplete:
- codeChallengeFromStorage := deviceToken.PKCE.CodeChallenge
- providedCodeVerifier := r.Form.Get("code_verifier")
-
- switch {
- case providedCodeVerifier != "" && codeChallengeFromStorage != "":
- calculatedCodeChallenge, err := s.calculateCodeChallenge(providedCodeVerifier, deviceToken.PKCE.CodeChallengeMethod)
- if err != nil {
- s.logger.Error(err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- if codeChallengeFromStorage != calculatedCodeChallenge {
- s.tokenErrHelper(w, errInvalidGrant, "Invalid code_verifier.", http.StatusBadRequest)
- return
- }
- case providedCodeVerifier != "":
- // Received no code_challenge on /auth, but a code_verifier on /token
- s.tokenErrHelper(w, errInvalidRequest, "No PKCE flow started. Cannot check code_verifier.", http.StatusBadRequest)
- return
- case codeChallengeFromStorage != "":
- // Received PKCE request on /auth, but no code_verifier on /token
- s.tokenErrHelper(w, errInvalidGrant, "Expecting parameter code_verifier in PKCE flow.", http.StatusBadRequest)
- return
- }
- w.Write([]byte(deviceToken.Token))
- }
-}
-
-func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- userCode := r.FormValue("state")
- code := r.FormValue("code")
-
- if userCode == "" || code == "" {
- s.renderError(r, w, http.StatusBadRequest, "Request was missing parameters")
- return
- }
-
- // Authorization redirect callback from OAuth2 auth flow.
- if errMsg := r.FormValue("error"); errMsg != "" {
- // escape the message to prevent cross-site scripting
- msg := html.EscapeString(errMsg + ": " + r.FormValue("error_description"))
- http.Error(w, msg, http.StatusBadRequest)
- return
- }
-
- authCode, err := s.storage.GetAuthCode(code)
- if err != nil || s.now().After(authCode.Expiry) {
- errCode := http.StatusBadRequest
- if err != nil && err != storage.ErrNotFound {
- s.logger.Errorf("failed to get auth code: %v", err)
- errCode = http.StatusInternalServerError
- }
- s.renderError(r, w, errCode, "Invalid or expired auth code.")
- return
- }
-
- // Grab the device request from storage
- deviceReq, err := s.storage.GetDeviceRequest(userCode)
- if err != nil || s.now().After(deviceReq.Expiry) {
- errCode := http.StatusBadRequest
- if err != nil && err != storage.ErrNotFound {
- s.logger.Errorf("failed to get device code: %v", err)
- errCode = http.StatusInternalServerError
- }
- s.renderError(r, w, errCode, "Invalid or expired user code.")
- return
- }
-
- client, err := s.storage.GetClient(deviceReq.ClientID)
- if err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get client: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- } else {
- s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
- }
- return
- }
- if client.Secret != deviceReq.ClientSecret {
- s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
- return
- }
-
- resp, err := s.exchangeAuthCode(w, authCode, client)
- if err != nil {
- s.logger.Errorf("Could not exchange auth code for client %q: %v", deviceReq.ClientID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Failed to exchange auth code.")
- return
- }
-
- // Grab the device token from storage
- old, err := s.storage.GetDeviceToken(deviceReq.DeviceCode)
- if err != nil || s.now().After(old.Expiry) {
- errCode := http.StatusBadRequest
- if err != nil && err != storage.ErrNotFound {
- s.logger.Errorf("failed to get device token: %v", err)
- errCode = http.StatusInternalServerError
- }
- s.renderError(r, w, errCode, "Invalid or expired device code.")
- return
- }
-
- updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
- if old.Status == deviceTokenComplete {
- return old, errors.New("device token already complete")
- }
- respStr, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- s.logger.Errorf("failed to marshal device token response: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "")
- return old, err
- }
-
- old.Token = string(respStr)
- old.Status = deviceTokenComplete
- return old, nil
- }
-
- // Update refresh token in the storage, store the token and mark as complete
- if err := s.storage.UpdateDeviceToken(deviceReq.DeviceCode, updater); err != nil {
- s.logger.Errorf("failed to update device token: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "")
- return
- }
-
- if err := s.templates.deviceSuccess(r, w, client.Name); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- s.renderError(r, w, http.StatusNotFound, "Page not found")
- }
-
- default:
- http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest)
- return
- }
-}
-
-func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodPost:
- err := r.ParseForm()
- if err != nil {
- s.logger.Warnf("Could not parse user code verification request body : %v", err)
- s.renderError(r, w, http.StatusBadRequest, "")
- return
- }
-
- userCode := r.Form.Get("user_code")
- if userCode == "" {
- s.renderError(r, w, http.StatusBadRequest, "No user code received")
- return
- }
-
- userCode = strings.ToUpper(userCode)
-
- // Find the user code in the available requests
- deviceRequest, err := s.storage.GetDeviceRequest(userCode)
- if err != nil || s.now().After(deviceRequest.Expiry) {
- if err != nil && err != storage.ErrNotFound {
- s.logger.Errorf("failed to get device request: %v", err)
- }
- if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, true); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- s.renderError(r, w, http.StatusNotFound, "Page not found")
- }
- return
- }
-
- // Redirect to Dex Auth Endpoint
- authURL := path.Join(s.issuerURL.Path, "/auth")
- u, err := url.Parse(authURL)
- if err != nil {
- s.renderError(r, w, http.StatusInternalServerError, "Invalid auth URI.")
- return
- }
- q := u.Query()
- q.Set("client_id", deviceRequest.ClientID)
- q.Set("client_secret", deviceRequest.ClientSecret)
- q.Set("state", deviceRequest.UserCode)
- q.Set("response_type", "code")
- q.Set("redirect_uri", "/device/callback")
- q.Set("scope", strings.Join(deviceRequest.Scopes, " "))
- u.RawQuery = q.Encode()
-
- http.Redirect(w, r, u.String(), http.StatusFound)
-
- default:
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- }
-}
diff --git a/server/deviceflowhandlers_test.go b/server/deviceflowhandlers_test.go
deleted file mode 100644
index 9a9f28584e..0000000000
--- a/server/deviceflowhandlers_test.go
+++ /dev/null
@@ -1,830 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "path"
- "strings"
- "testing"
- "time"
-
- "github.com/dexidp/dex/storage"
-)
-
-func TestDeviceVerificationURI(t *testing.T) {
- t0 := time.Now()
-
- now := func() time.Time { return t0 }
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- c.Issuer += "/non-root-path"
- c.Now = now
- })
- defer httpServer.Close()
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- t.Fatalf("Could not parse issuer URL %v", err)
- }
- u.Path = path.Join(u.Path, "/device/auth/verify_code")
-
- uri := s.getDeviceVerificationURI()
- if uri != u.Path {
- t.Errorf("Invalid verification URI. Expected %v got %v", u.Path, uri)
- }
-}
-
-func TestHandleDeviceCode(t *testing.T) {
- t0 := time.Now()
-
- now := func() time.Time { return t0 }
-
- tests := []struct {
- testName string
- clientID string
- codeChallengeMethod string
- requestType string
- scopes []string
- expectedResponseCode int
- expectedContentType string
- expectedServerResponse string
- }{
- {
- testName: "New Code",
- clientID: "test",
- requestType: "POST",
- scopes: []string{"openid", "profile", "email"},
- expectedResponseCode: http.StatusOK,
- expectedContentType: "application/json",
- },
- {
- testName: "Invalid request Type (GET)",
- clientID: "test",
- requestType: "GET",
- scopes: []string{"openid", "profile", "email"},
- expectedResponseCode: http.StatusBadRequest,
- expectedContentType: "application/json",
- },
- {
- testName: "New Code with valid PKCE",
- clientID: "test",
- requestType: "POST",
- scopes: []string{"openid", "profile", "email"},
- codeChallengeMethod: "S256",
- expectedResponseCode: http.StatusOK,
- expectedContentType: "application/json",
- },
- {
- testName: "Invalid code challenge method",
- clientID: "test",
- requestType: "POST",
- codeChallengeMethod: "invalid",
- scopes: []string{"openid", "profile", "email"},
- expectedResponseCode: http.StatusBadRequest,
- expectedContentType: "application/json",
- },
- }
- for _, tc := range tests {
- t.Run(tc.testName, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- c.Issuer += "/non-root-path"
- c.Now = now
- })
- defer httpServer.Close()
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- t.Fatalf("Could not parse issuer URL %v", err)
- }
- u.Path = path.Join(u.Path, "device/code")
-
- data := url.Values{}
- data.Set("client_id", tc.clientID)
- data.Set("code_challenge_method", tc.codeChallengeMethod)
- for _, scope := range tc.scopes {
- data.Add("scope", scope)
- }
- req, _ := http.NewRequest(tc.requestType, u.String(), bytes.NewBufferString(data.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
-
- rr := httptest.NewRecorder()
- s.ServeHTTP(rr, req)
- if rr.Code != tc.expectedResponseCode {
- t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
- }
-
- if rr.Header().Get("content-type") != tc.expectedContentType {
- t.Errorf("Unexpected Response Content Type. Expected %v got %v", tc.expectedContentType, rr.Header().Get("content-type"))
- }
-
- body, err := io.ReadAll(rr.Body)
- if err != nil {
- t.Errorf("Could read token response %v", err)
- }
- if tc.expectedResponseCode == http.StatusOK {
- var resp deviceCodeResponse
- if err := json.Unmarshal(body, &resp); err != nil {
- t.Errorf("Unexpected Device Code Response Format %v", string(body))
- }
- }
- })
- }
-}
-
-func TestDeviceCallback(t *testing.T) {
- t0 := time.Now()
-
- now := func() time.Time { return t0 }
-
- type formValues struct {
- state string
- code string
- error string
- }
-
- // Base "Control" test values
- baseFormValues := formValues{
- state: "XXXX-XXXX",
- code: "somecode",
- }
- baseAuthCode := storage.AuthCode{
- ID: "somecode",
- ClientID: "testclient",
- RedirectURI: deviceCallbackURI,
- Nonce: "",
- Scopes: []string{"openid", "profile", "email"},
- ConnectorID: "mock",
- ConnectorData: nil,
- Claims: storage.Claims{},
- Expiry: now().Add(5 * time.Minute),
- }
- baseDeviceRequest := storage.DeviceRequest{
- UserCode: "XXXX-XXXX",
- DeviceCode: "devicecode",
- ClientID: "testclient",
- ClientSecret: "",
- Scopes: []string{"openid", "profile", "email"},
- Expiry: now().Add(5 * time.Minute),
- }
- baseDeviceToken := storage.DeviceToken{
- DeviceCode: "devicecode",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- }
-
- tests := []struct {
- testName string
- expectedResponseCode int
- expectedServerResponse string
- values formValues
- testAuthCode storage.AuthCode
- testDeviceRequest storage.DeviceRequest
- testDeviceToken storage.DeviceToken
- }{
- {
- testName: "Missing State",
- values: formValues{
- state: "",
- code: "somecode",
- error: "",
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Missing Code",
- values: formValues{
- state: "XXXX-XXXX",
- code: "",
- error: "",
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Error During Authorization",
- values: formValues{
- state: "XXXX-XXXX",
- code: "somecode",
- error: "Error Condition",
- },
- expectedResponseCode: http.StatusBadRequest,
- expectedServerResponse: "Error Condition: \n",
- },
- {
- testName: "Expired Auth Code",
- values: baseFormValues,
- testAuthCode: storage.AuthCode{
- ID: "somecode",
- ClientID: "testclient",
- RedirectURI: deviceCallbackURI,
- Nonce: "",
- Scopes: []string{"openid", "profile", "email"},
- ConnectorID: "pic",
- ConnectorData: nil,
- Claims: storage.Claims{},
- Expiry: now().Add(-5 * time.Minute),
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Invalid Auth Code",
- values: baseFormValues,
- testAuthCode: storage.AuthCode{
- ID: "somecode",
- ClientID: "testclient",
- RedirectURI: deviceCallbackURI,
- Nonce: "",
- Scopes: []string{"openid", "profile", "email"},
- ConnectorID: "pic",
- ConnectorData: nil,
- Claims: storage.Claims{},
- Expiry: now().Add(5 * time.Minute),
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Expired Device Request",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "XXXX-XXXX",
- DeviceCode: "devicecode",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "email"},
- Expiry: now().Add(-5 * time.Minute),
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Non-Existent User Code",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "ZZZZ-ZZZZ",
- DeviceCode: "devicecode",
- Scopes: []string{"openid", "profile", "email"},
- Expiry: now().Add(5 * time.Minute),
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Bad Device Request Client",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "XXXX-XXXX",
- DeviceCode: "devicecode",
- Scopes: []string{"openid", "profile", "email"},
- Expiry: now().Add(5 * time.Minute),
- },
- expectedResponseCode: http.StatusUnauthorized,
- },
- {
- testName: "Bad Device Request Secret",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "XXXX-XXXX",
- DeviceCode: "devicecode",
- ClientSecret: "foobar",
- Scopes: []string{"openid", "profile", "email"},
- Expiry: now().Add(5 * time.Minute),
- },
- expectedResponseCode: http.StatusUnauthorized,
- },
- {
- testName: "Expired Device Token",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "devicecode",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(-5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Device Code Already Redeemed",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "devicecode",
- Status: deviceTokenComplete,
- Token: "",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Successful Exchange",
- values: baseFormValues,
- testAuthCode: baseAuthCode,
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: baseDeviceToken,
- expectedResponseCode: http.StatusOK,
- },
- {
- testName: "Prevent cross-site scripting",
- values: formValues{
- state: "XXXX-XXXX",
- code: "somecode",
- error: "",
- },
- expectedResponseCode: http.StatusBadRequest,
- expectedServerResponse: "<script>console.log(window);</script>: \n",
- },
- }
- for _, tc := range tests {
- t.Run(tc.testName, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- // c.Issuer = c.Issuer + "/non-root-path"
- c.Now = now
- })
- defer httpServer.Close()
-
- if err := s.storage.CreateAuthCode(tc.testAuthCode); err != nil {
- t.Fatalf("failed to create auth code: %v", err)
- }
-
- if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
- t.Fatalf("failed to create device request: %v", err)
- }
-
- if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil {
- t.Fatalf("failed to create device token: %v", err)
- }
-
- client := storage.Client{
- ID: "testclient",
- Secret: "",
- RedirectURIs: []string{deviceCallbackURI},
- }
- if err := s.storage.CreateClient(client); err != nil {
- t.Fatalf("failed to create client: %v", err)
- }
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- t.Fatalf("Could not parse issuer URL %v", err)
- }
- u.Path = path.Join(u.Path, "device/callback")
- q := u.Query()
- q.Set("state", tc.values.state)
- q.Set("code", tc.values.code)
- q.Set("error", tc.values.error)
- u.RawQuery = q.Encode()
- req, _ := http.NewRequest("GET", u.String(), nil)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
-
- rr := httptest.NewRecorder()
- s.ServeHTTP(rr, req)
- if rr.Code != tc.expectedResponseCode {
- t.Errorf("%s: Unexpected Response Type. Expected %v got %v", tc.testName, tc.expectedResponseCode, rr.Code)
- }
-
- if len(tc.expectedServerResponse) > 0 {
- result, _ := io.ReadAll(rr.Body)
- if string(result) != tc.expectedServerResponse {
- t.Errorf("%s: Unexpected Response. Expected %q got %q", tc.testName, tc.expectedServerResponse, result)
- }
- }
- })
- }
-}
-
-func TestDeviceTokenResponse(t *testing.T) {
- t0 := time.Now()
-
- now := func() time.Time { return t0 }
-
- // Base PKCE values
- // base64-urlencoded, sha256 digest of code_verifier
- codeChallenge := "L7ZqsT_zNwvrH8E7J0CqPHx1wgBaFiaE-fAZcKUUAbc"
- codeChallengeMethod := "S256"
- // "random" string between 43 & 128 ASCII characters
- codeVerifier := "66114650f56cc45dee7ee03c49f048ddf9aa53cbf5b09985832fa4f790ff2604"
-
- baseDeviceRequest := storage.DeviceRequest{
- UserCode: "ABCD-WXYZ",
- DeviceCode: "foo",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "offline_access"},
- Expiry: now().Add(5 * time.Minute),
- }
-
- tests := []struct {
- testName string
- testDeviceRequest storage.DeviceRequest
- testDeviceToken storage.DeviceToken
- testGrantType string
- testDeviceCode string
- testCodeVerifier string
- expectedServerResponse string
- expectedResponseCode int
- }{
- {
- testName: "Valid but pending token",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "f00bar",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "f00bar",
- expectedServerResponse: deviceTokenPending,
- expectedResponseCode: http.StatusUnauthorized,
- },
- {
- testName: "Invalid Grant Type",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "f00bar",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "f00bar",
- testGrantType: grantTypeAuthorizationCode,
- expectedServerResponse: errInvalidGrant,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test Slow Down State",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "f00bar",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: now(),
- PollIntervalSeconds: 10,
- },
- testDeviceCode: "f00bar",
- expectedServerResponse: deviceTokenSlowDown,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test Expired Device Token",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "f00bar",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(-5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "f00bar",
- expectedServerResponse: deviceTokenExpired,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test Non-existent Device Code",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(-5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "bar",
- expectedServerResponse: errInvalidRequest,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Empty Device Code in Request",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "bar",
- Status: deviceTokenPending,
- Token: "",
- Expiry: now().Add(-5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "",
- expectedServerResponse: errInvalidRequest,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Claim validated token from Device Code",
- testDeviceRequest: baseDeviceRequest,
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "foo",
- expectedServerResponse: "{\"access_token\": \"foobar\"}",
- expectedResponseCode: http.StatusOK,
- },
- {
- testName: "Successful Exchange with PKCE",
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- PKCE: storage.PKCE{
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- },
- },
- testDeviceCode: "foo",
- testCodeVerifier: codeVerifier,
- testDeviceRequest: baseDeviceRequest,
- expectedServerResponse: "{\"access_token\": \"foobar\"}",
- expectedResponseCode: http.StatusOK,
- },
- {
- testName: "Test Exchange started with PKCE but without verifier provided",
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- PKCE: storage.PKCE{
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- },
- },
- testDeviceCode: "foo",
- testDeviceRequest: baseDeviceRequest,
- expectedServerResponse: errInvalidGrant,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test Exchange not started with PKCE but verifier provided",
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- },
- testDeviceCode: "foo",
- testCodeVerifier: codeVerifier,
- testDeviceRequest: baseDeviceRequest,
- expectedServerResponse: errInvalidRequest,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test with PKCE but incorrect verifier provided",
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- PKCE: storage.PKCE{
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- },
- },
- testDeviceCode: "foo",
- testCodeVerifier: "invalid",
- testDeviceRequest: baseDeviceRequest,
- expectedServerResponse: errInvalidGrant,
- expectedResponseCode: http.StatusBadRequest,
- },
- {
- testName: "Test with PKCE but incorrect challenge provided",
- testDeviceToken: storage.DeviceToken{
- DeviceCode: "foo",
- Status: deviceTokenComplete,
- Token: "{\"access_token\": \"foobar\"}",
- Expiry: now().Add(5 * time.Minute),
- LastRequestTime: time.Time{},
- PollIntervalSeconds: 0,
- PKCE: storage.PKCE{
- CodeChallenge: "invalid",
- CodeChallengeMethod: codeChallengeMethod,
- },
- },
- testDeviceCode: "foo",
- testCodeVerifier: codeVerifier,
- testDeviceRequest: baseDeviceRequest,
- expectedServerResponse: errInvalidGrant,
- expectedResponseCode: http.StatusBadRequest,
- },
- }
- for _, tc := range tests {
- t.Run(tc.testName, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- c.Issuer += "/non-root-path"
- c.Now = now
- })
- defer httpServer.Close()
-
- if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
- t.Fatalf("Failed to store device token %v", err)
- }
-
- if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil {
- t.Fatalf("Failed to store device token %v", err)
- }
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- t.Fatalf("Could not parse issuer URL %v", err)
- }
- u.Path = path.Join(u.Path, "device/token")
-
- data := url.Values{}
- grantType := grantTypeDeviceCode
- if tc.testGrantType != "" {
- grantType = tc.testGrantType
- }
- data.Set("grant_type", grantType)
- data.Set("device_code", tc.testDeviceCode)
- if tc.testCodeVerifier != "" {
- data.Set("code_verifier", tc.testCodeVerifier)
- }
- req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
-
- rr := httptest.NewRecorder()
- s.ServeHTTP(rr, req)
- if rr.Code != tc.expectedResponseCode {
- t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
- }
-
- body, err := io.ReadAll(rr.Body)
- if err != nil {
- t.Errorf("Could read token response %v", err)
- }
- if tc.expectedResponseCode == http.StatusBadRequest || tc.expectedResponseCode == http.StatusUnauthorized {
- expectJSONErrorResponse(tc.testName, body, tc.expectedServerResponse, t)
- } else if string(body) != tc.expectedServerResponse {
- t.Errorf("Unexpected Server Response. Expected %v got %v", tc.expectedServerResponse, string(body))
- }
- })
- }
-}
-
-func expectJSONErrorResponse(testCase string, body []byte, expectedError string, t *testing.T) {
- jsonMap := make(map[string]interface{})
- err := json.Unmarshal(body, &jsonMap)
- if err != nil {
- t.Errorf("Unexpected error unmarshalling response: %v", err)
- }
- if jsonMap["error"] != expectedError {
- t.Errorf("Test Case %s expected error %v, received %v", testCase, expectedError, jsonMap["error"])
- }
-}
-
-func TestVerifyCodeResponse(t *testing.T) {
- t0 := time.Now()
-
- now := func() time.Time { return t0 }
-
- tests := []struct {
- testName string
- testDeviceRequest storage.DeviceRequest
- userCode string
- expectedResponseCode int
- expectedRedirectPath string
- }{
- {
- testName: "Unknown user code",
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "ABCD-WXYZ",
- DeviceCode: "f00bar",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "offline_access"},
- Expiry: now().Add(5 * time.Minute),
- },
- userCode: "CODE-TEST",
- expectedResponseCode: http.StatusBadRequest,
- expectedRedirectPath: "",
- },
- {
- testName: "Expired user code",
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "ABCD-WXYZ",
- DeviceCode: "f00bar",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "offline_access"},
- Expiry: now().Add(-5 * time.Minute),
- },
- userCode: "ABCD-WXYZ",
- expectedResponseCode: http.StatusBadRequest,
- expectedRedirectPath: "",
- },
- {
- testName: "No user code",
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "ABCD-WXYZ",
- DeviceCode: "f00bar",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "offline_access"},
- Expiry: now().Add(-5 * time.Minute),
- },
- userCode: "",
- expectedResponseCode: http.StatusBadRequest,
- expectedRedirectPath: "",
- },
- {
- testName: "Valid user code, expect redirect to auth endpoint",
- testDeviceRequest: storage.DeviceRequest{
- UserCode: "ABCD-WXYZ",
- DeviceCode: "f00bar",
- ClientID: "testclient",
- Scopes: []string{"openid", "profile", "offline_access"},
- Expiry: now().Add(5 * time.Minute),
- },
- userCode: "ABCD-WXYZ",
- expectedResponseCode: http.StatusFound,
- expectedRedirectPath: "/auth",
- },
- }
- for _, tc := range tests {
- t.Run(tc.testName, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- c.Issuer += "/non-root-path"
- c.Now = now
- })
- defer httpServer.Close()
-
- if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
- t.Fatalf("Failed to store device token %v", err)
- }
-
- u, err := url.Parse(s.issuerURL.String())
- if err != nil {
- t.Fatalf("Could not parse issuer URL %v", err)
- }
-
- u.Path = path.Join(u.Path, "device/auth/verify_code")
- data := url.Values{}
- data.Set("user_code", tc.userCode)
- req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
-
- rr := httptest.NewRecorder()
- s.ServeHTTP(rr, req)
- if rr.Code != tc.expectedResponseCode {
- t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
- }
-
- u, err = url.Parse(s.issuerURL.String())
- if err != nil {
- t.Errorf("Could not parse issuer URL %v", err)
- }
- u.Path = path.Join(u.Path, tc.expectedRedirectPath)
-
- location := rr.Header().Get("Location")
- if rr.Code == http.StatusFound && !strings.HasPrefix(location, u.Path) {
- t.Errorf("Invalid Redirect. Expected %v got %v", u.Path, location)
- }
- })
- }
-}
diff --git a/server/discovery/discovery.go b/server/discovery/discovery.go
new file mode 100644
index 0000000000..d9119d4b68
--- /dev/null
+++ b/server/discovery/discovery.go
@@ -0,0 +1,180 @@
+package discovery
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strconv"
+ "sync"
+ "time"
+
+ jose "github.com/go-jose/go-jose/v4"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/templates"
+)
+
+// Handler serves the discovery document and the JWKS. Like every other domain
+// handler it takes the issuer URL and the templates directly, so it can be
+// built without a Server.
+type Handler struct {
+ IssuerURL oauth2.IssuerURL
+ Templates *templates.Templates
+ Signer signer.Signer
+ Logger *slog.Logger
+ ResponseTypes map[string]bool
+ GrantTypes []string
+ PKCEMethods []string
+ SessionsEnabled bool
+
+ docOnce sync.Once
+ docData []byte
+ docErr error
+}
+
+// renderError renders a user-facing HTML error page.
+func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
+ templates.RenderError(h.Templates, h.Logger, r, w, status, description)
+}
+
+// Mount registers the discovery routes.
+func (h *Handler) Mount(m router.Mux) {
+ m.HandleCORS("/.well-known/openid-configuration", h.serveDocument)
+ m.HandleCORS("/keys", h.Keys)
+}
+
+// Document is the OIDC discovery document.
+type Document struct {
+ Issuer string `json:"issuer"`
+ Auth string `json:"authorization_endpoint"`
+ Token string `json:"token_endpoint"`
+ Keys string `json:"jwks_uri"`
+ UserInfo string `json:"userinfo_endpoint"`
+ DeviceEndpoint string `json:"device_authorization_endpoint"`
+ Introspect string `json:"introspection_endpoint"`
+ EndSession string `json:"end_session_endpoint,omitempty"`
+ // BackchannelLogout and BackchannelLogoutSession advertise OIDC Back-Channel
+ // Logout 1.0. Both are omitted rather than sent as false when sessions are off,
+ // matching how end_session_endpoint disappears with them.
+ BackchannelLogout bool `json:"backchannel_logout_supported,omitempty"`
+ BackchannelLogoutSession bool `json:"backchannel_logout_session_supported,omitempty"`
+ GrantTypes []string `json:"grant_types_supported"`
+ ResponseTypes []string `json:"response_types_supported"`
+ Subjects []string `json:"subject_types_supported"`
+ IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
+ CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
+ Scopes []string `json:"scopes_supported"`
+ AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
+ Claims []string `json:"claims_supported"`
+}
+
+// Keys serves the JSON Web Key Set.
+func (h *Handler) Keys(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ // TODO(ericchiang): Cache this.
+ keys, err := h.Signer.ValidationKeys(ctx)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to get keys", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ return
+ }
+
+ if len(keys) == 0 {
+ h.Logger.ErrorContext(ctx, "no public keys found.")
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ return
+ }
+
+ jwks := jose.JSONWebKeySet{
+ Keys: make([]jose.JSONWebKey, len(keys)),
+ }
+ for i, key := range keys {
+ jwks.Keys[i] = *key
+ }
+
+ data, err := json.MarshalIndent(jwks, "", " ")
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to marshal discovery data", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ return
+ }
+
+ // We don't have NextRotation info from Signer interface easily,
+ // so we'll just set a reasonable default cache time.
+ maxAge := time.Minute * 10
+
+ w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds())))
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ w.Write(data)
+}
+
+// serveDocument serves the discovery document, marshaling it once on first use.
+func (h *Handler) serveDocument(w http.ResponseWriter, r *http.Request) {
+ h.docOnce.Do(func() {
+ h.docData, h.docErr = json.MarshalIndent(h.Construct(r.Context()), "", " ")
+ })
+ if h.docErr != nil {
+ h.Logger.ErrorContext(r.Context(), "failed to marshal discovery data", "err", h.docErr)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Length", strconv.Itoa(len(h.docData)))
+ w.Write(h.docData)
+}
+
+// Construct builds the discovery document from the current configuration.
+func (h *Handler) Construct(ctx context.Context) Document {
+ d := Document{
+ Issuer: h.IssuerURL.String(),
+ Auth: h.IssuerURL.AbsURL("/auth"),
+ Token: h.IssuerURL.AbsURL("/token"),
+ Keys: h.IssuerURL.AbsURL("/keys"),
+ UserInfo: h.IssuerURL.AbsURL("/userinfo"),
+ DeviceEndpoint: h.IssuerURL.AbsURL("/device/code"),
+ Introspect: h.IssuerURL.AbsURL("/token/introspect"),
+ Subjects: []string{"public"},
+ IDTokenAlgs: []string{string(jose.RS256)},
+ CodeChallengeAlgs: h.PKCEMethods,
+ Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
+ AuthMethods: []string{"client_secret_basic", "client_secret_post"},
+ Claims: []string{
+ "iss", "sub", "aud", "iat", "exp", "email", "email_verified",
+ "locale", "name", "preferred_username", "at_hash", "groups",
+ "federated_claims",
+ },
+ }
+
+ // Determine signing algorithm from signer.
+ signingAlg, err := h.Signer.Algorithm(ctx)
+ if err != nil {
+ h.Logger.Error("failed to get signing algorithm", "err", err)
+ } else {
+ d.IDTokenAlgs = []string{string(signingAlg)}
+ }
+
+ for responseType := range h.ResponseTypes {
+ d.ResponseTypes = append(d.ResponseTypes, responseType)
+ }
+ sort.Strings(d.ResponseTypes)
+
+ d.GrantTypes = h.GrantTypes
+
+ if h.SessionsEnabled {
+ d.EndSession = h.IssuerURL.AbsURL("/logout")
+ d.BackchannelLogout = true
+ // Dex always puts a sid in its logout tokens, so clients never need to set
+ // backchannel_logout_session_required to get one.
+ d.BackchannelLogoutSession = true
+ d.Claims = append(d.Claims, "sid")
+ }
+
+ return d
+}
diff --git a/server/discovery/discovery_test.go b/server/discovery/discovery_test.go
new file mode 100644
index 0000000000..720acee18e
--- /dev/null
+++ b/server/discovery/discovery_test.go
@@ -0,0 +1,59 @@
+package discovery
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "log/slog"
+ "net/url"
+ "testing"
+
+ jose "github.com/go-jose/go-jose/v4"
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/signer"
+)
+
+func testHandler(t *testing.T, sessionsEnabled bool) *Handler {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ sig, err := signer.NewMockSigner(key)
+ require.NoError(t, err)
+
+ u, err := url.Parse("https://dex.example.com")
+ require.NoError(t, err)
+
+ return &Handler{
+ IssuerURL: oauth2.IssuerURL{URL: *u},
+ Signer: sig,
+ Logger: slog.New(slog.DiscardHandler),
+ ResponseTypes: map[string]bool{"id_token": true, "code": true},
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ PKCEMethods: []string{"S256", "plain"},
+ SessionsEnabled: sessionsEnabled,
+ }
+}
+
+func TestConstruct(t *testing.T) {
+ doc := testHandler(t, true).Construct(context.Background())
+
+ require.Equal(t, "https://dex.example.com", doc.Issuer)
+ require.Equal(t, "https://dex.example.com/auth", doc.Auth)
+ require.Equal(t, "https://dex.example.com/token", doc.Token)
+ require.Equal(t, "https://dex.example.com/keys", doc.Keys)
+ require.Equal(t, "https://dex.example.com/token/introspect", doc.Introspect)
+ // Response types are sorted.
+ require.Equal(t, []string{"code", "id_token"}, doc.ResponseTypes)
+ require.Equal(t, []string{"authorization_code", "refresh_token"}, doc.GrantTypes)
+ require.Equal(t, []string{"S256", "plain"}, doc.CodeChallengeAlgs)
+ require.Equal(t, []string{string(jose.RS256)}, doc.IDTokenAlgs)
+ // end_session_endpoint is present only when sessions are enabled.
+ require.Equal(t, "https://dex.example.com/logout", doc.EndSession)
+}
+
+func TestConstructNoSessions(t *testing.T) {
+ doc := testHandler(t, false).Construct(context.Background())
+ require.Empty(t, doc.EndSession)
+}
diff --git a/server/discovery/doc.go b/server/discovery/doc.go
new file mode 100644
index 0000000000..2b4f2751b2
--- /dev/null
+++ b/server/discovery/doc.go
@@ -0,0 +1,3 @@
+// Package discovery serves the OIDC discovery document
+// (/.well-known/openid-configuration) and the JWKS endpoint (/keys).
+package discovery
diff --git a/server/grants/authcode.go b/server/grants/authcode.go
new file mode 100644
index 0000000000..d75716c3e1
--- /dev/null
+++ b/server/grants/authcode.go
@@ -0,0 +1,122 @@
+package grants
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// authorizationCode serves the authorization_code grant: the client redeems a
+// code minted at the /auth endpoint for tokens.
+type authorizationCode struct {
+ issuer *tokens.Issuer
+ storage storage.Storage
+ connectors *connectors.Cache
+ now func() time.Time
+ logger *slog.Logger
+}
+
+func (g *authorizationCode) GrantType() string {
+ return oauth2.GrantTypeAuthorizationCode
+}
+
+func (g *authorizationCode) RequiresClientAuth() bool {
+ return true
+}
+
+// Scopes are passed through: they were validated at /auth and stored on the code.
+func (g *authorizationCode) ScopePolicy() ScopePolicy {
+ return ScopePolicy{}
+}
+
+// ConnectorID is empty: the connector is recorded on the stored auth code and
+// was already authorized at /auth. The grant resolves it (without re-running the
+// invariant) only to decide on a refresh token, inside ExchangeAuthCode.
+func (g *authorizationCode) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ return "", nil
+}
+
+// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3
+func (g *authorizationCode) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ if req.Code == "" {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Required param: code.", Status: http.StatusBadRequest}
+ }
+
+ authCode, err := g.storage.GetAuthCode(ctx, req.Code)
+ if err != nil || g.now().After(authCode.Expiry) || authCode.ClientID != client.ID {
+ if err != nil && err != storage.ErrNotFound {
+ g.logger.ErrorContext(ctx, "failed to get auth code", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+ return nil, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid or expired code parameter.", Status: http.StatusBadRequest}
+ }
+
+ if oerr := verifyPKCE(req.CodeVerifier, authCode.PKCE); oerr != nil {
+ return nil, oerr
+ }
+
+ if authCode.RedirectURI != req.RedirectURI {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "redirect_uri did not match URI from initial request.", Status: http.StatusBadRequest}
+ }
+
+ auth, withRefresh, err := ExchangeAuthCode(ctx, g.storage, g.connectors, g.logger, authCode, client)
+ if err != nil {
+ return nil, err
+ }
+ return issueTokens(ctx, g.logger, g.issuer, auth, authCode.ID, withRefresh)
+}
+
+// ExchangeAuthCode consumes a validated authorization code and returns the
+// authorization to issue tokens for and whether a refresh token is warranted. The
+// caller then mints the tokens, binding authCode.ID into c_hash. It is shared by
+// the authorization_code grant and the device flow, which both redeem an auth
+// code for tokens.
+//
+// DeleteAuthCode is the atomic single-use gate: it serializes concurrent
+// redemptions of the same code, so a second request finds the code already gone
+// and is rejected โ a code yields tokens at most once. Consuming it before
+// minting means a signing failure afterwards leaves the code spent, which is the
+// right trade: replay safety over a retry on a rare signer outage.
+func ExchangeAuthCode(ctx context.Context, s storage.Storage, conns *connectors.Cache, logger *slog.Logger, authCode storage.AuthCode, client storage.Client) (tokens.Authorization, bool, error) {
+ if err := s.DeleteAuthCode(ctx, authCode.ID); err != nil {
+ if err == storage.ErrNotFound {
+ return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid or expired code parameter.", Status: http.StatusBadRequest}
+ }
+ logger.ErrorContext(ctx, "failed to delete auth code", "err", err)
+ return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+
+ auth := tokens.Authorization{
+ Client: client,
+ Claims: authCode.Claims,
+ Scopes: authCode.Scopes,
+ ConnectorID: authCode.ConnectorID,
+ Nonce: authCode.Nonce,
+ AuthTime: authCode.AuthTime,
+ ConnectorData: authCode.ConnectorData,
+
+ // Stamped on the code while the browser was still here: the token endpoint
+ // has no cookie to consult, and resolving the session from the user would
+ // hand this token whichever session that user has open elsewhere. A code
+ // redeemed after its session ended still names it, which is what the sid
+ // means โ where the token came from. Whether that token is good for
+ // anything is the session check in introspection and refresh.
+ SessionID: authCode.SessionID,
+ }
+
+ // A refresh token is only issued when the connector supports it, the grant
+ // type is allowed and offline_access was requested (RFC 6749 ยง1.5).
+ conn, err := conns.Get(ctx, authCode.ConnectorID)
+ if err != nil {
+ logger.ErrorContext(ctx, "connector not found", "connector_id", authCode.ConnectorID, "err", err)
+ return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+
+ return auth, shouldIssueRefreshToken(conn, authCode.Scopes), nil
+}
diff --git a/server/grants/clientcredentials.go b/server/grants/clientcredentials.go
new file mode 100644
index 0000000000..fbdca60b13
--- /dev/null
+++ b/server/grants/clientcredentials.go
@@ -0,0 +1,82 @@
+package grants
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// clientCredentials serves the client_credentials grant: a confidential client
+// obtains tokens for itself, with no user involved.
+type clientCredentials struct {
+ issuer *tokens.Issuer
+ logger *slog.Logger
+}
+
+func (g *clientCredentials) GrantType() string {
+ return oauth2.GrantTypeClientCredentials
+}
+
+func (g *clientCredentials) RequiresClientAuth() bool {
+ return true
+}
+
+var clientCredentialsScopePolicy = ScopePolicy{
+ Standard: map[string]bool{
+ tokens.ScopeOpenID: true,
+ tokens.ScopeEmail: true,
+ tokens.ScopeProfile: true,
+ tokens.ScopeGroups: true,
+ },
+ Rejected: map[string]string{
+ tokens.ScopeOfflineAccess: "client_credentials grant does not support offline_access scope.",
+ tokens.ScopeFederatedID: "client_credentials grant does not support federated:id scope.",
+ },
+ ErrorType: oauth2.InvalidScope,
+}
+
+func (g *clientCredentials) ScopePolicy() ScopePolicy {
+ return clientCredentialsScopePolicy
+}
+
+// ConnectorID is empty: client_credentials involves no connector.
+func (g *clientCredentials) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ return "", nil
+}
+
+func (g *clientCredentials) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ // client_credentials requires a confidential client.
+ if client.Public {
+ return nil, &oauth2.Error{Type: oauth2.UnauthorizedClient, Description: "Public clients cannot use client_credentials grant.", Status: http.StatusBadRequest}
+ }
+
+ // Build claims from the client itself โ no user involved.
+ claims := storage.Claims{UserID: client.ID}
+ for _, scope := range req.Scopes {
+ switch scope {
+ case tokens.ScopeProfile:
+ claims.Username = client.Name
+ claims.PreferredUsername = client.Name
+ case tokens.ScopeGroups:
+ if client.ClientCredentialsClaims != nil {
+ claims.Groups = client.ClientCredentialsClaims.Groups
+ }
+ }
+ }
+
+ auth := tokens.Authorization{
+ Client: client,
+ Claims: claims,
+ Scopes: req.Scopes,
+ // Empty connector ID is unique for client credentials grant. Creating
+ // connectors with an empty ID via the config and API is prohibited.
+ ConnectorID: "",
+ Nonce: req.Nonce,
+ }
+ return issueTokens(ctx, g.logger, g.issuer, auth, "", false)
+}
diff --git a/server/grants/devicecode.go b/server/grants/devicecode.go
new file mode 100644
index 0000000000..81a5ef446f
--- /dev/null
+++ b/server/grants/devicecode.go
@@ -0,0 +1,104 @@
+package grants
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/storage"
+)
+
+// deviceCode serves the RFC 8628 device_code grant: the device polls for the
+// token minted and stored by the browser callback once the user authorizes it.
+// It issues nothing itself โ a Minter returning the stored token โ and drives the
+// authorization_pending / slow_down polling protocol.
+type deviceCode struct {
+ storage storage.Storage
+ now func() time.Time
+ logger *slog.Logger
+}
+
+func (g *deviceCode) GrantType() string {
+ return oauth2.GrantTypeDeviceCode
+}
+
+// RequiresClientAuth is false: the device is identified by the device code, not
+// client credentials.
+func (g *deviceCode) RequiresClientAuth() bool {
+ return false
+}
+
+func (g *deviceCode) ScopePolicy() ScopePolicy {
+ return ScopePolicy{}
+}
+
+func (g *deviceCode) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ return "", nil
+}
+
+func (g *deviceCode) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ if req.DeviceCode == "" {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No device code received", Status: http.StatusBadRequest}
+ }
+
+ now := g.now()
+ deviceToken, err := g.storage.GetDeviceToken(ctx, req.DeviceCode)
+ if err != nil {
+ if err != storage.ErrNotFound {
+ g.logger.ErrorContext(ctx, "failed to get device code", "err", err)
+ }
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Invalid Device code.", Status: http.StatusBadRequest}
+ }
+ if now.After(deviceToken.Expiry) {
+ return nil, &oauth2.Error{Type: oauth2.DeviceTokenExpired, Status: http.StatusBadRequest}
+ }
+
+ // Rate limiting: increase the poll interval until the device waits long enough.
+ slowDown := false
+ pollInterval := deviceToken.PollIntervalSeconds
+ if now.Before(deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval))) {
+ slowDown = true
+ pollInterval += 5
+ } else {
+ pollInterval = 5
+ }
+
+ switch deviceToken.Status {
+ case oauth2.DeviceTokenPending:
+ updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
+ old.PollIntervalSeconds = pollInterval
+ old.LastRequestTime = now
+ return old, nil
+ }
+ if err := g.storage.UpdateDeviceToken(ctx, req.DeviceCode, updater); err != nil {
+ g.logger.ErrorContext(ctx, "failed to update device token", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+ if slowDown {
+ return nil, &oauth2.Error{Type: oauth2.DeviceTokenSlowDown, Status: http.StatusBadRequest}
+ }
+ return nil, &oauth2.Error{Type: oauth2.DeviceTokenPending, Status: http.StatusBadRequest}
+
+ case oauth2.DeviceTokenComplete:
+ if oerr := verifyPKCE(req.CodeVerifier, deviceToken.PKCE); oerr != nil {
+ return nil, oerr
+ }
+ // The token was minted and stored by the browser callback; relay it verbatim.
+ return storedResponse(deviceToken.Token), nil
+
+ default:
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Invalid Device code.", Status: http.StatusBadRequest}
+ }
+}
+
+// storedResponse writes an already-serialized token response verbatim.
+type storedResponse string
+
+func (s storedResponse) Write(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ _, err := w.Write([]byte(s))
+ return err
+}
diff --git a/server/grants/doc.go b/server/grants/doc.go
new file mode 100644
index 0000000000..e73e6cb199
--- /dev/null
+++ b/server/grants/doc.go
@@ -0,0 +1,5 @@
+// Package grants implements the OAuth2 token endpoint (/token). It defines a
+// Grant abstraction โ one handler per grant_type โ and a Handler that
+// dispatches a token request to the grant registered for its grant_type,
+// authenticating the client first when the grant requires it.
+package grants
diff --git a/server/grants/grants.go b/server/grants/grants.go
new file mode 100644
index 0000000000..04eba65cf5
--- /dev/null
+++ b/server/grants/grants.go
@@ -0,0 +1,404 @@
+package grants
+
+import (
+ "context"
+ "crypto/subtle"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// Request is the parsed token-endpoint request. Every field the grants read is
+// parsed once, here, so a grant never reaches into the raw form.
+type Request struct {
+ ClientID string
+ ClientSecret string
+ Scopes []string
+ Nonce string
+ ConnectorID string
+
+ // authorization_code
+ Code string
+ RedirectURI string
+ CodeVerifier string
+
+ // refresh_token
+ RefreshToken string
+
+ // device_code
+ DeviceCode string
+
+ // password
+ Username string
+ Password string
+
+ // token exchange (RFC 8693)
+ SubjectToken string
+ SubjectTokenType string
+ RequestedTokenType string
+
+ // refresh holds the refresh token the refresh grant looks up while resolving
+ // the connector, so it is fetched and decoded once and reused in Authorize.
+ refresh *storage.RefreshToken
+ refreshID *internal.RefreshToken
+}
+
+// parseRequest reads the whole token request form once. Client credentials come
+// from the Authorization header when present, otherwise from the form.
+func parseRequest(r *http.Request) (*Request, *oauth2.Error) {
+ if err := r.ParseForm(); err != nil {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusBadRequest}
+ }
+
+ req := &Request{
+ Scopes: strings.Fields(r.PostFormValue("scope")),
+ Nonce: r.PostFormValue("nonce"),
+ ConnectorID: r.PostFormValue("connector_id"),
+ DeviceCode: r.PostFormValue("device_code"),
+ Code: r.PostFormValue("code"),
+ RedirectURI: r.PostFormValue("redirect_uri"),
+ CodeVerifier: r.PostFormValue("code_verifier"),
+ RefreshToken: r.PostFormValue("refresh_token"),
+ Username: r.PostFormValue("username"),
+ Password: r.PostFormValue("password"),
+ SubjectToken: r.PostFormValue("subject_token"),
+ SubjectTokenType: r.PostFormValue("subject_token_type"),
+ RequestedTokenType: r.PostFormValue("requested_token_type"),
+ }
+
+ if id, secret, ok := r.BasicAuth(); ok {
+ var err error
+ if req.ClientID, err = url.QueryUnescape(id); err != nil {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "client_id improperly encoded", Status: http.StatusBadRequest}
+ }
+ if req.ClientSecret, err = url.QueryUnescape(secret); err != nil {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "client_secret improperly encoded", Status: http.StatusBadRequest}
+ }
+ } else {
+ req.ClientID = r.PostFormValue("client_id")
+ req.ClientSecret = r.PostFormValue("client_secret")
+ }
+
+ return req, nil
+}
+
+// Responder writes the token endpoint's HTTP response body. tokens.Response is
+// the usual one; a grant that returns an already-serialized body (device_code
+// relays the token stored by the browser callback) returns its own.
+type Responder interface {
+ Write(w http.ResponseWriter) error
+}
+
+// Grant serves one OAuth2 grant type at the token endpoint. It is a set of hooks
+// the Handler calls in order โ the shared phases (client auth, scope validation,
+// connector resolution, response writing) live on the Handler, so a grant only
+// fills in the parts unique to it and cannot forget a shared step.
+type Grant interface {
+ // GrantType is the grant_type value this grant serves.
+ GrantType() string
+ // RequiresClientAuth reports whether the endpoint must authenticate the
+ // client before the request is processed.
+ RequiresClientAuth() bool
+ // ScopePolicy reports how the endpoint validates the requested scopes for
+ // this grant.
+ ScopePolicy() ScopePolicy
+ // ConnectorID is the connector this grant authenticates against; the endpoint
+ // resolves it and enforces the connector-authorization invariant (client
+ // allows it, connector allows the grant type) before Authorize. The grant may
+ // read it from the request or look it up in storage; returning an error
+ // rejects the request. Returning "" skips the step โ for a grant that uses no
+ // connector (client_credentials, device_code), or one already authorized
+ // elsewhere (authorization_code was gated at /auth and resolves its connector
+ // inside Authorize only to decide on a refresh token).
+ ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error)
+ // Authorize proves the identity against conn (the zero Connector when
+ // ConnectorID is "") and produces the response to write. Standard grants build
+ // it with the shared issueTokens helper; a grant with a non-standard response
+ // builds its own. Returning an *oauth2.Error makes the endpoint write it.
+ Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error)
+}
+
+// ScopePolicy configures the shared scope-validation phase for a grant. It is
+// the single place scope rules are enforced, so no grant re-implements the
+// cross-client trust check or scope filtering.
+type ScopePolicy struct {
+ // Standard is the set of standard (non cross-client) scopes the grant
+ // accepts. When nil, scopes are passed through unvalidated (token exchange).
+ Standard map[string]bool
+ // RequireOpenID rejects the request when the openid scope is absent.
+ RequireOpenID bool
+ // Rejected maps an explicitly refused scope to its rejection message.
+ Rejected map[string]string
+ // ErrorType is the OAuth2 error code returned for scope violations.
+ ErrorType string
+}
+
+// Handler is the /token endpoint. It owns the phases shared by every grant โ
+// dispatch by grant_type, client authentication, scope validation, connector
+// resolution and writing the response or error โ while each grant carries only
+// its own narrow dependencies. It mounts its own routes (router.Handler).
+type Handler struct {
+ Issuer *tokens.Issuer
+ Storage storage.Storage
+ Connectors *connectors.Cache
+ Now func() time.Time
+ Logger *slog.Logger
+ PasswordConnector string
+ RefreshPolicy *tokens.RefreshStrategy
+ Sessions *session.Manager
+ SessionsEnabled bool
+ SupportedGrantTypes []string
+
+ grants map[string]Grant
+}
+
+func (h *Handler) register(supported []string, gs ...Grant) {
+ for _, g := range gs {
+ if slices.Contains(supported, g.GrantType()) {
+ h.grants[g.GrantType()] = g
+ }
+ }
+}
+
+// Mount wires the endpoint's grants and registers the token route. Only grants
+// whose type is in SupportedGrantTypes are registered, so a grant type disabled
+// by config is simply not served.
+func (h *Handler) Mount(m router.Mux) {
+ h.grants = map[string]Grant{}
+ h.register(h.SupportedGrantTypes,
+ &clientCredentials{issuer: h.Issuer, logger: h.Logger},
+ &password{issuer: h.Issuer, logger: h.Logger, connectorID: h.PasswordConnector},
+ &tokenExchange{issuer: h.Issuer, logger: h.Logger},
+ &authorizationCode{issuer: h.Issuer, storage: h.Storage, connectors: h.Connectors, now: h.Now, logger: h.Logger},
+ &refresh{storage: h.Storage, issuer: h.Issuer, policy: h.RefreshPolicy, sessions: h.Sessions, sessionsEnabled: h.SessionsEnabled, now: h.Now, logger: h.Logger},
+ &deviceCode{storage: h.Storage, now: h.Now, logger: h.Logger},
+ )
+ m.HandleCORS("/token", h.handleToken)
+}
+
+// handleToken serves /token: it validates the request shape and dispatches to the
+// grant for its grant_type.
+func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method != http.MethodPost {
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "method not allowed", Status: http.StatusBadRequest})
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ h.Logger.ErrorContext(ctx, "could not parse request body", "err", err)
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusBadRequest})
+ return
+ }
+
+ grantType := r.PostFormValue("grant_type")
+ if !h.dispatch(w, r, grantType) {
+ h.Logger.ErrorContext(ctx, "unsupported grant type", "grant_type", grantType)
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.UnsupportedGrantType, Status: http.StatusBadRequest})
+ }
+}
+
+// dispatch runs the token-endpoint pipeline for the grant registered for
+// grantType. It reports whether a grant handled the request, so the caller can
+// fall back (e.g. the implicit grant, which is not a token-endpoint grant).
+func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, grantType string) bool {
+ grant, ok := h.grants[grantType]
+ if !ok {
+ return false
+ }
+
+ ctx := r.Context()
+ req, oerr := parseRequest(r)
+ if oerr != nil {
+ h.writeError(ctx, w, oerr)
+ return true
+ }
+
+ // 1. Authenticate the client.
+ client := storage.Client{}
+ if grant.RequiresClientAuth() {
+ client, ok = h.authenticateClient(ctx, w, req)
+ if !ok {
+ return true
+ }
+ }
+
+ // 2. Validate the requested scopes.
+ if oerr := h.validateScopes(ctx, client, req, grant.ScopePolicy()); oerr != nil {
+ h.writeError(ctx, w, oerr)
+ return true
+ }
+
+ // 3. Resolve the grant's connector and enforce the connector-authorization
+ // invariant. A grant that uses no connector resolves to the zero Connector.
+ connID, oerr := grant.ConnectorID(ctx, req, client)
+ if oerr != nil {
+ h.writeError(ctx, w, oerr)
+ return true
+ }
+ conn, oerr := h.resolveConnector(ctx, connID, client, grant.GrantType())
+ if oerr != nil {
+ h.writeError(ctx, w, oerr)
+ return true
+ }
+
+ // 4. Let the grant prove the identity and produce the response.
+ resp, err := grant.Authorize(ctx, req, client, conn)
+ if err != nil {
+ h.writeError(ctx, w, err)
+ return true
+ }
+
+ // 5. Write the response.
+ if err := resp.Write(w); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to write token response", "err", err)
+ }
+ return true
+}
+
+// validateScopes validates the requested scopes per the grant's policy: it
+// rejects refused scopes, filters unknown ones, enforces openid when required,
+// and verifies cross-client trust โ the security-sensitive check that must run
+// for every grant. A nil policy set passes scopes through unvalidated.
+func (h *Handler) validateScopes(ctx context.Context, client storage.Client, req *Request, p ScopePolicy) *oauth2.Error {
+ if p.Standard == nil {
+ return nil
+ }
+
+ var unrecognized, invalid []string
+ for _, scope := range req.Scopes {
+ if msg, refused := p.Rejected[scope]; refused {
+ return &oauth2.Error{Type: p.ErrorType, Description: msg, Status: http.StatusBadRequest}
+ }
+ if p.Standard[scope] {
+ continue
+ }
+
+ peerID, ok := tokens.ParseCrossClientScope(scope)
+ if !ok {
+ unrecognized = append(unrecognized, scope)
+ continue
+ }
+ trusted, err := tokens.CrossClientTrusted(ctx, h.Storage, client.ID, peerID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err)
+ return &oauth2.Error{Type: oauth2.InvalidClient, Description: "Error validating cross client trust.", Status: http.StatusBadRequest}
+ }
+ if !trusted {
+ invalid = append(invalid, scope)
+ }
+ }
+
+ if p.RequireOpenID && !tokens.HasOpenID(req.Scopes) {
+ return &oauth2.Error{Type: p.ErrorType, Description: `Missing required scope(s) ["openid"].`, Status: http.StatusBadRequest}
+ }
+ if len(unrecognized) > 0 {
+ return oauth2.Errorf(p.ErrorType, http.StatusBadRequest, "Unrecognized scope(s) %q", unrecognized)
+ }
+ if len(invalid) > 0 {
+ return oauth2.Errorf(p.ErrorType, http.StatusBadRequest, "Client can't request scope(s) %q", invalid)
+ }
+ return nil
+}
+
+// resolveConnector enforces the connector-authorization invariant and returns the
+// opened connector: the client must allow the connector, and the connector must
+// permit the grant type. connID == "" (a grant that uses no connector) resolves
+// to the zero Connector. Running here, before Authorize, means no grant can
+// forget the check.
+func (h *Handler) resolveConnector(ctx context.Context, connID string, client storage.Client, grantType string) (connectors.Connector, *oauth2.Error) {
+ if connID == "" {
+ return connectors.Connector{}, nil
+ }
+
+ if !connectors.ConnectorAllowed(client.AllowedConnectors, connID) {
+ h.Logger.WarnContext(ctx, "connector not allowed for client", "client_id", client.ID, "connector_id", connID)
+ return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Connector not allowed for this client.", Status: http.StatusBadRequest}
+ }
+ conn, err := h.Connectors.Get(ctx, connID)
+ if err != nil {
+ h.Logger.ErrorContext(ctx, "failed to get connector", "connector_id", connID, "err", err)
+ return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not exist.", Status: http.StatusBadRequest}
+ }
+ if !connectors.GrantTypeAllowed(conn.GrantTypes, grantType) {
+ h.Logger.ErrorContext(ctx, "connector does not allow grant", "connector_id", connID, "grant_type", grantType)
+ return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not support this grant type.", Status: http.StatusBadRequest}
+ }
+ return conn, nil
+}
+
+// authenticateClient resolves the client from the parsed credentials. On failure
+// it writes the error response and returns ok=false.
+func (h *Handler) authenticateClient(ctx context.Context, w http.ResponseWriter, req *Request) (storage.Client, bool) {
+ client, err := h.Storage.GetClient(ctx, req.ClientID)
+ if err != nil {
+ if err != storage.ErrNotFound {
+ h.Logger.ErrorContext(ctx, "failed to get client", "err", err)
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError})
+ } else {
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidClient, Description: "Invalid client credentials.", Status: http.StatusUnauthorized})
+ }
+ return storage.Client{}, false
+ }
+
+ if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(req.ClientSecret)) != 1 {
+ if req.ClientSecret == "" {
+ h.Logger.InfoContext(ctx, "missing client_secret on token request", "client_id", client.ID)
+ } else {
+ h.Logger.InfoContext(ctx, "invalid client_secret on token request", "client_id", client.ID)
+ }
+ h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidClient, Description: "Invalid client credentials.", Status: http.StatusUnauthorized})
+ return storage.Client{}, false
+ }
+
+ return client, true
+}
+
+// writeError writes err as an OAuth2 error response. An *oauth2.Error carries its
+// own type/description/status; anything else is reported as a server error.
+func (h *Handler) writeError(ctx context.Context, w http.ResponseWriter, err error) {
+ var oerr *oauth2.Error
+ if !errors.As(err, &oerr) || oerr == nil {
+ h.Logger.ErrorContext(ctx, "token request failed", "err", err)
+ oerr = &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+ oauth2.WriteErrorResponse(h.Logger, w, oerr.Type, oerr.Description, oerr.Status)
+}
+
+// issue mints the standard token response โ the single mint every standard grant
+// shares โ logging and mapping a signing failure to a server error. code is the
+// authorization code bound into the ID token's c_hash, empty when there is none.
+func issueTokens(ctx context.Context, logger *slog.Logger, issuer *tokens.Issuer, auth tokens.Authorization, code string, withRefresh bool) (Responder, error) {
+ resp, err := issuer.IssueResponse(ctx, auth, code, withRefresh)
+ if err != nil {
+ logger.ErrorContext(ctx, "failed to issue tokens", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+ return resp, nil
+}
+
+// shouldIssueRefreshToken reports whether a refresh token should be issued: the
+// connector supports refresh, the connector permits the refresh_token grant, and
+// offline_access was requested. A refresh token is never mandatory (RFC 6749 ยง1.5).
+func shouldIssueRefreshToken(conn connectors.Connector, scopes []string) bool {
+ if _, ok := conn.Connector.(connector.RefreshConnector); !ok {
+ return false
+ }
+ if !connectors.GrantTypeAllowed(conn.GrantTypes, oauth2.GrantTypeRefreshToken) {
+ return false
+ }
+ return slices.Contains(scopes, tokens.ScopeOfflineAccess)
+}
diff --git a/server/grants/password.go b/server/grants/password.go
new file mode 100644
index 0000000000..1442f86684
--- /dev/null
+++ b/server/grants/password.go
@@ -0,0 +1,77 @@
+package grants
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// password serves the Resource Owner Password Credentials grant: the client
+// exchanges a username and password for tokens via a password-capable connector.
+type password struct {
+ issuer *tokens.Issuer
+ logger *slog.Logger
+ connectorID string
+}
+
+func (g *password) GrantType() string {
+ return oauth2.GrantTypePassword
+}
+
+func (g *password) RequiresClientAuth() bool {
+ return true
+}
+
+var passwordScopePolicy = ScopePolicy{
+ Standard: map[string]bool{
+ tokens.ScopeOpenID: true,
+ tokens.ScopeOfflineAccess: true,
+ tokens.ScopeEmail: true,
+ tokens.ScopeProfile: true,
+ tokens.ScopeGroups: true,
+ tokens.ScopeFederatedID: true,
+ },
+ RequireOpenID: true,
+ ErrorType: oauth2.InvalidRequest,
+}
+
+func (g *password) ScopePolicy() ScopePolicy {
+ return passwordScopePolicy
+}
+
+// ConnectorID is the connector the password grant is configured to use.
+func (g *password) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ return g.connectorID, nil
+}
+
+func (g *password) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ passwordConnector, ok := conn.Connector.(connector.PasswordConnector)
+ if !ok {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested password connector does not correct type.", Status: http.StatusBadRequest}
+ }
+
+ identity, ok, err := passwordConnector.Login(ctx, tokens.ParseScopes(req.Scopes), req.Username, req.Password)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to login user", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Could not login user", Status: http.StatusBadRequest}
+ }
+ if !ok {
+ return nil, &oauth2.Error{Type: oauth2.AccessDenied, Description: "Invalid username or password", Status: http.StatusUnauthorized}
+ }
+
+ auth := tokens.Authorization{
+ Client: client,
+ Claims: tokens.ClaimsFromIdentity(identity),
+ Scopes: req.Scopes,
+ ConnectorID: g.connectorID,
+ Nonce: req.Nonce,
+ ConnectorData: identity.ConnectorData,
+ }
+ return issueTokens(ctx, g.logger, g.issuer, auth, "", shouldIssueRefreshToken(conn, req.Scopes))
+}
diff --git a/server/grants/pkce.go b/server/grants/pkce.go
new file mode 100644
index 0000000000..a505809a38
--- /dev/null
+++ b/server/grants/pkce.go
@@ -0,0 +1,31 @@
+package grants
+
+import (
+ "net/http"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/storage"
+)
+
+// verifyPKCE checks a code_verifier against a stored PKCE challenge (RFC 7636).
+// It is the single PKCE check, shared by the authorization_code and device_code
+// grants, which redeem a code challenge stored at /auth.
+func verifyPKCE(codeVerifier string, pkce storage.PKCE) *oauth2.Error {
+ switch {
+ case codeVerifier != "" && pkce.CodeChallenge != "":
+ calculated, err := oauth2.CalculateCodeChallenge(codeVerifier, pkce.CodeChallengeMethod)
+ if err != nil {
+ return &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+ if pkce.CodeChallenge != calculated {
+ return &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid code_verifier.", Status: http.StatusBadRequest}
+ }
+ case codeVerifier != "":
+ // No code_challenge on /auth, but a code_verifier on /token.
+ return &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No PKCE flow started. Cannot check code_verifier.", Status: http.StatusBadRequest}
+ case pkce.CodeChallenge != "":
+ // PKCE started on /auth, but no code_verifier on /token.
+ return &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Expecting parameter code_verifier in PKCE flow.", Status: http.StatusBadRequest}
+ }
+ return nil
+}
diff --git a/server/grants/refresh.go b/server/grants/refresh.go
new file mode 100644
index 0000000000..300d93d5f8
--- /dev/null
+++ b/server/grants/refresh.go
@@ -0,0 +1,307 @@
+package grants
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "slices"
+ "time"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/internal"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// refresh serves the refresh_token grant: it validates and rotates a refresh
+// token, re-reads the identity (from the session or the upstream connector) and
+// issues a fresh token set. Its response reuses the rotated refresh token rather
+// than minting a new one, so it mints its own instead of the standard Issue.
+type refresh struct {
+ sessions *session.Manager
+ storage storage.Storage
+ issuer *tokens.Issuer
+ policy *tokens.RefreshStrategy
+ sessionsEnabled bool
+ now func() time.Time
+ logger *slog.Logger
+}
+
+func (g *refresh) GrantType() string {
+ return oauth2.GrantTypeRefreshToken
+}
+
+func (g *refresh) RequiresClientAuth() bool {
+ return true
+}
+
+// Scopes are validated against the token's originally authorized scopes in
+// Authorize, not against a fixed set, so the shared phase passes them through.
+func (g *refresh) ScopePolicy() ScopePolicy {
+ return ScopePolicy{}
+}
+
+// ConnectorID validates the refresh token and reports the connector recorded on
+// it, so the endpoint resolves and re-checks that connector on every refresh: a
+// client's allowed connectors, or a connector's grant types, may have been
+// tightened after the token was issued. The looked-up and decoded token is
+// stashed on the request so Authorize reuses it without a second lookup or parse.
+func (g *refresh) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ token, oerr := parseRefreshToken(req.RefreshToken)
+ if oerr != nil {
+ return "", oerr
+ }
+
+ refreshToken, err := tokens.LookupRefreshToken(ctx, g.storage, g.policy, g.logger, &client.ID, token)
+ if err != nil {
+ return "", refreshLookupError(err)
+ }
+
+ req.refresh, req.refreshID = refreshToken, token
+ return refreshToken.ConnectorID, nil
+}
+
+// Authorize rotates the refresh token, re-reads the identity against the resolved
+// connector, and returns the token set โ reusing the rotated refresh token, so it
+// mints its own response rather than the standard set (which would mint a second
+// refresh token).
+func (g *refresh) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ refreshToken := req.refresh
+
+ scopes, oerr := g.refreshScopes(req, refreshToken)
+ if oerr != nil {
+ return nil, oerr
+ }
+
+ // Resolved before anything is rotated or read from the connector: a token whose
+ // session has ended is not going to produce a token set. Skipped outright when
+ // sessions are off โ nothing was ever bound to one, so the read would only
+ // confirm that, and refusing a refresh over it would be indefensible.
+ var sessionID string
+ if g.sessionsEnabled {
+ var oerr *oauth2.Error
+ if sessionID, oerr = g.sessionID(ctx, refreshToken, client); oerr != nil {
+ return nil, oerr
+ }
+ }
+
+ var userIdent *storage.UserIdentity
+ if g.sessionsEnabled {
+ ui, err := g.storage.GetUserIdentity(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to get user identity", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError}
+ }
+ userIdent = &ui
+ }
+
+ authTime := time.Time{}
+ if userIdent != nil {
+ authTime = userIdent.LastLogin
+ }
+
+ // When sessions are enabled, downstream refresh is disconnected from the
+ // upstream provider: use the claims cached in UserIdentity at the last login
+ // instead of contacting the connector (which may fail if the upstream token
+ // has expired). Otherwise re-read the identity from the connector.
+ freshIdentity := func(ctx context.Context) (connector.Identity, error) {
+ if userIdent != nil {
+ return tokens.IdentityFromClaims(userIdent.Claims), nil
+ }
+ connectorData, err := g.refreshConnectorData(ctx, refreshToken)
+ if err != nil {
+ return connector.Identity{}, err
+ }
+ return g.refreshWithConnector(ctx, conn, connectorData, scopes, tokens.IdentityFromClaims(refreshToken.Claims))
+ }
+
+ rawNewToken, ident, err := g.issuer.Refresh.Rotate(ctx, refreshToken, req.refreshID, g.policy, freshIdentity)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to rotate refresh token", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError}
+ }
+
+ auth := tokens.Authorization{
+ Client: client,
+ Claims: tokens.ClaimsFromIdentity(ident),
+ Scopes: scopes,
+ ConnectorID: refreshToken.ConnectorID,
+ Nonce: refreshToken.Nonce,
+ AuthTime: authTime,
+ SessionID: sessionID,
+ }
+
+ accessToken, _, err := g.issuer.SignAccessToken(ctx, auth)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to create new access token", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError}
+ }
+ idToken, expiry, err := g.issuer.SignIDToken(ctx, auth, accessToken, "")
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to create ID token", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError}
+ }
+
+ ts := tokens.TokenSet{AccessToken: accessToken, IDToken: idToken, RefreshToken: rawNewToken, Expiry: expiry}
+ return ts.Response(g.now()), nil
+}
+
+// sessionID returns the sid for the refreshed tokens, and refuses the refresh when
+// the session has ended and the client asked its tokens to end with it.
+//
+// The sid names where a token came from and is carried across refreshes unchanged,
+// dead session or not; stripping it would make the token active again at the next
+// refresh, undoing what introspection just reported. Whether the token is still good
+// for anything is the client's RefreshTokenLifetime, read the same way here and in
+// introspection (see sessionAlive in server/introspection).
+//
+// Origin comes from the stored reference and nowhere else: a token minted outside a
+// browser flow has none and must not acquire one from whatever session its user
+// happens to have open.
+func (g *refresh) sessionID(ctx context.Context, refreshToken *storage.RefreshToken, client storage.Client) (string, *oauth2.Error) {
+ bound := client.RefreshBoundToSession()
+
+ offlineSessions, err := g.storage.GetOfflineSessions(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID)
+ if err != nil {
+ if !errors.Is(err, storage.ErrNotFound) {
+ g.logger.ErrorContext(ctx, "refresh: failed to read offline session for sid", "err", err)
+ }
+ if bound {
+ // Nothing to check the token against. For a standalone token that costs
+ // only its sid, which can make it look less bound than it is but never
+ // more; for a bound one it would mean handing out a token whose whole
+ // validity rests on a session nobody could read.
+ return "", sessionEndedError()
+ }
+ return "", nil
+ }
+
+ ref, ok := offlineSessions.Refresh[refreshToken.ClientID]
+ if !ok || ref.SessionID == "" {
+ // Issued outside a browser flow โ the password grant, or before sessions were
+ // turned on. There is no session to be bound to, so there is none to end.
+ return "", nil
+ }
+
+ if !bound {
+ return ref.SessionID, nil
+ }
+
+ if !g.sessions.Alive(ctx, ref.SessionID) {
+ // Through the store, not storage.DeleteRefresh: the token and the offline
+ // session's reference to it have to go together, or the admin API lists a
+ // token that no longer exists and fails trying to revoke it.
+ g.issuer.Refresh.RevokeClients(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID,
+ []string{refreshToken.ClientID})
+
+ g.logger.InfoContext(ctx, "refresh: refused, session ended",
+ "client_id", refreshToken.ClientID, "user_id", refreshToken.Claims.UserID)
+ return "", sessionEndedError()
+ }
+ return ref.SessionID, nil
+}
+
+// sessionEndedError reports a refused refresh as invalid_grant, the one code RFC
+// 6749 ยง5.2 has for a refresh token that is no longer good for anything. The client
+// owns the session in question, so the description names the reason.
+func sessionEndedError() *oauth2.Error {
+ return &oauth2.Error{
+ Type: oauth2.InvalidGrant,
+ Description: "The session this refresh token belongs to has ended.",
+ Status: http.StatusBadRequest,
+ }
+}
+
+// refreshScopes resolves the scopes for this refresh. Per RFC 6749 ยง6 the client
+// may omit them (defaulting to the originally authorized scopes) but may not
+// widen them.
+func (g *refresh) refreshScopes(req *Request, refreshToken *storage.RefreshToken) ([]string, *oauth2.Error) {
+ if len(req.Scopes) == 0 {
+ return refreshToken.Scopes, nil
+ }
+
+ var unauthorized []string
+ for _, scope := range req.Scopes {
+ if !slices.Contains(refreshToken.Scopes, scope) {
+ unauthorized = append(unauthorized, scope)
+ }
+ }
+ if len(unauthorized) > 0 {
+ return nil, oauth2.Errorf(oauth2.InvalidRequest, http.StatusBadRequest, "Requested scopes contain unauthorized scope(s): %q.", unauthorized)
+ }
+ return req.Scopes, nil
+}
+
+// refreshConnectorData returns the connector data for the upstream refresh: the
+// token's own data for legacy tokens that still carry it, otherwise the value on
+// the user's offline session.
+func (g *refresh) refreshConnectorData(ctx context.Context, refreshToken *storage.RefreshToken) ([]byte, error) {
+ if len(refreshToken.ConnectorData) > 0 {
+ return refreshToken.ConnectorData, nil
+ }
+
+ session, err := g.storage.GetOfflineSessions(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID)
+ if err != nil {
+ if err != storage.ErrNotFound {
+ g.logger.ErrorContext(ctx, "failed to get offline session", "err", err)
+ return nil, err
+ }
+ return nil, nil
+ }
+ return session.ConnectorData, nil
+}
+
+// refreshWithConnector re-reads the identity from the upstream connector when it
+// supports refreshing.
+func (g *refresh) refreshWithConnector(ctx context.Context, conn connectors.Connector, connectorData []byte, scopes []string, ident connector.Identity) (connector.Identity, error) {
+ refreshConn, ok := conn.Connector.(connector.RefreshConnector)
+ if !ok {
+ return ident, nil
+ }
+
+ ident.ConnectorData = connectorData
+ g.logger.Debug("connector data before refresh", "connector_data", ident.ConnectorData)
+
+ newIdent, err := refreshConn.Refresh(ctx, tokens.ParseScopes(scopes), ident)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to refresh identity", "err", err)
+ return ident, err
+ }
+ return newIdent, nil
+}
+
+// parseRefreshToken decodes the refresh_token parameter, tolerating the legacy
+// raw-ID form for backward compatibility.
+func parseRefreshToken(code string) (*internal.RefreshToken, *oauth2.Error) {
+ if code == "" {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No refresh token is found in request.", Status: http.StatusBadRequest}
+ }
+
+ token := new(internal.RefreshToken)
+ if err := internal.Unmarshal(code, token); err != nil {
+ // Assume a raw refresh token ID generated by an older server that has no
+ // Token value. Reuse is still rejected because Token stays empty.
+ token = &internal.RefreshToken{RefreshId: code, Token: ""}
+ }
+ return token, nil
+}
+
+// refreshLookupError maps a tokens.LookupRefreshToken sentinel to the grant's
+// OAuth2 error response.
+func refreshLookupError(err error) *oauth2.Error {
+ const claimedDesc = "Refresh token is invalid or has already been claimed by another client."
+ switch {
+ case errors.Is(err, tokens.ErrRefreshTokenInvalid):
+ return &oauth2.Error{Type: oauth2.InvalidRequest, Description: claimedDesc, Status: http.StatusBadRequest}
+ case errors.Is(err, tokens.ErrRefreshTokenClaimedByOtherClient):
+ return &oauth2.Error{Type: oauth2.InvalidGrant, Description: claimedDesc, Status: http.StatusBadRequest}
+ case errors.Is(err, tokens.ErrRefreshTokenExpired):
+ return &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Refresh token expired.", Status: http.StatusBadRequest}
+ default:
+ return &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError}
+ }
+}
diff --git a/server/grants/tokenexchange.go b/server/grants/tokenexchange.go
new file mode 100644
index 0000000000..256fcfb654
--- /dev/null
+++ b/server/grants/tokenexchange.go
@@ -0,0 +1,119 @@
+package grants
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+)
+
+// tokenExchange serves the RFC 8693 token-exchange grant: a subject token
+// (ID or access token) verified by a connector is exchanged for a new token.
+// Its response carries a single requested token plus issued_token_type, so it
+// builds its own response from the issuer primitives instead of the standard
+// Issue mint.
+type tokenExchange struct {
+ issuer *tokens.Issuer
+ logger *slog.Logger
+}
+
+func (g *tokenExchange) GrantType() string {
+ return oauth2.GrantTypeTokenExchange
+}
+
+func (g *tokenExchange) RequiresClientAuth() bool {
+ return true
+}
+
+// Scopes are passed through: for token exchange the requested scope maps to the
+// issued token's scope and is not validated against a fixed set.
+func (g *tokenExchange) ScopePolicy() ScopePolicy {
+ return ScopePolicy{}
+}
+
+// ConnectorID reads the required connector_id parameter (an RFC 8693 extension).
+func (g *tokenExchange) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) {
+ return req.ConnectorID, nil
+}
+
+func (g *tokenExchange) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) {
+ switch req.SubjectTokenType {
+ case oauth2.TokenTypeID, oauth2.TokenTypeAccess: // ok, continue
+ default:
+ return nil, &oauth2.Error{Type: oauth2.RequestNotSupported, Description: "Invalid subject_token_type.", Status: http.StatusBadRequest}
+ }
+ if req.SubjectToken == "" {
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Missing subject_token", Status: http.StatusBadRequest}
+ }
+
+ teConn, ok := conn.Connector.(connector.TokenIdentityConnector)
+ if !ok {
+ g.logger.ErrorContext(ctx, "connector doesn't implement token exchange", "connector_id", req.ConnectorID)
+ return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not exist.", Status: http.StatusBadRequest}
+ }
+ identity, err := teConn.TokenIdentity(ctx, req.SubjectTokenType, req.SubjectToken)
+ if err != nil {
+ g.logger.ErrorContext(ctx, "failed to verify subject token", "err", err)
+ return nil, &oauth2.Error{Type: oauth2.AccessDenied, Status: http.StatusUnauthorized}
+ }
+
+ email := identity.Email
+ if !identity.EmailVerified {
+ email += " (unverified)"
+ }
+ reqType := requestedTokenType(req)
+ g.logger.InfoContext(ctx, "token exchange successful",
+ "connector_id", req.ConnectorID, "client_id", client.ID,
+ "user_id", identity.UserID,
+ "username", identity.Username, "preferred_username", identity.PreferredUsername,
+ "email", email, "groups", identity.Groups,
+ "subject_token_type", req.SubjectTokenType, "requested_token_type", reqType)
+
+ auth := tokens.Authorization{
+ Client: client,
+ Claims: tokens.ClaimsFromIdentity(identity),
+ Scopes: req.Scopes,
+ ConnectorID: req.ConnectorID,
+ }
+
+ // RFC 8693 returns a single requested token plus issued_token_type, not the
+ // standard access+id+refresh set, so it signs from the issuer primitives.
+ var (
+ token string
+ expiry time.Time
+ )
+ switch reqType {
+ case oauth2.TokenTypeID:
+ token, expiry, err = g.issuer.SignIDToken(ctx, auth, "", "")
+ case oauth2.TokenTypeAccess:
+ token, expiry, err = g.issuer.SignAccessToken(ctx, auth)
+ default:
+ return nil, &oauth2.Error{Type: oauth2.RequestNotSupported, Description: "Invalid requested_token_type.", Status: http.StatusBadRequest}
+ }
+ if err != nil {
+ g.logger.ErrorContext(ctx, "token exchange failed to create new token", "requested_token_type", reqType, "err", err)
+ return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}
+ }
+
+ return tokens.Response{
+ AccessToken: token,
+ IssuedTokenType: reqType,
+ TokenType: "bearer",
+ ExpiresIn: int(time.Until(expiry).Seconds()),
+ }, nil
+}
+
+// requestedTokenType is the requested_token_type param, defaulting to an access
+// token (RFC 8693 ยง2.1).
+func requestedTokenType(req *Request) string {
+ if req.RequestedTokenType != "" {
+ return req.RequestedTokenType
+ }
+ return oauth2.TokenTypeAccess
+}
diff --git a/server/handlers.go b/server/handlers.go
deleted file mode 100755
index 5f8caf11af..0000000000
--- a/server/handlers.go
+++ /dev/null
@@ -1,1317 +0,0 @@
-package server
-
-import (
- "crypto/sha256"
- "crypto/subtle"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "html/template"
- "net/http"
- "net/url"
- "path"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "github.com/coreos/go-oidc/v3/oidc"
- "github.com/gorilla/mux"
- jose "gopkg.in/square/go-jose.v2"
-
- "github.com/dexidp/dex/connector"
- "github.com/dexidp/dex/server/internal"
- "github.com/dexidp/dex/storage"
-)
-
-const (
- codeChallengeMethodPlain = "plain"
- codeChallengeMethodS256 = "S256"
-)
-
-func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) {
- // TODO(ericchiang): Cache this.
- keys, err := s.storage.GetKeys()
- if err != nil {
- s.logger.Errorf("failed to get keys: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
- return
- }
-
- if keys.SigningKeyPub == nil {
- s.logger.Errorf("No public keys found.")
- s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
- return
- }
-
- jwks := jose.JSONWebKeySet{
- Keys: make([]jose.JSONWebKey, len(keys.VerificationKeys)+1),
- }
- jwks.Keys[0] = *keys.SigningKeyPub
- for i, verificationKey := range keys.VerificationKeys {
- jwks.Keys[i+1] = *verificationKey.PublicKey
- }
-
- data, err := json.MarshalIndent(jwks, "", " ")
- if err != nil {
- s.logger.Errorf("failed to marshal discovery data: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
- return
- }
- maxAge := keys.NextRotation.Sub(s.now())
- if maxAge < (time.Minute * 2) {
- maxAge = time.Minute * 2
- }
-
- w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds())))
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Length", strconv.Itoa(len(data)))
- w.Write(data)
-}
-
-type discovery struct {
- Issuer string `json:"issuer"`
- Auth string `json:"authorization_endpoint"`
- Token string `json:"token_endpoint"`
- Keys string `json:"jwks_uri"`
- UserInfo string `json:"userinfo_endpoint"`
- DeviceEndpoint string `json:"device_authorization_endpoint"`
- GrantTypes []string `json:"grant_types_supported"`
- ResponseTypes []string `json:"response_types_supported"`
- Subjects []string `json:"subject_types_supported"`
- IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
- CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
- Scopes []string `json:"scopes_supported"`
- AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
- Claims []string `json:"claims_supported"`
-}
-
-func (s *Server) discoveryHandler() (http.HandlerFunc, error) {
- d := discovery{
- Issuer: s.issuerURL.String(),
- Auth: s.absURL("/auth"),
- Token: s.absURL("/token"),
- Keys: s.absURL("/keys"),
- UserInfo: s.absURL("/userinfo"),
- DeviceEndpoint: s.absURL("/device/code"),
- Subjects: []string{"public"},
- IDTokenAlgs: []string{string(jose.RS256)},
- CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain},
- Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
- AuthMethods: []string{"client_secret_basic", "client_secret_post"},
- Claims: []string{
- "iss", "sub", "aud", "iat", "exp", "email", "email_verified",
- "locale", "name", "preferred_username", "at_hash",
- },
- }
-
- for responseType := range s.supportedResponseTypes {
- d.ResponseTypes = append(d.ResponseTypes, responseType)
- }
- sort.Strings(d.ResponseTypes)
-
- d.GrantTypes = s.supportedGrantTypes
-
- data, err := json.MarshalIndent(d, "", " ")
- if err != nil {
- return nil, fmt.Errorf("failed to marshal discovery data: %v", err)
- }
-
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Length", strconv.Itoa(len(data)))
- w.Write(data)
- }), nil
-}
-
-// handleAuthorization handles the OAuth2 auth endpoint.
-func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
- // Extract the arguments
- if err := r.ParseForm(); err != nil {
- s.logger.Errorf("Failed to parse arguments: %v", err)
-
- s.renderError(r, w, http.StatusBadRequest, err.Error())
- return
- }
-
- connectorID := r.Form.Get("connector_id")
-
- connectors, err := s.storage.ListConnectors()
- if err != nil {
- s.logger.Errorf("Failed to get list of connectors: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.")
- return
- }
-
- // We don't need connector_id any more
- r.Form.Del("connector_id")
-
- // Construct a URL with all of the arguments in its query
- connURL := url.URL{
- RawQuery: r.Form.Encode(),
- }
-
- // Redirect if a client chooses a specific connector_id
- if connectorID != "" {
- for _, c := range connectors {
- if c.ID == connectorID {
- connURL.Path = s.absPath("/auth", url.PathEscape(c.ID))
- http.Redirect(w, r, connURL.String(), http.StatusFound)
- return
- }
- }
- s.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector")
- return
- }
-
- if len(connectors) == 1 && !s.alwaysShowLogin {
- connURL.Path = s.absPath("/auth", url.PathEscape(connectors[0].ID))
- http.Redirect(w, r, connURL.String(), http.StatusFound)
- }
-
- connectorInfos := make([]connectorInfo, len(connectors))
- for index, conn := range connectors {
- connURL.Path = s.absPath("/auth", url.PathEscape(conn.ID))
- connectorInfos[index] = connectorInfo{
- ID: conn.ID,
- Name: conn.Name,
- Type: conn.Type,
- URL: template.URL(connURL.String()),
- }
- }
-
- if err := s.templates.login(r, w, connectorInfos); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
-}
-
-func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
- authReq, err := s.parseAuthorizationRequest(r)
- if err != nil {
- s.logger.Errorf("Failed to parse authorization request: %v", err)
-
- switch authErr := err.(type) {
- case *redirectedAuthErr:
- authErr.Handler().ServeHTTP(w, r)
- case *displayedAuthErr:
- s.renderError(r, w, authErr.Status, err.Error())
- default:
- panic("unsupported error type")
- }
-
- return
- }
-
- connID, err := url.PathUnescape(mux.Vars(r)["connector"])
- if err != nil {
- s.logger.Errorf("Failed to parse connector: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist")
- return
- }
-
- conn, err := s.getConnector(connID)
- if err != nil {
- s.logger.Errorf("Failed to get connector: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist")
- return
- }
-
- // Set the connector being used for the login.
- if authReq.ConnectorID != "" && authReq.ConnectorID != connID {
- s.logger.Errorf("Mismatched connector ID in auth request: %s vs %s",
- authReq.ConnectorID, connID)
- s.renderError(r, w, http.StatusBadRequest, "Bad connector ID")
- return
- }
-
- authReq.ConnectorID = connID
-
- // Actually create the auth request
- authReq.Expiry = s.now().Add(s.authRequestsValidFor)
- if err := s.storage.CreateAuthRequest(*authReq); err != nil {
- s.logger.Errorf("Failed to create authorization request: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.")
- return
- }
-
- scopes := parseScopes(authReq.Scopes)
-
- // Work out where the "Select another login method" link should go.
- backLink := ""
- if len(s.connectors) > 1 {
- backLinkURL := url.URL{
- Path: s.absPath("/auth"),
- RawQuery: r.Form.Encode(),
- }
- backLink = backLinkURL.String()
- }
-
- switch r.Method {
- case http.MethodGet:
- switch conn := conn.Connector.(type) {
- case connector.CallbackConnector:
- // Use the auth request ID as the "state" token.
- //
- // TODO(ericchiang): Is this appropriate or should we also be using a nonce?
- callbackURL, err := conn.LoginURL(scopes, s.absURL("/callback"), authReq.ID)
- if err != nil {
- s.logger.Errorf("Connector %q returned error when creating callback: %v", connID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Login error.")
- return
- }
- http.Redirect(w, r, callbackURL, http.StatusFound)
- case connector.PasswordConnector:
- loginURL := url.URL{
- Path: s.absPath("/auth", connID, "login"),
- }
- q := loginURL.Query()
- q.Set("state", authReq.ID)
- q.Set("back", backLink)
- loginURL.RawQuery = q.Encode()
-
- http.Redirect(w, r, loginURL.String(), http.StatusFound)
- case connector.SAMLConnector:
- action, value, err := conn.POSTData(scopes, authReq.ID)
- if err != nil {
- s.logger.Errorf("Creating SAML data: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Connector Login Error")
- return
- }
-
- // TODO(ericchiang): Don't inline this.
- fmt.Fprintf(w, `
-
-
-
- SAML login
-
-
-
-
-
- `, action, value, authReq.ID)
- default:
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- }
- default:
- s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.")
- }
-}
-
-func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
- authID := r.URL.Query().Get("state")
- if authID == "" {
- s.renderError(r, w, http.StatusBadRequest, "User session error.")
- return
- }
-
- backLink := r.URL.Query().Get("back")
-
- authReq, err := s.storage.GetAuthRequest(authID)
- if err != nil {
- if err == storage.ErrNotFound {
- s.logger.Errorf("Invalid 'state' parameter provided: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- return
- }
- s.logger.Errorf("Failed to get auth request: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Database error.")
- return
- }
-
- connID, err := url.PathUnescape(mux.Vars(r)["connector"])
- if err != nil {
- s.logger.Errorf("Failed to parse connector: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist")
- return
- } else if connID != "" && connID != authReq.ConnectorID {
- s.logger.Errorf("Connector mismatch: authentication started with id %q, but password login for id %q was triggered", authReq.ConnectorID, connID)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- conn, err := s.getConnector(authReq.ConnectorID)
- if err != nil {
- s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- pwConn, ok := conn.Connector.(connector.PasswordConnector)
- if !ok {
- s.logger.Errorf("Expected password connector in handlePasswordLogin(), but got %v", pwConn)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
- case http.MethodPost:
- username := r.FormValue("login")
- password := r.FormValue("password")
- scopes := parseScopes(authReq.Scopes)
-
- identity, ok, err := pwConn.Login(r.Context(), scopes, username, password)
- if err != nil {
- s.logger.Errorf("Failed to login user: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err))
- return
- }
- if !ok {
- if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
- return
- }
- redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector)
- if err != nil {
- s.logger.Errorf("Failed to finalize login: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Login error.")
- return
- }
-
- http.Redirect(w, r, redirectURL, http.StatusSeeOther)
- default:
- s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.")
- }
-}
-
-func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
- var authID string
- switch r.Method {
- case http.MethodGet: // OAuth2 callback
- if authID = r.URL.Query().Get("state"); authID == "" {
- s.renderError(r, w, http.StatusBadRequest, "User session error.")
- return
- }
- case http.MethodPost: // SAML POST binding
- if authID = r.PostFormValue("RelayState"); authID == "" {
- s.renderError(r, w, http.StatusBadRequest, "User session error.")
- return
- }
- default:
- s.renderError(r, w, http.StatusBadRequest, "Method not supported")
- return
- }
-
- authReq, err := s.storage.GetAuthRequest(authID)
- if err != nil {
- if err == storage.ErrNotFound {
- s.logger.Errorf("Invalid 'state' parameter provided: %v", err)
- s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
- return
- }
- s.logger.Errorf("Failed to get auth request: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Database error.")
- return
- }
-
- connID, err := url.PathUnescape(mux.Vars(r)["connector"])
- if err != nil {
- s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- } else if connID != "" && connID != authReq.ConnectorID {
- s.logger.Errorf("Connector mismatch: authentication started with id %q, but callback for id %q was triggered", authReq.ConnectorID, connID)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- conn, err := s.getConnector(authReq.ConnectorID)
- if err != nil {
- s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- var identity connector.Identity
- switch conn := conn.Connector.(type) {
- case connector.CallbackConnector:
- if r.Method != http.MethodGet {
- s.logger.Errorf("SAML request mapped to OAuth2 connector")
- s.renderError(r, w, http.StatusBadRequest, "Invalid request")
- return
- }
- identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r)
- case connector.SAMLConnector:
- if r.Method != http.MethodPost {
- s.logger.Errorf("OAuth2 request mapped to SAML connector")
- s.renderError(r, w, http.StatusBadRequest, "Invalid request")
- return
- }
- identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID)
- default:
- s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
- return
- }
-
- if err != nil {
- s.logger.Errorf("Failed to authenticate: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Failed to authenticate: %v", err))
- return
- }
-
- redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector)
- if err != nil {
- s.logger.Errorf("Failed to finalize login: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Login error.")
- return
- }
-
- http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-}
-
-// finalizeLogin associates the user's identity with the current AuthRequest, then returns
-// the approval page's path.
-func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, error) {
- claims := storage.Claims{
- UserID: identity.UserID,
- Username: identity.Username,
- PreferredUsername: identity.PreferredUsername,
- Email: identity.Email,
- EmailVerified: identity.EmailVerified,
- Groups: identity.Groups,
- }
-
- updater := func(a storage.AuthRequest) (storage.AuthRequest, error) {
- a.LoggedIn = true
- a.Claims = claims
- a.ConnectorData = identity.ConnectorData
- return a, nil
- }
- if err := s.storage.UpdateAuthRequest(authReq.ID, updater); err != nil {
- return "", fmt.Errorf("failed to update auth request: %v", err)
- }
-
- email := claims.Email
- if !claims.EmailVerified {
- email += " (unverified)"
- }
-
- s.logger.Infof("login successful: connector %q, username=%q, preferred_username=%q, email=%q, groups=%q",
- authReq.ConnectorID, claims.Username, claims.PreferredUsername, email, claims.Groups)
-
- returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID
- _, ok := conn.(connector.RefreshConnector)
- if !ok {
- return returnURL, nil
- }
-
- // Try to retrieve an existing OfflineSession object for the corresponding user.
- session, err := s.storage.GetOfflineSessions(identity.UserID, authReq.ConnectorID)
- if err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get offline session: %v", err)
- return "", err
- }
- offlineSessions := storage.OfflineSessions{
- UserID: identity.UserID,
- ConnID: authReq.ConnectorID,
- Refresh: make(map[string]*storage.RefreshTokenRef),
- ConnectorData: identity.ConnectorData,
- }
-
- // Create a new OfflineSession object for the user and add a reference object for
- // the newly received refreshtoken.
- if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil {
- s.logger.Errorf("failed to create offline session: %v", err)
- return "", err
- }
-
- return returnURL, nil
- }
-
- // Update existing OfflineSession obj with new RefreshTokenRef.
- if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
- if len(identity.ConnectorData) > 0 {
- old.ConnectorData = identity.ConnectorData
- }
- return old, nil
- }); err != nil {
- s.logger.Errorf("failed to update offline session: %v", err)
- return "", err
- }
-
- return returnURL, nil
-}
-
-func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
- authReq, err := s.storage.GetAuthRequest(r.FormValue("req"))
- if err != nil {
- s.logger.Errorf("Failed to get auth request: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Database error.")
- return
- }
- if !authReq.LoggedIn {
- s.logger.Errorf("Auth request does not have an identity for approval")
- s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.")
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- if s.skipApproval {
- s.sendCodeResponse(w, r, authReq)
- return
- }
- client, err := s.storage.GetClient(authReq.ClientID)
- if err != nil {
- s.logger.Errorf("Failed to get client %q: %v", authReq.ClientID, err)
- s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.")
- return
- }
- if err := s.templates.approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
- case http.MethodPost:
- if r.FormValue("approval") != "approve" {
- s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.")
- return
- }
- s.sendCodeResponse(w, r, authReq)
- }
-}
-
-func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) {
- if s.now().After(authReq.Expiry) {
- s.renderError(r, w, http.StatusBadRequest, "User session has expired.")
- return
- }
-
- if err := s.storage.DeleteAuthRequest(authReq.ID); err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("Failed to delete authorization request: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
- } else {
- s.renderError(r, w, http.StatusBadRequest, "User session error.")
- }
- return
- }
- u, err := url.Parse(authReq.RedirectURI)
- if err != nil {
- s.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.")
- return
- }
-
- var (
- // Was the initial request using the implicit or hybrid flow instead of
- // the "normal" code flow?
- implicitOrHybrid = false
-
- // Only present in hybrid or code flow. code.ID == "" if this is not set.
- code storage.AuthCode
-
- // ID token returned immediately if the response_type includes "id_token".
- // Only valid for implicit and hybrid flows.
- idToken string
- idTokenExpiry time.Time
-
- // Access token
- accessToken string
- )
-
- for _, responseType := range authReq.ResponseTypes {
- switch responseType {
- case responseTypeCode:
- code = storage.AuthCode{
- ID: storage.NewID(),
- ClientID: authReq.ClientID,
- ConnectorID: authReq.ConnectorID,
- Nonce: authReq.Nonce,
- Scopes: authReq.Scopes,
- Claims: authReq.Claims,
- Expiry: s.now().Add(time.Minute * 30),
- RedirectURI: authReq.RedirectURI,
- ConnectorData: authReq.ConnectorData,
- PKCE: authReq.PKCE,
- }
- if err := s.storage.CreateAuthCode(code); err != nil {
- s.logger.Errorf("Failed to create auth code: %v", err)
- s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
- return
- }
-
- // Implicit and hybrid flows that try to use the OOB redirect URI are
- // rejected earlier. If we got here we're using the code flow.
- if authReq.RedirectURI == redirectURIOOB {
- if err := s.templates.oob(r, w, code.ID); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
- return
- }
- case responseTypeToken:
- implicitOrHybrid = true
- case responseTypeIDToken:
- implicitOrHybrid = true
- var err error
-
- accessToken, err = s.newAccessToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID)
- if err != nil {
- s.logger.Errorf("failed to create new access token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
-
- idToken, idTokenExpiry, err = s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, code.ID, authReq.ConnectorID)
- if err != nil {
- s.logger.Errorf("failed to create ID token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- }
- }
-
- if implicitOrHybrid {
- v := url.Values{}
- v.Set("access_token", accessToken)
- v.Set("token_type", "bearer")
- v.Set("state", authReq.State)
- if idToken != "" {
- v.Set("id_token", idToken)
- // The hybrid flow with only "code token" or "code id_token" doesn't return an
- // "expires_in" value. If "code" wasn't provided, indicating the implicit flow,
- // don't add it.
- //
- // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse
- if code.ID == "" {
- v.Set("expires_in", strconv.Itoa(int(idTokenExpiry.Sub(s.now()).Seconds())))
- }
- }
- if code.ID != "" {
- v.Set("code", code.ID)
- }
-
- // Implicit and hybrid flows return their values as part of the fragment.
- //
- // HTTP/1.1 303 See Other
- // Location: https://client.example.org/cb#
- // access_token=SlAV32hkKG
- // &token_type=bearer
- // &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso
- // &expires_in=3600
- // &state=af0ifjsldkj
- //
- u.Fragment = v.Encode()
- } else {
- // The code flow add values to the URL query.
- //
- // HTTP/1.1 303 See Other
- // Location: https://client.example.org/cb?
- // code=SplxlOBeZQQYbYS6WxSbIA
- // &state=af0ifjsldkj
- //
- q := u.Query()
- q.Set("code", code.ID)
- q.Set("state", authReq.State)
- u.RawQuery = q.Encode()
- }
-
- http.Redirect(w, r, u.String(), http.StatusSeeOther)
-}
-
-func (s *Server) withClientFromStorage(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request, storage.Client)) {
- clientID, clientSecret, ok := r.BasicAuth()
- if ok {
- var err error
- if clientID, err = url.QueryUnescape(clientID); err != nil {
- s.tokenErrHelper(w, errInvalidRequest, "client_id improperly encoded", http.StatusBadRequest)
- return
- }
- if clientSecret, err = url.QueryUnescape(clientSecret); err != nil {
- s.tokenErrHelper(w, errInvalidRequest, "client_secret improperly encoded", http.StatusBadRequest)
- return
- }
- } else {
- clientID = r.PostFormValue("client_id")
- clientSecret = r.PostFormValue("client_secret")
- }
-
- client, err := s.storage.GetClient(clientID)
- if err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get client: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- } else {
- s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
- }
- return
- }
-
- if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(clientSecret)) != 1 {
- if clientSecret == "" {
- s.logger.Infof("missing client_secret on token request for client: %s", client.ID)
- } else {
- s.logger.Infof("invalid client_secret on token request for client: %s", client.ID)
- }
- s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
- return
- }
-
- handler(w, r, client)
-}
-
-func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if r.Method != http.MethodPost {
- s.tokenErrHelper(w, errInvalidRequest, "method not allowed", http.StatusBadRequest)
- return
- }
-
- err := r.ParseForm()
- if err != nil {
- s.logger.Errorf("Could not parse request body: %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
- return
- }
-
- grantType := r.PostFormValue("grant_type")
- switch grantType {
- case grantTypeDeviceCode:
- s.handleDeviceToken(w, r)
- case grantTypeAuthorizationCode:
- s.withClientFromStorage(w, r, s.handleAuthCode)
- case grantTypeRefreshToken:
- s.withClientFromStorage(w, r, s.handleRefreshToken)
- case grantTypePassword:
- s.withClientFromStorage(w, r, s.handlePasswordGrant)
- default:
- s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest)
- }
-}
-
-func (s *Server) calculateCodeChallenge(codeVerifier, codeChallengeMethod string) (string, error) {
- switch codeChallengeMethod {
- case codeChallengeMethodPlain:
- return codeVerifier, nil
- case codeChallengeMethodS256:
- shaSum := sha256.Sum256([]byte(codeVerifier))
- return base64.RawURLEncoding.EncodeToString(shaSum[:]), nil
- default:
- return "", fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod)
- }
-}
-
-// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3
-func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client storage.Client) {
- code := r.PostFormValue("code")
- redirectURI := r.PostFormValue("redirect_uri")
-
- if code == "" {
- s.tokenErrHelper(w, errInvalidRequest, `Required param: code.`, http.StatusBadRequest)
- return
- }
-
- authCode, err := s.storage.GetAuthCode(code)
- if err != nil || s.now().After(authCode.Expiry) || authCode.ClientID != client.ID {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get auth code: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- } else {
- s.tokenErrHelper(w, errInvalidGrant, "Invalid or expired code parameter.", http.StatusBadRequest)
- }
- return
- }
-
- // RFC 7636 (PKCE)
- codeChallengeFromStorage := authCode.PKCE.CodeChallenge
- providedCodeVerifier := r.PostFormValue("code_verifier")
-
- switch {
- case providedCodeVerifier != "" && codeChallengeFromStorage != "":
- calculatedCodeChallenge, err := s.calculateCodeChallenge(providedCodeVerifier, authCode.PKCE.CodeChallengeMethod)
- if err != nil {
- s.logger.Error(err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- if codeChallengeFromStorage != calculatedCodeChallenge {
- s.tokenErrHelper(w, errInvalidGrant, "Invalid code_verifier.", http.StatusBadRequest)
- return
- }
- case providedCodeVerifier != "":
- // Received no code_challenge on /auth, but a code_verifier on /token
- s.tokenErrHelper(w, errInvalidRequest, "No PKCE flow started. Cannot check code_verifier.", http.StatusBadRequest)
- return
- case codeChallengeFromStorage != "":
- // Received PKCE request on /auth, but no code_verifier on /token
- s.tokenErrHelper(w, errInvalidGrant, "Expecting parameter code_verifier in PKCE flow.", http.StatusBadRequest)
- return
- }
-
- if authCode.RedirectURI != redirectURI {
- s.tokenErrHelper(w, errInvalidRequest, "redirect_uri did not match URI from initial request.", http.StatusBadRequest)
- return
- }
-
- tokenResponse, err := s.exchangeAuthCode(w, authCode, client)
- if err != nil {
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- s.writeAccessToken(w, tokenResponse)
-}
-
-func (s *Server) exchangeAuthCode(w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) {
- accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID)
- if err != nil {
- s.logger.Errorf("failed to create new access token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return nil, err
- }
-
- idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ID, authCode.ConnectorID)
- if err != nil {
- s.logger.Errorf("failed to create ID token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return nil, err
- }
-
- if err := s.storage.DeleteAuthCode(authCode.ID); err != nil {
- s.logger.Errorf("failed to delete auth code: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return nil, err
- }
-
- reqRefresh := func() bool {
- // Ensure the connector supports refresh tokens.
- //
- // Connectors like `saml` do not implement RefreshConnector.
- conn, err := s.getConnector(authCode.ConnectorID)
- if err != nil {
- s.logger.Errorf("connector with ID %q not found: %v", authCode.ConnectorID, err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return false
- }
-
- _, ok := conn.Connector.(connector.RefreshConnector)
- if !ok {
- return false
- }
-
- for _, scope := range authCode.Scopes {
- if scope == scopeOfflineAccess {
- return true
- }
- }
- return false
- }()
- var refreshToken string
- if reqRefresh {
- refresh := storage.RefreshToken{
- ID: storage.NewID(),
- Token: storage.NewID(),
- ClientID: authCode.ClientID,
- ConnectorID: authCode.ConnectorID,
- Scopes: authCode.Scopes,
- Claims: authCode.Claims,
- Nonce: authCode.Nonce,
- ConnectorData: authCode.ConnectorData,
- CreatedAt: s.now(),
- LastUsed: s.now(),
- }
- token := &internal.RefreshToken{
- RefreshId: refresh.ID,
- Token: refresh.Token,
- }
- if refreshToken, err = internal.Marshal(token); err != nil {
- s.logger.Errorf("failed to marshal refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return nil, err
- }
-
- if err := s.storage.CreateRefresh(refresh); err != nil {
- s.logger.Errorf("failed to create refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return nil, err
- }
-
- // deleteToken determines if we need to delete the newly created refresh token
- // due to a failure in updating/creating the OfflineSession object for the
- // corresponding user.
- var deleteToken bool
- defer func() {
- if deleteToken {
- // Delete newly created refresh token from storage.
- if err := s.storage.DeleteRefresh(refresh.ID); err != nil {
- s.logger.Errorf("failed to delete refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- }
- }()
-
- tokenRef := storage.RefreshTokenRef{
- ID: refresh.ID,
- ClientID: refresh.ClientID,
- CreatedAt: refresh.CreatedAt,
- LastUsed: refresh.LastUsed,
- }
-
- // Try to retrieve an existing OfflineSession object for the corresponding user.
- if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return nil, err
- }
- offlineSessions := storage.OfflineSessions{
- UserID: refresh.Claims.UserID,
- ConnID: refresh.ConnectorID,
- Refresh: make(map[string]*storage.RefreshTokenRef),
- }
- offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef
-
- // Create a new OfflineSession object for the user and add a reference object for
- // the newly received refreshtoken.
- if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil {
- s.logger.Errorf("failed to create offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return nil, err
- }
- } else {
- if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok {
- // Delete old refresh token from storage.
- if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil && err != storage.ErrNotFound {
- s.logger.Errorf("failed to delete refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return nil, err
- }
- }
-
- // Update existing OfflineSession obj with new RefreshTokenRef.
- if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
- old.Refresh[tokenRef.ClientID] = &tokenRef
- return old, nil
- }); err != nil {
- s.logger.Errorf("failed to update offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return nil, err
- }
- }
- }
- return s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry), nil
-}
-
-func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
- const prefix = "Bearer "
-
- auth := r.Header.Get("authorization")
- if len(auth) < len(prefix) || !strings.EqualFold(prefix, auth[:len(prefix)]) {
- w.Header().Set("WWW-Authenticate", "Bearer")
- s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusUnauthorized)
- return
- }
- rawIDToken := auth[len(prefix):]
-
- verifier := oidc.NewVerifier(s.issuerURL.String(), &storageKeySet{s.storage}, &oidc.Config{SkipClientIDCheck: true})
- idToken, err := verifier.Verify(r.Context(), rawIDToken)
- if err != nil {
- s.tokenErrHelper(w, errAccessDenied, err.Error(), http.StatusForbidden)
- return
- }
-
- var claims json.RawMessage
- if err := idToken.Claims(&claims); err != nil {
- s.tokenErrHelper(w, errServerError, err.Error(), http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.Write(claims)
-}
-
-func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) {
- // Parse the fields
- if err := r.ParseForm(); err != nil {
- s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest)
- return
- }
- q := r.Form
-
- nonce := q.Get("nonce")
- // Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
- scopes := strings.Fields(q.Get("scope"))
-
- // Parse the scopes if they are passed
- var (
- unrecognized []string
- invalidScopes []string
- )
- hasOpenIDScope := false
- for _, scope := range scopes {
- switch scope {
- case scopeOpenID:
- hasOpenIDScope = true
- case scopeOfflineAccess, scopeEmail, scopeProfile, scopeGroups, scopeFederatedID:
- default:
- peerID, ok := parseCrossClientScope(scope)
- if !ok {
- unrecognized = append(unrecognized, scope)
- continue
- }
-
- isTrusted, err := s.validateCrossClientTrust(client.ID, peerID)
- if err != nil {
- s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Error validating cross client trust %v.", err), http.StatusBadRequest)
- return
- }
- if !isTrusted {
- invalidScopes = append(invalidScopes, scope)
- }
- }
- }
- if !hasOpenIDScope {
- s.tokenErrHelper(w, errInvalidRequest, `Missing required scope(s) ["openid"].`, http.StatusBadRequest)
- return
- }
- if len(unrecognized) > 0 {
- s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest)
- return
- }
- if len(invalidScopes) > 0 {
- s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest)
- return
- }
-
- // Which connector
- connID := s.passwordConnector
- conn, err := s.getConnector(connID)
- if err != nil {
- s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest)
- return
- }
-
- passwordConnector, ok := conn.Connector.(connector.PasswordConnector)
- if !ok {
- s.tokenErrHelper(w, errInvalidRequest, "Requested password connector does not correct type.", http.StatusBadRequest)
- return
- }
-
- // Login
- username := q.Get("username")
- password := q.Get("password")
- identity, ok, err := passwordConnector.Login(r.Context(), parseScopes(scopes), username, password)
- if err != nil {
- s.logger.Errorf("Failed to login user: %v", err)
- s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest)
- return
- }
- if !ok {
- s.tokenErrHelper(w, errAccessDenied, "Invalid username or password", http.StatusUnauthorized)
- return
- }
-
- // Build the claims to send the id token
- claims := storage.Claims{
- UserID: identity.UserID,
- Username: identity.Username,
- PreferredUsername: identity.PreferredUsername,
- Email: identity.Email,
- EmailVerified: identity.EmailVerified,
- Groups: identity.Groups,
- }
-
- accessToken, err := s.newAccessToken(client.ID, claims, scopes, nonce, connID)
- if err != nil {
- s.logger.Errorf("password grant failed to create new access token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
-
- idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, "", connID)
- if err != nil {
- s.logger.Errorf("password grant failed to create new ID token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
-
- reqRefresh := func() bool {
- // Ensure the connector supports refresh tokens.
- //
- // Connectors like `saml` do not implement RefreshConnector.
- _, ok := conn.Connector.(connector.RefreshConnector)
- if !ok {
- return false
- }
-
- for _, scope := range scopes {
- if scope == scopeOfflineAccess {
- return true
- }
- }
- return false
- }()
- var refreshToken string
- if reqRefresh {
- refresh := storage.RefreshToken{
- ID: storage.NewID(),
- Token: storage.NewID(),
- ClientID: client.ID,
- ConnectorID: connID,
- Scopes: scopes,
- Claims: claims,
- Nonce: nonce,
- // ConnectorData: authCode.ConnectorData,
- CreatedAt: s.now(),
- LastUsed: s.now(),
- }
- token := &internal.RefreshToken{
- RefreshId: refresh.ID,
- Token: refresh.Token,
- }
- if refreshToken, err = internal.Marshal(token); err != nil {
- s.logger.Errorf("failed to marshal refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
-
- if err := s.storage.CreateRefresh(refresh); err != nil {
- s.logger.Errorf("failed to create refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
-
- // deleteToken determines if we need to delete the newly created refresh token
- // due to a failure in updating/creating the OfflineSession object for the
- // corresponding user.
- var deleteToken bool
- defer func() {
- if deleteToken {
- // Delete newly created refresh token from storage.
- if err := s.storage.DeleteRefresh(refresh.ID); err != nil {
- s.logger.Errorf("failed to delete refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- }
- }()
-
- tokenRef := storage.RefreshTokenRef{
- ID: refresh.ID,
- ClientID: refresh.ClientID,
- CreatedAt: refresh.CreatedAt,
- LastUsed: refresh.LastUsed,
- }
-
- // Try to retrieve an existing OfflineSession object for the corresponding user.
- if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil {
- if err != storage.ErrNotFound {
- s.logger.Errorf("failed to get offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return
- }
- offlineSessions := storage.OfflineSessions{
- UserID: refresh.Claims.UserID,
- ConnID: refresh.ConnectorID,
- Refresh: make(map[string]*storage.RefreshTokenRef),
- ConnectorData: identity.ConnectorData,
- }
- offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef
-
- // Create a new OfflineSession object for the user and add a reference object for
- // the newly received refreshtoken.
- if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil {
- s.logger.Errorf("failed to create offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return
- }
- } else {
- if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok {
- // Delete old refresh token from storage.
- if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil {
- if err == storage.ErrNotFound {
- s.logger.Warnf("database inconsistent, refresh token missing: %v", oldTokenRef.ID)
- } else {
- s.logger.Errorf("failed to delete refresh token: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return
- }
- }
- }
-
- // Update existing OfflineSession obj with new RefreshTokenRef.
- if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
- old.Refresh[tokenRef.ClientID] = &tokenRef
- old.ConnectorData = identity.ConnectorData
- return old, nil
- }); err != nil {
- s.logger.Errorf("failed to update offline session: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- deleteToken = true
- return
- }
- }
- }
-
- resp := s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry)
- s.writeAccessToken(w, resp)
-}
-
-type accessTokenResponse struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- ExpiresIn int `json:"expires_in"`
- RefreshToken string `json:"refresh_token,omitempty"`
- IDToken string `json:"id_token"`
-}
-
-func (s *Server) toAccessTokenResponse(idToken, accessToken, refreshToken string, expiry time.Time) *accessTokenResponse {
- return &accessTokenResponse{
- accessToken,
- "bearer",
- int(expiry.Sub(s.now()).Seconds()),
- refreshToken,
- idToken,
- }
-}
-
-func (s *Server) writeAccessToken(w http.ResponseWriter, resp *accessTokenResponse) {
- data, err := json.Marshal(resp)
- if err != nil {
- s.logger.Errorf("failed to marshal access token response: %v", err)
- s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Length", strconv.Itoa(len(data)))
-
- // Token response must include cache headers https://tools.ietf.org/html/rfc6749#section-5.1
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Pragma", "no-cache")
- w.Write(data)
-}
-
-func (s *Server) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
- if err := s.templates.err(r, w, status, description); err != nil {
- s.logger.Errorf("Server template error: %v", err)
- }
-}
-
-func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description string, statusCode int) {
- if err := tokenErr(w, typ, description, statusCode); err != nil {
- s.logger.Errorf("token error response: %v", err)
- }
-}
-
-// Check for username prompt override from connector. Defaults to "Username".
-func usernamePrompt(conn connector.PasswordConnector) string {
- if attr := conn.Prompt(); attr != "" {
- return attr
- }
- return "Username"
-}
diff --git a/server/handlers_test.go b/server/handlers_test.go
deleted file mode 100644
index fb1a05064f..0000000000
--- a/server/handlers_test.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package server
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "net/url"
- "path"
- "testing"
- "time"
-
- gosundheit "github.com/AppsFlyer/go-sundheit"
- "github.com/AppsFlyer/go-sundheit/checks"
- "github.com/coreos/go-oidc/v3/oidc"
- "github.com/stretchr/testify/require"
- "golang.org/x/oauth2"
-
- "github.com/dexidp/dex/storage"
-)
-
-func TestHandleHealth(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, server := newTestServer(ctx, t, nil)
- defer httpServer.Close()
-
- rr := httptest.NewRecorder()
- server.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil))
- if rr.Code != http.StatusOK {
- t.Errorf("expected 200 got %d", rr.Code)
- }
-}
-
-func TestHandleHealthFailure(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, server := newTestServer(ctx, t, func(c *Config) {
- c.HealthChecker = gosundheit.New()
-
- c.HealthChecker.RegisterCheck(
- &checks.CustomCheck{
- CheckName: "fail",
- CheckFunc: func(_ context.Context) (details interface{}, err error) {
- return nil, errors.New("error")
- },
- },
- gosundheit.InitiallyPassing(false),
- gosundheit.ExecutionPeriod(1*time.Second),
- )
- })
- defer httpServer.Close()
-
- rr := httptest.NewRecorder()
- server.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil))
- if rr.Code != http.StatusInternalServerError {
- t.Errorf("expected 500 got %d", rr.Code)
- }
-}
-
-type emptyStorage struct {
- storage.Storage
-}
-
-func (*emptyStorage) GetAuthRequest(string) (storage.AuthRequest, error) {
- return storage.AuthRequest{}, storage.ErrNotFound
-}
-
-func TestHandleInvalidOAuth2Callbacks(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, server := newTestServer(ctx, t, func(c *Config) {
- c.Storage = &emptyStorage{c.Storage}
- })
- defer httpServer.Close()
-
- tests := []struct {
- TargetURI string
- ExpectedCode int
- }{
- {"/callback", http.StatusBadRequest},
- {"/callback?code=&state=", http.StatusBadRequest},
- {"/callback?code=AAAAAAA&state=BBBBBBB", http.StatusBadRequest},
- }
-
- rr := httptest.NewRecorder()
-
- for i, r := range tests {
- server.ServeHTTP(rr, httptest.NewRequest("GET", r.TargetURI, nil))
- if rr.Code != r.ExpectedCode {
- t.Fatalf("test %d expected %d, got %d", i, r.ExpectedCode, rr.Code)
- }
- }
-}
-
-func TestHandleInvalidSAMLCallbacks(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, server := newTestServer(ctx, t, func(c *Config) {
- c.Storage = &emptyStorage{c.Storage}
- })
- defer httpServer.Close()
-
- type requestForm struct {
- RelayState string
- }
- tests := []struct {
- RequestForm requestForm
- ExpectedCode int
- }{
- {requestForm{}, http.StatusBadRequest},
- {requestForm{RelayState: "AAAAAAA"}, http.StatusBadRequest},
- }
-
- rr := httptest.NewRecorder()
-
- for i, r := range tests {
- jsonValue, err := json.Marshal(r.RequestForm)
- if err != nil {
- t.Fatal(err.Error())
- }
- server.ServeHTTP(rr, httptest.NewRequest("POST", "/callback", bytes.NewBuffer(jsonValue)))
- if rr.Code != r.ExpectedCode {
- t.Fatalf("test %d expected %d, got %d", i, r.ExpectedCode, rr.Code)
- }
- }
-}
-
-// TestHandleAuthCode checks that it is forbidden to use same code twice
-func TestHandleAuthCode(t *testing.T) {
- tests := []struct {
- name string
- handleCode func(*testing.T, context.Context, *oauth2.Config, string)
- }{
- {
- name: "Code Reuse should return invalid_grant",
- handleCode: func(t *testing.T, ctx context.Context, oauth2Config *oauth2.Config, code string) {
- _, err := oauth2Config.Exchange(ctx, code)
- require.NoError(t, err)
-
- _, err = oauth2Config.Exchange(ctx, code)
- require.Error(t, err)
-
- oauth2Err, ok := err.(*oauth2.RetrieveError)
- require.True(t, ok)
-
- var errResponse struct{ Error string }
- err = json.Unmarshal(oauth2Err.Body, &errResponse)
- require.NoError(t, err)
-
- // invalid_grant must be returned for invalid values
- // https://tools.ietf.org/html/rfc6749#section-5.2
- require.Equal(t, errInvalidGrant, errResponse.Error)
- },
- },
- {
- name: "No Code should return invalid_request",
- handleCode: func(t *testing.T, ctx context.Context, oauth2Config *oauth2.Config, _ string) {
- _, err := oauth2Config.Exchange(ctx, "")
- require.Error(t, err)
-
- oauth2Err, ok := err.(*oauth2.RetrieveError)
- require.True(t, ok)
-
- var errResponse struct{ Error string }
- err = json.Unmarshal(oauth2Err.Body, &errResponse)
- require.NoError(t, err)
-
- require.Equal(t, errInvalidRequest, errResponse.Error)
- },
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- httpServer, s := newTestServer(ctx, t, func(c *Config) { c.Issuer += "/non-root-path" })
- defer httpServer.Close()
-
- p, err := oidc.NewProvider(ctx, httpServer.URL)
- require.NoError(t, err)
-
- var oauth2Client oauth2Client
- oauth2Client.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/callback" {
- http.Redirect(w, r, oauth2Client.config.AuthCodeURL(""), http.StatusSeeOther)
- return
- }
-
- q := r.URL.Query()
- require.Equal(t, q.Get("error"), "", q.Get("error_description"))
-
- code := q.Get("code")
- tc.handleCode(t, ctx, oauth2Client.config, code)
-
- w.WriteHeader(http.StatusOK)
- }))
- defer oauth2Client.server.Close()
-
- redirectURL := oauth2Client.server.URL + "/callback"
- client := storage.Client{
- ID: "testclient",
- Secret: "testclientsecret",
- RedirectURIs: []string{redirectURL},
- }
- err = s.storage.CreateClient(client)
- require.NoError(t, err)
-
- oauth2Client.config = &oauth2.Config{
- ClientID: client.ID,
- ClientSecret: client.Secret,
- Endpoint: p.Endpoint(),
- Scopes: []string{oidc.ScopeOpenID, "email", "offline_access"},
- RedirectURL: redirectURL,
- }
-
- resp, err := http.Get(oauth2Client.server.URL + "/login")
- require.NoError(t, err)
-
- resp.Body.Close()
- })
- }
-}
-
-func mockConnectorDataTestStorage(t *testing.T, s storage.Storage) {
- c := storage.Client{
- ID: "test",
- Secret: "barfoo",
- RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"},
- Name: "dex client",
- LogoURL: "https://goo.gl/JIyzIC",
- }
-
- err := s.CreateClient(c)
- require.NoError(t, err)
-
- c1 := storage.Connector{
- ID: "test",
- Type: "mockPassword",
- Name: "mockPassword",
- Config: []byte(`{
-"username": "test",
-"password": "test"
-}`),
- }
-
- err = s.CreateConnector(c1)
- require.NoError(t, err)
-
- c2 := storage.Connector{
- ID: "http://any.valid.url/",
- Type: "mock",
- Name: "mockURLID",
- }
-
- err = s.CreateConnector(c2)
- require.NoError(t, err)
-}
-
-func TestPasswordConnectorDataNotEmpty(t *testing.T) {
- t0 := time.Now()
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup a dex server.
- httpServer, s := newTestServer(ctx, t, func(c *Config) {
- c.PasswordConnector = "test"
- c.Now = func() time.Time { return t0 }
- })
- defer httpServer.Close()
-
- mockConnectorDataTestStorage(t, s.storage)
-
- u, err := url.Parse(s.issuerURL.String())
- require.NoError(t, err)
-
- u.Path = path.Join(u.Path, "/token")
- v := url.Values{}
- v.Add("scope", "openid offline_access email")
- v.Add("grant_type", "password")
- v.Add("username", "test")
- v.Add("password", "test")
-
- req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
- req.SetBasicAuth("test", "barfoo")
-
- rr := httptest.NewRecorder()
- s.ServeHTTP(rr, req)
-
- require.Equal(t, 200, rr.Code)
-
- // Check that we received expected refresh token
- var ref struct {
- Token string `json:"refresh_token"`
- }
- err = json.Unmarshal(rr.Body.Bytes(), &ref)
- require.NoError(t, err)
-
- newSess, err := s.storage.GetOfflineSessions("0-385-28089-0", "test")
- require.NoError(t, err)
- require.Equal(t, `{"test": "true"}`, string(newSess.ConnectorData))
-}
diff --git a/server/helpers_test.go b/server/helpers_test.go
new file mode 100644
index 0000000000..e3448e690e
--- /dev/null
+++ b/server/helpers_test.go
@@ -0,0 +1,330 @@
+package server
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ gosundheit "github.com/AppsFlyer/go-sundheit"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/stretchr/testify/require"
+
+ "github.com/dexidp/dex/connector"
+ "github.com/dexidp/dex/pkg/featureflags"
+ "github.com/dexidp/dex/server/connectors"
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/signer"
+ "github.com/dexidp/dex/server/tokens"
+ "github.com/dexidp/dex/storage"
+ "github.com/dexidp/dex/storage/memory"
+)
+
+func newLogger(t *testing.T) *slog.Logger {
+ return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug}))
+}
+
+func boolPtr(v bool) *bool {
+ return &v
+}
+
+// isFlowPath reports whether a redirect target is an internal step of the login
+// chain (the /auth dispatcher, MFA factors, consent) rather than the client
+// redirect.
+func isFlowPath(p string) bool {
+ if strings.HasSuffix(p, "/auth") { // dispatcher re-entry
+ return true
+ }
+ for _, step := range []string{"/mfa/", "/approval"} {
+ if strings.Contains(p, step) {
+ return true
+ }
+ }
+ return false
+}
+
+// followFlow walks the internal redirects of the login chain starting from rr
+// (each hop is HMAC-protected and followed with a GET) and returns the path the
+// flow comes to rest at: the client redirect_uri when the request is issued, or
+// the flow step that renders a page (e.g. /approval). It leaves rr on the final
+// response so callers can still inspect status, body or the Location query.
+func followFlow(t *testing.T, s *Server, rr *httptest.ResponseRecorder) (*httptest.ResponseRecorder, string) {
+ t.Helper()
+ for range 10 {
+ if rr.Code != http.StatusFound && rr.Code != http.StatusSeeOther {
+ return rr, "" // rendered a page with no redirect
+ }
+ loc := rr.Header().Get("Location")
+ u, err := url.Parse(loc)
+ require.NoError(t, err)
+ if !isFlowPath(u.Path) {
+ return rr, u.Path // left the flow โ this is the client redirect
+ }
+ next := httptest.NewRecorder()
+ s.ServeHTTP(next, httptest.NewRequest(http.MethodGet, loc, nil))
+ if next.Code != http.StatusFound && next.Code != http.StatusSeeOther {
+ return next, u.Path // this flow step rendered (e.g. the consent screen)
+ }
+ rr = next
+ }
+ t.Fatal("followFlow: redirect loop did not settle")
+ return rr, ""
+}
+
+type emptyStorage struct {
+ storage.Storage
+}
+
+func (*emptyStorage) GetAuthRequest(context.Context, string) (storage.AuthRequest, error) {
+ return storage.AuthRequest{}, storage.ErrNotFound
+}
+
+func mockConnectorDataTestStorage(t *testing.T, s storage.Storage) {
+ ctx := t.Context()
+ c := storage.Client{
+ ID: "test",
+ Secret: "barfoo",
+ RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"},
+ Name: "dex client",
+ LogoURL: "https://goo.gl/JIyzIC",
+ }
+
+ err := s.CreateClient(ctx, c)
+ require.NoError(t, err)
+
+ c1 := storage.Connector{
+ ID: "test",
+ Type: "mockPassword",
+ Name: "mockPassword",
+ Config: []byte(`{
+"username": "test",
+"password": "test"
+}`),
+ }
+
+ err = s.CreateConnector(ctx, c1)
+ require.NoError(t, err)
+
+ c2 := storage.Connector{
+ ID: "http://any.valid.url/",
+ Type: "mock",
+ Name: "mockURLID",
+ }
+
+ err = s.CreateConnector(ctx, c2)
+ require.NoError(t, err)
+}
+
+func setSessionsEnabled(t *testing.T, enabled bool) {
+ t.Helper()
+ if enabled {
+ t.Setenv("DEX_SESSIONS_ENABLED", "true")
+ } else {
+ t.Setenv("DEX_SESSIONS_ENABLED", "false")
+ }
+}
+
+// spnegoShortCircuit implements connector.PasswordConnector and connector.SPNEGOAware
+// to simulate successful SPNEGO authentication on GET.
+type spnegoShortCircuit struct{ Identity connector.Identity }
+
+func (s spnegoShortCircuit) Close() error { return nil }
+
+func (s spnegoShortCircuit) Prompt() string { return "" }
+
+func (s spnegoShortCircuit) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) {
+ return connector.Identity{}, false, nil
+}
+
+func (s spnegoShortCircuit) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) {
+ id := s.Identity
+ return &id, true, nil
+}
+
+// spnegoError implements connector.PasswordConnector and connector.SPNEGOAware
+// to simulate SPNEGO authentication that fails with an error (e.g., LDAP lookup failed).
+type spnegoError struct{ Err error }
+
+func (s spnegoError) Close() error { return nil }
+
+func (s spnegoError) Prompt() string { return "" }
+
+func (s spnegoError) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) {
+ return connector.Identity{}, false, nil
+}
+
+func (s spnegoError) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) {
+ return nil, true, s.Err
+}
+
+func setNonEmpty(vals url.Values, key, value string) {
+ if value != "" {
+ vals.Set(key, value)
+ }
+}
+
+// registerTestConnector creates a connector in storage and registers it in the server's connectors map.
+func registerTestConnector(t *testing.T, s *Server, connID string, c connector.Connector) {
+ t.Helper()
+ ctx := t.Context()
+
+ storageConn := storage.Connector{
+ ID: connID,
+ Type: "saml",
+ Name: "Test SAML",
+ ResourceVersion: "1",
+ }
+ if err := s.storage.CreateConnector(ctx, storageConn); err != nil {
+ t.Fatalf("failed to create connector in storage: %v", err)
+ }
+
+ s.connectors.Set(connID, connectors.Connector{
+ ResourceVersion: "1",
+ Connector: c,
+ })
+}
+
+// mockSAMLRefreshConnector implements SAMLConnector + RefreshConnector for testing.
+type mockSAMLRefreshConnector struct {
+ refreshIdentity connector.Identity
+}
+
+func (m *mockSAMLRefreshConnector) POSTData(s connector.Scopes, requestID string) (ssoURL, samlRequest string, err error) {
+ return "", "", nil
+}
+
+func (m *mockSAMLRefreshConnector) HandlePOST(s connector.Scopes, samlResponse, inResponseTo string) (connector.Identity, error) {
+ return connector.Identity{}, nil
+}
+
+func (m *mockSAMLRefreshConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
+ return m.refreshIdentity, nil
+}
+
+// testSessionKey is the AES key the test servers encrypt session cookies with.
+// Tests that forge a session cookie sign it with this key rather than reading
+// the key back off the server they are exercising.
+var testSessionKey = []byte("0123456789abcdef0123456789abcdef")
+
+// mockConnector is the connector every test server serves.
+func mockConnector(id string) storage.Connector {
+ return storage.Connector{
+ ID: id,
+ Type: "mockCallback",
+ Name: "Mock",
+ ResourceVersion: "1",
+ }
+}
+
+// testSessionConfig is the session config the test servers run with.
+func testSessionConfig() *session.Config {
+ return &session.Config{
+ CookieName: "dex_session",
+ CookieEncryptionKey: testSessionKey,
+ AbsoluteLifetime: 24 * time.Hour,
+ ValidIfNotUsedFor: time.Hour,
+ }
+}
+
+// newTestServerWith builds a server serving conns, behind an httptest.Server
+// that dispatches to it. updateConfig adjusts the shared default config before
+// the server is built; the caller-facing constructors below are thin wrappers
+// that differ only in the connectors and the grant types they enable.
+func newTestServerWith(t *testing.T, conns []storage.Connector, updateConfig func(c *Config)) (*httptest.Server, *Server) {
+ t.Helper()
+
+ var server *Server
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ server.ServeHTTP(w, r)
+ }))
+
+ logger := newLogger(t)
+ ctx := t.Context()
+
+ sig, err := signer.NewMockSigner(testKey)
+ require.NoError(t, err, "failed to create mock signer")
+
+ config := Config{
+ Issuer: s.URL,
+ Storage: memory.New(logger),
+ Web: WebConfig{
+ Dir: "../web",
+ },
+ Logger: logger,
+ PrometheusRegistry: prometheus.NewRegistry(),
+ HealthChecker: gosundheit.New(),
+ SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code.
+ Signer: sig,
+ }
+ if updateConfig != nil {
+ updateConfig(&config)
+ }
+ s.URL = config.Issuer
+
+ // Default rotation policy, set before the server is built so the token
+ // endpoint captures it.
+ if config.RefreshTokenPolicy == nil {
+ config.RefreshTokenPolicy = tokens.NewRefreshStrategy(true, 0, 0, 0, config.Now)
+ }
+
+ // Mirror cmd: the session config is present iff the sessions feature flag is on.
+ if featureflags.SessionsEnabled.Enabled() && config.SessionConfig == nil {
+ config.SessionConfig = testSessionConfig()
+ }
+
+ for _, conn := range conns {
+ require.NoError(t, config.Storage.CreateConnector(ctx, conn), "create connector")
+ }
+
+ server, err = newServer(ctx, config)
+ require.NoError(t, err)
+
+ return s, server
+}
+
+// newTestServer serves one mock connector with every implemented grant enabled.
+func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) {
+ return newTestServerWith(t, []storage.Connector{mockConnector("mock")}, func(c *Config) {
+ c.AllowedGrantTypes = []string{ // all implemented types
+ oauth2.GrantTypeDeviceCode,
+ oauth2.GrantTypeAuthorizationCode,
+ oauth2.GrantTypeClientCredentials,
+ oauth2.GrantTypeRefreshToken,
+ oauth2.GrantTypeTokenExchange,
+ oauth2.GrantTypeImplicit,
+ oauth2.GrantTypePassword,
+ }
+ if updateConfig != nil {
+ updateConfig(c)
+ }
+ })
+}
+
+// newTestServerMultipleConnectors serves two mock connectors, for the paths that
+// depend on the connector selection screen.
+func newTestServerMultipleConnectors(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) {
+ return newTestServerWith(t, []storage.Connector{mockConnector("mock"), mockConnector("mock2")}, updateConfig)
+}
+
+// newTestServerWithSessions serves one mock connector with sessions always on,
+// regardless of the feature flag.
+func newTestServerWithSessions(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) {
+ return newTestServerWith(t, []storage.Connector{mockConnector("mock")}, func(c *Config) {
+ c.AllowedGrantTypes = []string{
+ oauth2.GrantTypeAuthorizationCode,
+ oauth2.GrantTypeClientCredentials,
+ oauth2.GrantTypeRefreshToken,
+ oauth2.GrantTypeTokenExchange,
+ oauth2.GrantTypeDeviceCode,
+ }
+ c.SessionConfig = testSessionConfig()
+ if updateConfig != nil {
+ updateConfig(c)
+ }
+ })
+}
diff --git a/server/home/doc.go b/server/home/doc.go
new file mode 100644
index 0000000000..d718d63fe2
--- /dev/null
+++ b/server/home/doc.go
@@ -0,0 +1,2 @@
+// Package home serves the dex landing page at "/".
+package home
diff --git a/server/home/home.go b/server/home/home.go
new file mode 100644
index 0000000000..6a65486292
--- /dev/null
+++ b/server/home/home.go
@@ -0,0 +1,137 @@
+package home
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/dexidp/dex/server/oauth2"
+ "github.com/dexidp/dex/server/router"
+ "github.com/dexidp/dex/server/session"
+ "github.com/dexidp/dex/server/templates"
+ "github.com/dexidp/dex/storage"
+)
+
+// Handler serves the landing page. When sessions are enabled and a home template
+// is available it renders the rich page (with logged-in details); otherwise it
+// falls back to a minimal inline page.
+type Handler struct {
+ IssuerURL oauth2.IssuerURL
+ Storage storage.Storage
+ Templates *templates.Templates
+ Logger *slog.Logger
+ // Sessions is the shared session manager; nil (or with a nil Config) when
+ // sessions are disabled.
+ Sessions *session.Manager
+}
+
+// Mount registers the landing-page route.
+func (h *Handler) Mount(m router.Mux) {
+ m.HandleCORS("/", h.handle)
+}
+
+func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
+ templates.RenderError(h.Templates, h.Logger, r, w, status, description)
+}
+
+func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
+ if h.Sessions == nil || h.Sessions.Config == nil || !h.Templates.HasHome() {
+ h.handleInline(w, r)
+ return
+ }
+
+ ctx := r.Context()
+
+ data := templates.HomeData{
+ DiscoveryURL: h.IssuerURL.JoinPath(".well-known", "openid-configuration").String(),
+ LogoutURL: h.IssuerURL.AbsURL("/logout"),
+ }
+
+ // ValidSession enforces the nonce AND absolute/idle expiry (clearing an
+ // expired session), so an expired-but-not-yet-purged cookie no longer renders
+ // a logged-in page.
+ if session := h.Sessions.ValidSession(ctx, w, r); session != nil {
+ data.LoggedIn = true
+ data.IPAddress = session.IPAddress
+ data.UserAgent = session.UserAgent
+ data.SignedInISO, data.SignedInText = timeFields(session.CreatedAt)
+ expiry, idle := sessionExpiry(session)
+ data.SessionExpiresISO, data.SessionExpiresText = timeFields(expiry)
+ data.SessionExpiryIsIdle = idle
+ h.populateData(ctx, &data, session.UserID, session.ConnectorID)
+ }
+
+ if err := h.Templates.Home(r, w, data); err != nil {
+ h.Logger.ErrorContext(ctx, "failed to render home template", "err", err)
+ h.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
+ }
+}
+
+// timeFields renders a timestamp for the page: an ISO 8601 string for the