cmd/tsidp: add web UI for managing OIDC clients (#16068)

Add comprehensive web interface at ui for managing OIDC clients, similar to tsrecorder's design. Features include list view, create/edit forms with validation, client secret management, delete functionality with confirmation dialogs, responsive design, and restricted tailnet access only.

Fixes #16067

Signed-off-by: Raj Singh <raj@tailscale.com>
This commit is contained in:
Raj Singh 2025-05-24 18:16:29 -04:00 committed by GitHub
parent 4980869977
commit 09582bdc00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1097 additions and 7 deletions

View File

@ -452,13 +452,7 @@ func (s *idpServer) newMux() *http.ServeMux {
mux.HandleFunc("/userinfo", s.serveUserInfo) mux.HandleFunc("/userinfo", s.serveUserInfo)
mux.HandleFunc("/token", s.serveToken) mux.HandleFunc("/token", s.serveToken)
mux.HandleFunc("/clients/", s.serveClients) mux.HandleFunc("/clients/", s.serveClients)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", s.handleUI)
if r.URL.Path == "/" {
io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>")
return
}
http.Error(w, "tsidp: not found", http.StatusNotFound)
})
return mux return mux
} }

199
cmd/tsidp/ui-edit.html Normal file
View File

@ -0,0 +1,199 @@
<!DOCTYPE html>
<html>
<head>
<title>
{{if .IsNew}}Add New Client{{else}}Edit Client{{end}} - Tailscale OIDC Identity Provider
</title>
<link rel="stylesheet" type="text/css" href="/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
{{template "header"}}
<main>
<div class="form-container">
<div class="form-header">
<h2>
{{if .IsNew}}Add New OIDC Client{{else}}Edit OIDC Client{{end}}
</h2>
<a href="/" class="btn btn-secondary">← Back to Clients</a>
</div>
{{if .Success}}
<div class="alert alert-success">
{{.Success}}
</div>
{{end}}
{{if .Error}}
<div class="alert alert-error">
{{.Error}}
</div>
{{end}}
{{if and .Secret .IsNew}}
<div class="client-info">
<h3>Client Created Successfully!</h3>
<p class="warning">⚠️ Save both the Client ID and Secret now! The secret will not be shown again.</p>
<div class="form-group">
<label>Client ID</label>
<div class="secret-field">
<input type="text" value="{{.ID}}" readonly class="secret-input" id="client-id">
<button type="button" onclick="copyClientId(event)" class="btn btn-secondary btn-small">Copy</button>
</div>
</div>
<div class="form-group">
<label>Client Secret</label>
<div class="secret-field">
<input type="text" value="{{.Secret}}" readonly class="secret-input" id="client-secret">
<button type="button" onclick="copySecret(event)" class="btn btn-secondary btn-small">Copy</button>
</div>
</div>
</div>
{{end}}
{{if and .Secret .IsEdit}}
<div class="secret-display">
<h3>New Client Secret</h3>
<p class="warning">⚠️ Save this secret now! It will not be shown again.</p>
<div class="secret-field">
<input type="text" value="{{.Secret}}" readonly class="secret-input" id="client-secret">
<button type="button" onclick="copySecret(event)" class="btn btn-secondary btn-small">Copy</button>
</div>
</div>
{{end}}
<form method="POST" class="client-form">
<div class="form-group">
<label for="name">Client Name</label>
<input
type="text"
id="name"
name="name"
value="{{.Name}}"
placeholder="e.g., My Application"
class="form-input"
>
<div class="form-help">
A descriptive name for this OIDC client (optional).
</div>
</div>
<div class="form-group">
<label for="redirect_uri">Redirect URI <span class="required">*</span></label>
<input
type="url"
id="redirect_uri"
name="redirect_uri"
value="{{.RedirectURI}}"
placeholder="https://example.com/auth/callback"
class="form-input"
required
>
<div class="form-help">
The URL where users will be redirected after authentication.
</div>
</div>
{{if .IsEdit}}
<div class="form-group">
<label>Client ID</label>
<input
type="text"
value="{{.ID}}"
readonly
class="form-input form-input-readonly"
>
<div class="form-help">
The client ID cannot be changed.
</div>
</div>
{{end}}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{{if .IsNew}}Create Client{{else}}Update Client{{end}}
</button>
{{if .IsEdit}}
<button type="submit" name="action" value="regenerate_secret" class="btn btn-warning"
onclick="return confirm('Are you sure you want to regenerate the client secret? The old secret will stop working immediately.')">
Regenerate Secret
</button>
<button type="submit" name="action" value="delete" class="btn btn-danger"
onclick="return confirm('Are you sure you want to delete this client? This cannot be undone.')">
Delete Client
</button>
{{end}}
</div>
</form>
{{if .IsEdit}}
<div class="client-info">
<h3>Client Information</h3>
<dl>
<dt>Client ID</dt>
<dd><code>{{.ID}}</code></dd>
<dt>Secret Status</dt>
<dd>
{{if .HasSecret}}
<span class="status-active">Secret configured</span>
{{else}}
<span class="status-inactive">No secret</span>
{{end}}
</dd>
</dl>
</div>
{{end}}
</div>
</main>
<script>
function copySecret(event) {
const secretInput = document.getElementById('client-secret');
secretInput.select();
secretInput.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(secretInput.value).then(function() {
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('btn-success');
setTimeout(function() {
button.textContent = originalText;
button.classList.remove('btn-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy: ', err);
alert('Failed to copy to clipboard. Please copy manually.');
});
}
function copyClientId(event) {
const clientIdInput = document.getElementById('client-id');
clientIdInput.select();
clientIdInput.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(clientIdInput.value).then(function() {
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('btn-success');
setTimeout(function() {
button.textContent = originalText;
button.classList.remove('btn-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy: ', err);
alert('Failed to copy to clipboard. Please copy manually.');
});
}
</script>
</body>
</html>

53
cmd/tsidp/ui-header.html Normal file
View File

@ -0,0 +1,53 @@
<header>
<nav>
<svg
width="18"
height="18"
viewBox="0 0 23 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="shrink-0"
>
<circle
opacity="0.2"
cx="3.4"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="3.4"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="11.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle
opacity="0.2"
cx="19.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="19.5"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
</svg>
<a href="/"><h1>Tailscale OIDC Identity Provider</h1></a>
</nav>
</header>

73
cmd/tsidp/ui-list.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<title>Tailscale OIDC Identity Provider</title>
<link rel="stylesheet" type="text/css" href="/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
{{template "header"}}
<main>
<div class="header-actions">
<div>
<h2>OIDC Clients</h2>
{{if .}}
<p class="client-count">{{len .}} client{{if ne (len .) 1}}s{{end}} configured</p>
{{end}}
</div>
<a href="/new" class="btn btn-primary">Add New Client</a>
</div>
{{if .}}
<table>
<thead>
<tr>
<td>Name</td>
<td>Client ID</td>
<td>Redirect URI</td>
<td>Status</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td>
{{if .Name}}
<strong>{{.Name}}</strong>
{{else}}
<span class="text-muted">Unnamed Client</span>
{{end}}
</td>
<td>
<code class="client-id">{{.ID}}</code>
</td>
<td>
<span class="redirect-uri">{{.RedirectURI}}</span>
</td>
<td>
{{if .HasSecret}}
<span class="status-active">Active</span>
{{else}}
<span class="status-inactive">No Secret</span>
{{end}}
</td>
<td>
<a href="/edit/{{.ID}}" class="btn btn-secondary btn-small">Edit</a>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<h3>No OIDC clients configured</h3>
<p>Create your first OIDC client to get started with authentication.</p>
<a href="/new" class="btn btn-primary">Add New Client</a>
</div>
{{end}}
</main>
</body>
</html>

446
cmd/tsidp/ui-style.css Normal file
View File

@ -0,0 +1,446 @@
:root {
--tw-text-opacity: 1;
--color-gray-100: 247 245 244;
--color-gray-200: 238 235 234;
--color-gray-500: 112 110 109;
--color-gray-700: 46 45 45;
--color-gray-800: 35 34 34;
--color-gray-900: 31 30 30;
--color-bg-app: rgb(var(--color-gray-900) / 1);
--color-border-base: rgb(var(--color-gray-200) / 1);
--color-primary: 59 130 246;
--color-primary-hover: 37 99 235;
--color-secondary: 107 114 128;
--color-secondary-hover: 75 85 99;
--color-success: 34 197 94;
--color-warning: 245 158 11;
--color-danger: 239 68 68;
--color-danger-hover: 220 38 38;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial,
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 16px;
line-height: 1.4;
margin: 0;
background-color: var(--color-bg-app);
color: rgb(var(--color-gray-200));
}
a {
text-decoration: none;
color: inherit;
}
header {
margin-top: 40px;
}
header nav {
margin: 0 auto;
max-width: 1120px;
display: flex;
align-items: center;
justify-content: center;
}
header nav h1 {
display: inline;
font-weight: 600;
font-size: 1.125rem;
line-height: 1.75rem;
margin-left: 0.75rem;
}
main {
margin: 40px auto 60px auto;
max-width: 1120px;
padding: 0 20px;
}
/* Header actions */
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-actions h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.client-count {
font-size: 0.875rem;
color: rgb(var(--color-gray-500));
margin: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
.btn-primary {
background-color: rgb(var(--color-primary));
color: white;
}
.btn-primary:hover {
background-color: rgb(var(--color-primary-hover));
}
.btn-secondary {
background-color: rgb(var(--color-secondary));
color: white;
}
.btn-secondary:hover {
background-color: rgb(var(--color-secondary-hover));
}
.btn-success {
background-color: rgb(var(--color-success));
color: white;
}
.btn-warning {
background-color: rgb(var(--color-warning));
color: white;
}
.btn-danger {
background-color: rgb(var(--color-danger));
color: white;
}
.btn-danger:hover {
background-color: rgb(var(--color-danger-hover));
}
/* Tables */
table {
width: 100%;
border-spacing: 0;
border: 1px solid rgb(var(--color-gray-700));
border-bottom-width: 0;
border-radius: 8px;
overflow: hidden;
}
td {
border: 0 solid rgb(var(--color-gray-700));
border-bottom-width: 1px;
padding: 12px 16px;
}
thead td {
text-transform: uppercase;
color: rgb(var(--color-gray-500) / var(--tw-text-opacity));
font-size: 12px;
letter-spacing: 0.08em;
font-weight: 600;
background-color: rgb(var(--color-gray-800));
}
tbody tr:hover {
background-color: rgb(var(--color-gray-800));
}
/* Client display elements */
.client-id {
font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
Menlo, Consolas, monospace;
font-size: 12px;
background-color: rgb(var(--color-gray-800));
padding: 2px 6px;
border-radius: 4px;
color: rgb(var(--color-gray-200));
}
.redirect-uri {
font-size: 14px;
color: rgb(var(--color-gray-200));
word-break: break-all;
}
.status-active {
color: rgb(var(--color-success));
font-weight: 500;
}
.status-inactive {
color: rgb(var(--color-gray-500));
font-weight: 500;
}
.text-muted {
color: rgb(var(--color-gray-500));
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
border: 1px solid rgb(var(--color-gray-700));
border-radius: 8px;
background-color: rgb(var(--color-gray-800) / 0.5);
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: rgb(var(--color-gray-200));
}
.empty-state p {
color: rgb(var(--color-gray-500));
margin-bottom: 1.5rem;
}
/* Forms */
.form-container {
max-width: 600px;
margin: 0 auto;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.form-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.client-form {
background-color: rgb(var(--color-gray-800) / 0.5);
border: 1px solid rgb(var(--color-gray-700));
border-radius: 8px;
padding: 24px;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: rgb(var(--color-gray-200));
}
.required {
color: rgb(var(--color-danger));
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid rgb(var(--color-gray-700));
border-radius: 6px;
background-color: rgb(var(--color-gray-900));
color: rgb(var(--color-gray-200));
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 3px rgb(var(--color-primary) / 0.1);
}
.form-input-readonly {
background-color: rgb(var(--color-gray-800));
color: rgb(var(--color-gray-500));
}
.form-help {
font-size: 12px;
color: rgb(var(--color-gray-500));
margin-top: 0.25rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgb(var(--color-gray-700));
}
/* Alerts */
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 14px;
}
.alert-success {
background-color: rgb(var(--color-success) / 0.1);
border: 1px solid rgb(var(--color-success) / 0.3);
color: rgb(var(--color-success));
}
.alert-error {
background-color: rgb(var(--color-danger) / 0.1);
border: 1px solid rgb(var(--color-danger) / 0.3);
color: rgb(var(--color-danger));
}
/* Secret display */
.secret-display {
background-color: rgb(var(--color-gray-800) / 0.5);
border: 1px solid rgb(var(--color-gray-700));
border-radius: 8px;
padding: 20px;
margin-bottom: 2rem;
}
.secret-display h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: rgb(var(--color-gray-200));
}
.warning {
color: rgb(var(--color-warning));
font-weight: 500;
margin-bottom: 1rem;
}
.secret-field {
display: flex;
gap: 0.5rem;
}
.secret-input {
flex: 1;
padding: 10px 12px;
border: 1px solid rgb(var(--color-gray-700));
border-radius: 6px;
background-color: rgb(var(--color-gray-900));
color: rgb(var(--color-gray-200));
font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
Menlo, Consolas, monospace;
font-size: 12px;
}
/* Client info */
.client-info {
background-color: rgb(var(--color-gray-800) / 0.5);
border: 1px solid rgb(var(--color-gray-700));
border-radius: 8px;
padding: 20px;
}
.client-info h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: rgb(var(--color-gray-200));
}
.client-info dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
border: none;
border-radius: 0;
padding: 0;
}
.client-info dt {
font-weight: 600;
color: rgb(var(--color-gray-400));
border: none;
padding: 0;
}
.client-info dd {
color: rgb(var(--color-gray-200));
border: none;
padding: 0;
}
.client-info code {
font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
Menlo, Consolas, monospace;
font-size: 12px;
background-color: rgb(var(--color-gray-800));
padding: 2px 6px;
border-radius: 4px;
color: rgb(var(--color-gray-200));
}
/* Responsive design */
@media (max-width: 768px) {
.header-actions {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.form-header {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.form-actions {
flex-direction: column;
}
.secret-field {
flex-direction: column;
}
table {
font-size: 14px;
}
td {
padding: 8px 12px;
}
.client-id {
font-size: 10px;
}
}

325
cmd/tsidp/ui.go Normal file
View File

@ -0,0 +1,325 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"bytes"
_ "embed"
"html/template"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
"tailscale.com/util/rands"
)
//go:embed ui-header.html
var headerHTML string
//go:embed ui-list.html
var listHTML string
//go:embed ui-edit.html
var editHTML string
//go:embed ui-style.css
var styleCSS string
var headerTmpl = template.Must(template.New("header").Parse(headerHTML))
var listTmpl = template.Must(headerTmpl.New("list").Parse(listHTML))
var editTmpl = template.Must(headerTmpl.New("edit").Parse(editHTML))
var processStart = time.Now()
func (s *idpServer) handleUI(w http.ResponseWriter, r *http.Request) {
if isFunnelRequest(r) {
http.Error(w, "tsidp: UI not available over Funnel", http.StatusNotFound)
return
}
switch r.URL.Path {
case "/":
s.handleClientsList(w, r)
return
case "/new":
s.handleNewClient(w, r)
return
case "/style.css":
http.ServeContent(w, r, "ui-style.css", processStart, strings.NewReader(styleCSS))
return
}
if strings.HasPrefix(r.URL.Path, "/edit/") {
s.handleEditClient(w, r)
return
}
http.Error(w, "tsidp: not found", http.StatusNotFound)
}
func (s *idpServer) handleClientsList(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
clients := make([]clientDisplayData, 0, len(s.funnelClients))
for _, c := range s.funnelClients {
clients = append(clients, clientDisplayData{
ID: c.ID,
Name: c.Name,
RedirectURI: c.RedirectURI,
HasSecret: c.Secret != "",
})
}
s.mu.Unlock()
sort.Slice(clients, func(i, j int) bool {
if clients[i].Name != clients[j].Name {
return clients[i].Name < clients[j].Name
}
return clients[i].ID < clients[j].ID
})
var buf bytes.Buffer
if err := listTmpl.Execute(&buf, clients); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buf.WriteTo(w)
}
func (s *idpServer) handleNewClient(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
if err := s.renderClientForm(w, clientDisplayData{IsNew: true}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if r.Method == "POST" {
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
baseData := clientDisplayData{
IsNew: true,
Name: name,
RedirectURI: redirectURI,
}
if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
s.renderFormError(w, baseData, errMsg)
return
}
clientID := rands.HexString(32)
clientSecret := rands.HexString(64)
newClient := funnelClient{
ID: clientID,
Secret: clientSecret,
Name: name,
RedirectURI: redirectURI,
}
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 {
log.Printf("could not write funnel clients db: %v", err)
s.renderFormError(w, baseData, "Failed to save client")
return
}
successData := clientDisplayData{
ID: clientID,
Name: name,
RedirectURI: redirectURI,
Secret: clientSecret,
IsNew: true,
}
s.renderFormSuccess(w, successData, "Client created successfully! Save the client secret - it won't be shown again.")
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
func (s *idpServer) handleEditClient(w http.ResponseWriter, r *http.Request) {
clientID := strings.TrimPrefix(r.URL.Path, "/edit/")
if clientID == "" {
http.Error(w, "Client ID required", http.StatusBadRequest)
return
}
s.mu.Lock()
client, exists := s.funnelClients[clientID]
s.mu.Unlock()
if !exists {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
if r.Method == "GET" {
data := createEditBaseData(client, client.Name, client.RedirectURI)
if err := s.renderClientForm(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
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 {
log.Printf("could not write funnel clients db: %v", err)
s.mu.Lock()
s.funnelClients[clientID] = client
s.mu.Unlock()
baseData := createEditBaseData(client, client.Name, client.RedirectURI)
s.renderFormError(w, 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 := createEditBaseData(client, client.Name, client.RedirectURI)
baseData.HasSecret = true
if err != nil {
log.Printf("could not write funnel clients db: %v", err)
s.renderFormError(w, baseData, "Failed to regenerate secret")
return
}
baseData.Secret = newSecret
s.renderFormSuccess(w, baseData, "New client secret generated! Save it - it won't be shown again.")
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
baseData := createEditBaseData(client, name, redirectURI)
if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
s.renderFormError(w, baseData, errMsg)
return
}
s.mu.Lock()
s.funnelClients[clientID].Name = name
s.funnelClients[clientID].RedirectURI = redirectURI
err := s.storeFunnelClientsLocked()
s.mu.Unlock()
if err != nil {
log.Printf("could not write funnel clients db: %v", err)
s.renderFormError(w, baseData, "Failed to update client")
return
}
s.renderFormSuccess(w, baseData, "Client updated successfully!")
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
type clientDisplayData struct {
ID string
Name string
RedirectURI string
Secret string
HasSecret bool
IsNew bool
IsEdit bool
Success string
Error string
}
func (s *idpServer) renderClientForm(w http.ResponseWriter, data clientDisplayData) error {
var buf bytes.Buffer
if err := editTmpl.Execute(&buf, data); err != nil {
return err
}
if _, err := buf.WriteTo(w); err != nil {
return err
}
return nil
}
func (s *idpServer) renderFormError(w http.ResponseWriter, data clientDisplayData, errorMsg string) {
data.Error = errorMsg
if err := s.renderClientForm(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *idpServer) renderFormSuccess(w http.ResponseWriter, data clientDisplayData, successMsg string) {
data.Success = successMsg
if err := s.renderClientForm(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func createEditBaseData(client *funnelClient, name, redirectURI string) clientDisplayData {
return clientDisplayData{
ID: client.ID,
Name: name,
RedirectURI: redirectURI,
HasSecret: client.Secret != "",
IsEdit: true,
}
}
func validateRedirectURI(redirectURI string) string {
if redirectURI == "" {
return "Redirect URI is required"
}
u, err := url.Parse(redirectURI)
if err != nil {
return "Invalid URL format"
}
if u.Scheme != "http" && u.Scheme != "https" {
return "Redirect URI must be a valid HTTP or HTTPS URL"
}
if u.Host == "" {
return "Redirect URI must include a valid host"
}
return ""
}