diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a089c8..9fe170e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.4.0-beta.11] - 2026-10-09 + +### Fixed +- Deployment access pages use the existing FlatRun logo and product control styles +- Unrecognized email addresses receive an access denial before any sign-in email is sent +- Opening a sign-in link waits for confirmation before consuming it + ## [0.4.0-beta.10] - 2026-10-09 ### Fixed diff --git a/VERSION b/VERSION index 27eb834..27eb735 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.10 +0.4.0-beta.11 diff --git a/internal/access/service.go b/internal/access/service.go index 7197386..64515c5 100644 --- a/internal/access/service.go +++ b/internal/access/service.go @@ -100,13 +100,30 @@ func (s *Service) MagicLink(email, host, returnPath string) (string, error) { return s.sign(tokenPayload{Kind: "verify", Email: normalizeEmail(email), Host: Hostname(host), Return: SafeReturn(returnPath), Expiry: s.now().Add(15 * time.Minute).Unix()}) } -func (s *Service) VerifyMagicLink(value string) (string, string, string, error) { +func (s *Service) InspectMagicLink(value string) (string, string, string, error) { payload, err := s.verify(value, "verify") if err != nil { return "", "", "", err } + if _, err := os.Stat(s.usedLinkPath(value, payload.Expiry)); err == nil { + return "", "", "", fmt.Errorf("token is invalid or expired") + } else if !os.IsNotExist(err) { + return "", "", "", fmt.Errorf("check used access link: %w", err) + } + return payload.Email, payload.Host, payload.Return, nil +} + +func (s *Service) usedLinkPath(value string, expiry int64) string { digest := sha256.Sum256([]byte(value)) - path := filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", payload.Expiry, digest)) + return filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", expiry, digest)) +} + +func (s *Service) VerifyMagicLink(value string) (string, string, string, error) { + payload, err := s.verify(value, "verify") + if err != nil { + return "", "", "", err + } + path := s.usedLinkPath(value, payload.Expiry) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if os.IsExist(err) { return "", "", "", fmt.Errorf("token is invalid or expired") diff --git a/internal/api/access_handlers.go b/internal/api/access_handlers.go index d9971f9..66d3fdc 100644 --- a/internal/api/access_handlers.go +++ b/internal/api/access_handlers.go @@ -1,6 +1,7 @@ package api import ( + _ "embed" "fmt" "html" "log" @@ -19,21 +20,21 @@ type accessEmailSender interface { } const accessPageStyles = `` -const accessBrand = `
FlatRun
` +//go:embed assets/flatrun-logo.svg +var accessLogo string func accessPage(title, content string) string { - return `` + title + ` | FlatRun` + accessPageStyles + `
` + accessBrand + `
` + content + `

Protected by FlatRun

` + return `` + title + ` | FlatRun` + accessPageStyles + `
` + accessLogo + `
` + content + `

Protected by FlatRun

` } func (s *Server) checkApplicationAccess(c *gin.Context) { @@ -61,7 +62,12 @@ func (s *Server) requestApplicationAccess(c *gin.Context) { email := strings.TrimSpace(c.PostForm("email")) returnPath := access.SafeReturn(c.PostForm("return")) policy, ok := s.applicationAccessPolicy(c.Request.Host, returnPath) - if ok && s.access != nil && s.accessEmailSender != nil && access.Allows(policy, email) && s.access.AllowEmailRequest(c.Request.Host, email) { + if !ok || !access.Allows(policy, email) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusForbidden, accessPage("Access denied", `

Access denied

This email address is not allowed to access this application.

`)) + return + } + if s.access != nil && s.accessEmailSender != nil && s.access.AllowEmailRequest(c.Request.Host, email) { token, err := s.access.MagicLink(email, c.Request.Host, returnPath) if err == nil { scheme := c.GetHeader("X-Forwarded-Proto") @@ -96,11 +102,30 @@ func (s *Server) getAccessEmailTargets(c *gin.Context) { } func (s *Server) verifyApplicationAccess(c *gin.Context) { + s.handleApplicationAccessVerification(c, c.Query("token"), false) +} + +type applicationAccessConfirmationRequest struct { + Token string `json:"token" form:"token" binding:"required"` +} + +func (s *Server) confirmApplicationAccess(c *gin.Context) { + var request applicationAccessConfirmationRequest + if err := c.ShouldBind(&request); err != nil { + c.String(http.StatusBadRequest, "A sign-in token is required") + return + } + s.handleApplicationAccessVerification(c, request.Token, true) +} + +func (s *Server) handleApplicationAccessVerification(c *gin.Context, token string, confirm bool) { if s.access == nil { c.String(http.StatusServiceUnavailable, "Access service is unavailable") return } - email, host, returnPath, err := s.access.VerifyMagicLink(c.Query("token")) + c.Header("Cache-Control", "no-store") + c.Header("Referrer-Policy", "no-referrer") + email, host, returnPath, err := s.access.InspectMagicLink(token) if err != nil { c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") return @@ -110,6 +135,16 @@ func (s *Server) verifyApplicationAccess(c *gin.Context) { c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") return } + if !confirm { + c.Header("Content-Type", "text/html; charset=utf-8") + content := fmt.Sprintf(`

Confirm sign-in

Continue to the application with your verified email address.

`, html.EscapeString(token)) + c.String(http.StatusOK, accessPage("Confirm sign-in", content)) + return + } + if _, _, _, err := s.access.VerifyMagicLink(token); err != nil { + c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") + return + } session, err := s.access.Session(email, host, policy.SessionHours) if err != nil { c.String(http.StatusInternalServerError, "Could not create an access session") diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go index 9e57118..c4ebc29 100644 --- a/internal/api/access_handlers_test.go +++ b/internal/api/access_handlers_test.go @@ -15,16 +15,19 @@ import ( "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/notify" "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" "github.com/gin-gonic/gin" ) type recordingAccessSender struct { + calls int targetID string recipient string message string } func (s *recordingAccessSender) SendEmailTo(targetID, recipient string, message notify.Notification) error { + s.calls++ s.targetID = targetID s.recipient = recipient s.message = message.Message @@ -68,6 +71,7 @@ domains: router.GET("/api/access/check", server.checkApplicationAccess) router.POST("/api/access/request", server.requestApplicationAccess) router.GET("/api/access/verify", server.verifyApplicationAccess) + router.POST("/api/access/verify", server.confirmApplicationAccess) request := httptest.NewRequest(http.MethodGet, "/api/access/check", nil) request.Header.Set("X-Original-Host", "private.example.com") @@ -113,13 +117,35 @@ domains: request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response = httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusAccepted || sender.message != "" { + if response.Code != http.StatusForbidden || sender.message != "" || sender.calls != 2 { t.Fatalf("unlisted email response = %d, message = %q", response.Code, sender.message) } request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "other.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("wrong-host verification response = %d", response.Code) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "private.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || len(response.Result().Cookies()) != 0 || !strings.Contains(response.Body.String(), "Confirm sign-in") { + t.Fatalf("verification preview response = %d, cookies = %v", response.Code, response.Result().Cookies()) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) request.Host = "private.example.com" response = httptest.NewRecorder() router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("repeated link preview response = %d", response.Code) + } + request = httptest.NewRequest(http.MethodPost, "/api/access/verify", strings.NewReader(url.Values{"token": {parsed.Query().Get("token")}}.Encode())) + request.Host = "private.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 { t.Fatalf("verification response = %d, cookies = %v", response.Code, response.Result().Cookies()) } @@ -159,6 +185,66 @@ domains: } } +func TestApplicationAccessRespectsConfiguredPolicyBeforeSending(t *testing.T) { + for _, test := range []struct { + name, mode, email string + allowed []string + status, emails int + }{ + {"unknown address", "allowlist", "unknown@example.com", []string{"person@example.com"}, http.StatusForbidden, 0}, + {"open policy", "any_verified", "unknown@example.com", nil, http.StatusAccepted, 1}, + {"open policy with allowlist", "any_verified", "unknown@example.com", []string{"person@example.com"}, http.StatusAccepted, 1}, + {"recognized address", "allowlist", "person@example.com", []string{"person@example.com"}, http.StatusAccepted, 1}, + {"recognized domain", "allowlist", "person@example.com", []string{"@example.com"}, http.StatusAccepted, 1}, + } { + t.Run(test.name, func(t *testing.T) { + base := t.TempDir() + createTestDeployment(t, base, "protected-site", &models.ServiceMetadata{ + Name: "protected-site", Type: "web", Domains: []models.DomainConfig{{ + ID: "login", Service: "web", ContainerPort: 80, Domain: "protected.example.com", PathPrefix: "/private", + Access: &models.DomainAccessConfig{Enabled: true, Mode: test.mode, AllowedEmails: test.allowed, EmailTargetID: "smtp"}, + }}, + }) + service, err := access.New(base) + if err != nil { + t.Fatal(err) + } + sender := &recordingAccessSender{} + server := &Server{manager: docker.NewManager(base), access: service, accessEmailSender: sender} + router := gin.New() + router.POST("/api/access/request", server.requestApplicationAccess) + router.GET("/api/access/check", server.checkApplicationAccess) + request := httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {test.email}, "return": {"/private"}, + }.Encode())) + request.Host = "protected.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != test.status || sender.calls != test.emails { + t.Fatalf("status = %d, email attempts = %d", response.Code, sender.calls) + } + session, err := service.Session("unknown@other.example", "protected.example.com", 24) + if err != nil { + t.Fatal(err) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/check", nil) + request.Header.Set("X-Original-Host", "protected.example.com") + request.Header.Set("X-Original-URI", "/private") + request.AddCookie(&http.Cookie{Name: access.CookieName, Value: session}) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + expected := http.StatusUnauthorized + if test.mode == "any_verified" { + expected = http.StatusNoContent + } + if response.Code != expected { + t.Fatalf("existing session response = %d, expected %d", response.Code, expected) + } + }) + } +} + func TestAccessEmailTargetsRespectDeploymentGrants(t *testing.T) { base := t.TempDir() cfg := &config.Config{Auth: config.AuthConfig{Enabled: true, JWTSecret: "access-test-secret"}} @@ -230,14 +316,14 @@ func TestApplicationAccessLoginRejectsUnsafeReturnPath(t *testing.T) { if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `name="return" value="/"`) { t.Fatalf("unsafe return path was accepted: %d %s", response.Code, response.Body.String()) } - for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Verify your email"} { + for _, expected := range []string{"FlatRun", "Protected by FlatRun", accessLogo, "Verify your email"} { if !strings.Contains(response.Body.String(), expected) { t.Fatalf("access page is missing %q", expected) } } } -func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) { +func TestApplicationAccessDenialUsesFlatRunBranding(t *testing.T) { server := &Server{} router := gin.New() router.POST("/api/access/request", server.requestApplicationAccess) @@ -247,10 +333,10 @@ func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) { request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusAccepted { + if response.Code != http.StatusForbidden { t.Fatalf("confirmation response = %d", response.Code) } - for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Check your email"} { + for _, expected := range []string{"FlatRun", "Protected by FlatRun", accessLogo, "Access denied"} { if !strings.Contains(response.Body.String(), expected) { t.Fatalf("confirmation page is missing %q", expected) } diff --git a/internal/api/assets/flatrun-logo.svg b/internal/api/assets/flatrun-logo.svg new file mode 100644 index 0000000..6977163 --- /dev/null +++ b/internal/api/assets/flatrun-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 79dfc56..a4d5c23 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "FlatRun Agent API", "description": "Generated from the agent's routes and the types its handlers bind and return.", - "version": "0.4.0-beta.10" + "version": "0.4.0-beta.11" }, "paths": { "/api/access/check": { @@ -74,6 +74,27 @@ "description": "Success" } } + }, + "post": { + "operationId": "post-access-verify", + "tags": [ + "access" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/api.applicationAccessConfirmationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } } }, "/api/agent/update": { @@ -12218,6 +12239,23 @@ "restart" ] }, + "api.applicationAccessConfirmationRequest": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "x-property-order": [ + "token" + ], + "x-columns": [ + "token" + ], + "required": [ + "token" + ] + }, "api.assistRequest": { "type": "object", "properties": { diff --git a/internal/api/server.go b/internal/api/server.go index bb24260..4de4a72 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -468,6 +468,7 @@ func (s *Server) setupRoutes() { api.GET("/access/login", s.applicationAccessLogin) api.POST("/access/request", s.requestApplicationAccess) api.GET("/access/verify", s.verifyApplicationAccess) + api.POST("/access/verify", s.confirmApplicationAccess) // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec)