1
0
mirror of https://github.com/juanfont/headscale.git synced 2026-02-07 20:04:00 +01:00

templates, oidc, handlers: generalise auth templates

Replace the single-purpose OIDCCallback and RegisterWeb templates
with two reusable templates:

- AuthSuccess: configurable success page used for node registration,
  reauthentication, and SSH session authorisation.
- AuthWeb: CLI command instruction page used for both node
  registration and auth approval flows.

Move successBox and checkboxIcon into design.go as shared primitives.

Also handle the non-registration OIDC callback path: look up the
auth session, send an accept verdict, and render an SSH authorisation
success page.
This commit is contained in:
Kristoffer Dalby 2026-02-18 14:09:06 +01:00
parent c4428d80b0
commit 4525734d25
No known key found for this signature in database
9 changed files with 324 additions and 139 deletions

View File

@ -262,6 +262,23 @@ func (a *AuthProviderWeb) AuthHandler(
writer http.ResponseWriter,
req *http.Request,
) {
authID, err := authIDFromRequest(req)
if err != nil {
httpError(writer, err)
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write([]byte(templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id "+authID.String(),
).Render()))
if err != nil {
log.Error().Err(err).Msg("failed to write auth response")
}
}
func authIDFromRequest(req *http.Request) (types.AuthID, error) {
@ -299,7 +316,11 @@ func (a *AuthProviderWeb) RegisterHandler(
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write([]byte(templates.RegisterWeb(registrationId).Render()))
_, err = writer.Write([]byte(templates.AuthWeb(
"Node registration",
"Run the command below in the headscale server to add this node to your network:",
fmt.Sprintf("headscale auth register --auth-id %s --user USERNAME", registrationId.String()),
).Render()))
if err != nil {
log.Error().Err(err).Msg("failed to write register response")
}

View File

@ -333,8 +333,6 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
// If this is a registration flow, then we need to register the node.
if authInfo.Registration {
verb := "Reauthenticated"
newNode, err := a.handleRegistration(user, authInfo.AuthID, nodeExpiry)
if err != nil {
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
@ -349,12 +347,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
return
}
if newNode {
verb = "Authenticated"
}
// TODO(kradalby): replace with go-elem
content := renderOIDCCallbackTemplate(user, verb)
content := renderRegistrationSuccessTemplate(user, newNode)
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
@ -366,8 +359,28 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
return
}
// TODO(kradalby): handle login flow (without registration) if needed.
// We need to send an update here to whatever might be waiting for this auth flow.
// If this is not a registration callback, then its a regular authentication callback
// and we need to send a response and confirm that the access was allowed.
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
if !ok {
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed")
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
return
}
// Send a finish auth verdict with no errors to let the CLI know that the authentication was successful.
authReq.FinishAuth(types.AuthVerdict{})
content := renderAuthSuccessTemplate(user)
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr
util.LogErr(err, "Failed to write HTTP response")
}
}
func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) time.Time {
@ -623,12 +636,38 @@ func (a *AuthProviderOIDC) handleRegistration(
return !nodeChange.IsEmpty(), nil
}
func renderOIDCCallbackTemplate(
func renderRegistrationSuccessTemplate(
user *types.User,
verb string,
newNode bool,
) *bytes.Buffer {
html := templates.OIDCCallback(user.Display(), verb).Render()
return bytes.NewBufferString(html)
result := templates.AuthSuccessResult{
Title: "Headscale - Node Reauthenticated",
Heading: "Node reauthenticated",
Verb: "Reauthenticated",
User: user.Display(),
Message: "You can now close this window.",
}
if newNode {
result.Title = "Headscale - Node Registered"
result.Heading = "Node registered"
result.Verb = "Registered"
}
return bytes.NewBufferString(templates.AuthSuccess(result).Render())
}
func renderAuthSuccessTemplate(
user *types.User,
) *bytes.Buffer {
result := templates.AuthSuccessResult{
Title: "Headscale - SSH Session Authorized",
Heading: "SSH session authorized",
Verb: "Authorized",
User: user.Display(),
Message: "You may return to your terminal.",
}
return bytes.NewBufferString(templates.AuthSuccess(result).Render())
}
// getCookieName generates a unique cookie name based on a cookie value.

View File

@ -7,35 +7,54 @@ import (
"github.com/stretchr/testify/assert"
)
func TestOIDCCallbackTemplate(t *testing.T) {
func TestAuthSuccessTemplate(t *testing.T) {
tests := []struct {
name string
userName string
verb string
name string
result templates.AuthSuccessResult
}{
{
name: "logged_in_user",
userName: "test@example.com",
verb: "Logged in",
name: "node_registered",
result: templates.AuthSuccessResult{
Title: "Headscale - Node Registered",
Heading: "Node registered",
Verb: "Registered",
User: "newuser@example.com",
Message: "You can now close this window.",
},
},
{
name: "registered_user",
userName: "newuser@example.com",
verb: "Registered",
name: "node_reauthenticated",
result: templates.AuthSuccessResult{
Title: "Headscale - Node Reauthenticated",
Heading: "Node reauthenticated",
Verb: "Reauthenticated",
User: "test@example.com",
Message: "You can now close this window.",
},
},
{
name: "ssh_session_authorized",
result: templates.AuthSuccessResult{
Title: "Headscale - SSH Session Authorized",
Heading: "SSH session authorized",
Verb: "Authorized",
User: "test@example.com",
Message: "You may return to your terminal.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Render using the elem-go template
html := templates.OIDCCallback(tt.userName, tt.verb).Render()
html := templates.AuthSuccess(tt.result).Render()
// Verify the HTML contains expected elements
// Verify the HTML contains expected structural elements
assert.Contains(t, html, "<!DOCTYPE html>")
assert.Contains(t, html, "<title>Headscale Authentication Succeeded</title>")
assert.Contains(t, html, tt.verb)
assert.Contains(t, html, tt.userName)
assert.Contains(t, html, "You can now close this window")
assert.Contains(t, html, "<title>"+tt.result.Title+"</title>")
assert.Contains(t, html, tt.result.Heading)
assert.Contains(t, html, tt.result.Verb+" as ")
assert.Contains(t, html, tt.result.User)
assert.Contains(t, html, tt.result.Message)
// Verify Material for MkDocs design system CSS is present
assert.Contains(t, html, "Material for MkDocs")

View File

@ -0,0 +1,62 @@
package templates
import (
"github.com/chasefleming/elem-go"
)
// AuthSuccessResult contains the text content for an authentication success page.
// Each field controls a distinct piece of user-facing text so that every auth
// flow (node registration, reauthentication, SSH check, …) can clearly
// communicate what just happened.
type AuthSuccessResult struct {
// Title is the browser tab / page title,
// e.g. "Headscale - Node Registered".
Title string
// Heading is the bold green text inside the success box,
// e.g. "Node registered".
Heading string
// Verb is the action prefix in the body text before "as <user>",
// e.g. "Registered", "Reauthenticated", "Authorized".
Verb string
// User is the display name shown in bold in the body text,
// e.g. "user@example.com".
User string
// Message is the follow-up instruction shown after the user name,
// e.g. "You can now close this window."
Message string
}
// AuthSuccess renders an authentication / authorisation success page.
// The caller controls every user-visible string via [AuthSuccessResult] so the
// page clearly describes what succeeded (registration, reauth, SSH check, …).
func AuthSuccess(result AuthSuccessResult) *elem.Element {
box := successBox(
result.Heading,
elem.Text(result.Verb+" as "),
elem.Strong(nil, elem.Text(result.User)),
elem.Text(". "+result.Message),
)
return HtmlStructure(
elem.Title(nil, elem.Text(result.Title)),
mdTypesetBody(
headscaleLogo(),
box,
H2(elem.Text("Getting started")),
P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")),
Ul(
elem.Li(nil,
externalLink("https://headscale.net/stable/", "Headscale documentation"),
),
elem.Li(nil,
externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"),
),
),
pageFooter(),
),
)
}

View File

@ -0,0 +1,21 @@
package templates
import (
"github.com/chasefleming/elem-go"
)
// AuthWeb renders a page that instructs an administrator to run a CLI command
// to complete an authentication or registration flow.
// It is used by both the registration and auth-approve web handlers.
func AuthWeb(title, description, command string) *elem.Element {
return HtmlStructure(
elem.Title(nil, elem.Text(title+" - Headscale")),
mdTypesetBody(
headscaleLogo(),
H1(elem.Text(title)),
P(elem.Text(description)),
Pre(PreCode(command)),
pageFooter(),
),
)
}

View File

@ -365,6 +365,47 @@ func orDivider() *elem.Element {
)
}
// successBox creates a green success feedback box with a checkmark icon.
// The heading is displayed as bold green text, and children are rendered below it.
// Pairs with warningBox for consistent feedback styling.
//
//nolint:unused // Used in auth_success.go template.
func successBox(heading string, children ...elem.Node) *elem.Element {
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: colorSuccessLight,
styles.Border: "1px solid " + colorSuccess,
styles.BorderRadius: "0.5rem",
styles.MarginBottom: spaceXL,
}.ToInline(),
},
checkboxIcon(),
elem.Div(nil,
append([]elem.Node{
elem.Strong(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "block",
styles.Color: colorSuccess,
styles.FontSize: fontSizeH3,
styles.MarginBottom: spaceXS,
}.ToInline(),
}, elem.Text(heading)),
}, children...)...,
),
)
}
// checkboxIcon returns the success checkbox SVG icon as raw HTML.
func checkboxIcon() elem.Node {
return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
<path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
</svg>`)
}
// warningBox creates a warning message box with icon and content.
//
//nolint:unused // Used in apple.go template.

View File

@ -1,69 +0,0 @@
package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
// checkboxIcon returns the success checkbox SVG icon as raw HTML.
func checkboxIcon() elem.Node {
return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
<path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
</svg>`)
}
// OIDCCallback renders the OIDC authentication success callback page.
func OIDCCallback(user, verb string) *elem.Element {
// Success message box
successBox := elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: colorSuccessLight,
styles.Border: "1px solid " + colorSuccess,
styles.BorderRadius: "0.5rem",
styles.MarginBottom: spaceXL,
}.ToInline(),
},
checkboxIcon(),
elem.Div(nil,
elem.Strong(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "block",
styles.Color: colorSuccess,
styles.FontSize: fontSizeH3,
styles.MarginBottom: spaceXS,
}.ToInline(),
}, elem.Text("Signed in successfully")),
elem.P(attrs.Props{
attrs.Style: styles.Props{
styles.Margin: "0",
styles.Color: colorTextPrimary,
styles.FontSize: fontSizeBase,
}.ToInline(),
}, elem.Text(verb), elem.Text(" as "), elem.Strong(nil, elem.Text(user)), elem.Text(". You can now close this window.")),
),
)
return HtmlStructure(
elem.Title(nil, elem.Text("Headscale Authentication Succeeded")),
mdTypesetBody(
headscaleLogo(),
successBox,
H2(elem.Text("Getting started")),
P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")),
Ul(
elem.Li(nil,
externalLink("https://headscale.net/stable/", "Headscale documentation"),
),
elem.Li(nil,
externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"),
),
),
pageFooter(),
),
)
}

View File

@ -1,21 +0,0 @@
package templates
import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/juanfont/headscale/hscontrol/types"
)
func RegisterWeb(registrationID types.AuthID) *elem.Element {
return HtmlStructure(
elem.Title(nil, elem.Text("Registration - Headscale")),
mdTypesetBody(
headscaleLogo(),
H1(elem.Text("Machine registration")),
P(elem.Text("Run the command below in the headscale server to add this machine to your network:")),
Pre(PreCode(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String()))),
pageFooter(),
),
)
}

View File

@ -5,7 +5,6 @@ import (
"testing"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
)
@ -16,12 +15,30 @@ func TestTemplateHTMLConsistency(t *testing.T) {
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
name: "Auth Success",
html: templates.AuthSuccess(templates.AuthSuccessResult{
Title: "Headscale - Node Registered",
Heading: "Node registered",
Verb: "Registered",
User: "test@example.com",
Message: "You can now close this window.",
}).Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.AuthID("test-key-123")).Render(),
name: "Auth Web Register",
html: templates.AuthWeb(
"Machine registration",
"Run the command below in the headscale server to add this machine to your network:",
"headscale auth register --auth-id test-key-123 --user USERNAME",
).Render(),
},
{
name: "Auth Web Approve",
html: templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id test-key-123",
).Render(),
},
{
name: "Windows Config",
@ -72,12 +89,30 @@ func TestTemplateModernHTMLFeatures(t *testing.T) {
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
name: "Auth Success",
html: templates.AuthSuccess(templates.AuthSuccessResult{
Title: "Headscale - Node Registered",
Heading: "Node registered",
Verb: "Registered",
User: "test@example.com",
Message: "You can now close this window.",
}).Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.AuthID("test-key-123")).Render(),
name: "Auth Web Register",
html: templates.AuthWeb(
"Machine registration",
"Run the command below in the headscale server to add this machine to your network:",
"headscale auth register --auth-id test-key-123 --user USERNAME",
).Render(),
},
{
name: "Auth Web Approve",
html: templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id test-key-123",
).Render(),
},
{
name: "Windows Config",
@ -116,16 +151,35 @@ func TestTemplateExternalLinkSecurity(t *testing.T) {
externalURLs []string // URLs that should have security attributes
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
name: "Auth Success",
html: templates.AuthSuccess(templates.AuthSuccessResult{
Title: "Headscale - Node Registered",
Heading: "Node registered",
Verb: "Registered",
User: "test@example.com",
Message: "You can now close this window.",
}).Render(),
externalURLs: []string{
"https://headscale.net/stable/",
"https://tailscale.com/kb/",
},
},
{
name: "Register Web",
html: templates.RegisterWeb(types.AuthID("test-key-123")).Render(),
name: "Auth Web Register",
html: templates.AuthWeb(
"Machine registration",
"Run the command below in the headscale server to add this machine to your network:",
"headscale auth register --auth-id test-key-123 --user USERNAME",
).Render(),
externalURLs: []string{}, // No external links
},
{
name: "Auth Web Approve",
html: templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id test-key-123",
).Render(),
externalURLs: []string{}, // No external links
},
{
@ -185,12 +239,30 @@ func TestTemplateAccessibilityAttributes(t *testing.T) {
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
name: "Auth Success",
html: templates.AuthSuccess(templates.AuthSuccessResult{
Title: "Headscale - Node Registered",
Heading: "Node registered",
Verb: "Registered",
User: "test@example.com",
Message: "You can now close this window.",
}).Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.AuthID("test-key-123")).Render(),
name: "Auth Web Register",
html: templates.AuthWeb(
"Machine registration",
"Run the command below in the headscale server to add this machine to your network:",
"headscale auth register --auth-id test-key-123 --user USERNAME",
).Render(),
},
{
name: "Auth Web Approve",
html: templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id test-key-123",
).Render(),
},
{
name: "Windows Config",