diff --git a/internal/handler/hosted_login.go b/internal/handler/hosted_login.go index a8628e0..77183df 100644 --- a/internal/handler/hosted_login.go +++ b/internal/handler/hosted_login.go @@ -465,6 +465,26 @@ input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(143, var hasError = document.querySelector('.error') !== null; var manualForm = document.getElementById('manual-form'); + // One submit per page: a second Sign In while the first is still redirecting + // replaces it, and the client's one-time OIDC state is gone by the time the + // second code reaches it. + var loginForm = manualForm.querySelector('form'); + var loginButton = loginForm.querySelector('button[type="submit"]'); + var loginLabel = loginButton.textContent; + loginForm.addEventListener('submit', function(e) { + if (loginForm.dataset.submitted) { e.preventDefault(); return; } + loginForm.dataset.submitted = '1'; + loginButton.disabled = true; + loginButton.textContent = 'Signing in...'; + }); + // Back/forward cache restores the page as it was left; let it sign in again. + window.addEventListener('pageshow', function(e) { + if (!e.persisted) return; + delete loginForm.dataset.submitted; + loginButton.disabled = false; + loginButton.textContent = loginLabel; + }); + function showManualOnly() { manualForm.classList.add('show'); manualForm.style.borderTop = 'none'; diff --git a/internal/handler/login_submit_once_test.go b/internal/handler/login_submit_once_test.go new file mode 100644 index 0000000..3f52330 --- /dev/null +++ b/internal/handler/login_submit_once_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// TestLoginPagesSubmitOnce pins the one-submit guard on both credential forms. +// A second Sign In while the first is still redirecting replaces the first +// navigation; the relying party has already spent its one-time state on the +// first code, so the second callback fails (OpenProject answers a bare 401). +// The guard is browser-side, so this checks it is wired into each page. +func TestLoginPagesSubmitOnce(t *testing.T) { + h, _ := testSetup(t) + + const cb = "https://once.example/cb" + w := doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{ + "app_id": "once", "audience": "once", "redirect_uris": []string{cb}, + }, adminHeaders()) + if w.Code != http.StatusCreated { + t.Fatalf("create app: %d %s", w.Code, w.Body.String()) + } + + authorize := url.Values{} + authorize.Set("client_id", "once") + authorize.Set("redirect_uri", cb) + authorize.Set("response_type", "code") + + pages := map[string]string{ + "hosted /login": "/login?manual=1", + "OIDC authorize": "/realms/test-issuer/protocol/openid-connect/auth?" + authorize.Encode(), + } + for name, path := range pages { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s: %d %s", name, rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + `if (loginForm.dataset.submitted) { e.preventDefault(); return; }`, + `loginButton.disabled = true;`, + `window.addEventListener('pageshow'`, + } { + if !strings.Contains(body, want) { + t.Errorf("%s: login form is missing the one-submit guard (%q)", name, want) + } + } + } +} diff --git a/internal/handler/oidc.go b/internal/handler/oidc.go index 75d58c8..fa9d6f4 100644 --- a/internal/handler/oidc.go +++ b/internal/handler/oidc.go @@ -1320,6 +1320,26 @@ input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(143, var hasError = document.querySelector('.error') !== null; var manualForm = document.getElementById('manual-form'); + // One submit per page: a second Sign In while the first is still redirecting + // replaces it, and the client's one-time OIDC state is gone by the time the + // second code reaches it. + var loginForm = manualForm.querySelector('form'); + var loginButton = loginForm.querySelector('button[type="submit"]'); + var loginLabel = loginButton.textContent; + loginForm.addEventListener('submit', function(e) { + if (loginForm.dataset.submitted) { e.preventDefault(); return; } + loginForm.dataset.submitted = '1'; + loginButton.disabled = true; + loginButton.textContent = 'Signing in...'; + }); + // Back/forward cache restores the page as it was left; let it sign in again. + window.addEventListener('pageshow', function(e) { + if (!e.persisted) return; + delete loginForm.dataset.submitted; + loginButton.disabled = false; + loginButton.textContent = loginLabel; + }); + function showManualOnly() { manualForm.classList.add('show'); manualForm.style.borderTop = 'none';