Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0-beta.10
0.4.0-beta.11
21 changes: 19 additions & 2 deletions internal/access/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
55 changes: 45 additions & 10 deletions internal/api/access_handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
_ "embed"
"fmt"
"html"
"log"
Expand All @@ -19,21 +20,21 @@ type accessEmailSender interface {
}

const accessPageStyles = `<style>
:root{color-scheme:light;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f8fafc;color:#1e293b}
:root{--radius-sm:3px;--radius-md:4px;--accent:#3b82f6;--accent-hover:#2563eb;--accent-contrast:#fff;color-scheme:light;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f8fafc;color:#1e293b}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#eff6ff 0,#f8fafc 42%,#f1f5f9 100%)}
.shell{width:min(420px,100%)}.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:24px;color:#0f172a;font-size:22px;font-weight:750;letter-spacing:-.03em}
.brand-mark{width:38px;height:38px;display:grid;align-content:center;gap:4px;padding:8px;border-radius:10px;background:#2563eb;box-shadow:0 8px 20px rgba(37,99,235,.24);transform:rotate(-5deg)}
.brand-mark span{display:block;height:5px;border:2px solid #fff;border-radius:2px}.card{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:32px;box-shadow:0 18px 45px rgba(15,23,42,.09)}
.shell{width:min(420px,100%)}.brand{display:flex;justify-content:center;margin-bottom:24px}.brand a{display:block;border-radius:var(--radius-sm)}.brand a:focus-visible{outline:2px solid var(--accent);outline-offset:6px}.brand svg{display:block;width:auto;height:52px;max-width:100%}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:var(--radius-md);padding:32px;box-shadow:0 18px 45px rgba(15,23,42,.09)}
h1{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 10px;color:#0f172a}p{color:#64748b;line-height:1.6;margin:0 0 26px}label{display:block;font-size:14px;font-weight:650;margin-bottom:8px;color:#334155}
input{width:100%;padding:12px 13px;border:1px solid #cbd5e1;border-radius:9px;background:#fff;color:#0f172a;font:inherit;outline:0;transition:border-color .15s,box-shadow .15s}input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.16)}
button{width:100%;margin-top:16px;padding:12px 16px;border:0;border-radius:9px;background:#2563eb;color:#fff;font:inherit;font-weight:650;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:#1d4ed8}button:active{transform:translateY(1px)}
input{width:100%;padding:12px 13px;border:1px solid #cbd5e1;border-radius:var(--radius-sm);background:#fff;color:#0f172a;font:inherit;outline:0;transition:border-color .15s,box-shadow .15s}input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.16)}
button{width:100%;margin-top:16px;padding:8px 16px;border:0;border-radius:var(--radius-sm);background:var(--accent);color:var(--accent-contrast);font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:var(--accent-hover)}button:active{transform:translateY(1px)}
.note{margin:22px 0 0;text-align:center;font-size:12px;color:#94a3b8}@media(max-width:480px){body{padding:16px}.card{padding:25px 22px}}
</style>`

const accessBrand = `<div class="brand" aria-label="FlatRun"><span class="brand-mark" aria-hidden="true"><span></span><span></span><span></span></span><span>FlatRun</span></div>`
//go:embed assets/flatrun-logo.svg
var accessLogo string

func accessPage(title, content string) string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>` + title + ` | FlatRun</title>` + accessPageStyles + `</head><body><main class="shell">` + accessBrand + `<section class="card">` + content + `</section><p class="note">Protected by FlatRun</p></main></body></html>`
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>` + title + ` | FlatRun</title>` + accessPageStyles + `</head><body><main class="shell"><div class="brand"><a href="https://flatrun.dev" aria-label="FlatRun website">` + accessLogo + `</a></div><section class="card">` + content + `</section><p class="note">Protected by FlatRun</p></main></body></html>`
}

func (s *Server) checkApplicationAccess(c *gin.Context) {
Expand Down Expand Up @@ -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", `<h1>Access denied</h1><p>This email address is not allowed to access this application.</p>`))
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")
Expand Down Expand Up @@ -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
Expand All @@ -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(`<h1>Confirm sign-in</h1><p>Continue to the application with your verified email address.</p><form method="post" action="/_flatrun/access/verify"><input type="hidden" name="token" value="%s"><button type="submit">Continue to application</button></form>`, 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")
Expand Down
96 changes: 91 additions & 5 deletions internal/api/access_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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"}}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
Loading
Loading