From 3b99293b10f58522ab0989ba56a120aa31895cd5 Mon Sep 17 00:00:00 2001 From: Alma Tuck Date: Tue, 17 Feb 2026 08:57:17 -0700 Subject: [PATCH 1/3] docs: add OAuth authentication guide for desktop apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the Loopback Redirect pattern with PKCE — the standard approach for OAuth in desktop apps. Includes full Go implementation with flow tracking, token exchange, callback HTML, and frontend polling. Co-Authored-By: Claude Opus 4.6 --- website/docs/guides/oauth.mdx | 365 ++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 website/docs/guides/oauth.mdx diff --git a/website/docs/guides/oauth.mdx b/website/docs/guides/oauth.mdx new file mode 100644 index 000000000..627fb5286 --- /dev/null +++ b/website/docs/guides/oauth.mdx @@ -0,0 +1,365 @@ +# OAuth Authentication + +Desktop apps need to use the user's default browser for OAuth so that existing sessions (Google, Apple, GitHub, etc.) +are available. This is known as the "Loopback Redirect" pattern and it's the same approach used by VS Code, Slack, +Docker Desktop, and the GitHub CLI. + +The general flow is: + +1. Your Go backend generates PKCE parameters and opens the provider's authorize URL in the system browser +2. The user authenticates (often one-click since they're already logged in) +3. The provider redirects to `http://localhost:{port}/callback` with an authorization code +4. Your Go backend exchanges the code for tokens + +Your app never sees the user's password. It only receives a short-lived authorization code. + +## A Note on Webview Popups + +It may be tempting to open a Wails webview window for a polished popup experience, but the webview is an isolated +WebKit/WebView2 instance. It doesn't share cookies or sessions with the user's browser, so they would need to +re-enter credentials and redo 2FA. Google also [discourages](https://developers.google.com/identity/protocols/oauth2/policies#embedded-webviews) +embedded webviews for OAuth and may block sign-in attempts from them. Use the system browser instead. + +## PKCE Helpers + +[PKCE (Proof Key for Code Exchange)](https://www.rfc-editor.org/rfc/rfc7636) prevents authorization code interception +attacks. It's required for public clients like desktop apps that can't safely store a client secret. + +```go title="oauth.go" +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +func generateCodeVerifier() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func computeCodeChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func generateState() string { + b := make([]byte, 16) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} +``` + +## Tracking Pending Flows + +Since the OAuth redirect arrives as a separate HTTP request, you need shared state between the handler that starts the +flow and the callback that finishes it. A `sync.Map` keyed by the `state` parameter works well for this. It's also +a good idea to clean up expired flows so they don't accumulate in memory. + +```go title="oauth.go" +import ( + "sync" + "time" +) + +const oauthFlowTimeout = 10 * time.Minute + +type oauthFlowState struct { + CodeVerifier string + CreatedAt time.Time + Completed bool + Error string + Email string + DisplayName string +} + +var pendingFlows sync.Map // state -> *oauthFlowState + +func init() { + go func() { + ticker := time.NewTicker(1 * time.Minute) + for range ticker.C { + now := time.Now() + pendingFlows.Range(func(key, value any) bool { + if flow, ok := value.(*oauthFlowState); ok { + if now.Sub(flow.CreatedAt) > oauthFlowTimeout { + pendingFlows.Delete(key) + } + } + return true + }) + } + }() +} +``` + +## Starting the OAuth Flow + +This handler generates PKCE parameters, stores the flow state, and opens the authorize URL in the system browser. + +```go title="oauth.go" +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" +) + +func openBrowser(targetURL string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", targetURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL) + default: + cmd = exec.Command("xdg-open", targetURL) + } + cmd.Start() +} + +func oauthStartHandler(w http.ResponseWriter, r *http.Request) { + state := generateState() + verifier := generateCodeVerifier() + challenge := computeCodeChallenge(verifier) + + pendingFlows.Store(state, &oauthFlowState{ + CodeVerifier: verifier, + CreatedAt: time.Now(), + }) + + redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", appPort) + + params := url.Values{ + "response_type": {"code"}, + "client_id": {"your_client_id"}, + "redirect_uri": {redirectURI}, + "scope": {"openid profile email"}, + "state": {state}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authorizeURL := "https://provider.example.com/oauth/authorize?" + params.Encode() + + openBrowser(authorizeURL) + + // Return the state so the frontend can poll for completion + json.NewEncoder(w).Encode(map[string]string{ + "authorize_url": authorizeURL, + "state": state, + }) +} +``` + +:::info +Wails apps already run an HTTP server for the frontend. You can use that same server for +the OAuth callback rather than spinning up a temporary listener. +::: + +## Handling the Callback + +When the provider redirects to your localhost, exchange the authorization code for tokens. The callback handler +also serves a small HTML page that tells the user they can close the tab. + +```go title="oauth.go" +func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + errParam := r.URL.Query().Get("error") + + flowVal, ok := pendingFlows.Load(state) + if !ok { + serveCallbackHTML(w, "Invalid or expired OAuth state") + return + } + flow := flowVal.(*oauthFlowState) + + if errParam != "" { + flow.Error = errParam + flow.Completed = true + serveCallbackHTML(w, "Authentication was denied: "+errParam) + return + } + + // Exchange code for tokens + tokenResp, err := exchangeCode(r.Context(), code, flow.CodeVerifier) + if err != nil { + flow.Error = err.Error() + flow.Completed = true + serveCallbackHTML(w, "Token exchange failed") + return + } + + // Use the access token (fetch profile, store credentials, etc.) + _ = tokenResp + + flow.Completed = true + serveCallbackHTML(w, "") +} +``` + +## Token Exchange + +```go title="oauth.go" +import ( + "context" + "io" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +func exchangeCode(ctx context.Context, code, codeVerifier string) (*tokenResponse, error) { + body, _ := json.Marshal(map[string]string{ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": fmt.Sprintf("http://localhost:%d/auth/callback", appPort), + "client_id": "your_client_id", + "code_verifier": codeVerifier, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://provider.example.com/oauth/token", + strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, b) + } + + var result tokenResponse + json.NewDecoder(resp.Body).Decode(&result) + return &result, nil +} +``` + +## Callback HTML + +After the OAuth redirect, this serves a brief confirmation page that attempts to auto-close the browser tab. + +```go title="oauth.go" +func serveCallbackHTML(w http.ResponseWriter, errMsg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + message := "Connected! You can close this window." + if errMsg != "" { + message = "Sign-in failed: " + errMsg + } + + fmt.Fprintf(w, ` +Authentication + +

%s

+ +`, message) +} +``` + +## Frontend Polling + +The frontend can't listen for the browser callback directly. A polling endpoint lets it detect when the flow completes. + +```go title="oauth.go" +func oauthStatusHandler(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + flowVal, ok := pendingFlows.Load(state) + if !ok { + json.NewEncoder(w).Encode(map[string]string{"status": "expired"}) + return + } + + flow := flowVal.(*oauthFlowState) + if !flow.Completed { + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + + if flow.Error != "" { + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "error": flow.Error, + }) + } else { + json.NewEncoder(w).Encode(map[string]string{ + "status": "complete", + "email": flow.Email, + }) + } + + pendingFlows.Delete(state) +} +``` + +On the frontend side, something like this works with any framework: + +```javascript title="frontend/src/auth.js" +async function startOAuth() { + const resp = await fetch('/auth/start', { method: 'POST' }); + const { state } = await resp.json(); + + const poll = setInterval(async () => { + const status = await fetch(`/auth/status?state=${state}`); + const result = await status.json(); + + if (result.status === 'complete') { + clearInterval(poll); + // Update your UI + } else if (result.status === 'error' || result.status === 'expired') { + clearInterval(poll); + } + }, 1000); +} +``` + +## Wiring It Up + +Register the handlers on your Wails app's existing HTTP server: + +```go title="main.go" +mux := http.NewServeMux() +mux.HandleFunc("POST /auth/start", oauthStartHandler) +mux.HandleFunc("GET /auth/callback", oauthCallbackHandler) +mux.HandleFunc("GET /auth/status", oauthStatusHandler) +``` + +## Things to Watch Out For + +- The Wails webview is isolated from the system browser. If you open the authorize URL in a webview window, the user + won't have their existing sessions and will need to log in from scratch. Some providers like Google may block + sign-in attempts from embedded webviews entirely. + +- PKCE is important for desktop apps. Without it, another application on the same machine could intercept the + authorization code from the localhost redirect. + +- If your port is configurable, make sure the `redirect_uri` in the start handler matches what the callback handler + listens on. You'll also need to register all possible redirect URIs with your OAuth provider. + +- Remember to clean up expired flows. Without the cleanup goroutine, abandoned flows will accumulate in memory. + +- Binding your callback listener to `127.0.0.1` rather than `0.0.0.0` ensures only local processes can reach it. From 4eb08327e2bbf6e858977efb130b53da5be141a4 Mon Sep 17 00:00:00 2001 From: Alma Tuck Date: Tue, 17 Feb 2026 09:12:23 -0700 Subject: [PATCH 2/3] docs: address CodeRabbit review feedback - Add appPort constant so examples compile - openBrowser now returns and surfaces errors - Use comma-ok type assertions to prevent panics - Use bytes.NewReader instead of strings.NewReader(string(...)) - Check json.Marshal and json.Decode errors - Escape error messages in callback HTML (XSS) - Add polling timeout to frontend example Co-Authored-By: Claude Opus 4.6 --- website/docs/guides/oauth.mdx | 52 +++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/website/docs/guides/oauth.mdx b/website/docs/guides/oauth.mdx index 627fb5286..51c9ff51a 100644 --- a/website/docs/guides/oauth.mdx +++ b/website/docs/guides/oauth.mdx @@ -64,7 +64,10 @@ import ( "time" ) -const oauthFlowTimeout = 10 * time.Minute +const ( + oauthFlowTimeout = 10 * time.Minute + appPort = 34115 // your Wails app's HTTP server port +) type oauthFlowState struct { CodeVerifier string @@ -101,16 +104,17 @@ This handler generates PKCE parameters, stores the flow state, and opens the aut ```go title="oauth.go" import ( + "bytes" "encoding/json" "fmt" + "html" "net/http" "net/url" "os/exec" "runtime" - "strings" ) -func openBrowser(targetURL string) { +func openBrowser(targetURL string) error { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": @@ -120,7 +124,7 @@ func openBrowser(targetURL string) { default: cmd = exec.Command("xdg-open", targetURL) } - cmd.Start() + return cmd.Start() } func oauthStartHandler(w http.ResponseWriter, r *http.Request) { @@ -147,7 +151,10 @@ func oauthStartHandler(w http.ResponseWriter, r *http.Request) { authorizeURL := "https://provider.example.com/oauth/authorize?" + params.Encode() - openBrowser(authorizeURL) + if err := openBrowser(authorizeURL); err != nil { + http.Error(w, "Failed to open browser", http.StatusInternalServerError) + return + } // Return the state so the frontend can poll for completion json.NewEncoder(w).Encode(map[string]string{ @@ -178,7 +185,11 @@ func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { serveCallbackHTML(w, "Invalid or expired OAuth state") return } - flow := flowVal.(*oauthFlowState) + flow, ok := flowVal.(*oauthFlowState) + if !ok { + serveCallbackHTML(w, "Invalid or expired OAuth state") + return + } if errParam != "" { flow.Error = errParam @@ -220,17 +231,20 @@ type tokenResponse struct { } func exchangeCode(ctx context.Context, code, codeVerifier string) (*tokenResponse, error) { - body, _ := json.Marshal(map[string]string{ + body, err := json.Marshal(map[string]string{ "grant_type": "authorization_code", "code": code, "redirect_uri": fmt.Sprintf("http://localhost:%d/auth/callback", appPort), "client_id": "your_client_id", "code_verifier": codeVerifier, }) + if err != nil { + return nil, fmt.Errorf("marshal token request: %w", err) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://provider.example.com/oauth/token", - strings.NewReader(string(body))) + bytes.NewReader(body)) if err != nil { return nil, err } @@ -248,7 +262,9 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*tokenRespons } var result tokenResponse - json.NewDecoder(resp.Body).Decode(&result) + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode token response: %w", err) + } return &result, nil } ``` @@ -263,7 +279,7 @@ func serveCallbackHTML(w http.ResponseWriter, errMsg string) { message := "Connected! You can close this window." if errMsg != "" { - message = "Sign-in failed: " + errMsg + message = "Sign-in failed: " + html.EscapeString(errMsg) } fmt.Fprintf(w, ` @@ -294,7 +310,13 @@ func oauthStatusHandler(w http.ResponseWriter, r *http.Request) { return } - flow := flowVal.(*oauthFlowState) + flow, ok := flowVal.(*oauthFlowState) + if !ok { + json.NewEncoder(w).Encode(map[string]string{"status": "expired"}) + pendingFlows.Delete(state) + return + } + if !flow.Completed { json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) return @@ -323,7 +345,15 @@ async function startOAuth() { const resp = await fetch('/auth/start', { method: 'POST' }); const { state } = await resp.json(); + let attempts = 0; + const maxAttempts = 120; // stop after 2 minutes + const poll = setInterval(async () => { + if (++attempts > maxAttempts) { + clearInterval(poll); + return; + } + const status = await fetch(`/auth/status?state=${state}`); const result = await status.json(); From f2a84f072c6cba3cd6c6677c9fe696e3d1be9077 Mon Sep 17 00:00:00 2001 From: Alma Tuck Date: Tue, 17 Feb 2026 09:17:42 -0700 Subject: [PATCH 3/3] docs: fix data race and use form-urlencoded for token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sync.Mutex to oauthFlowState to prevent data races between the callback handler (writer) and status handler (reader) - Use application/x-www-form-urlencoded for token exchange per RFC 6749 §4.1.3 instead of JSON Co-Authored-By: Claude Opus 4.6 --- website/docs/guides/oauth.mdx | 43 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/website/docs/guides/oauth.mdx b/website/docs/guides/oauth.mdx index 51c9ff51a..55bf405b2 100644 --- a/website/docs/guides/oauth.mdx +++ b/website/docs/guides/oauth.mdx @@ -70,6 +70,7 @@ const ( ) type oauthFlowState struct { + mu sync.Mutex CodeVerifier string CreatedAt time.Time Completed bool @@ -104,7 +105,6 @@ This handler generates PKCE parameters, stores the flow state, and opens the aut ```go title="oauth.go" import ( - "bytes" "encoding/json" "fmt" "html" @@ -112,6 +112,7 @@ import ( "net/url" "os/exec" "runtime" + "strings" ) func openBrowser(targetURL string) error { @@ -192,8 +193,10 @@ func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { } if errParam != "" { + flow.mu.Lock() flow.Error = errParam flow.Completed = true + flow.mu.Unlock() serveCallbackHTML(w, "Authentication was denied: "+errParam) return } @@ -201,8 +204,10 @@ func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { // Exchange code for tokens tokenResp, err := exchangeCode(r.Context(), code, flow.CodeVerifier) if err != nil { + flow.mu.Lock() flow.Error = err.Error() flow.Completed = true + flow.mu.Unlock() serveCallbackHTML(w, "Token exchange failed") return } @@ -210,7 +215,9 @@ func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { // Use the access token (fetch profile, store credentials, etc.) _ = tokenResp + flow.mu.Lock() flow.Completed = true + flow.mu.Unlock() serveCallbackHTML(w, "") } ``` @@ -231,24 +238,22 @@ type tokenResponse struct { } func exchangeCode(ctx context.Context, code, codeVerifier string) (*tokenResponse, error) { - body, err := json.Marshal(map[string]string{ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": fmt.Sprintf("http://localhost:%d/auth/callback", appPort), - "client_id": "your_client_id", - "code_verifier": codeVerifier, - }) - if err != nil { - return nil, fmt.Errorf("marshal token request: %w", err) + // RFC 6749 §4.1.3 requires application/x-www-form-urlencoded + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d/auth/callback", appPort)}, + "client_id": {"your_client_id"}, + "code_verifier": {codeVerifier}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://provider.example.com/oauth/token", - bytes.NewReader(body)) + strings.NewReader(form.Encode())) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -317,20 +322,26 @@ func oauthStatusHandler(w http.ResponseWriter, r *http.Request) { return } - if !flow.Completed { + flow.mu.Lock() + completed := flow.Completed + flowErr := flow.Error + email := flow.Email + flow.mu.Unlock() + + if !completed { json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) return } - if flow.Error != "" { + if flowErr != "" { json.NewEncoder(w).Encode(map[string]string{ "status": "error", - "error": flow.Error, + "error": flowErr, }) } else { json.NewEncoder(w).Encode(map[string]string{ "status": "complete", - "email": flow.Email, + "email": email, }) }