From 4525734d252c5720c8a2ee4463af6df7ab553492 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Feb 2026 14:09:06 +0100 Subject: [PATCH] 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. --- hscontrol/handlers.go | 23 ++++- hscontrol/oidc.go | 67 +++++++++++---- hscontrol/oidc_template_test.go | 53 ++++++++---- hscontrol/templates/auth_success.go | 62 ++++++++++++++ hscontrol/templates/auth_web.go | 21 +++++ hscontrol/templates/design.go | 41 +++++++++ hscontrol/templates/oidc_callback.go | 69 --------------- hscontrol/templates/register_web.go | 21 ----- hscontrol/templates_consistency_test.go | 106 ++++++++++++++++++++---- 9 files changed, 324 insertions(+), 139 deletions(-) create mode 100644 hscontrol/templates/auth_success.go create mode 100644 hscontrol/templates/auth_web.go delete mode 100644 hscontrol/templates/oidc_callback.go delete mode 100644 hscontrol/templates/register_web.go diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index b7aa8460..9f544f8d 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -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") } diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 2bc62fa9..ee6dbeb9 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -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. diff --git a/hscontrol/oidc_template_test.go b/hscontrol/oidc_template_test.go index 367451b1..24dfc0b0 100644 --- a/hscontrol/oidc_template_test.go +++ b/hscontrol/oidc_template_test.go @@ -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, "") - assert.Contains(t, html, "Headscale Authentication Succeeded") - 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, ""+tt.result.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") diff --git a/hscontrol/templates/auth_success.go b/hscontrol/templates/auth_success.go new file mode 100644 index 00000000..1a212b6e --- /dev/null +++ b/hscontrol/templates/auth_success.go @@ -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 ", + // 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(), + ), + ) +} diff --git a/hscontrol/templates/auth_web.go b/hscontrol/templates/auth_web.go new file mode 100644 index 00000000..8b6d6f97 --- /dev/null +++ b/hscontrol/templates/auth_web.go @@ -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(), + ), + ) +} diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go index 615c0e41..221eaf11 100644 --- a/hscontrol/templates/design.go +++ b/hscontrol/templates/design.go @@ -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(``) +} + // warningBox creates a warning message box with icon and content. // //nolint:unused // Used in apple.go template. diff --git a/hscontrol/templates/oidc_callback.go b/hscontrol/templates/oidc_callback.go deleted file mode 100644 index 16c08fde..00000000 --- a/hscontrol/templates/oidc_callback.go +++ /dev/null @@ -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(``) -} - -// 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(), - ), - ) -} diff --git a/hscontrol/templates/register_web.go b/hscontrol/templates/register_web.go deleted file mode 100644 index cdede03b..00000000 --- a/hscontrol/templates/register_web.go +++ /dev/null @@ -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(), - ), - ) -} diff --git a/hscontrol/templates_consistency_test.go b/hscontrol/templates_consistency_test.go index 0464fb88..4836c1d1 100644 --- a/hscontrol/templates_consistency_test.go +++ b/hscontrol/templates_consistency_test.go @@ -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",