From 3866788f1a8a80fb4b9db9f2a1f679d022d37257 Mon Sep 17 00:00:00 2001 From: alghanim Date: Sat, 10 Oct 2026 20:06:24 +0300 Subject: [PATCH] fix(login): send the sign-in form only once A second Sign In while the first is still redirecting replaces the first navigation. SimpleAuth issues a second authorization code, but the relying party has already spent its one-time state on the first one, so the second callback fails. OpenProject answers it with a bare "401 Unauthorized" and the user is left on a dead page even though the first login succeeded. The OIDC authorize page and the hosted /login page now ignore any submit after the first one, disable the button and show "Signing in...". A page restored from the back/forward cache accepts a new submit. Co-Authored-By: Claude Opus 5.5 --- internal/handler/hosted_login.go | 20 ++++++++ internal/handler/login_submit_once_test.go | 53 ++++++++++++++++++++++ internal/handler/oidc.go | 20 ++++++++ 3 files changed, 93 insertions(+) create mode 100644 internal/handler/login_submit_once_test.go 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';