mirror of
https://github.com/tailscale/tailscale.git
synced 2025-06-08 16:58:35 +00:00
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:
parent
4980869977
commit
09582bdc00
@ -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
199
cmd/tsidp/ui-edit.html
Normal 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
53
cmd/tsidp/ui-header.html
Normal 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
73
cmd/tsidp/ui-list.html
Normal 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
446
cmd/tsidp/ui-style.css
Normal 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
325
cmd/tsidp/ui.go
Normal 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 ""
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user