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