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",