diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..da9fee111 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# Project: tsidp + +## Project Description: + +`tsidp` is an OIDC / OAuth Identity Provider (IdP) server that integrates with your Tailscale network. It allows you to use Tailscale identities for authentication into applications that support OpenID Connect as well as authenticated MCP client / server connections. + +## Tech stack + +- golang +- html and javascript (ui.go) + +## Testing + +- `make test-dev` - runs all unit tests on code + +## Workflow Tasks + +### Plan Improvements + +Work plans are located in ai-plans/. Plans written by the user may be incomplete, contain inconsistencies or errors. + +When the user asks to improve a plan follow these guidelines for expanding and improving it. + +- Identify any inconsistencies. +- Expand plans out to be detailed specification of requirements and changes to be made. +- Plans should have at least these sections: + - Title - very short, describes changes + - Overview: A more detailed summary of goal and outcomes desired + - Design Requirements: Detailed descriptions of what needs to be done + - Testing Plan: Tests to be implemented + - Checklist: A detailed list of changes to be made + +Look for "plan expansion" as explicit instructions to improve a plan. + +### Implementation of plans + +When the user says "paint it", respond with "commencing automated assembly". Then implement the changes as described by the plan. Update the checklist as you complete items. + +## General Rules + +- when summarizing changes only include details that require further action (action items) +- when there are no action items, just say "Done." diff --git a/Makefile b/Makefile index 80bb3d76b..f41914e22 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-osx build-linux test clean docker-image +.PHONY: build build-osx build-linux test test-dev clean docker-image build: build-osx build-linux @@ -16,5 +16,9 @@ docker-image: test: go test -count 1 . ./server +test-dev: + go test . ./server + staticcheck ./server/... || true + clean: rm -f build/tsidp-server* \ No newline at end of file diff --git a/ai-plans/issue-78-consolidate-client-api.md b/ai-plans/issue-78-consolidate-client-api.md new file mode 100644 index 000000000..dfc2e5958 --- /dev/null +++ b/ai-plans/issue-78-consolidate-client-api.md @@ -0,0 +1,521 @@ +# Title: Consolidate OAuth Client Management to Use /clients API Endpoints + +## Overview + +This plan addresses [issue #78](https://github.com/tailscale/tsidp/issues/78): Consolidate endpoints for managing OAuth clients. + +Currently, there are two parallel systems for managing `IDPServer.funnelClients`: + +1. **Admin UI** (served under `/`) - Uses server-side rendered HTML with form POST to `/new` and `/edit/{id}` endpoints that directly manipulate `funnelClients` data +2. **API endpoints** (served under `/clients`) - RESTful JSON API with GET, POST, and DELETE operations + +The `/clients` API was implemented first and provides a clean, well-tested interface. The UI was added later and introduced duplicate logic for client mutations. This creates maintenance overhead, potential for bugs, and inconsistency. + +**Goal**: Refactor the UI to use the existing `/clients` API endpoints via client-side JavaScript, eliminating duplicate server-side mutation logic while preserving the user experience. + +**Outcomes**: +- Single source of truth for client mutations (`/clients` API) +- Reduced code complexity and maintenance burden +- Preserved HTML templates and UI styling +- All existing tests continue to pass +- API contract of `/clients` endpoints remains unchanged + +## Current State Analysis + +### Existing API Endpoints (/clients) + +Located in [server/clients.go](../server/clients.go): + +1. **GET /clients** (serveGetClientsList:208-228) + - Returns JSON array of all clients + - Secrets are omitted from response + - Requires tailnet access (blocked over funnel) + +2. **POST /clients/new** (serveNewClient:166-204) + - Creates new client from form data + - Expects: `name` and `redirect_uri` (newline-separated) + - Returns: Full client object including secret + - Generates random client_id and client_secret + - Persists to disk via `storeFunnelClientsLocked()` + +3. **GET /clients/{id}** (serveClients:149-162) + - Returns single client by ID + - Secret is omitted from response + +4. **DELETE /clients/{id}** (serveDeleteClient:232-270) + - Deletes client by ID + - Cleans up associated tokens (code, accessToken, refreshToken) + - Returns 204 No Content on success + +### Existing UI Endpoints (/) + +Located in [server/ui.go](../server/ui.go): + +1. **GET /** (handleClientsList:84-110) + - Server-rendered list of clients using `ui-list.html` template + - Displays client name, ID, redirect URIs, status, and edit button + +2. **GET /new** (handleNewClient:114-186) + - Shows empty form using `ui-edit.html` template + +3. **POST /new** (handleNewClient:122-182) + - Creates client directly in handler + - Duplicates logic from `serveNewClient` + - Generates client_id and client_secret inline + - Persists via `storeFunnelClientsLocked()` + - Re-renders form with success message and displays secret + +4. **GET /edit/{id}** (handleEditClient:189-217) + - Shows populated form using `ui-edit.html` template + +5. **POST /edit/{id}** (handleEditClient:219-320) + - Handles three actions: + - `action=delete`: Deletes client (duplicates `/clients/{id}` DELETE logic) + - `action=regenerate_secret`: Regenerates client secret + - Default: Updates name and redirect_uris + - All mutations directly access `s.funnelClients` and call `storeFunnelClientsLocked()` + +### Identified Inconsistencies + +1. **Missing UPDATE endpoint**: The `/clients` API has no PUT/PATCH endpoint for updating client name and redirect URIs, but the UI needs this functionality + +2. **Secret regeneration**: The UI supports regenerating secrets (`action=regenerate_secret`), but this functionality doesn't exist in `/clients` API + +3. **Response format mismatch**: + - API returns JSON with secrets included on creation + - UI needs to display secrets immediately after creation + - API omits secrets on GET requests (security feature) + +4. **Error handling**: + - UI renders errors in HTML form with error messages + - API returns HTTP status codes with JSON error objects + - Need JavaScript to translate API responses into UI feedback + +5. **Form data vs JSON**: + - Current UI uses `application/x-www-form-urlencoded` form submissions + - `/clients` API expects form data for POST /clients/new but returns JSON + - Need to handle both in JavaScript + +## Design Requirements + +### DR-1: Extend /clients API with Missing Operations + +Add the following endpoints to [server/clients.go](../server/clients.go): + +1. **PUT /clients/{id}** - Update existing client + - Method: PUT + - Content-Type: application/x-www-form-urlencoded (matches POST /clients/new) + - Parameters: + - `name` (string, optional): Client display name + - `redirect_uri` (string, required): Newline-separated redirect URIs + - Response: 200 OK with updated client JSON (secret omitted) + - Errors: + - 400 if redirect_uri is empty or invalid + - 404 if client_id not found + - 500 if persistence fails + - Side effects: Calls `storeFunnelClientsLocked()` + +2. **POST /clients/{id}/regenerate-secret** - Regenerate client secret + - Method: POST + - No body required + - Response: 200 OK with client JSON including new secret + - Errors: + - 404 if client_id not found + - 500 if persistence fails + - Side effects: + - Generates new secret via `generateClientSecret()` + - Updates `s.funnelClients[clientID].Secret` + - Calls `storeFunnelClientsLocked()` + - Invalidates existing tokens (optional enhancement) + +### DR-2: Convert UI Templates to Use Client-Side JavaScript + +Modify existing HTML templates to use fetch API for AJAX calls: + +#### DR-2.1: Update [server/ui-list.html](../server/ui-list.html) +- Keep existing template structure (lines 1-83) +- Template continues to render initial page server-side +- No changes needed - list page is read-only + +#### DR-2.2: Update [server/ui-edit.html](../server/ui-edit.html) + +Add JavaScript at the end (before ``) to: + +1. **Intercept form submission** (lines 69-132) + - Prevent default form POST behavior + - Use `fetch()` to call appropriate `/clients` API endpoint + - Handle loading states (disable submit button, show spinner) + +2. **Handle create client** (form on /new) + - POST to `/clients/new` with FormData + - On success (200): + - Extract client_id and client_secret from JSON response + - Display success message + - Show client_id and client_secret in readonly fields + - Update page state to show created client (keep form visible with secrets) + - On error (4xx/5xx): + - Parse JSON error response + - Display error message in `.alert-error` div + +3. **Handle update client** (form on /edit/{id}) + - PUT to `/clients/{id}` with FormData + - On success (200): + - Display success message in `.alert-success` div + - Update page content if needed + - On error (4xx/5xx): + - Display error message in `.alert-error` div + +4. **Handle regenerate secret** (button with `name="action" value="regenerate_secret"`) + - POST to `/clients/{id}/regenerate-secret` + - Confirm action before proceeding (keep existing confirm dialog) + - On success (200): + - Extract new secret from JSON response + - Show `.secret-display` section with new secret + - Display success message + - On error (4xx/5xx): + - Display error message + +5. **Handle delete client** (button with `name="action" value="delete"`) + - DELETE to `/clients/{id}` + - Confirm action before proceeding (keep existing confirm dialog) + - On success (204): + - Redirect to `/` (client list) + - On error (4xx/5xx): + - Display error message + +6. **Error message formatting** + - Parse JSON error responses: `{"error": "code", "error_description": "message"}` + - Display `error_description` in user-friendly format + - Show generic "An error occurred" message if parsing fails + +7. **Keep existing copy button functionality** (lines 154-195) + - No changes needed to `copySecret()` and `copyClientId()` functions + +### DR-3: Remove Duplicate Mutation Logic from ui.go + +Modify [server/ui.go](../server/ui.go): + +1. **Keep `handleUI` router function** (lines 45-80) + - Still serves HTML templates + - Still handles authorization checks + +2. **Simplify `handleNewClient`** (lines 114-186) + - **GET /new**: Keep as-is (renders empty form) + - **POST /new**: REMOVE entirely (lines 122-182) + - Form submission will be handled by JavaScript calling POST /clients/new + +3. **Simplify `handleEditClient`** (lines 189-320) + - **GET /edit/{id}**: Keep as-is (renders populated form) + - **POST /edit/{id}**: REMOVE entirely (lines 219-320) + - All mutations (update, delete, regenerate) handled by JavaScript + +4. **Keep helper functions**: + - `renderClientForm` (lines 338-347) - still needed for GET requests + - `renderFormError` (lines 351-356) - REMOVE (errors now shown via JavaScript) + - `renderFormSuccess` (lines 360-365) - REMOVE (success now shown via JavaScript) + - `clientDisplayData` struct (lines 324-334) - keep but Success/Error fields no longer used + +5. **Keep `validateRedirectURI`** (lines 368-379) + - Still used by API endpoints + - Could be called client-side as additional validation (optional enhancement) + +### DR-4: Maintain API Contract Compatibility + +Ensure no breaking changes to existing `/clients` API: + +1. All existing endpoints maintain same: + - URL paths + - HTTP methods + - Request/response formats + - Status codes + - Error response structure + +2. New endpoints follow existing patterns: + - Same authentication/authorization checks (`isFunnelRequest` blocked) + - Same error handling via `writeHTTPError` + - Same mutex locking patterns (`s.mu.Lock()`) + - Same persistence mechanism (`storeFunnelClientsLocked()`) + +3. Response JSON uses same field names: + - `client_id`, `client_secret`, `client_name`, `redirect_uris`, etc. + - Matches `FunnelClient` struct JSON tags (lines 20-40 in clients.go) + +### DR-5: Security Considerations + +1. **Authorization**: All endpoints remain protected by application capability checks +2. **Funnel blocking**: All mutation endpoints remain blocked over funnel +3. **Secret exposure**: Secrets only returned on creation and regeneration (not on GET) +4. **CSRF protection**: Not currently implemented, but form-based approach had same exposure +5. **Input validation**: Maintain existing validation for redirect URIs + +### DR-6: Backward Compatibility + +1. **Direct API access**: External tools/scripts using `/clients` API continue to work unchanged +2. **Template structure**: HTML templates remain compatible with existing CSS (ui-style.css) +3. **URLs**: All UI URLs (`/`, `/new`, `/edit/{id}`) remain the same +4. **Session data**: No session/state management required (stateless API calls) + +## Testing Plan + +### TP-1: Unit Tests for New API Endpoints + +Add to [server/client_test.go](../server/client_test.go): + +1. **TestServeUpdateClient** + - Test updating client name and redirect URIs + - Test validation errors (empty redirect_uri, invalid URIs) + - Test updating non-existent client (404) + - Test persistence by loading from disk + - Test that secret is NOT included in response + +2. **TestServeRegenerateSecret** + - Test regenerating secret for existing client + - Test that new secret is different from old secret + - Test that secret IS included in response + - Test regenerating for non-existent client (404) + - Test persistence by loading from disk + - Verify old secret is replaced + +3. **TestClientAPIRouting** + - Verify PUT /clients/{id} routes to update handler + - Verify POST /clients/{id}/regenerate-secret routes correctly + - Test method validation (405 errors) + +### TP-2: Integration Tests for UI with JavaScript + +Add to [server/ui_test.go](../server/ui_test.go): + +**TestUIRouting** +- Verify GET / still returns HTML +- Verify GET /new returns HTML form +- Verify GET /edit/{id} returns HTML form + +**FYI - Manual browser testing checklist** (no code required): +- Create new client via UI +- Verify client_id and client_secret are displayed +- Verify client appears in list +- Edit client name and redirect URIs +- Verify changes are saved +- Regenerate secret +- Verify new secret is displayed +- Delete client +- Verify redirect to list and client is gone +- Test error scenarios (invalid redirect URI, network errors) + +### TP-3: Regression Testing + +Run existing tests: `make test-dev` +- All tests in `client_test.go` should pass (existing functionality plus new endpoint tests) +- All tests in `ui_test.go` should pass (routing and rendering still work) +- New API endpoint tests from TP-1 provide coverage for PUT and POST regenerate-secret + +### TP-4: End-to-End Testing + +**FYI - Manual test scenarios** (no code required): + +1. Start fresh server with no clients +2. Navigate to UI in browser +3. Create first client → verify success message and secrets shown +4. Return to list → verify client appears +5. Edit client → change name and add redirect URI → verify success +6. Regenerate secret → verify new secret shown +7. Copy secret button → verify copies to clipboard +8. Delete client → verify confirmation and removal +9. Test validation errors: + - Try to create/update client with empty redirect URI + - Try to update non-existent client (should not be possible via UI) + +## Detailed Checklist + +### Phase 1: Extend API Endpoints +- [x] Add `serveUpdateClient` function to [server/clients.go](../server/clients.go) + - [x] Parse form data (name, redirect_uri) + - [x] Validate redirect URIs using `splitRedirectURIs` and `validateRedirectURI` + - [x] Update `s.funnelClients[clientID]` with new values + - [x] Call `s.storeFunnelClientsLocked()` + - [x] Return updated client JSON (without secret) + - [x] Handle errors (404 for not found, 400 for validation, 500 for storage) + +- [x] Add `serveRegenerateSecret` function to [server/clients.go](../server/clients.go) + - [x] Generate new secret via `generateClientSecret()` + - [x] Update `s.funnelClients[clientID].Secret` + - [x] Call `s.storeFunnelClientsLocked()` + - [x] Return client JSON with new secret included + - [x] Handle errors (404 for not found, 500 for storage) + +- [x] Update `serveClients` router function (line 123) + - [x] Add handling for PUT method when client ID is in path + - [x] Add handling for POST method with `/regenerate-secret` suffix + - [x] Route to `serveUpdateClient` for PUT requests + - [x] Route to `serveRegenerateSecret` for POST regenerate requests + +### Phase 2: Add Unit Tests +- [x] Create `TestServeUpdateClient` in [server/client_test.go](../server/client_test.go) + - [x] Test successful update with valid data + - [x] Test update with empty redirect_uri (expect 400) + - [x] Test update with invalid redirect URI (expect 400) + - [x] Test update non-existent client (expect 404) + - [x] Test persistence (create new server instance, verify changes saved) + - [x] Verify secret not included in response + +- [x] Create `TestServeRegenerateSecret` in [server/client_test.go](../server/client_test.go) + - [x] Test successful regeneration + - [x] Verify new secret different from old + - [x] Verify new secret included in response + - [x] Test regenerate for non-existent client (expect 404) + - [x] Test persistence + +- [x] Run `make test-dev` and ensure all tests pass + +### Phase 3: Update UI Templates with JavaScript +- [x] Modify [server/ui-edit.html](../server/ui-edit.html) + - [x] Add ` \ No newline at end of file diff --git a/server/ui.go b/server/ui.go index 42f1592f7..e85eff705 100644 --- a/server/ui.go +++ b/server/ui.go @@ -6,16 +6,12 @@ package server import ( "bytes" _ "embed" - "fmt" "html/template" - "log/slog" "net/http" "net/url" "sort" "strings" "time" - - "tailscale.com/util/rands" ) //go:embed ui-header.html @@ -112,81 +108,21 @@ func (s *IDPServer) handleClientsList(w http.ResponseWriter, r *http.Request) { // handleNewClient handles creating a new OAuth/OIDC client // Migrated from legacy/ui.go:115-186 func (s *IDPServer) handleNewClient(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - if err := s.renderClientForm(w, clientDisplayData{IsNew: true}); err != nil { - writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) - } - return + if r.Method != "GET" { + writeHTTPError(w, r, http.StatusMethodNotAllowed, ecInvalidRequest, "Method not allowed", nil) } - if r.Method == "POST" { - if err := r.ParseForm(); err != nil { - writeHTTPError(w, r, http.StatusBadRequest, ecInvalidRequest, "Failed to parse form", err) - return - } - - name := strings.TrimSpace(r.FormValue("name")) - redirectURIsText := strings.TrimSpace(r.FormValue("redirect_uris")) - redirectURIs := splitRedirectURIs(redirectURIsText) - - baseData := clientDisplayData{ - IsNew: true, - Name: name, - RedirectURIs: redirectURIs, - } - - if len(redirectURIs) == 0 { - s.renderFormError(w, r, baseData, "At least one redirect URI is required") - return - } - - for _, uri := range redirectURIs { - if errMsg := validateRedirectURI(uri); errMsg != "" { - s.renderFormError(w, r, baseData, fmt.Sprintf("Invalid redirect URI '%s': %s", uri, errMsg)) - return - } - } - - clientID := rands.HexString(32) - clientSecret := rands.HexString(64) - newClient := FunnelClient{ - ID: clientID, - Secret: clientSecret, - Name: name, - RedirectURIs: redirectURIs, - } - - s.mu.Lock() - if s.funnelClients == nil { - s.funnelClients = make(map[string]*FunnelClient) - } - s.funnelClients[clientID] = &newClient - err := s.storeFunnelClientsLocked() - s.mu.Unlock() - - if err != nil { - slog.Error("client create: could not write funnel clients db", slog.Any("error", err)) - s.renderFormError(w, r, baseData, "Failed to save client") - return - } - - successData := clientDisplayData{ - ID: clientID, - Name: name, - RedirectURIs: redirectURIs, - Secret: clientSecret, - IsNew: true, - } - s.renderFormSuccess(w, r, successData, "Client created successfully! Save the client secret - it won't be shown again.") - return + if err := s.renderClientForm(w, clientDisplayData{IsNew: true}); err != nil { + writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) } - - writeHTTPError(w, r, http.StatusMethodNotAllowed, ecInvalidRequest, "Method not allowed", nil) } // handleEditClient handles editing an existing OAuth/OIDC client -// Migrated from legacy/ui.go:188-319 func (s *IDPServer) handleEditClient(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeHTTPError(w, r, http.StatusMethodNotAllowed, ecInvalidRequest, "Method not allowed", nil) + } + clientID := strings.TrimPrefix(r.URL.Path, "/edit/") if clientID == "" { writeHTTPError(w, r, http.StatusBadRequest, ecInvalidRequest, "Client ID required", nil) @@ -202,121 +138,16 @@ func (s *IDPServer) handleEditClient(w http.ResponseWriter, r *http.Request) { return } - if r.Method == "GET" { - data := clientDisplayData{ - ID: client.ID, - Name: client.Name, - RedirectURIs: client.RedirectURIs, - HasSecret: client.Secret != "", - IsEdit: true, - } - if err := s.renderClientForm(w, data); err != nil { - writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) - } - return + data := clientDisplayData{ + ID: client.ID, + Name: client.Name, + RedirectURIs: client.RedirectURIs, + HasSecret: client.Secret != "", + IsEdit: true, } - - if r.Method == "POST" { - action := r.FormValue("action") - - if action == "delete" { - s.mu.Lock() - delete(s.funnelClients, clientID) - err := s.storeFunnelClientsLocked() - s.mu.Unlock() - - if err != nil { - slog.Error("client delete: could not write funnel clients db", slog.Any("error", err)) - s.mu.Lock() - s.funnelClients[clientID] = client - s.mu.Unlock() - - baseData := clientDisplayData{ - ID: client.ID, - Name: client.Name, - RedirectURIs: client.RedirectURIs, - HasSecret: client.Secret != "", - IsEdit: true, - } - s.renderFormError(w, r, baseData, "Failed to delete client. Please try again.") - return - } - - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - - if action == "regenerate_secret" { - newSecret := rands.HexString(64) - s.mu.Lock() - s.funnelClients[clientID].Secret = newSecret - err := s.storeFunnelClientsLocked() - s.mu.Unlock() - - baseData := clientDisplayData{ - ID: client.ID, - Name: client.Name, - RedirectURIs: client.RedirectURIs, - HasSecret: true, - IsEdit: true, - } - - if err != nil { - slog.Error("client regen secret: could not write funnel clients db", slog.Any("error", err)) - s.renderFormError(w, r, baseData, "Failed to regenerate secret") - return - } - - baseData.Secret = newSecret - s.renderFormSuccess(w, r, baseData, "New client secret generated! Save it - it won't be shown again.") - return - } - - if err := r.ParseForm(); err != nil { - writeHTTPError(w, r, http.StatusBadRequest, ecInvalidRequest, "Failed to parse form", err) - return - } - - name := strings.TrimSpace(r.FormValue("name")) - redirectURIsText := strings.TrimSpace(r.FormValue("redirect_uris")) - redirectURIs := splitRedirectURIs(redirectURIsText) - baseData := clientDisplayData{ - ID: client.ID, - Name: name, - RedirectURIs: redirectURIs, - HasSecret: client.Secret != "", - IsEdit: true, - } - - if len(redirectURIs) == 0 { - s.renderFormError(w, r, baseData, "At least one redirect URI is required") - return - } - - for _, uri := range redirectURIs { - if errMsg := validateRedirectURI(uri); errMsg != "" { - s.renderFormError(w, r, baseData, fmt.Sprintf("Invalid redirect URI '%s': %s", uri, errMsg)) - return - } - } - - s.mu.Lock() - s.funnelClients[clientID].Name = name - s.funnelClients[clientID].RedirectURIs = redirectURIs - err := s.storeFunnelClientsLocked() - s.mu.Unlock() - - if err != nil { - slog.Error("client update: could not write funnel clients db", slog.Any("error", err)) - s.renderFormError(w, r, baseData, "Failed to update client") - return - } - - s.renderFormSuccess(w, r, baseData, "Client updated successfully!") - return + if err := s.renderClientForm(w, data); err != nil { + writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) } - - writeHTTPError(w, r, http.StatusMethodNotAllowed, ecInvalidRequest, "Method not allowed", nil) } // clientDisplayData holds data for rendering client forms and lists @@ -329,8 +160,6 @@ type clientDisplayData struct { HasSecret bool IsNew bool IsEdit bool - Success string - Error string } // renderClientForm renders the client edit/create form @@ -346,24 +175,6 @@ func (s *IDPServer) renderClientForm(w http.ResponseWriter, data clientDisplayDa return nil } -// renderFormError renders the form with an error message -// Migrated from legacy/ui.go:344-349 -func (s *IDPServer) renderFormError(w http.ResponseWriter, r *http.Request, data clientDisplayData, errorMsg string) { - data.Error = errorMsg - if err := s.renderClientForm(w, data); err != nil { - writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) - } -} - -// renderFormSuccess renders the form with a success message -// Migrated from legacy/ui.go:351-356 -func (s *IDPServer) renderFormSuccess(w http.ResponseWriter, r *http.Request, data clientDisplayData, successMsg string) { - data.Success = successMsg - if err := s.renderClientForm(w, data); err != nil { - writeHTTPError(w, r, http.StatusInternalServerError, ecServerError, "failed to render form", err) - } -} - // validateRedirectURI validates that a redirect URI is well-formed func validateRedirectURI(redirectURI string) string { u, err := url.Parse(redirectURI)