oidc: serve the registration confirmation page from a reloadable URL

The interstitial was the body of /oidc/callback, the URL carrying the
single-use code, so any reload re-entered the spent exchange. Redirect to
GET /register/confirm/{auth_id}, also missing from the route table.

0.29 lacks the authPathURL helper from main, so it is added here.

(cherry picked from commit 6d377b5348)
This commit is contained in:
Sean Reifschneider
2026-09-01 15:06:18 +00:00
committed by Kristoffer Dalby
parent 2b28f5756b
commit 2da47a77d6
6 changed files with 491 additions and 36 deletions
+16 -11
View File
@@ -1128,6 +1128,11 @@ func (j *debugJar) Dump(w io.Writer) {
}
}
// registerConfirmCSRFField is the name of both the hidden CSRF form field
// and the cookie on the OIDC registration confirmation interstitial. It
// mirrors registerConfirmCSRFCookie in hscontrol, which is unexported.
const registerConfirmCSRFField = "headscale_register_confirm"
func copyCookie(c *http.Cookie) *http.Cookie {
cc := *c
return &cc
@@ -1241,10 +1246,11 @@ func doLoginURLWithClient(hostname string, loginURL *url.URL, hc *http.Client, f
}
}
// The OIDC registration flow now renders a confirmation interstitial
// (POST form) instead of completing immediately. Detect the form and
// The OIDC registration flow renders a confirmation interstitial
// (POST form) instead of completing immediately. Detect the form by its
// CSRF field, which does not move when the form action does, and
// auto-submit it so integration tests behave like a real browser.
if followRedirects && strings.Contains(body, `action="/register/confirm/`) {
if followRedirects && strings.Contains(body, `name="`+registerConfirmCSRFField+`"`) {
confirmBody, confirmURL, confirmErr := submitConfirmForm(hostname, body, resp, hc)
if confirmErr != nil {
return body, redirectURL, confirmErr
@@ -1283,7 +1289,7 @@ func submitConfirmForm(
// Extract hidden CSRF input value. The rendered <input> has
// attributes in name-type-value order so we grab the whole tag.
before, _, ok := strings.Cut(htmlBody, `name="headscale_register_confirm"`)
before, _, ok := strings.Cut(htmlBody, `name="`+registerConfirmCSRFField+`"`)
if !ok {
return "", nil, fmt.Errorf("%s confirm form: no CSRF input", hostname) //nolint:err113
}
@@ -1309,18 +1315,17 @@ func submitConfirmForm(
valEnd := strings.Index(inputTag[valStart:], `"`)
csrfToken := inputTag[valStart : valStart+valEnd]
// Build the absolute POST URL from the response's request URL.
base := prevResp.Request.URL
confirmURL := &url.URL{
Scheme: base.Scheme,
Host: base.Host,
Path: formAction,
// Resolve the form action against the page it was served from, so an
// absolute and a relative action both work.
confirmURL, err := prevResp.Request.URL.Parse(formAction)
if err != nil {
return "", nil, fmt.Errorf("%s confirm form: resolving action %q: %w", hostname, formAction, err)
}
log.Printf("%s auto-submitting confirm form: %s", hostname, confirmURL)
formData := url.Values{
"headscale_register_confirm": {csrfToken},
registerConfirmCSRFField: {csrfToken},
}
ctx := context.Background()