Files
rustguac/static/index.html
T
Dave Kempe 2fa9877d85 oidc: bind users to their provider subject, match email ignoring case
Users are found by email, and with auto_create_users = false the email
becomes the gate. An email alone is not proof of identity, since some
providers let people set it, so a user is now bound to the OIDC subject
of their first login and a login with the same email but a different
subject is refused (and logged). A login the provider explicitly marks
email_verified: false is refused too.

New CLI-only command reset-oidc-subject releases the binding, for moving
to a different identity provider or a recreated provider account; it is
written to the CLI audit log.

Emails match ignoring case, so an admin typing JSmith@Example.com no
longer locks out a provider that sends jsmith@example.com, nor creates a
second account with auto-create on; a user cannot be added twice in
different case. add-user and POST /api/users check the email and name,
and create_user reports "already exists" as a type instead of matching
"UNIQUE" in the error text.

Tests for the login rules, and CLI integration tests for add-user and
reset-oidc-subject including their audit rows. Docs: how a login is
matched, moving providers, POST /api/users, the CLI commands, and the
login page message (a redirect, not a 403).
2026-10-18 13:23:14 +11:00

225 lines
9.7 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>rustguac</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
body { display: flex; flex-direction: column; align-items: center; }
.login-container { max-width: 380px; width: 100%; margin: var(--s-4) 0; }
#sso-section { display: none; margin-bottom: var(--s-4); }
#sso-btn {
width: 100%;
height: var(--ctl-lg);
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
font-size: var(--fz-md);
font-weight: bold;
}
#sso-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
#sso-btn:disabled, #sso-btn:disabled:hover {
background: var(--surface);
border-color: var(--border);
color: var(--text-muted);
cursor: not-allowed;
}
#sso-error {
margin-top: var(--s-2);
font-size: var(--fz-sm);
color: var(--text-muted);
text-align: center;
}
#sso-error:empty { display: none; }
.api-key-toggle {
color: var(--text-muted);
font-size: var(--fz-sm);
cursor: pointer;
user-select: none;
margin-top: var(--s-3);
display: none;
}
.api-key-toggle:hover { color: var(--text); }
.api-key-toggle .chevron {
display: inline-block;
transition: transform .2s;
margin-right: var(--s-1);
}
.api-key-toggle .chevron.open { transform: rotate(90deg); }
#login-form {
background: var(--surface);
border: 1px solid var(--border);
padding: var(--s-4);
border-radius: var(--radius);
margin-top: var(--s-2);
}
#login-form input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: var(--s-1);
}
#login-form button {
margin-top: var(--s-4);
width: 100%;
height: var(--ctl-lg);
font-size: var(--fz-md);
}
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
<body>
<div class="brand-row">
<img id="site-logo" src="/logo.svg" alt="">
<h1>rustguac</h1>
</div>
<div class="login-container">
<div id="sso-section">
<button id="sso-btn">Sign in with SSO</button>
<div id="sso-error" role="alert"></div>
</div>
<div class="api-key-toggle" id="api-key-toggle">
<span class="chevron" id="api-key-chevron">&#9654;</span> Sign in with API key
</div>
<form id="login-form">
<strong>Login</strong>
<label>API Key
<input type="password" id="api-key" placeholder="Bearer API key" required>
</label>
<button type="submit" id="login-btn">Login</button>
<div id="error"></div>
</form>
</div>
<script>
// Messages shown under the SSO button. Keys other than `unavailable`
// match the sso_error codes the OIDC callback redirects with.
var ssoMessages = {
failed: 'SSO login failed — please try again.',
no_account: 'You do not have an account here. Ask an administrator to add you.',
account_mismatch: 'This sign-in does not match the account used here before. Ask an administrator.',
email_unverified: 'Your identity provider has not verified your email address.',
disabled: 'Your account is disabled. Contact an administrator.',
unavailable: 'SSO provider is temporarily unavailable — retrying…'
};
var ssoErrorEl = document.getElementById('sso-error');
// Show SSO error if redirected back from a failed callback
var ssoError = new URLSearchParams(window.location.search).get('sso_error');
if (ssoError) {
ssoErrorEl.textContent = Object.prototype.hasOwnProperty.call(ssoMessages, ssoError)
? ssoMessages[ssoError] : ssoMessages.failed;
history.replaceState(null, '', '/');
}
// If already logged in via API key, redirect
if (sessionStorage.getItem('rustguac_api_key')) {
window.location.href = '/connections.html';
}
// Check if already authenticated via cookie
fetch('/api/me', { credentials: 'same-origin' })
.then(function(res) {
if (res.ok) window.location.href = '/connections.html';
});
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='aurora';
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}}
var loginForm = document.getElementById('login-form');
var apiKeyToggle = document.getElementById('api-key-toggle');
var chevron = document.getElementById('api-key-chevron');
var ssoBtn = document.getElementById('sso-btn');
var ssoPollTimer = null;
// Reflect live SSO availability: when the OIDC provider is reachable the
// button is active; when it's down (e.g. provider not yet up after a
// restart) the button is disabled with a notice, and we poll until the
// provider recovers — no page reload or click needed.
function applySsoAvailability(available) {
if (available) {
ssoBtn.disabled = false;
ssoBtn.textContent = 'Sign in with SSO';
// Clear only our own notice; keep a login error from the callback.
if (ssoErrorEl.textContent === ssoMessages.unavailable) ssoErrorEl.textContent = '';
if (ssoPollTimer) { clearInterval(ssoPollTimer); ssoPollTimer = null; }
} else {
ssoBtn.disabled = true;
ssoBtn.textContent = 'SSO unavailable';
ssoErrorEl.textContent = ssoMessages.unavailable;
if (!ssoPollTimer) {
ssoPollTimer = setInterval(function() {
fetch('/api/auth/status')
.then(function(res) { return res.json(); })
.then(function(d) { applySsoAvailability(!!d.oidc_available); })
.catch(function() {});
}, 5000);
}
}
}
// Check if OIDC is enabled
fetch('/api/auth/status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.oidc_enabled) {
document.getElementById('sso-section').style.display = 'block';
apiKeyToggle.style.display = 'block';
// Hide API key form by default when SSO is available
loginForm.style.display = 'none';
applySsoAvailability(!!data.oidc_available);
}
if (data.site_title) {
document.title = data.site_title;
document.querySelector('h1').textContent = data.site_title;
}
initTheme(data.theme);
});
ssoBtn.addEventListener('click', function() {
if (ssoBtn.disabled) return;
window.location.href = '/auth/login';
});
// Toggle API key form
apiKeyToggle.addEventListener('click', function() {
var hidden = loginForm.style.display === 'none';
loginForm.style.display = hidden ? '' : 'none';
chevron.className = hidden ? 'chevron open' : 'chevron';
});
var errorEl = document.getElementById('error');
var btn = document.getElementById('login-btn');
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
errorEl.textContent = '';
btn.disabled = true;
btn.textContent = 'Checking...';
var key = document.getElementById('api-key').value;
fetch('/api/sessions', {
headers: { 'Authorization': 'Bearer ' + key }
})
.then(function(res) {
if (!res.ok) throw new Error('Invalid API key');
sessionStorage.setItem('rustguac_api_key', key);
window.location.href = '/connections.html';
})
.catch(function(err) {
errorEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Login';
});
});
</script>
</body>
</html>