diff --git a/cmd/tsidp/tsidp.go b/cmd/tsidp/tsidp.go
index 2d9450e96..5df99e1b8 100644
--- a/cmd/tsidp/tsidp.go
+++ b/cmd/tsidp/tsidp.go
@@ -452,13 +452,7 @@ func (s *idpServer) newMux() *http.ServeMux {
mux.HandleFunc("/userinfo", s.serveUserInfo)
mux.HandleFunc("/token", s.serveToken)
mux.HandleFunc("/clients/", s.serveClients)
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/" {
- io.WriteString(w, "
Tailscale OIDC IdP
")
- return
- }
- http.Error(w, "tsidp: not found", http.StatusNotFound)
- })
+ mux.HandleFunc("/", s.handleUI)
return mux
}
diff --git a/cmd/tsidp/ui-edit.html b/cmd/tsidp/ui-edit.html
new file mode 100644
index 000000000..d463981aa
--- /dev/null
+++ b/cmd/tsidp/ui-edit.html
@@ -0,0 +1,199 @@
+
+
+
+
+ {{if .IsNew}}Add New Client{{else}}Edit Client{{end}} - Tailscale OIDC Identity Provider
+
+
+
+
+
+
+ {{template "header"}}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/tsidp/ui-header.html b/cmd/tsidp/ui-header.html
new file mode 100644
index 000000000..68e9bc0df
--- /dev/null
+++ b/cmd/tsidp/ui-header.html
@@ -0,0 +1,53 @@
+
\ No newline at end of file
diff --git a/cmd/tsidp/ui-list.html b/cmd/tsidp/ui-list.html
new file mode 100644
index 000000000..d45b88349
--- /dev/null
+++ b/cmd/tsidp/ui-list.html
@@ -0,0 +1,73 @@
+
+
+
+ Tailscale OIDC Identity Provider
+
+
+
+
+
+ {{template "header"}}
+
+
+
+
+ {{if .}}
+
+
+
+ Name |
+ Client ID |
+ Redirect URI |
+ Status |
+ Actions |
+
+
+
+ {{range .}}
+
+
+ {{if .Name}}
+ {{.Name}}
+ {{else}}
+ Unnamed Client
+ {{end}}
+ |
+
+ {{.ID}}
+ |
+
+ {{.RedirectURI}}
+ |
+
+ {{if .HasSecret}}
+ Active
+ {{else}}
+ No Secret
+ {{end}}
+ |
+
+ Edit
+ |
+
+ {{end}}
+
+
+ {{else}}
+
+
No OIDC clients configured
+
Create your first OIDC client to get started with authentication.
+
Add New Client
+
+ {{end}}
+
+
+
\ No newline at end of file
diff --git a/cmd/tsidp/ui-style.css b/cmd/tsidp/ui-style.css
new file mode 100644
index 000000000..148ec3030
--- /dev/null
+++ b/cmd/tsidp/ui-style.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/cmd/tsidp/ui.go b/cmd/tsidp/ui.go
new file mode 100644
index 000000000..d37b64990
--- /dev/null
+++ b/cmd/tsidp/ui.go
@@ -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 ""
+}