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>main
parent
4980869977
commit
09582bdc00
@ -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> |
||||
@ -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> |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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 "" |
||||
} |
||||
Loading…
Reference in new issue