feat: example app session refactoring (#4712)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
Maksim Nabokikh
2026-04-02 14:19:10 +02:00
committed by GitHub
parent 3bf25fd6e0
commit 6f2e233c7a
24 changed files with 1373 additions and 965 deletions
+2
View File
@@ -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.
-141
View File
@@ -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)
}
-273
View File
@@ -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)
}
+20 -151
View File
@@ -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
}
+194
View File
@@ -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
}
+220
View File
@@ -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)
}
+36
View File
@@ -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)
}
+119
View File
@@ -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.
}
}
+204
View File
@@ -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())
}
}
@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="112px" height="109px" viewBox="0 0 112 109" enable-background="new 0 0 112 109" xml:space="preserve">
<g>
<path fill="#449FD8" d="M88.345,51.574c7.588-3.55,12.764-10.49,14.175-18.53C96.396,19.395,84.663,9.054,70.094,4.851
c4.923,7.133,7.272,15.583,6.771,24.17C83.311,34.466,87.716,42.55,88.345,51.574z M27.27,38.542
c-8.207-1.045-16.333,1.973-21.858,8.054C3.23,61.683,7.869,76.84,18.099,88.158c-0.527-8.64,1.856-17.306,6.831-24.483
C22.19,55.048,23.32,45.944,27.27,38.542z M33.01,76.928c-2.997,8.079-1.755,17.193,3.642,24.215
c12.155,4.943,26.051,5.146,38.643-0.035c-7.818-2.516-14.886-7.518-19.887-14.731C47.233,86.23,39.124,83.032,33.01,76.928z
M63.122,22.202C61.615,14.044,56.069,6.819,47.892,3.47C33.778,5.711,20.745,13.966,12.76,26.631
c8.115-2.487,16.74-2.178,24.529,0.639C44.816,22.008,54.043,20.144,63.122,22.202z M85.891,66.457
c-3.086,7.399-8.722,13.188-15.678,16.61c6.194,5.604,14.805,7.758,22.852,5.834c9.054-9.587,13.884-22.198,13.9-35.009
C101.549,60.198,94.131,64.67,85.891,66.457z"/>
<g>
<circle fill="#F04D5C" cx="56.035" cy="53.892" r="15.972"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="112px" height="109px" viewBox="0 0 112 109" enable-background="new 0 0 112 109" xml:space="preserve">
<g>
<path fill="#449FD8" d="M88.345,51.574c7.588-3.55,12.764-10.49,14.175-18.53C96.396,19.395,84.663,9.054,70.094,4.851
c4.923,7.133,7.272,15.583,6.771,24.17C83.311,34.466,87.716,42.55,88.345,51.574z M27.27,38.542
c-8.207-1.045-16.333,1.973-21.858,8.054C3.23,61.683,7.869,76.84,18.099,88.158c-0.527-8.64,1.856-17.306,6.831-24.483
C22.19,55.048,23.32,45.944,27.27,38.542z M33.01,76.928c-2.997,8.079-1.755,17.193,3.642,24.215
c12.155,4.943,26.051,5.146,38.643-0.035c-7.818-2.516-14.886-7.518-19.887-14.731C47.233,86.23,39.124,83.032,33.01,76.928z
M63.122,22.202C61.615,14.044,56.069,6.819,47.892,3.47C33.778,5.711,20.745,13.966,12.76,26.631
c8.115-2.487,16.74-2.178,24.529,0.639C44.816,22.008,54.043,20.144,63.122,22.202z M85.891,66.457
c-3.086,7.399-8.722,13.188-15.678,16.61c6.194,5.604,14.805,7.758,22.852,5.834c9.054-9.587,13.884-22.198,13.9-35.009
C101.549,60.198,94.131,64.67,85.891,66.457z"/>
<g>
<circle fill="#F04D5C" cx="56.035" cy="53.892" r="15.972"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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;
@@ -3,12 +3,38 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example App - Login</title>
<title>Example App</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<img class="logo" src="{{.LogoURI}}" alt="Dex">
{{if .User}}
<div class="user-card">
<div class="user-card-title">Authenticated User</div>
<div class="user-info-grid">
{{if .User.PreferredUsername}}
<div class="user-info-label">Username:</div>
<div class="user-info-value">{{.User.PreferredUsername}}</div>
{{end}}
{{if .User.Name}}
<div class="user-info-label">Name:</div>
<div class="user-info-value">{{.User.Name}}</div>
{{end}}
{{if .User.Email}}
<div class="user-info-label">Email:</div>
<div class="user-info-value">{{.User.Email}}</div>
{{end}}
<div class="user-info-label">Subject:</div>
<div class="user-info-value user-info-sub">{{.User.Subject}}</div>
</div>
{{if .LogoutURL}}
<a href="{{.LogoutURL}}" class="logout-button">Logout</a>
{{end}}
</div>
{{else if .NotLoggedIn}}
<div class="guest-message">No active session. Log in to get started.</div>
{{end}}
<form id="login-form" action="/login" method="post">
<div class="app-description">
This is an example application for <b>Dex</b> OpenID Connect provider.<br>
@@ -7,6 +7,9 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="token-page">
<div class="token-header">
<a href="/" class="header-back-link">&larr; Back to Home</a>
</div>
{{ if .IDToken }}
<div class="token-block">
<div class="token-title">
@@ -76,8 +79,6 @@
</div>
{{ end }}
<a href="/" class="back-button">Back to Home</a>
<script src="/static/token.js"></script>
</body>
</html>
+163
View File
@@ -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,
}))
}
+104
View File
@@ -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)
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More