Files
headscale/hscontrol/oidc_confirm_test.go
Kristoffer Dalby d66d3a4269 oidc: add confirmation page for node registration
Render an interstitial showing device hostname, OS, and machine-key
fingerprint before finalising OIDC registration. The user must POST
to /register/confirm/{auth_id} with a CSRF double-submit cookie.
Removes the TODO at oidc.go:201.
2026-04-10 14:09:57 +01:00

103 lines
3.2 KiB
Go

package hscontrol
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newConfirmRequest(t *testing.T, authID types.AuthID, formCSRF, cookieCSRF string) *http.Request {
t.Helper()
form := strings.NewReader(registerConfirmCSRFCookie + "=" + formCSRF)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/register/confirm/"+authID.String(),
form,
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{
Name: registerConfirmCSRFCookie,
Value: cookieCSRF,
})
rctx := chi.NewRouteContext()
rctx.URLParams.Add("auth_id", authID.String())
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
return req
}
// TestRegisterConfirmHandler_RejectsCSRFMismatch verifies that the
// /register/confirm POST handler refuses to finalise a pending
// registration when the form CSRF token does not match the cookie.
func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
t.Parallel()
app := createTestApp(t)
provider := &AuthProviderOIDC{h: app}
// Mint a pending registration with a stashed pending-confirmation,
// as the OIDC callback would have done after resolving the user
// identity but before the user clicked the interstitial form.
authID := types.MustAuthID()
regReq := types.NewRegisterAuthRequest(&types.RegistrationData{
Hostname: "phish-target",
})
regReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
UserID: 1,
CSRF: "expected-csrf",
})
app.state.SetAuthCacheEntry(authID, regReq)
rec := httptest.NewRecorder()
provider.RegisterConfirmHandler(rec,
newConfirmRequest(t, authID, "wrong-csrf", "expected-csrf"),
)
assert.Equal(t, http.StatusForbidden, rec.Code,
"CSRF cookie/form mismatch must be rejected with 403")
// And the registration must still be pending — the rejected POST
// must not have called handleRegistration.
cached, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok, "rejected POST must not evict the cached registration")
require.NotNil(t, cached.PendingConfirmation(),
"rejected POST must not clear the pending confirmation")
}
// TestRegisterConfirmHandler_RejectsWithoutPending verifies that
// /register/confirm refuses to finalise a registration that did not
// first complete the OIDC interstitial. Without this check an attacker
// who knew an auth_id could POST directly to the confirm endpoint and
// claim the device.
func TestRegisterConfirmHandler_RejectsWithoutPending(t *testing.T) {
t.Parallel()
app := createTestApp(t)
provider := &AuthProviderOIDC{h: app}
authID := types.MustAuthID()
// Cached registration with NO pending confirmation set — i.e. the
// OIDC callback has not run yet.
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(
&types.RegistrationData{Hostname: "no-oidc-yet"},
))
rec := httptest.NewRecorder()
provider.RegisterConfirmHandler(rec,
newConfirmRequest(t, authID, "fake", "fake"),
)
assert.Equal(t, http.StatusForbidden, rec.Code,
"confirm without prior OIDC pending state must be rejected with 403")
}