diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 2714dee3..2966eee3 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -179,6 +179,8 @@ staticClients: redirectURIs: - 'http://127.0.0.1:5555/callback' - '/dex/device/callback' + postLogoutRedirectURIs: + - 'http://127.0.0.1:5555/' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 # Optional: restrict which connectors this client can use for authentication. diff --git a/examples/example-app/handlers.go b/examples/example-app/handlers.go deleted file mode 100644 index fce65d77..00000000 --- a/examples/example-app/handlers.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "net/url" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { - renderIndex(w, indexPageData{ - ScopesSupported: a.scopesSupported, - LogoURI: dexLogoDataURI, - }) -} - -func (a *app) handleLogin(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 - } - - // Only use scopes that are checked in the form - scopes := r.Form["extra_scopes"] - crossClients := r.Form["cross_client"] - - // Build complete scope list with audience scopes - scopes = buildScopes(scopes, crossClients) - - connectorID := "" - if id := r.FormValue("connector_id"); id != "" { - connectorID = id - } - - authCodeURL := "" - - var authCodeOptions []oauth2.AuthCodeOption - - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge", codeChallenge)) - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge_method", "S256")) - } - - // Check if offline_access scope is present to determine offline access mode - hasOfflineAccess := false - for _, scope := range scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - - if hasOfflineAccess && !a.offlineAsScope { - // Provider uses access_type=offline instead of offline_access scope - authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) - // Remove offline_access from scopes as it's not supported - filteredScopes := make([]string, 0, len(scopes)) - for _, scope := range scopes { - if scope != "offline_access" { - filteredScopes = append(filteredScopes, scope) - } - } - scopes = filteredScopes - } - - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) - - // Parse the auth code URL and safely add connector_id parameter if provided - u, err := url.Parse(authCodeURL) - if err != nil { - http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) - return - } - - if connectorID != "" { - query := u.Query() - query.Set("connector_id", connectorID) - u.RawQuery = query.Encode() - } - - http.Redirect(w, r, u.String(), http.StatusSeeOther) -} - -func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) { - var ( - err error - token *oauth2.Token - ) - - ctx := oidc.ClientContext(r.Context(), a.client) - oauth2Config := a.oauth2Config(nil) - switch r.Method { - case http.MethodGet: - // Authorization redirect callback from OAuth2 auth flow. - if errMsg := r.FormValue("error"); errMsg != "" { - http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) - return - } - code := r.FormValue("code") - if code == "" { - http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) - return - } - if state := r.FormValue("state"); state != exampleAppState { - http.Error(w, fmt.Sprintf("expected state %q got %q", exampleAppState, state), http.StatusBadRequest) - return - } - - var authCodeOptions []oauth2.AuthCodeOption - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) - } - - token, err = oauth2Config.Exchange(ctx, code, authCodeOptions...) - case http.MethodPost: - // Form request from frontend to refresh a token. - refresh := r.FormValue("refresh_token") - if refresh == "" { - http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) - return - } - t := &oauth2.Token{ - RefreshToken: refresh, - Expiry: time.Now().Add(-time.Hour), - } - token, err = oauth2Config.TokenSource(ctx, t).Token() - default: - http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) - return - } - - parseAndRenderToken(w, r, a, token) -} diff --git a/examples/example-app/handlers_device.go b/examples/example-app/handlers_device.go deleted file mode 100644 index 40209ca2..00000000 --- a/examples/example-app/handlers_device.go +++ /dev/null @@ -1,273 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - "golang.org/x/oauth2" -) - -func (a *app) handleDeviceLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse request body to get options - var reqBody struct { - Scopes []string `json:"scopes"` - CrossClients []string `json:"cross_clients"` - ConnectorID string `json:"connector_id"` - } - - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { - http.Error(w, fmt.Sprintf("failed to parse request body: %v", err), http.StatusBadRequest) - return - } - - // Build complete scope list with audience scopes (same as handleLogin) - scopes := buildScopes(reqBody.Scopes, reqBody.CrossClients) - - // Build scope string - scopeStr := strings.Join(scopes, " ") - - // Get device authorization endpoint - // Properly construct the device code endpoint URL - authURL := a.provider.Endpoint().AuthURL - deviceAuthURL := strings.TrimSuffix(authURL, "/auth") + "/device/code" - - // Request device code - data := url.Values{} - data.Set("client_id", a.clientID) - data.Set("client_secret", a.clientSecret) - data.Set("scope", scopeStr) - - // Add connector_id if specified - if reqBody.ConnectorID != "" { - data.Set("connector_id", reqBody.ConnectorID) - } - - resp, err := a.client.PostForm(deviceAuthURL, data) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body := new(bytes.Buffer) - body.ReadFrom(resp.Body) - http.Error(w, fmt.Sprintf("Device code request failed: %s", body.String()), resp.StatusCode) - return - } - - var deviceResp struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - VerificationURIComplete string `json:"verification_uri_complete"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` - } - - if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { - http.Error(w, fmt.Sprintf("Failed to decode device response: %v", err), http.StatusInternalServerError) - return - } - - // Store device flow data with new session - sessionID := generateSessionID() - - a.deviceFlowMutex.Lock() - a.deviceFlowData.sessionID = sessionID - a.deviceFlowData.deviceCode = deviceResp.DeviceCode - a.deviceFlowData.userCode = deviceResp.UserCode - a.deviceFlowData.verificationURI = deviceResp.VerificationURI - a.deviceFlowData.pollInterval = deviceResp.Interval - if a.deviceFlowData.pollInterval == 0 { - a.deviceFlowData.pollInterval = 5 - } - a.deviceFlowData.token = nil - a.deviceFlowMutex.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ok", - "session_id": sessionID, - }) -} - -func (a *app) handleDevicePage(w http.ResponseWriter, r *http.Request) { - a.deviceFlowMutex.Lock() - data := devicePageData{ - SessionID: a.deviceFlowData.sessionID, - DeviceCode: a.deviceFlowData.deviceCode, - UserCode: a.deviceFlowData.userCode, - VerificationURI: a.deviceFlowData.verificationURI, - PollInterval: a.deviceFlowData.pollInterval, - LogoURI: dexLogoDataURI, - } - a.deviceFlowMutex.Unlock() - - if data.DeviceCode == "" { - http.Error(w, "No device flow in progress", http.StatusBadRequest) - return - } - - renderDevice(w, data) -} - -func (a *app) handleDevicePoll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceCode string `json:"device_code"` - SessionID string `json:"session_id"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - a.deviceFlowMutex.Lock() - storedSessionID := a.deviceFlowData.sessionID - storedDeviceCode := a.deviceFlowData.deviceCode - existingToken := a.deviceFlowData.token - a.deviceFlowMutex.Unlock() - - // Check if this session has been superseded by a new one - if req.SessionID != storedSessionID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusGone) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "session_expired", - "error_description": "This device flow session has been superseded by a new one", - }) - return - } - - if req.DeviceCode != storedDeviceCode { - http.Error(w, "Invalid device code", http.StatusBadRequest) - return - } - - // If we already have a token, return success - if existingToken != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "complete", - }) - return - } - - // Poll the token endpoint - tokenURL := a.provider.Endpoint().TokenURL - - data := url.Values{} - data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") - data.Set("device_code", req.DeviceCode) - data.Set("client_id", a.clientID) - data.Set("client_secret", a.clientSecret) - - tokenResp, err := a.client.PostForm(tokenURL, data) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "pending", - }) - return - } - defer tokenResp.Body.Close() - - if tokenResp.StatusCode == http.StatusOK { - // Success! We got the token - // Parse the full response including id_token - var tokenData struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token"` - } - - if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { - http.Error(w, "Failed to decode token", http.StatusInternalServerError) - return - } - - // Create oauth2.Token with all fields - token := &oauth2.Token{ - AccessToken: tokenData.AccessToken, - TokenType: tokenData.TokenType, - RefreshToken: tokenData.RefreshToken, - } - - // Add id_token to Extra - token = token.WithExtra(map[string]interface{}{ - "id_token": tokenData.IDToken, - }) - - // Store the token - a.deviceFlowMutex.Lock() - a.deviceFlowData.token = token - a.deviceFlowMutex.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "complete", - }) - return - } - - // Check for errors - var errorResp struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - - if err := json.NewDecoder(tokenResp.Body).Decode(&errorResp); err == nil { - if errorResp.Error == "authorization_pending" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "pending", - }) - return - } - - // Other errors - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(tokenResp.StatusCode) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": errorResp.Error, - "error_description": errorResp.ErrorDescription, - }) - return - } - - // Unknown response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "pending", - }) -} - -func (a *app) handleDeviceResult(w http.ResponseWriter, r *http.Request) { - a.deviceFlowMutex.Lock() - token := a.deviceFlowData.token - a.deviceFlowMutex.Unlock() - - if token == nil { - http.Error(w, "No token available", http.StatusBadRequest) - return - } - - parseAndRenderToken(w, r, a, token) -} diff --git a/examples/example-app/main.go b/examples/example-app/main.go index 389d2ff0..fc265139 100644 --- a/examples/example-app/main.go +++ b/examples/example-app/main.go @@ -1,182 +1,51 @@ package main import ( - "context" "errors" "fmt" - "log" - "net/http" - "net/url" "os" - "sync" - "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" - "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/server" ) -const exampleAppState = "I wish to wash my irish wristwatch" - -var ( - codeVerifier string - codeChallenge string -) - -func init() { - codeVerifier = oauth2.GenerateVerifier() - codeChallenge = oauth2.S256ChallengeFromVerifier(codeVerifier) -} - -type app struct { - clientID string - clientSecret string - pkce bool - redirectURI string - - verifier *oidc.IDTokenVerifier - provider *oidc.Provider - scopesSupported []string - - // Does the provider use "offline_access" scope to request a refresh token - // or does it use "access_type=offline" (e.g. Google)? - offlineAsScope bool - - client *http.Client - - // Device flow state - // Only one session is possible at a time - // Since it is an example, we don't bother locking', this is a simplicity tradeoff - deviceFlowMutex sync.Mutex - deviceFlowData struct { - sessionID string // Unique ID for current flow session - deviceCode string - userCode string - verificationURI string - pollInterval int - token *oauth2.Token - } -} - func cmd() *cobra.Command { var ( - a app - issuerURL string - listen string - tlsCert string - tlsKey string - rootCAs string - debug bool + opts server.Options + listen string + tlsCert string + tlsKey string ) + c := cobra.Command{ Use: "example-app", Short: "An example OpenID Connect client", - Long: "", RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errors.New("surplus arguments provided") } - u, err := url.Parse(a.redirectURI) + s, err := server.New(opts) if err != nil { - return fmt.Errorf("parse redirect-uri: %v", err) - } - listenURL, err := url.Parse(listen) - if err != nil { - return fmt.Errorf("parse listen address: %v", err) - } - - if rootCAs != "" { - client, err := httpClientForRootCAs(rootCAs) - if err != nil { - return err - } - a.client = client - } - - if debug { - if a.client == nil { - a.client = &http.Client{ - Transport: debugTransport{http.DefaultTransport}, - } - } else { - a.client.Transport = debugTransport{a.client.Transport} - } - } - - if a.client == nil { - a.client = http.DefaultClient - } - - // TODO(ericchiang): Retry with backoff - ctx := oidc.ClientContext(context.Background(), a.client) - provider, err := oidc.NewProvider(ctx, issuerURL) - if err != nil { - return fmt.Errorf("failed to query provider %q: %v", issuerURL, err) - } - - var s struct { - // What scopes does a provider support? - // - // See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - ScopesSupported []string `json:"scopes_supported"` - } - if err := provider.Claims(&s); err != nil { - return fmt.Errorf("failed to parse provider scopes_supported: %v", err) - } - - if len(s.ScopesSupported) == 0 { - // scopes_supported is a "RECOMMENDED" discovery claim, not a required - // one. If missing, assume that the provider follows the spec and has - // an "offline_access" scope. - a.offlineAsScope = true - } else { - // See if scopes_supported has the "offline_access" scope. - a.offlineAsScope = func() bool { - for _, scope := range s.ScopesSupported { - if scope == oidc.ScopeOfflineAccess { - return true - } - } - return false - }() - } - - a.provider = provider - a.verifier = provider.Verifier(&oidc.Config{ClientID: a.clientID}) - a.scopesSupported = s.ScopesSupported - - http.Handle("/static/", http.StripPrefix("/static/", staticHandler)) - http.HandleFunc("/", a.handleIndex) - http.HandleFunc("/login", a.handleLogin) - http.HandleFunc("/device/login", a.handleDeviceLogin) - http.HandleFunc("/device", a.handleDevicePage) - http.HandleFunc("/device/poll", a.handleDevicePoll) - http.HandleFunc("/device/result", a.handleDeviceResult) - http.HandleFunc("/userinfo", a.handleUserInfo) - http.HandleFunc(u.Path, a.handleCallback) - - switch listenURL.Scheme { - case "http": - log.Printf("listening on %s", listen) - return http.ListenAndServe(listenURL.Host, nil) - case "https": - log.Printf("listening on %s", listen) - return http.ListenAndServeTLS(listenURL.Host, tlsCert, tlsKey, nil) - default: - return fmt.Errorf("listen address %q is not using http or https", listen) + return err } + return s.Run(listen, tlsCert, tlsKey) }, } - c.Flags().StringVar(&a.clientID, "client-id", "example-app", "OAuth2 client ID of this application.") - c.Flags().StringVar(&a.clientSecret, "client-secret", "ZXhhbXBsZS1hcHAtc2VjcmV0", "OAuth2 client secret of this application.") - c.Flags().BoolVar(&a.pkce, "pkce", true, "Use PKCE flow for the code exchange.") - c.Flags().StringVar(&a.redirectURI, "redirect-uri", "http://127.0.0.1:5555/callback", "Callback URL for OAuth2 responses.") - c.Flags().StringVar(&issuerURL, "issuer", "http://127.0.0.1:5556/dex", "URL of the OpenID Connect issuer.") + + c.Flags().StringVar(&opts.ClientID, "client-id", "example-app", "OAuth2 client ID of this application.") + c.Flags().StringVar(&opts.ClientSecret, "client-secret", "ZXhhbXBsZS1hcHAtc2VjcmV0", "OAuth2 client secret of this application.") + c.Flags().BoolVar(&opts.PKCE, "pkce", true, "Use PKCE flow for the code exchange.") + c.Flags().StringVar(&opts.RedirectURI, "redirect-uri", "http://127.0.0.1:5555/callback", "Callback URL for OAuth2 responses.") + c.Flags().StringVar(&opts.IssuerURL, "issuer", "http://127.0.0.1:5556/dex", "URL of the OpenID Connect issuer.") c.Flags().StringVar(&listen, "listen", "http://127.0.0.1:5555", "HTTP(S) address to listen at.") c.Flags().StringVar(&tlsCert, "tls-cert", "", "X509 cert file to present when serving HTTPS.") c.Flags().StringVar(&tlsKey, "tls-key", "", "Private key for the HTTPS cert.") - c.Flags().StringVar(&rootCAs, "issuer-root-ca", "", "Root certificate authorities for the issuer. Defaults to host certs.") - c.Flags().BoolVar(&debug, "debug", false, "Print all request and responses from the OpenID Connect issuer.") + c.Flags().StringVar(&opts.RootCAs, "issuer-root-ca", "", "Root certificate authorities for the issuer. Defaults to host certs.") + c.Flags().BoolVar(&opts.Debug, "debug", false, "Print all request and responses from the OpenID Connect issuer.") + c.Flags().BoolVar(&opts.SessionAware, "session-aware", false, "Check Dex session on index page via prompt=none and show logout button.") + return &c } diff --git a/examples/example-app/server/authcode.go b/examples/example-app/server/authcode.go new file mode 100644 index 00000000..aedf8ef5 --- /dev/null +++ b/examples/example-app/server/authcode.go @@ -0,0 +1,194 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleLoginPage renders the login page with available scopes. +// When session-aware mode is enabled, it checks for an existing Dex session +// via prompt=none and displays the authenticated user if found. +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + data := LoginPageData{ + ScopesSupported: s.scopesSupported, + LogoURI: dexLogoDataURI, + } + + if s.sessionAware { + authState := s.auth.Get() + + if authState.Claims != nil { + data.User = authState.Claims + data.LogoutURL = "/app-logout" + } else if !authState.Checked { + // First visit: redirect to Dex with prompt=none to check session. + scopes := []string{"openid", "profile", "email"} + + var opts []oauth2.AuthCodeOption + opts = append(opts, oauth2.SetAuthURLParam("prompt", "none")) + if s.pkce { + opts = append(opts, oauth2.SetAuthURLParam("code_challenge", s.codeChallenge)) + opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256")) + } + + authCodeURL := s.oauth2Config(scopes).AuthCodeURL(silentAuthState, opts...) + http.Redirect(w, r, authCodeURL, http.StatusFound) + return + } else { + data.NotLoggedIn = true + } + } + + s.renderer.RenderLoginPage(w, data) +} + +// handleLogin initiates the Authorization Code Flow by redirecting to the IdP. +func (s *Server) handleLogin(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 + } + + scopes := buildScopes(r.Form["extra_scopes"], r.Form["cross_client"]) + connectorID := r.FormValue("connector_id") + + var authCodeOptions []oauth2.AuthCodeOption + + if s.pkce { + authCodeOptions = append(authCodeOptions, + oauth2.SetAuthURLParam("code_challenge", s.codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + } + + // If provider doesn't support "offline_access" scope natively, + // use "access_type=offline" parameter instead (e.g. Google). + hasOfflineAccess := false + for _, scope := range scopes { + if scope == oidc.ScopeOfflineAccess { + hasOfflineAccess = true + break + } + } + + if hasOfflineAccess && !s.offlineAsScope { + authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) + filtered := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if scope != oidc.ScopeOfflineAccess { + filtered = append(filtered, scope) + } + } + scopes = filtered + } + + authCodeURL := s.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) + + u, err := url.Parse(authCodeURL) + if err != nil { + http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) + return + } + + if connectorID != "" { + query := u.Query() + query.Set("connector_id", connectorID) + u.RawQuery = query.Encode() + } + + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +// handleAuthCallback handles the OAuth2 authorization redirect callback. +// It validates the state parameter and exchanges the authorization code for tokens. +// It also handles silent auth callbacks (prompt=none) for session detection. +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.FormValue("state") + + // Silent auth callback (prompt=none). + if state == silentAuthState { + ctx := oidc.ClientContext(r.Context(), s.client) + claims, rawIDToken := s.exchangeSilentAuth(ctx, r, s.oauth2Config(nil)) + s.auth.Set(claims, rawIDToken) + http.Redirect(w, r, "/", http.StatusFound) + return + } + + // Normal authorization code callback. + if errMsg := r.FormValue("error"); errMsg != "" { + http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) + return + } + + code := r.FormValue("code") + if code == "" { + http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) + return + } + + if state != exampleAppState { + http.Error(w, fmt.Sprintf("expected state %q got %q", exampleAppState, state), http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + var exchangeOpts []oauth2.AuthCodeOption + if s.pkce { + exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("code_verifier", s.codeVerifier)) + } + + token, err := s.oauth2Config(nil).Exchange(ctx, code, exchangeOpts...) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) + return + } + + s.renderTokenResult(w, r, token) +} + +// exchangeSilentAuth attempts a token exchange for a silent auth callback. +// Returns the parsed claims and raw ID token on success, or (nil, "") on any failure. +func (s *Server) exchangeSilentAuth(ctx context.Context, r *http.Request, oauth2Config *oauth2.Config) (*session.UserClaims, string) { + if r.FormValue("error") != "" { + return nil, "" + } + + code := r.FormValue("code") + if code == "" { + return nil, "" + } + + var opts []oauth2.AuthCodeOption + if s.pkce { + opts = append(opts, oauth2.SetAuthURLParam("code_verifier", s.codeVerifier)) + } + + token, err := oauth2Config.Exchange(ctx, code, opts...) + if err != nil { + return nil, "" + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + log.Printf("silent auth: no id_token in response") + return nil, "" + } + + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + return nil, "" + } + + var claims session.UserClaims + _ = idToken.Claims(&claims) + return &claims, rawIDToken +} diff --git a/examples/example-app/server/devicecode.go b/examples/example-app/server/devicecode.go new file mode 100644 index 00000000..b121c30d --- /dev/null +++ b/examples/example-app/server/devicecode.go @@ -0,0 +1,220 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleDeviceStart initiates the Device Code Flow by requesting a device code from the IdP. +func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { + var reqBody struct { + Scopes []string `json:"scopes"` + CrossClients []string `json:"cross_clients"` + ConnectorID string `json:"connector_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, fmt.Sprintf("failed to parse request body: %v", err), http.StatusBadRequest) + return + } + + scopes := buildScopes(reqBody.Scopes, reqBody.CrossClients) + + data := url.Values{} + data.Set("client_id", s.clientID) + data.Set("client_secret", s.clientSecret) + data.Set("scope", strings.Join(scopes, " ")) + if reqBody.ConnectorID != "" { + data.Set("connector_id", reqBody.ConnectorID) + } + + resp, err := s.client.PostForm(s.deviceAuthURL, data) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := new(bytes.Buffer) + body.ReadFrom(resp.Body) + http.Error(w, fmt.Sprintf("Device code request failed: %s", body.String()), resp.StatusCode) + return + } + + var deviceResp struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + } + + if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { + http.Error(w, fmt.Sprintf("Failed to decode device response: %v", err), http.StatusInternalServerError) + return + } + + pollInterval := deviceResp.Interval + if pollInterval == 0 { + pollInterval = 5 + } + + sessionID := s.devices.Save(session.DeviceState{ + DeviceCode: deviceResp.DeviceCode, + UserCode: deviceResp.UserCode, + VerificationURI: deviceResp.VerificationURI, + PollInterval: pollInterval, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "session_id": sessionID, + }) +} + +// handleDeviceStatus renders the device flow pending page with verification URL and user code. +func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) { + // JS redirects here without session_id, so always show the latest session. + sessionID, state, ok := s.devices.GetLatest() + if !ok { + http.Error(w, "No device flow in progress", http.StatusBadRequest) + return + } + + s.renderer.RenderDevicePage(w, DevicePageData{ + SessionID: sessionID, + DeviceCode: state.DeviceCode, + UserCode: state.UserCode, + VerificationURI: state.VerificationURI, + PollInterval: state.PollInterval, + LogoURI: dexLogoDataURI, + }) +} + +// handleDevicePoll polls the token endpoint on behalf of the device. +func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceCode string `json:"device_code"` + SessionID string `json:"session_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + state, ok := s.devices.Get(req.SessionID) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGone) + json.NewEncoder(w).Encode(map[string]any{ + "error": "session_expired", + "error_description": "This device flow session has been superseded by a new one", + }) + return + } + + if req.DeviceCode != state.DeviceCode { + http.Error(w, "Invalid device code", http.StatusBadRequest) + return + } + + // If we already have a token, return success. + if state.Token != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "complete"}) + return + } + + // Poll the token endpoint. + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + data.Set("device_code", req.DeviceCode) + data.Set("client_id", s.clientID) + data.Set("client_secret", s.clientSecret) + + tokenResp, err := s.client.PostForm(s.provider.Endpoint().TokenURL, data) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + defer tokenResp.Body.Close() + + if tokenResp.StatusCode == http.StatusOK { + var tokenData struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + } + + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { + http.Error(w, "Failed to decode token", http.StatusInternalServerError) + return + } + + token := (&oauth2.Token{ + AccessToken: tokenData.AccessToken, + TokenType: tokenData.TokenType, + RefreshToken: tokenData.RefreshToken, + }).WithExtra(map[string]any{ + "id_token": tokenData.IDToken, + }) + + s.devices.SetToken(req.SessionID, token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "complete"}) + return + } + + // Check for OAuth2 error response. + var errorResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + if err := json.NewDecoder(tokenResp.Body).Decode(&errorResp); err == nil { + if errorResp.Error == "authorization_pending" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tokenResp.StatusCode) + json.NewEncoder(w).Encode(map[string]any{ + "error": errorResp.Error, + "error_description": errorResp.ErrorDescription, + }) + return + } + + // Unknown response — treat as pending. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) +} + +// handleDeviceComplete displays the token obtained via the Device Code Flow. +func (s *Server) handleDeviceComplete(w http.ResponseWriter, r *http.Request) { + // JS redirects here without session_id, so always show the latest session. + _, state, ok := s.devices.GetLatest() + if !ok || state.Token == nil { + http.Error(w, "No token available", http.StatusBadRequest) + return + } + + s.renderTokenResult(w, r, state.Token) +} diff --git a/examples/example-app/server/logout.go b/examples/example-app/server/logout.go new file mode 100644 index 00000000..7aa9c730 --- /dev/null +++ b/examples/example-app/server/logout.go @@ -0,0 +1,36 @@ +package server + +import ( + "net/http" + "net/url" +) + +// handleAppLogout clears the local session and redirects to the provider's +// end_session_endpoint for RP-Initiated Logout (if available). +func (s *Server) handleAppLogout(w http.ResponseWriter, r *http.Request) { + idToken := s.auth.Clear() + + if s.endSessionEndpoint == "" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + logoutURL, err := url.Parse(s.endSessionEndpoint) + if err != nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + q := logoutURL.Query() + if idToken != "" { + q.Set("id_token_hint", idToken) + } + // Derive app base URL from redirect URI for post-logout redirect. + if appURL, err := url.Parse(s.redirectURI); err == nil { + appURL.Path = "/" + appURL.RawQuery = "" + q.Set("post_logout_redirect_uri", appURL.String()) + } + logoutURL.RawQuery = q.Encode() + http.Redirect(w, r, logoutURL.String(), http.StatusFound) +} diff --git a/examples/example-app/server/render.go b/examples/example-app/server/render.go new file mode 100644 index 00000000..93f12562 --- /dev/null +++ b/examples/example-app/server/render.go @@ -0,0 +1,119 @@ +package server + +import ( + "embed" + "html/template" + "io/fs" + "log" + "net/http" + + "github.com/dexidp/dex/examples/example-app/session" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +const dexLogoDataURI = "/static/dex-glyph-color.svg" + +// staticHandler serves embedded static assets. +var staticHandler http.Handler + +func init() { + staticSubFS, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatalf("failed to create static sub filesystem: %v", err) + } + staticHandler = http.FileServer(http.FS(staticSubFS)) +} + +// LoginPageData holds data for the login page template. +type LoginPageData struct { + ScopesSupported []string + LogoURI string + User *session.UserClaims + NotLoggedIn bool + LogoutURL string +} + +// TokenPageData holds data for the token display template. +type TokenPageData struct { + IDToken string + IDTokenJWTLink string + AccessToken string + AccessTokenJWTLink string + RefreshToken string + RedirectURL string + Claims string + PublicKeyPEM string +} + +// DevicePageData holds data for the device flow template. +type DevicePageData struct { + SessionID string + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + LogoURI string +} + +// Renderer renders HTML pages for the application. +type Renderer interface { + RenderLoginPage(w http.ResponseWriter, data LoginPageData) + RenderTokenPage(w http.ResponseWriter, data TokenPageData) + RenderDevicePage(w http.ResponseWriter, data DevicePageData) +} + +// templateRenderer implements Renderer using Go html/template. +type templateRenderer struct { + index *template.Template + token *template.Template + device *template.Template +} + +// newTemplateRenderer parses embedded templates and returns a Renderer. +func newTemplateRenderer() Renderer { + parse := func(name string) *template.Template { + t, err := template.ParseFS(templatesFS, name) + if err != nil { + log.Fatalf("failed to parse template %s: %v", name, err) + } + return t + } + + return &templateRenderer{ + index: parse("templates/index.html"), + token: parse("templates/token.html"), + device: parse("templates/device.html"), + } +} + +func (r *templateRenderer) RenderLoginPage(w http.ResponseWriter, data LoginPageData) { + renderTemplate(w, r.index, data) +} + +func (r *templateRenderer) RenderTokenPage(w http.ResponseWriter, data TokenPageData) { + renderTemplate(w, r.token, data) +} + +func (r *templateRenderer) RenderDevicePage(w http.ResponseWriter, data DevicePageData) { + renderTemplate(w, r.device, data) +} + +func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data any) { + err := tmpl.Execute(w, data) + if err == nil { + return + } + + switch err := err.(type) { + case *template.Error: + log.Printf("Error rendering template %s: %s", tmpl.Name(), err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + default: + // An error with the underlying writer (e.g. connection dropped). Ignore. + } +} diff --git a/examples/example-app/server/server.go b/examples/example-app/server/server.go new file mode 100644 index 00000000..3e46aadc --- /dev/null +++ b/examples/example-app/server/server.go @@ -0,0 +1,204 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "slices" + "syscall" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +const ( + // exampleAppState is a static CSRF state parameter. + // In production, this must be a cryptographically random per-request value. + exampleAppState = "I wish to wash my irish wristwatch" + + // silentAuthState is the state value used for prompt=none session checks. + silentAuthState = "silent-auth-check" +) + +// Options configures the Server. +type Options struct { + ClientID string + ClientSecret string + RedirectURI string + IssuerURL string + PKCE bool + SessionAware bool + RootCAs string + Debug bool +} + +// Server is the HTTP server for the example OIDC client application. +type Server struct { + clientID string + clientSecret string + redirectURI string + pkce bool + sessionAware bool + + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + scopesSupported []string + offlineAsScope bool + codeVerifier string + codeChallenge string + + // Discovered endpoint URLs + deviceAuthURL string + userInfoURL string + jwksURL string + endSessionEndpoint string + + client *http.Client + renderer Renderer + devices session.DeviceStore + auth session.AuthStore +} + +// New creates a Server by performing OIDC discovery and initializing dependencies. +func New(opts Options) (*Server, error) { + client, err := newHTTPClient(opts.RootCAs, opts.Debug) + if err != nil { + return nil, err + } + + ctx := oidc.ClientContext(context.Background(), client) + provider, err := oidc.NewProvider(ctx, opts.IssuerURL) + if err != nil { + return nil, fmt.Errorf("failed to query provider %q: %v", opts.IssuerURL, err) + } + + // Extract discovery metadata: scopes and endpoint URLs. + var discovery struct { + ScopesSupported []string `json:"scopes_supported"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + JWKSURI string `json:"jwks_uri"` + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := provider.Claims(&discovery); err != nil { + return nil, fmt.Errorf("failed to parse provider discovery claims: %v", err) + } + + // Determine offline access strategy. + offlineAsScope := true + if len(discovery.ScopesSupported) > 0 { + offlineAsScope = slices.Contains(discovery.ScopesSupported, oidc.ScopeOfflineAccess) + } + + s := &Server{ + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + redirectURI: opts.RedirectURI, + pkce: opts.PKCE, + sessionAware: opts.SessionAware, + + provider: provider, + verifier: provider.Verifier(&oidc.Config{ClientID: opts.ClientID}), + scopesSupported: discovery.ScopesSupported, + offlineAsScope: offlineAsScope, + + deviceAuthURL: discovery.DeviceAuthorizationEndpoint, + userInfoURL: discovery.UserInfoEndpoint, + jwksURL: discovery.JWKSURI, + endSessionEndpoint: discovery.EndSessionEndpoint, + + client: client, + renderer: newTemplateRenderer(), + devices: session.NewMemoryDeviceStore(), + auth: session.NewMemoryAuthStore(), + } + + if s.pkce { + s.codeVerifier = oauth2.GenerateVerifier() + s.codeChallenge = oauth2.S256ChallengeFromVerifier(s.codeVerifier) + } + + return s, nil +} + +// oauth2Config returns an oauth2.Config for the given scopes. +func (s *Server) oauth2Config(scopes []string) *oauth2.Config { + return &oauth2.Config{ + ClientID: s.clientID, + ClientSecret: s.clientSecret, + Endpoint: s.provider.Endpoint(), + Scopes: scopes, + RedirectURL: s.redirectURI, + } +} + +// routes builds the HTTP handler with all application routes. +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler)) + + mux.HandleFunc("GET /{$}", s.handleLoginPage) + mux.HandleFunc("POST /login", s.handleLogin) + + // Parse redirect URI to register callback on the correct path. + callbackPath := "/callback" + if u, err := url.Parse(s.redirectURI); err == nil { + callbackPath = u.Path + } + mux.HandleFunc("GET "+callbackPath, s.handleAuthCallback) + mux.HandleFunc("POST "+callbackPath, s.handleTokenRefresh) + + mux.HandleFunc("POST /device/login", s.handleDeviceStart) + mux.HandleFunc("GET /device", s.handleDeviceStatus) + mux.HandleFunc("POST /device/poll", s.handleDevicePoll) + mux.HandleFunc("GET /device/result", s.handleDeviceComplete) + + mux.HandleFunc("POST /userinfo", s.handleUserInfo) + mux.HandleFunc("GET /app-logout", s.handleAppLogout) + + return mux +} + +// Run starts the HTTP(S) server with graceful shutdown on SIGINT/SIGTERM. +func (s *Server) Run(listenAddr, tlsCert, tlsKey string) error { + u, err := url.Parse(listenAddr) + if err != nil { + return fmt.Errorf("parse listen address: %v", err) + } + + srv := &http.Server{ + Addr: u.Host, + Handler: s.routes(), + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + log.Printf("listening on %s", listenAddr) + switch u.Scheme { + case "http": + errCh <- srv.ListenAndServe() + case "https": + errCh <- srv.ListenAndServeTLS(tlsCert, tlsKey) + default: + errCh <- fmt.Errorf("listen address %q is not using http or https", listenAddr) + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Println("shutting down...") + return srv.Shutdown(context.Background()) + } +} diff --git a/examples/example-app/static/app.js b/examples/example-app/server/static/app.js similarity index 100% rename from examples/example-app/static/app.js rename to examples/example-app/server/static/app.js diff --git a/examples/example-app/static/device.js b/examples/example-app/server/static/device.js similarity index 100% rename from examples/example-app/static/device.js rename to examples/example-app/server/static/device.js diff --git a/examples/example-app/static/dex-glyph-color.svg b/examples/example-app/server/static/dex-glyph-color.svg similarity index 98% rename from examples/example-app/static/dex-glyph-color.svg rename to examples/example-app/server/static/dex-glyph-color.svg index 2668039f..5852a5f3 100644 --- a/examples/example-app/static/dex-glyph-color.svg +++ b/examples/example-app/server/static/dex-glyph-color.svg @@ -1,20 +1,20 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/examples/example-app/static/style.css b/examples/example-app/server/static/style.css similarity index 86% rename from examples/example-app/static/style.css rename to examples/example-app/server/static/style.css index fafca567..8abc600f 100644 --- a/examples/example-app/static/style.css +++ b/examples/example-app/server/static/style.css @@ -262,28 +262,100 @@ button { background-color: #357FAA; } -/* Token page styles */ -.back-button { +/* Token page header */ +.token-header { + position: sticky; + top: 0; + background-color: #f2f2f2; + padding: 12px 0; + margin-bottom: 15px; + z-index: 10; +} + +.header-back-link { + color: #3F9FD8; + text-decoration: none; + font-size: 14px; + font-weight: 500; +} + +.header-back-link:hover { + text-decoration: underline; +} + +/* User card on index page */ +.user-card { + background-color: #fff; + padding: 20px 25px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; + margin-bottom: 20px; + border-left: 4px solid #3F9FD8; +} + +.user-card-title { + font-weight: 700; + font-size: 15px; + color: #333; + margin-bottom: 12px; +} + +.user-info-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + font-size: 14px; +} + +.user-info-label { + color: #666; + font-weight: 500; +} + +.user-info-value { + color: #333; + word-break: break-all; +} + +.user-info-sub { + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + color: #888; +} + +.logout-button { display: inline-block; - padding: 8px 16px; + margin-top: 15px; + padding: 8px 20px; background-color: #EF4B5C; color: white; border: none; border-radius: 4px; - cursor: pointer; - font-size: 12px; + font-size: 13px; + font-weight: 500; text-decoration: none; - transition: background-color 0.3s ease, transform 0.2s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - position: fixed; - right: 20px; - bottom: 20px; + cursor: pointer; + transition: background-color 0.2s; } -.back-button:hover { +.logout-button:hover { background-color: #C43B4B; } +.guest-message { + background-color: #fff; + padding: 15px 25px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; + margin-bottom: 20px; + text-align: center; + color: #666; + font-size: 14px; + border-left: 4px solid #ccc; +} + .token-block { background-color: #fff; padding: 10px 15px; diff --git a/examples/example-app/static/token.js b/examples/example-app/server/static/token.js similarity index 100% rename from examples/example-app/static/token.js rename to examples/example-app/server/static/token.js diff --git a/examples/example-app/templates/device.html b/examples/example-app/server/templates/device.html similarity index 100% rename from examples/example-app/templates/device.html rename to examples/example-app/server/templates/device.html diff --git a/examples/example-app/templates/index.html b/examples/example-app/server/templates/index.html similarity index 72% rename from examples/example-app/templates/index.html rename to examples/example-app/server/templates/index.html index 063b154f..c072c718 100644 --- a/examples/example-app/templates/index.html +++ b/examples/example-app/server/templates/index.html @@ -3,12 +3,38 @@ - Example App - Login + Example App
+ {{if .User}} +
+
Authenticated User
+ + {{if .LogoutURL}} + Logout + {{end}} +
+ {{else if .NotLoggedIn}} +
No active session. Log in to get started.
+ {{end}}
This is an example application for Dex OpenID Connect provider.
diff --git a/examples/example-app/templates/token.html b/examples/example-app/server/templates/token.html similarity index 96% rename from examples/example-app/templates/token.html rename to examples/example-app/server/templates/token.html index b003deeb..c538a612 100644 --- a/examples/example-app/templates/token.html +++ b/examples/example-app/server/templates/token.html @@ -7,6 +7,9 @@ + {{ if .IDToken }}
@@ -76,8 +79,6 @@
{{ end }} - Back to Home - diff --git a/examples/example-app/server/token.go b/examples/example-app/server/token.go new file mode 100644 index 00000000..a708a099 --- /dev/null +++ b/examples/example-app/server/token.go @@ -0,0 +1,163 @@ +package server + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleTokenRefresh redeems a refresh token for a new token set. +func (s *Server) handleTokenRefresh(w http.ResponseWriter, r *http.Request) { + refresh := r.FormValue("refresh_token") + if refresh == "" { + http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + t := &oauth2.Token{ + RefreshToken: refresh, + Expiry: time.Now().Add(-time.Hour), + } + token, err := s.oauth2Config(nil).TokenSource(ctx, t).Token() + if err != nil { + http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) + return + } + + s.renderTokenResult(w, r, token) +} + +// renderTokenResult verifies an ID token, extracts claims, and renders the token page. +func (s *Server) renderTokenResult(w http.ResponseWriter, r *http.Request, token *oauth2.Token) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "no id_token in token response", http.StatusInternalServerError) + return + } + + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) + return + } + + accessToken, ok := token.Extra("access_token").(string) + if !ok { + accessToken = token.AccessToken + if accessToken == "" { + http.Error(w, "no access_token in token response", http.StatusInternalServerError) + return + } + } + + // Persist claims for session-aware index page and logout. + var uc session.UserClaims + _ = idToken.Claims(&uc) + s.auth.Set(&uc, rawIDToken) + + claims, err := encodeIDTokenClaims(idToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderer.RenderTokenPage(w, TokenPageData{ + IDToken: rawIDToken, + IDTokenJWTLink: jwtIOLink(rawIDToken), + AccessToken: accessToken, + AccessTokenJWTLink: jwtIOLink(accessToken), + RefreshToken: token.RefreshToken, + RedirectURL: s.redirectURI, + Claims: claims, + PublicKeyPEM: s.fetchPublicKeyPEM(), + }) +} + +// encodeIDTokenClaims extracts and pretty-prints the claims from an ID token. +func encodeIDTokenClaims(idToken *oidc.IDToken) (string, error) { + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + return "", fmt.Errorf("error decoding ID token claims: %v", err) + } + + buf := new(bytes.Buffer) + if err := json.Indent(buf, claims, "", " "); err != nil { + return "", fmt.Errorf("error indenting ID token claims: %v", err) + } + return buf.String(), nil +} + +// 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, + })) +} diff --git a/examples/example-app/server/transport.go b/examples/example-app/server/transport.go new file mode 100644 index 00000000..47fa9fa9 --- /dev/null +++ b/examples/example-app/server/transport.go @@ -0,0 +1,104 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "os" + "slices" + "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 +} + +// buildScopes constructs a scope list from base scopes and cross-client IDs. +func buildScopes(baseScopes, crossClients []string) []string { + scopes := make([]string, len(baseScopes)) + copy(scopes, baseScopes) + + for _, client := range crossClients { + if client != "" { + scopes = append(scopes, "audience:server:client_id:"+client) + } + } + + return uniqueStrings(scopes) +} + +// uniqueStrings deduplicates and sorts a string slice in place. +func uniqueStrings(values []string) []string { + slices.Sort(values) + return slices.Compact(values) +} diff --git a/examples/example-app/handlers_userinfo.go b/examples/example-app/server/userinfo.go similarity index 59% rename from examples/example-app/handlers_userinfo.go rename to examples/example-app/server/userinfo.go index 36bab851..c8e331fd 100644 --- a/examples/example-app/handlers_userinfo.go +++ b/examples/example-app/server/userinfo.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" @@ -7,13 +7,8 @@ import ( "net/http" ) -func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse form to get access token +// handleUserInfo fetches user information from the provider's UserInfo endpoint. +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 @@ -25,25 +20,15 @@ func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { return } - // Get UserInfo endpoint from provider - userInfoEndpoint := a.provider.Endpoint().AuthURL - if len(userInfoEndpoint) > 5 { - // Replace /auth with /userinfo - userInfoEndpoint = userInfoEndpoint[:len(userInfoEndpoint)-5] + "/userinfo" - } - - // Create request to UserInfo endpoint - req, err := http.NewRequestWithContext(r.Context(), "GET", userInfoEndpoint, nil) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.userInfoURL, nil) if err != nil { http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) return } - // Add Authorization header with access token req.Header.Set("Authorization", "Bearer "+accessToken) - // Make the request - resp, err := a.client.Do(req) + resp, err := s.client.Do(req) if err != nil { http.Error(w, fmt.Sprintf("Failed to fetch userinfo: %v", err), http.StatusInternalServerError) return @@ -56,8 +41,7 @@ func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { return } - // Parse and return the userinfo - var userInfo map[string]interface{} + var userInfo map[string]any if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { http.Error(w, fmt.Sprintf("Failed to decode userinfo: %v", err), http.StatusInternalServerError) return diff --git a/examples/example-app/session/auth.go b/examples/example-app/session/auth.go new file mode 100644 index 00000000..46fdc025 --- /dev/null +++ b/examples/example-app/session/auth.go @@ -0,0 +1,68 @@ +package session + +import "sync" + +// 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"` +} + +// AuthState holds the authenticated user's state. +type AuthState struct { + Claims *UserClaims + IDToken string + Checked bool +} + +// AuthStore manages the application's authentication session state. +type AuthStore interface { + // Set stores authenticated user claims and the raw ID token, + // and marks the session as checked. + Set(claims *UserClaims, rawIDToken string) + + // Get returns the current authentication session state. + Get() AuthState + + // Clear resets the session and returns the last raw ID token (for logout). + Clear() string +} + +// memoryAuthStore is an in-memory AuthStore. +type memoryAuthStore struct { + mu sync.RWMutex + state AuthState +} + +// NewMemoryAuthStore creates an in-memory AuthStore. +func NewMemoryAuthStore() AuthStore { + return &memoryAuthStore{} +} + +func (s *memoryAuthStore) Set(claims *UserClaims, rawIDToken string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.state.Claims = claims + if rawIDToken != "" { + s.state.IDToken = rawIDToken + } + s.state.Checked = true +} + +func (s *memoryAuthStore) Get() AuthState { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state +} + +func (s *memoryAuthStore) Clear() string { + s.mu.Lock() + defer s.mu.Unlock() + + idToken := s.state.IDToken + s.state = AuthState{} + return idToken +} diff --git a/examples/example-app/session/device.go b/examples/example-app/session/device.go new file mode 100644 index 00000000..4f0a1bcd --- /dev/null +++ b/examples/example-app/session/device.go @@ -0,0 +1,104 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" + + "golang.org/x/oauth2" +) + +// DeviceState holds the state of an active Device Code Flow. +type DeviceState struct { + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + Token *oauth2.Token +} + +// DeviceStore manages Device Code Flow sessions. +type DeviceStore interface { + // Save stores a new session and returns its ID. + // Previous sessions are considered invalidated. + Save(state DeviceState) string + + // Get returns a session by ID. Returns false if the session + // is not found or has been invalidated by a newer one. + Get(sessionID string) (DeviceState, bool) + + // GetLatest returns the most recent session and its ID. + // Returns false if no session exists. + GetLatest() (string, DeviceState, bool) + + // SetToken attaches a token to the session. + // Returns false if the session is not found or not current. + SetToken(sessionID string, token *oauth2.Token) bool +} + +// memoryDeviceStore is an in-memory DeviceStore that supports +// a single active session at a time. +type memoryDeviceStore struct { + mu sync.Mutex + sessionID string + state DeviceState +} + +// NewMemoryDeviceStore creates an in-memory DeviceStore. +func NewMemoryDeviceStore() DeviceStore { + return &memoryDeviceStore{} +} + +func (s *memoryDeviceStore) Save(state DeviceState) string { + id := generateSessionID() + + s.mu.Lock() + defer s.mu.Unlock() + + s.sessionID = id + s.state = state + + return id +} + +func (s *memoryDeviceStore) Get(sessionID string) (DeviceState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID != s.sessionID { + return DeviceState{}, false + } + return s.state, true +} + +func (s *memoryDeviceStore) GetLatest() (string, DeviceState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.sessionID == "" { + return "", DeviceState{}, false + } + return s.sessionID, s.state, true +} + +func (s *memoryDeviceStore) SetToken(sessionID string, token *oauth2.Token) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID != s.sessionID { + return false + } + s.state.Token = token + return true +} + +// generateSessionID creates a random session identifier. +func generateSessionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/examples/example-app/templates.go b/examples/example-app/templates.go deleted file mode 100644 index b3ae7396..00000000 --- a/examples/example-app/templates.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "context" - "crypto/rsa" - "crypto/x509" - "embed" - "encoding/base64" - "encoding/json" - "encoding/pem" - "html/template" - "io/fs" - "log" - "math/big" - "net/http" - "net/url" - - "github.com/coreos/go-oidc/v3/oidc" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -//go:embed static/* -var staticFS embed.FS - -const dexLogoDataURI = "/static/dex-glyph-color.svg" - -var ( - indexTmpl *template.Template - tokenTmpl *template.Template - deviceTmpl *template.Template - staticHandler http.Handler -) - -func init() { - var err error - indexTmpl, err = template.ParseFS(templatesFS, "templates/index.html") - if err != nil { - log.Fatalf("failed to parse index template: %v", err) - } - - tokenTmpl, err = template.ParseFS(templatesFS, "templates/token.html") - if err != nil { - log.Fatalf("failed to parse token template: %v", err) - } - - deviceTmpl, err = template.ParseFS(templatesFS, "templates/device.html") - if err != nil { - log.Fatalf("failed to parse device template: %v", err) - } - - // Create handler for static files - staticSubFS, err := fs.Sub(staticFS, "static") - if err != nil { - log.Fatalf("failed to create static sub filesystem: %v", err) - } - staticHandler = http.FileServer(http.FS(staticSubFS)) -} - -func renderIndex(w http.ResponseWriter, data indexPageData) { - renderTemplate(w, indexTmpl, data) -} - -func renderDevice(w http.ResponseWriter, data devicePageData) { - renderTemplate(w, deviceTmpl, data) -} - -type indexPageData struct { - ScopesSupported []string - LogoURI string -} - -type devicePageData struct { - SessionID string - DeviceCode string - UserCode string - VerificationURI string - PollInterval int - LogoURI string -} - -type tokenTmplData struct { - IDToken string - IDTokenJWTLink string - AccessToken string - AccessTokenJWTLink string - RefreshToken string - RedirectURL string - Claims string - PublicKeyPEM string -} - -func generateJWTIOLink(token string, provider *oidc.Provider, ctx context.Context) string { - // JWT.io doesn't support automatic public key via URL parameter - // The public key is displayed separately on the page for manual copy-paste - return "https://jwt.io/#debugger-io?token=" + url.QueryEscape(token) -} - -func getPublicKeyPEM(provider *oidc.Provider) string { - if provider == nil { - return "" - } - - jwksURL := provider.Endpoint().AuthURL - if len(jwksURL) > 5 { - jwksURL = jwksURL[:len(jwksURL)-5] + "/keys" - } else { - return "" - } - - resp, err := http.Get(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 "" - } - - pubKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: pubKeyBytes, - }) - - return string(pubKeyPEM) -} - -func renderToken(w http.ResponseWriter, ctx context.Context, provider *oidc.Provider, redirectURL, idToken, accessToken, refreshToken, claims string) { - data := tokenTmplData{ - IDToken: idToken, - IDTokenJWTLink: generateJWTIOLink(idToken, provider, ctx), - AccessToken: accessToken, - AccessTokenJWTLink: generateJWTIOLink(accessToken, provider, ctx), - RefreshToken: refreshToken, - RedirectURL: redirectURL, - Claims: claims, - PublicKeyPEM: getPublicKeyPEM(provider), - } - renderTemplate(w, tokenTmpl, data) -} - -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: - log.Printf("Error rendering template %s: %s", tmpl.Name(), err) - 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/example-app/utils.go b/examples/example-app/utils.go deleted file mode 100644 index 099c1062..00000000 --- a/examples/example-app/utils.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "bytes" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "net/http/httputil" - "os" - "slices" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -// generateSessionID creates a random session identifier -func generateSessionID() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - // Fallback to timestamp if random fails - return fmt.Sprintf("%d", time.Now().UnixNano()) - } - return hex.EncodeToString(b) -} - -// buildScopes constructs a scope list from base scopes and cross-client IDs -func buildScopes(baseScopes []string, crossClients []string) []string { - scopes := make([]string, len(baseScopes)) - copy(scopes, baseScopes) - - // Add audience scopes for cross-client authorization - for _, client := range crossClients { - if client != "" { - scopes = append(scopes, "audience:server:client_id:"+client) - } - } - - return uniqueStrings(scopes) -} - -func (a *app) oauth2Config(scopes []string) *oauth2.Config { - return &oauth2.Config{ - ClientID: a.clientID, - ClientSecret: a.clientSecret, - Endpoint: a.provider.Endpoint(), - Scopes: scopes, - RedirectURL: a.redirectURI, - } -} - -func uniqueStrings(values []string) []string { - slices.Sort(values) - values = slices.Compact(values) - return values -} - -// return an HTTP client which trusts the provided root CAs. -func httpClientForRootCAs(rootCAs string) (*http.Client, error) { - 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) - } - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - }, nil -} - -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 -} - -func encodeToken(idToken *oidc.IDToken) (string, error) { - var claims json.RawMessage - if err := idToken.Claims(&claims); err != nil { - return "", fmt.Errorf("error decoding ID token claims: %v", err) - } - - buff := new(bytes.Buffer) - if err := json.Indent(buff, claims, "", " "); err != nil { - return "", fmt.Errorf("error indenting ID token claims: %v", err) - } - return buff.String(), nil -} - -func parseAndRenderToken(w http.ResponseWriter, r *http.Request, a *app, token *oauth2.Token) { - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - http.Error(w, "no id_token in token response", http.StatusInternalServerError) - return - } - - idToken, err := a.verifier.Verify(r.Context(), rawIDToken) - if err != nil { - http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) - return - } - - accessToken, ok := token.Extra("access_token").(string) - if !ok { - accessToken = token.AccessToken - if accessToken == "" { - http.Error(w, "no access_token in token response", http.StatusInternalServerError) - return - } - } - - buf, err := encodeToken(idToken) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buf) -}