Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions internal/handler/hosted_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
53 changes: 53 additions & 0 deletions internal/handler/login_submit_once_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
20 changes: 20 additions & 0 deletions internal/handler/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading