Skip to content

Commit 5882829

Browse files
mhalsuwaidiclaude
authored andcommitted
Redesign login page: SSO-first layout + auto-SSO option, bump to v0.7.0
Login page redesign (both hosted and OIDC): - SSO button is now the primary prominent action - Manual login form collapsed below ("Or sign in with username and password") - When SSO fails (error), both SSO button and manual form shown expanded - When no Kerberos configured, only manual form shown (no SSO section) Auto-SSO feature: - New auto_sso setting in RuntimeSettings (admin UI Settings page) - When enabled, login page shows spinner "Attempting Single Sign-On..." then auto-redirects to /login/sso via full page navigation (SPNEGO works) - If SSO fails, user is redirected back and sees manual form - Uses window.location.href (not fetch) — critical for SPNEGO to work Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0787cc0 commit 5882829

5 files changed

Lines changed: 179 additions & 57 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.6.3
1+
0.7.0

internal/handler/hosted_login.go

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,38 @@ func (h *Handler) handleHostedLoginPage(w http.ResponseWriter, r *http.Request)
5252
errorHTML = `<div class="error">` + errorMsg + `</div>`
5353
}
5454

55-
ssoHTML := ""
56-
if h.getKeytabPath() != "" {
57-
ssoLink := h.url("/login/sso")
55+
ssoEnabled := h.getKeytabPath() != ""
56+
ssoLink := ""
57+
if ssoEnabled {
58+
ssoLink = h.url("/login/sso")
5859
if redirectURI != "" {
5960
ssoLink += "?redirect_uri=" + url.QueryEscape(redirectURI)
6061
}
61-
ssoHTML = fmt.Sprintf(`<a href="%s" class="sso-btn">Sign in with SSO</a><div class="divider"><span>or sign in with credentials</span></div>`, ssoLink)
62+
}
63+
64+
// Check auto_sso setting — only auto-redirect if SSO is enabled, no error, and not already tried
65+
autoSSO := false
66+
if ssoEnabled && errorMsg == "" && r.URL.Query().Get("manual") != "1" {
67+
if rs := h.runtimeSettings.get(); rs != nil && rs.AutoSSO {
68+
autoSSO = true
69+
}
6270
}
6371

6472
csrfToken := generateCSRFToken()
6573
setCSRFCookie(w, csrfToken)
6674

6775
w.Header().Set("Content-Type", "text/html; charset=utf-8")
68-
fmt.Fprintf(w, h.bp(hostedLoginHTML), redirectURI, errorHTML, ssoHTML, csrfToken)
76+
// %[1]s = redirectURI, %[2]s = errorHTML, %[3]s = ssoLink, %[4]s = csrfToken,
77+
// %[5]s = ssoEnabled ("1"/""), %[6]s = autoSSO ("1"/"")
78+
ssoEnabledStr := ""
79+
if ssoEnabled {
80+
ssoEnabledStr = "1"
81+
}
82+
autoSSOStr := ""
83+
if autoSSO {
84+
autoSSOStr = "1"
85+
}
86+
fmt.Fprintf(w, h.bp(hostedLoginHTML), redirectURI, errorHTML, ssoLink, csrfToken, ssoEnabledStr, autoSSOStr)
6987
}
7088

7189
// handleHostedLoginSubmit processes the hosted login form submission.
@@ -227,45 +245,81 @@ const hostedLoginHTML = `<!DOCTYPE html>
227245
}}
228246
*{box-sizing:border-box;margin:0;padding:0}
229247
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
230-
.card{width:400px;padding:40px;background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 4px 16px rgba(51,63,72,0.1)}
248+
.card{width:420px;padding:40px;background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 4px 16px rgba(51,63,72,0.1)}
231249
.brand{text-align:center;margin-bottom:32px}
232250
.brand h1{font-size:1.5rem;font-weight:700;margin-bottom:4px}
233251
.brand p{color:var(--muted);font-size:0.875rem}
234252
.gold-bar{height:3px;background:linear-gradient(90deg,var(--gold-light),var(--gold-dark));border-radius:999px;margin-bottom:24px}
235253
.error{background:var(--error-bg);color:var(--error-text);padding:12px 16px;border-radius:8px;font-size:0.875rem;margin-bottom:16px}
236254
label{display:block;font-size:0.875rem;font-weight:600;margin-bottom:8px}
237-
input{width:100%%;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:0.875rem;font-family:inherit;color:var(--text);margin-bottom:16px}
255+
input[type=text],input[type=password]{width:100%%;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:0.875rem;font-family:inherit;color:var(--text);margin-bottom:16px}
238256
input:focus{outline:none;border-color:var(--burgundy);box-shadow:0 0 0 3px rgba(139,21,61,0.15)}
239-
button{width:100%%;padding:12px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.875rem;font-weight:600;cursor:pointer;font-family:inherit}
240-
button:hover{background:var(--burgundy-hover)}
241-
.sso-btn{display:block;width:100%%;padding:12px;background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:8px;font-size:0.875rem;font-weight:600;text-align:center;text-decoration:none;font-family:inherit;cursor:pointer}
242-
.sso-btn:hover{border-color:var(--burgundy);color:var(--burgundy)}
243-
.divider{display:flex;align-items:center;margin:20px 0;gap:12px}
244-
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
245-
.divider span{color:var(--muted);font-size:0.75rem;white-space:nowrap}
257+
.btn-primary{width:100%%;padding:14px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.95rem;font-weight:600;cursor:pointer;font-family:inherit;text-align:center;text-decoration:none;display:block}
258+
.btn-primary:hover{background:var(--burgundy-hover)}
259+
.btn-submit{width:100%%;padding:12px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.875rem;font-weight:600;cursor:pointer;font-family:inherit}
260+
.btn-submit:hover{background:var(--burgundy-hover)}
261+
.manual-toggle{display:block;width:100%%;text-align:center;padding:10px;color:var(--muted);font-size:0.8rem;cursor:pointer;border:none;background:none;margin-top:16px;font-family:inherit}
262+
.manual-toggle:hover{color:var(--text)}
263+
.manual-form{display:none;margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
264+
.manual-form.show{display:block}
265+
.sso-status{text-align:center;padding:24px 0;color:var(--muted);font-size:0.9rem}
266+
.sso-status .spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--burgundy);border-radius:50%%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
267+
@keyframes spin{to{transform:rotate(360deg)}}
246268
</style>
247269
</head>
248270
<body>
249271
<div class="card">
250272
<div class="brand"><h1>SimpleAuth</h1><p>Sign in to continue</p></div>
251273
<div class="gold-bar"></div>
252274
%[2]s
253-
%[3]s
254-
<form method="POST" action="{{BASE_PATH}}/login">
255-
<input type="hidden" name="redirect_uri" value="%[1]s">
256-
<input type="hidden" name="_csrf" value="%[4]s">
257-
<label>Username</label>
258-
<input type="text" name="username" placeholder="Enter your username" autofocus required>
259-
<label>Password</label>
260-
<input type="password" name="password" placeholder="Enter your password" required>
261-
<button type="submit">Sign In</button>
262-
</form>
275+
<div id="sso-section" style="display:none">
276+
<a href="%[3]s" class="btn-primary" id="sso-btn">Sign in with Single Sign-On</a>
277+
<button class="manual-toggle" onclick="document.getElementById('manual-form').classList.add('show');this.style.display='none'">
278+
Or sign in with username and password
279+
</button>
280+
</div>
281+
<div id="auto-sso-status" style="display:none">
282+
<div class="sso-status"><span class="spinner"></span> Attempting Single Sign-On...</div>
283+
</div>
284+
<div id="manual-form" class="manual-form">
285+
<form method="POST" action="{{BASE_PATH}}/login">
286+
<input type="hidden" name="redirect_uri" value="%[1]s">
287+
<input type="hidden" name="_csrf" value="%[4]s">
288+
<label>Username</label>
289+
<input type="text" name="username" placeholder="Enter your username" autofocus required>
290+
<label>Password</label>
291+
<input type="password" name="password" placeholder="Enter your password" required>
292+
<button type="submit" class="btn-submit">Sign In</button>
293+
</form>
294+
</div>
263295
</div>
264296
<script>
265-
// If already logged in, show account link
266-
try{if(sessionStorage.getItem('sa_access_token')){
267-
document.querySelector('.brand p').innerHTML='Sign in to continue or <a href="{{BASE_PATH}}/account" style="color:var(--burgundy);font-weight:600">go to your account</a>';
268-
}}catch(e){}
297+
(function(){
298+
var ssoEnabled = "%[5]s" === "1";
299+
var autoSSO = "%[6]s" === "1";
300+
var ssoLink = "%[3]s";
301+
var hasError = document.querySelector('.error') !== null;
302+
303+
if (ssoEnabled && !hasError) {
304+
document.getElementById('sso-section').style.display = 'block';
305+
if (autoSSO && ssoLink) {
306+
// Auto-SSO: show spinner then redirect (full page navigation = SPNEGO works)
307+
document.getElementById('sso-section').style.display = 'none';
308+
document.getElementById('auto-sso-status').style.display = 'block';
309+
setTimeout(function(){ window.location.href = ssoLink; }, 500);
310+
}
311+
} else if (ssoEnabled && hasError) {
312+
// SSO failed — show SSO button + manual form expanded
313+
document.getElementById('sso-section').style.display = 'block';
314+
document.getElementById('manual-form').classList.add('show');
315+
} else {
316+
// No SSO — show manual form directly
317+
document.getElementById('manual-form').classList.add('show');
318+
document.getElementById('manual-form').style.borderTop = 'none';
319+
document.getElementById('manual-form').style.marginTop = '0';
320+
document.getElementById('manual-form').style.paddingTop = '0';
321+
}
322+
})();
269323
</script>
270324
</body>
271325
</html>`

internal/handler/oidc.go

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,10 @@ func (h *Handler) showOIDCLoginPage(w http.ResponseWriter, r *http.Request) {
233233
appName = "your application"
234234
}
235235

236-
ssoHTML := ""
237-
if h.getKeytabPath() != "" {
238-
ssoLink := h.url("/login/sso") + "?oidc=1"
236+
ssoEnabled := h.getKeytabPath() != ""
237+
ssoLink := ""
238+
if ssoEnabled {
239+
ssoLink = h.url("/login/sso") + "?oidc=1"
239240
if redirectURI != "" {
240241
ssoLink += "&redirect_uri=" + url.QueryEscape(redirectURI)
241242
}
@@ -245,11 +246,26 @@ func (h *Handler) showOIDCLoginPage(w http.ResponseWriter, r *http.Request) {
245246
if nonce != "" {
246247
ssoLink += "&nonce=" + url.QueryEscape(nonce)
247248
}
248-
ssoHTML = fmt.Sprintf(`<a href="%s" class="sso-btn">Sign in with SSO</a><div class="divider"><span>or sign in with credentials</span></div>`, ssoLink)
249+
}
250+
251+
autoSSO := false
252+
if ssoEnabled && errorMsg == "" {
253+
if rs := h.runtimeSettings.get(); rs != nil && rs.AutoSSO {
254+
autoSSO = true
255+
}
256+
}
257+
258+
ssoEnabledStr := ""
259+
if ssoEnabled {
260+
ssoEnabledStr = "1"
261+
}
262+
autoSSOStr := ""
263+
if autoSSO {
264+
autoSSOStr = "1"
249265
}
250266

251267
w.Header().Set("Content-Type", "text/html; charset=utf-8")
252-
fmt.Fprintf(w, oidcLoginHTML, action, h.oidcClientID(), redirectURI, state, nonce, scope, appName, errorHTML, ssoHTML)
268+
fmt.Fprintf(w, oidcLoginHTML, action, h.oidcClientID(), redirectURI, state, nonce, scope, appName, errorHTML, ssoLink, ssoEnabledStr, autoSSOStr)
253269
}
254270

255271
// handleOIDCToken handles the OAuth2 token endpoint.
@@ -740,6 +756,10 @@ func oidcError(w http.ResponseWriter, errorCode, description string, status int)
740756
}
741757

742758
// OIDC login page template
759+
// oidcLoginHTML format args:
760+
// %[1]s = form action, %[2]s = client_id, %[3]s = redirect_uri, %[4]s = state,
761+
// %[5]s = nonce, %[6]s = scope, %[7]s = appName, %[8]s = errorHTML,
762+
// %[9]s = ssoLink, %[10]s = ssoEnabled ("1"/""), %[11]s = autoSSO ("1"/"")
743763
const oidcLoginHTML = `<!DOCTYPE html>
744764
<html lang="en">
745765
<head>
@@ -759,8 +779,8 @@ const oidcLoginHTML = `<!DOCTYPE html>
759779
--error-bg:rgba(139,21,61,0.2);--error-text:#D4A0A0;
760780
}}
761781
*{box-sizing:border-box;margin:0;padding:0}
762-
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
763-
.card{width:400px;padding:40px;background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 4px 16px rgba(51,63,72,0.1)}
782+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
783+
.card{width:420px;padding:40px;background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 4px 16px rgba(51,63,72,0.1)}
764784
.brand{text-align:center;margin-bottom:32px}
765785
.brand h1{font-size:1.5rem;font-weight:700;margin-bottom:4px}
766786
.brand p{color:var(--muted);font-size:0.875rem}
@@ -769,36 +789,75 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--t
769789
label{display:block;font-size:0.875rem;font-weight:600;margin-bottom:8px}
770790
input[type=text],input[type=password]{width:100%%;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:0.875rem;font-family:inherit;color:var(--text);margin-bottom:16px}
771791
input:focus{outline:none;border-color:var(--burgundy);box-shadow:0 0 0 3px rgba(139,21,61,0.15)}
772-
button{width:100%%;padding:12px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.875rem;font-weight:600;cursor:pointer;font-family:inherit}
773-
button:hover{background:var(--burgundy-hover)}
792+
.btn-primary{width:100%%;padding:14px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.95rem;font-weight:600;cursor:pointer;font-family:inherit;text-align:center;text-decoration:none;display:block}
793+
.btn-primary:hover{background:var(--burgundy-hover)}
794+
.btn-submit{width:100%%;padding:12px;background:var(--burgundy);color:#fff;border:none;border-radius:8px;font-size:0.875rem;font-weight:600;cursor:pointer;font-family:inherit}
795+
.btn-submit:hover{background:var(--burgundy-hover)}
796+
.manual-toggle{display:block;width:100%%;text-align:center;padding:10px;color:var(--muted);font-size:0.8rem;cursor:pointer;border:none;background:none;margin-top:16px;font-family:inherit}
797+
.manual-toggle:hover{color:var(--text)}
798+
.manual-form{display:none;margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
799+
.manual-form.show{display:block}
774800
.app-name{font-size:0.75rem;color:var(--muted);text-align:center;margin-top:16px}
775-
.sso-btn{display:block;width:100%%;padding:12px;background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:8px;font-size:0.875rem;font-weight:600;text-align:center;text-decoration:none;font-family:inherit;cursor:pointer}
776-
.sso-btn:hover{border-color:var(--burgundy);color:var(--burgundy)}
777-
.divider{display:flex;align-items:center;margin:20px 0;gap:12px}
778-
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
779-
.divider span{color:var(--muted);font-size:0.75rem;white-space:nowrap}
801+
.sso-status{text-align:center;padding:24px 0;color:var(--muted);font-size:0.9rem}
802+
.sso-status .spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--burgundy);border-radius:50%%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
803+
@keyframes spin{to{transform:rotate(360deg)}}
780804
</style>
781805
</head>
782806
<body>
783807
<div class="card">
784808
<div class="brand"><h1>SimpleAuth</h1><p>Sign in to continue</p></div>
785809
<div class="gold-bar"></div>
786810
%[8]s
787-
%[9]s
788-
<form method="POST" action="%[1]s">
789-
<input type="hidden" name="client_id" value="%[2]s">
790-
<input type="hidden" name="redirect_uri" value="%[3]s">
791-
<input type="hidden" name="state" value="%[4]s">
792-
<input type="hidden" name="nonce" value="%[5]s">
793-
<input type="hidden" name="scope" value="%[6]s">
794-
<input type="hidden" name="response_type" value="code">
795-
<label>Username</label>
796-
<input type="text" name="username" placeholder="Enter your username" autofocus required>
797-
<label>Password</label>
798-
<input type="password" name="password" placeholder="Enter your password" required>
799-
<button type="submit">Sign In</button>
800-
</form>
811+
<div id="sso-section" style="display:none">
812+
<a href="%[9]s" class="btn-primary" id="sso-btn">Sign in with Single Sign-On</a>
813+
<button class="manual-toggle" onclick="document.getElementById('manual-form').classList.add('show');this.style.display='none'">
814+
Or sign in with username and password
815+
</button>
816+
</div>
817+
<div id="auto-sso-status" style="display:none">
818+
<div class="sso-status"><span class="spinner"></span> Attempting Single Sign-On...</div>
819+
</div>
820+
<div id="manual-form" class="manual-form">
821+
<form method="POST" action="%[1]s">
822+
<input type="hidden" name="client_id" value="%[2]s">
823+
<input type="hidden" name="redirect_uri" value="%[3]s">
824+
<input type="hidden" name="state" value="%[4]s">
825+
<input type="hidden" name="nonce" value="%[5]s">
826+
<input type="hidden" name="scope" value="%[6]s">
827+
<input type="hidden" name="response_type" value="code">
828+
<label>Username</label>
829+
<input type="text" name="username" placeholder="Enter your username" autofocus required>
830+
<label>Password</label>
831+
<input type="password" name="password" placeholder="Enter your password" required>
832+
<button type="submit" class="btn-submit">Sign In</button>
833+
</form>
834+
</div>
801835
<div class="app-name">Signing into %[7]s</div>
802836
</div>
837+
<script>
838+
(function(){
839+
var ssoEnabled = "%[10]s" === "1";
840+
var autoSSO = "%[11]s" === "1";
841+
var ssoLink = "%[9]s";
842+
var hasError = document.querySelector('.error') !== null;
843+
844+
if (ssoEnabled && !hasError) {
845+
document.getElementById('sso-section').style.display = 'block';
846+
if (autoSSO && ssoLink) {
847+
document.getElementById('sso-section').style.display = 'none';
848+
document.getElementById('auto-sso-status').style.display = 'block';
849+
setTimeout(function(){ window.location.href = ssoLink; }, 500);
850+
}
851+
} else if (ssoEnabled && hasError) {
852+
document.getElementById('sso-section').style.display = 'block';
853+
document.getElementById('manual-form').classList.add('show');
854+
} else {
855+
document.getElementById('manual-form').classList.add('show');
856+
document.getElementById('manual-form').style.borderTop = 'none';
857+
document.getElementById('manual-form').style.marginTop = '0';
858+
document.getElementById('manual-form').style.paddingTop = '0';
859+
}
860+
})();
861+
</script>
803862
</body>
804863
</html>`

internal/store/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,5 @@ type RuntimeSettings struct {
130130
RateLimitMax int `json:"rate_limit_max"`
131131
RateLimitWindowS int `json:"rate_limit_window_s"` // seconds
132132
AuditRetentionDays int `json:"audit_retention_days"`
133+
AutoSSO bool `json:"auto_sso"`
133134
}

ui/dist/app.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,6 +1677,14 @@ function SettingsPage() {
16771677
</div>
16781678
</div>
16791679
1680+
<div class="card" style="margin-top: 16px;">
1681+
<div class="card-header"><h3>Single Sign-On</h3></div>
1682+
<div class="card-body">
1683+
${field('Auto-attempt SSO on login page', 'auto_sso', 'boolean')}
1684+
<p style="font-size: 0.75rem; color: var(--muted); margin-top: -8px;">When enabled, the login page automatically attempts Kerberos SSO without the user clicking a button. Falls back to the manual login form if SSO fails.</p>
1685+
</div>
1686+
</div>
1687+
16801688
<div class="card" style="margin-top: 16px;">
16811689
<div class="card-header"><h3>Audit Log</h3></div>
16821690
<div class="card-body">

0 commit comments

Comments
 (0)