mirror of https://github.com/dexidp/dex.git
Browse Source
This is a KubeCon 2026 preparation: 1. Add device flow to the example-app 2. Add userinfo checker 3. Refactor the structure Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>pull/4573/head
14 changed files with 1222 additions and 239 deletions
@ -0,0 +1,141 @@ |
|||||||
|
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) |
||||||
|
} |
||||||
@ -0,0 +1,273 @@ |
|||||||
|
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) |
||||||
|
} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"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
|
||||||
|
if err := r.ParseForm(); err != nil { |
||||||
|
http.Error(w, fmt.Sprintf("Failed to parse form: %v", err), http.StatusBadRequest) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
accessToken := r.FormValue("access_token") |
||||||
|
if accessToken == "" { |
||||||
|
http.Error(w, "access_token is required", http.StatusBadRequest) |
||||||
|
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) |
||||||
|
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) |
||||||
|
if err != nil { |
||||||
|
http.Error(w, fmt.Sprintf("Failed to fetch userinfo: %v", err), http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
defer resp.Body.Close() |
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK { |
||||||
|
body, _ := io.ReadAll(resp.Body) |
||||||
|
http.Error(w, fmt.Sprintf("UserInfo request failed: %s", string(body)), resp.StatusCode) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Parse and return the userinfo
|
||||||
|
var userInfo map[string]interface{} |
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { |
||||||
|
http.Error(w, fmt.Sprintf("Failed to decode userinfo: %v", err), http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json") |
||||||
|
json.NewEncoder(w).Encode(userInfo) |
||||||
|
} |
||||||
@ -0,0 +1,110 @@ |
|||||||
|
(function() { |
||||||
|
const sessionID = document.getElementById("session-id")?.value; |
||||||
|
const deviceCode = document.getElementById("device-code")?.value; |
||||||
|
const pollInterval = parseInt(document.getElementById("poll-interval")?.value || "5", 10); |
||||||
|
const verificationURL = document.getElementById("verification-url")?.textContent; |
||||||
|
const userCode = document.getElementById("user-code")?.textContent; |
||||||
|
const statusText = document.getElementById("status-text"); |
||||||
|
const errorMessage = document.getElementById("error-message"); |
||||||
|
const openAuthBtn = document.getElementById("open-auth-btn"); |
||||||
|
|
||||||
|
let pollTimer = null; |
||||||
|
|
||||||
|
document.querySelectorAll(".copy-btn").forEach(btn => { |
||||||
|
btn.addEventListener("click", async function() { |
||||||
|
const targetId = this.getAttribute("data-copy"); |
||||||
|
const targetElement = document.getElementById(targetId); |
||||||
|
|
||||||
|
if (targetElement) { |
||||||
|
const textToCopy = targetElement.textContent; |
||||||
|
|
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(textToCopy); |
||||||
|
const originalText = this.textContent; |
||||||
|
this.textContent = "✓"; |
||||||
|
setTimeout(() => { |
||||||
|
this.textContent = originalText; |
||||||
|
}, 2000); |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to copy:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
openAuthBtn?.addEventListener("click", () => { |
||||||
|
if (verificationURL && userCode) { |
||||||
|
const url = verificationURL + "?user_code=" + encodeURIComponent(userCode); |
||||||
|
window.open(url, "_blank", "width=600,height=800"); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function pollForToken() { |
||||||
|
try { |
||||||
|
const response = await fetch('/device/poll', { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
session_id: sessionID, |
||||||
|
device_code: deviceCode |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
|
||||||
|
if (response.ok && data.status === 'complete') { |
||||||
|
statusText.textContent = "Authentication successful! Redirecting..."; |
||||||
|
stopPolling(); |
||||||
|
window.location.href = '/device/result'; |
||||||
|
} else if (response.ok && data.status === 'pending') { |
||||||
|
statusText.textContent = "Waiting for authentication..."; |
||||||
|
} else { |
||||||
|
const errorText = data.error_description || data.error || 'Unknown error'; |
||||||
|
|
||||||
|
if (data.error === 'session_expired') { |
||||||
|
showError('This session has been superseded by a new device flow. Please start over.'); |
||||||
|
stopPolling(); |
||||||
|
} else if (data.error === 'expired_token' || data.error === 'access_denied') { |
||||||
|
showError(data.error === 'expired_token' ? |
||||||
|
'The device code has expired. Please start over.' : |
||||||
|
'Authentication was denied.'); |
||||||
|
stopPolling(); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Polling error:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function showError(message) { |
||||||
|
errorMessage.textContent = message; |
||||||
|
errorMessage.style.display = 'block'; |
||||||
|
|
||||||
|
// Hide the status indicator (contains spinner and status text)
|
||||||
|
const statusIndicator = document.querySelector('.status-indicator'); |
||||||
|
if (statusIndicator) { |
||||||
|
statusIndicator.style.display = 'none'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function startPolling() { |
||||||
|
pollForToken(); |
||||||
|
pollTimer = setInterval(pollForToken, pollInterval * 1000); |
||||||
|
} |
||||||
|
|
||||||
|
function stopPolling() { |
||||||
|
if (pollTimer) { |
||||||
|
clearInterval(pollTimer); |
||||||
|
pollTimer = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (deviceCode) { |
||||||
|
startPolling(); |
||||||
|
} |
||||||
|
|
||||||
|
window.addEventListener('beforeunload', stopPolling); |
||||||
|
})(); |
||||||
|
|
||||||
@ -0,0 +1,61 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Device Login - Example App</title> |
||||||
|
<link rel="stylesheet" href="/static/style.css"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
<img class="logo" src="{{.LogoURI}}" alt="Dex"> |
||||||
|
<div class="device-flow-container"> |
||||||
|
<div class="device-instructions"> |
||||||
|
<h2>Device Login</h2> |
||||||
|
<p class="instruction-text">Please authenticate on your device:</p> |
||||||
|
|
||||||
|
<div class="verification-info"> |
||||||
|
<div class="info-item"> |
||||||
|
<label>Verification URL:</label> |
||||||
|
<div class="code-display"> |
||||||
|
<code id="verification-url">{{.VerificationURI}}</code> |
||||||
|
<button type="button" class="copy-btn" data-copy="verification-url">📋</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="info-item"> |
||||||
|
<label>User Code:</label> |
||||||
|
<div class="code-display large"> |
||||||
|
<code id="user-code" class="user-code">{{.UserCode}}</code> |
||||||
|
<button type="button" class="copy-btn" data-copy="user-code">📋</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="actions"> |
||||||
|
<button type="button" id="open-auth-btn" class="primary-button"> |
||||||
|
Open Authentication Page |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="polling-status"> |
||||||
|
<div class="status-indicator"> |
||||||
|
<div class="spinner"></div> |
||||||
|
<span id="status-text">Waiting for authentication...</span> |
||||||
|
</div> |
||||||
|
<div id="error-message" class="error-message" style="display: none;"></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="device-data" style="display: none;"> |
||||||
|
<input type="hidden" id="session-id" value="{{.SessionID}}"> |
||||||
|
<input type="hidden" id="device-code" value="{{.DeviceCode}}"> |
||||||
|
<input type="hidden" id="poll-interval" value="{{.PollInterval}}"> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="/static/device.js"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
|
|
||||||
@ -0,0 +1,154 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"crypto/rand" |
||||||
|
"crypto/tls" |
||||||
|
"crypto/x509" |
||||||
|
"encoding/hex" |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"log" |
||||||
|
"net" |
||||||
|
"net/http" |
||||||
|
"net/http/httputil" |
||||||
|
"os" |
||||||
|
"slices" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc" |
||||||
|
"golang.org/x/oauth2" |
||||||
|
) |
||||||
|
|
||||||
|
// generateSessionID creates a random session identifier
|
||||||
|
func generateSessionID() string { |
||||||
|
b := make([]byte, 16) |
||||||
|
if _, err := rand.Read(b); err != nil { |
||||||
|
// Fallback to timestamp if random fails
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano()) |
||||||
|
} |
||||||
|
return hex.EncodeToString(b) |
||||||
|
} |
||||||
|
|
||||||
|
// buildScopes constructs a scope list from base scopes and cross-client IDs
|
||||||
|
func buildScopes(baseScopes []string, crossClients []string) []string { |
||||||
|
scopes := make([]string, len(baseScopes)) |
||||||
|
copy(scopes, baseScopes) |
||||||
|
|
||||||
|
// Add audience scopes for cross-client authorization
|
||||||
|
for _, client := range crossClients { |
||||||
|
if client != "" { |
||||||
|
scopes = append(scopes, "audience:server:client_id:"+client) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return uniqueStrings(scopes) |
||||||
|
} |
||||||
|
|
||||||
|
func (a *app) oauth2Config(scopes []string) *oauth2.Config { |
||||||
|
return &oauth2.Config{ |
||||||
|
ClientID: a.clientID, |
||||||
|
ClientSecret: a.clientSecret, |
||||||
|
Endpoint: a.provider.Endpoint(), |
||||||
|
Scopes: scopes, |
||||||
|
RedirectURL: a.redirectURI, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string { |
||||||
|
slices.Sort(values) |
||||||
|
values = slices.Compact(values) |
||||||
|
return values |
||||||
|
} |
||||||
|
|
||||||
|
// return an HTTP client which trusts the provided root CAs.
|
||||||
|
func httpClientForRootCAs(rootCAs string) (*http.Client, error) { |
||||||
|
tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} |
||||||
|
rootCABytes, err := os.ReadFile(rootCAs) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("failed to read root-ca: %v", err) |
||||||
|
} |
||||||
|
if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { |
||||||
|
return nil, fmt.Errorf("no certs found in root CA file %q", rootCAs) |
||||||
|
} |
||||||
|
return &http.Client{ |
||||||
|
Transport: &http.Transport{ |
||||||
|
TLSClientConfig: &tlsConfig, |
||||||
|
Proxy: http.ProxyFromEnvironment, |
||||||
|
Dial: (&net.Dialer{ |
||||||
|
Timeout: 30 * time.Second, |
||||||
|
KeepAlive: 30 * time.Second, |
||||||
|
}).Dial, |
||||||
|
TLSHandshakeTimeout: 10 * time.Second, |
||||||
|
ExpectContinueTimeout: 1 * time.Second, |
||||||
|
}, |
||||||
|
}, nil |
||||||
|
} |
||||||
|
|
||||||
|
type debugTransport struct { |
||||||
|
t http.RoundTripper |
||||||
|
} |
||||||
|
|
||||||
|
func (d debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
||||||
|
reqDump, err := httputil.DumpRequest(req, true) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
log.Printf("%s", reqDump) |
||||||
|
|
||||||
|
resp, err := d.t.RoundTrip(req) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
respDump, err := httputil.DumpResponse(resp, true) |
||||||
|
if err != nil { |
||||||
|
resp.Body.Close() |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
log.Printf("%s", respDump) |
||||||
|
return resp, nil |
||||||
|
} |
||||||
|
|
||||||
|
func encodeToken(idToken *oidc.IDToken) (string, error) { |
||||||
|
var claims json.RawMessage |
||||||
|
if err := idToken.Claims(&claims); err != nil { |
||||||
|
return "", fmt.Errorf("error decoding ID token claims: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
buff := new(bytes.Buffer) |
||||||
|
if err := json.Indent(buff, claims, "", " "); err != nil { |
||||||
|
return "", fmt.Errorf("error indenting ID token claims: %v", err) |
||||||
|
} |
||||||
|
return buff.String(), nil |
||||||
|
} |
||||||
|
|
||||||
|
func parseAndRenderToken(w http.ResponseWriter, r *http.Request, a *app, token *oauth2.Token) { |
||||||
|
rawIDToken, ok := token.Extra("id_token").(string) |
||||||
|
if !ok { |
||||||
|
http.Error(w, "no id_token in token response", http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
idToken, err := a.verifier.Verify(r.Context(), rawIDToken) |
||||||
|
if err != nil { |
||||||
|
http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
accessToken, ok := token.Extra("access_token").(string) |
||||||
|
if !ok { |
||||||
|
accessToken = token.AccessToken |
||||||
|
if accessToken == "" { |
||||||
|
http.Error(w, "no access_token in token response", http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
buf, err := encodeToken(idToken) |
||||||
|
if err != nil { |
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buf) |
||||||
|
} |
||||||
Loading…
Reference in new issue