oidc: show requested tags on the registration confirmation page

This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:37 +00:00
1 parent c27a13eed8
commit f0ecba2bd7
3 files changed
+137 -10

No files matched your search

+6 -1
View File
@@ -947,6 +947,7 @@ func (a *AuthProviderOIDC) RegisterConfirmGetHandler(
if regData.Hostinfo != nil {
info.OS = regData.Hostinfo.OS
info.Tags = regData.Hostinfo.RequestTags
}
content = templates.RegisterConfirm(info).Render()
@@ -1116,7 +1117,7 @@ func (a *AuthProviderOIDC) handleRegistration(
func renderRegistrationSuccessTemplate(
user *types.User,
_ types.NodeView,
node types.NodeView,
reauth bool,
) *bytes.Buffer {
result := templates.AuthSuccessResult{
@@ -1126,6 +1127,10 @@ func renderRegistrationSuccessTemplate(
User: user.Display(),
Message: "You can now close this window.",
}
if node.IsTagged() {
result.User = strings.Join(node.Tags().AsSlice(), ", ")
}
if !reauth {
result.Title = "Headscale - Node Registered"
result.Heading = "Node registered"
+111
View File
@@ -1800,6 +1800,117 @@ func TestRegisterConfirmShowsExistingNode(t *testing.T) {
}
}
func TestRegisterConfirmShowsRequestedTags(t *testing.T) {
const ownerName = "tag-owner"
b := newOIDCBrowser(t)
_, err := b.app.state.SetPolicy([]byte(`{
"tagOwners": {"tag:prod": ["tag-owner@"]},
"acls": [{"action":"accept", "src":["*"], "dst":["*:*"]}]
}`))
require.NoError(t, err)
machineKey := key.NewMachine().Public()
authID := types.MustAuthID()
require.NoError(t, b.app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: machineKey,
NodeKey: key.NewNode().Public(),
Hostname: "tagged-laptop",
Hostinfo: &tailcfg.Hostinfo{RequestTags: []string{"tag:prod"}},
})))
b.idp.QueueUser(&mockoidc.MockUser{Subject: ownerName, PreferredUsername: ownerName})
status, landed, body := b.get(t, b.publicURL+"/register/"+authID.String())
require.Equal(t, http.StatusOK, status)
registered := regexp.MustCompile(`>Registered to</td><td[^>]*>([^<]*)</td>`).FindStringSubmatch(body)
require.Len(t, registered, 2)
assert.Equal(t, "tag:prod", registered[1])
approved := regexp.MustCompile(`>Approved by</td><td[^>]*>([^<]*)</td>`).FindStringSubmatch(body)
if assert.Len(t, approved, 2) {
assert.Equal(t, ownerName, approved[1])
}
csrf := csrfInputRe.FindStringSubmatch(body)
require.Len(t, csrf, 2)
//nolint:noctx,bodyclose // test response closed below
confirmed, err := b.client.PostForm(landed.String(), url.Values{
registerConfirmCSRFCookie: {csrf[1]},
})
require.NoError(t, err)
confirmedBody, err := io.ReadAll(confirmed.Body)
confirmed.Body.Close()
require.NoError(t, err)
require.Equal(t, http.StatusOK, confirmed.StatusCode)
success := regexp.MustCompile(`Registered as <strong>([^<]*)</strong>`).FindSubmatch(confirmedBody)
require.Len(t, success, 2)
assert.Equal(t, "tag:prod", string(success[1]))
nodes := b.app.state.ListNodes()
require.Equal(t, 1, nodes.Len())
require.True(t, nodes.At(0).IsTagged())
require.Equal(t, []string{"tag:prod"}, nodes.At(0).Tags().AsSlice())
reloginID := types.MustAuthID()
require.NoError(t, b.app.state.SetAuthCacheEntry(reloginID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: machineKey,
NodeKey: key.NewNode().Public(),
Hostname: "different-client-name",
})))
b.idp.QueueUser(&mockoidc.MockUser{Subject: ownerName, PreferredUsername: ownerName})
status, _, body = b.get(t, b.publicURL+"/register/"+reloginID.String())
require.Equal(t, http.StatusOK, status)
existing := regexp.MustCompile(`>Existing node</td><td[^>]*>([^<]*)</td>`).FindStringSubmatch(body)
require.Len(t, existing, 2)
require.Equal(t, "tagged-laptop", existing[1])
require.True(t, b.app.state.ListNodes().At(0).IsTagged(), "viewing confirmation must not change ownership")
}
func TestRegisterConfirmEscapesAndRejectsUnownedTags(t *testing.T) {
for _, tt := range []struct {
tag string
display string
}{
{tag: "tag:unowned", display: "tag:unowned"},
{tag: "tag:<em>prod</em>", display: "tag:&lt;em&gt;prod&lt;/em&gt;"},
} {
t.Run(tt.tag, func(t *testing.T) {
b := newOIDCBrowser(t)
authID := types.MustAuthID()
require.NoError(t, b.app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: key.NewMachine().Public(),
NodeKey: key.NewNode().Public(),
Hostname: "unowned-tag-node",
Hostinfo: &tailcfg.Hostinfo{RequestTags: []string{tt.tag}},
})))
status, landed, body := b.get(t, b.publicURL+"/register/"+authID.String())
require.Equal(t, http.StatusOK, status)
row := regexp.MustCompile(`>Registered to</td><td[^>]*>(.*?)</td>`).FindStringSubmatch(body)
require.Len(t, row, 2)
assert.Equal(t, tt.display, row[1])
csrf := csrfInputRe.FindStringSubmatch(body)
require.Len(t, csrf, 2)
//nolint:noctx,bodyclose // test response closed below
confirmed, err := b.client.PostForm(landed.String(), url.Values{
registerConfirmCSRFCookie: {csrf[1]},
})
require.NoError(t, err)
confirmed.Body.Close()
require.NotEqual(t, http.StatusOK, confirmed.StatusCode)
require.Zero(t, b.app.state.ListNodes().Len())
})
}
}
func TestOIDCRegistrationRejectsOversizedMergedHostinfo(t *testing.T) {
t.Parallel()
+20 -9
View File
@@ -2,6 +2,7 @@ package templates
import (
"cmp"
"strings"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
@@ -46,6 +47,9 @@ type RegisterConfirmInfo struct {
// ExistingNode is the stored node name, empty for a new device.
ExistingNode string
// Tags are the ownership tags requested by the registering client.
Tags []string
}
// RegisterConfirm renders an interstitial page that asks the
@@ -55,15 +59,22 @@ type RegisterConfirmInfo struct {
// silently complete a phishing-style registration when the victim's
// IdP allows silent SSO.
func RegisterConfirm(info RegisterConfirmInfo) *elem.Element {
deviceList := deviceTable(
[]deviceRow{
{"Hostname", elem.Text(info.Hostname)},
{"OS", elem.Text(cmp.Or(info.OS, "(unknown)"))},
{"Machine key", Code(elem.Text(info.MachineKey))},
{"Existing node", elem.Text(cmp.Or(info.ExistingNode, "None, this is a new device"))},
{"Registered to", elem.Text(info.User)},
},
)
rows := []deviceRow{
{"Hostname", elem.Text(info.Hostname)},
{"OS", elem.Text(cmp.Or(info.OS, "(unknown)"))},
{"Machine key", Code(elem.Text(info.MachineKey))},
{"Existing node", elem.Text(cmp.Or(info.ExistingNode, "None, this is a new device"))},
}
if len(info.Tags) > 0 {
rows = append(rows,
deviceRow{"Registered to", elem.Text(strings.Join(info.Tags, ", "))},
deviceRow{"Approved by", elem.Text(info.User)},
)
} else {
rows = append(rows, deviceRow{"Registered to", elem.Text(info.User)})
}
deviceList := deviceTable(rows)
return page(
"Headscale - Confirm node registration",