mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat: example app session refactoring (#4712)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -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 |
+83
-11
@@ -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;
|
||||
+27
-1
@@ -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>
|
||||
+3
-2
@@ -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">← 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>
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+6
-22
@@ -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
Reference in New Issue
Block a user