detached 59b81ca

feat(web): sign the owner in with a one-time link, session cookie and CSRF gate

SessionService (CreateLoginLink, SignOutEverywhere), ermite web login and
logout --all, GET/POST /-/login/<nonce>, POST /-/logout, the __Host-ermite
cookie, the Origin and session gate on every browser POST, and private
cache headers on pages rendered for the owner.
commit
59b81ca47033d0274534b320e2534bca071f57dc
author
brice · · 2026-10-10 17:05 UTC
committer
brice · · 2026-10-10 17:06 UTC
parent
c1f3cc9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
33 files · +1347 -41
M cmd/ermite/api_helpers_test.go +4 -0
@@ -31,6 +31,8 @@ type clients struct {
3131 event ermiteconnect.EventServiceClient
3232 server ermiteconnect.ServerServiceClient
3333 mr ermiteconnect.MergeRequestServiceClient
34+
35+ session ermiteconnect.SessionServiceClient
3436 }
3537
3638 // bearerTransport adds the Authorization header of a secret, if any.
@@ -77,6 +79,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
7779 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
7880 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
7981 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
82+
83+ session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
8084 }
8185 }
8286
@@ -31,6 +31,8 @@ type clients struct {
31 event ermiteconnect.EventServiceClient31 event ermiteconnect.EventServiceClient
32 server ermiteconnect.ServerServiceClient32 server ermiteconnect.ServerServiceClient
33 mr ermiteconnect.MergeRequestServiceClient33 mr ermiteconnect.MergeRequestServiceClient
34+
35+ session ermiteconnect.SessionServiceClient
34 }36 }
35 37
36 // bearerTransport adds the Authorization header of a secret, if any.38 // bearerTransport adds the Authorization header of a secret, if any.
@@ -77,6 +79,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
77 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),79 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
78 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),80 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
79 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),81 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
82+
83+ session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
80 }84 }
81 }85 }
82 86
M cmd/ermite/api_test.go +1 -1
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158158 for _, who := range allPrincipals {
159159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who)
161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who)
162162 assert.NotEmpty(t, resp.Msg.GetVersion())
163163 }
164164 })
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158 for _, who := range allPrincipals {158 for _, who := range allPrincipals {
159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160 require.NoError(t, err, who)160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who)161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who)
162 assert.NotEmpty(t, resp.Msg.GetVersion())162 assert.NotEmpty(t, resp.Msg.GetVersion())
163 }163 }
164 })164 })
M cmd/ermite/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
M cmd/ermite/cli_version.go +1 -0
@@ -104,4 +104,5 @@ var featureUse = map[string]string{
104104 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105105 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106106 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
107+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
107108 }
@@ -104,4 +104,5 @@ var featureUse = map[string]string{
104 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",104 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",105 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",106 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
107+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
107 }108 }
M cmd/ermite/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "signing_key")
46+ f := versionFake("repo.update", "merge_requests", "signing_key", "session")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 // allFeatures are the features this client uses.16 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
18 18
19 func versionFake(features ...string) *fakeAPI {19 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)35 f := versionFake(allFeatures...)
36 url := f.serve(t)36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)37 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout)38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")39 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 40
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout)42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout)
43 }43 }
44 44
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "signing_key")46+ f := versionFake("repo.update", "merge_requests", "signing_key", "session")
47 url := f.serve(t)47 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)48 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")49 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")51 assert.NotContains(t, res.stderr, "repo.update")
52 52
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout)54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 56
57 f = versionFake()57 f = versionFake()
A cmd/ermite/cli_web.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "fmt"
8+
9+ "connectrpc.com/connect"
10+ "github.com/spf13/cobra"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// jsonLoginLink is the stable JSON shape of `ermite web login --json`.
16+type jsonLoginLink struct {
17+ URL string `json:"url"`
18+ ExpiresAt *string `json:"expires_at"`
19+}
20+
21+func newWebCmd(env *cliEnv) *cobra.Command {
22+ web := &cobra.Command{
23+ Use: "web",
24+ Short: "Sign the owner in to the web interface of a remote server",
25+ Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26+ "token that holds every scope and is not limited to repositories.",
27+ }
28+ web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
29+ return web
30+}
31+
32+func newWebLoginCmd(env *cliEnv) *cobra.Command {
33+ cmd := &cobra.Command{
34+ Use: "login",
35+ Short: "Print a one-time link that signs your browser in",
36+ Long: "Ask the server for a login link and print it on standard output. Open it in your browser and confirm:\n" +
37+ "the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" +
38+ "The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" +
39+ "printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. Opening\n" +
40+ "it only shows a confirmation page; confirming it signs the browser in.\n\n" +
41+ "JSON fields (--json): url, expires_at (RFC 3339 UTC).",
42+ Args: cobra.NoArgs,
43+ }
44+ asJSON := addJSONFlag(cmd)
45+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
46+ c, err := env.client(cmd, true)
47+ if err != nil {
48+ return err
49+ }
50+ res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{}))
51+ if err != nil {
52+ return c.Describe(err)
53+ }
54+ if *asJSON {
55+ return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())})
56+ }
57+ noticef(cmd, "open this link in your browser and confirm; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly"))
58+ _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl())
59+ return err
60+ }
61+ return cmd
62+}
63+
64+func newWebLogoutCmd(env *cliEnv) *cobra.Command {
65+ var all bool
66+ cmd := &cobra.Command{
67+ Use: "logout --all",
68+ Short: "Sign every browser out",
69+ Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" +
70+ "(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.",
71+ Args: cobra.NoArgs,
72+ }
73+ cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)")
74+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
75+ if !all {
76+ return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
77+ }
78+ c, err := env.client(cmd, true)
79+ if err != nil {
80+ return err
81+ }
82+ if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil {
83+ return c.Describe(err)
84+ }
85+ noticef(cmd, "every web session is signed out")
86+ return nil
87+ }
88+ return cmd
89+}
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "fmt"
8+
9+ "connectrpc.com/connect"
10+ "github.com/spf13/cobra"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// jsonLoginLink is the stable JSON shape of `ermite web login --json`.
16+type jsonLoginLink struct {
17+ URL string `json:"url"`
18+ ExpiresAt *string `json:"expires_at"`
19+}
20+
21+func newWebCmd(env *cliEnv) *cobra.Command {
22+ web := &cobra.Command{
23+ Use: "web",
24+ Short: "Sign the owner in to the web interface of a remote server",
25+ Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26+ "token that holds every scope and is not limited to repositories.",
27+ }
28+ web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
29+ return web
30+}
31+
32+func newWebLoginCmd(env *cliEnv) *cobra.Command {
33+ cmd := &cobra.Command{
34+ Use: "login",
35+ Short: "Print a one-time link that signs your browser in",
36+ Long: "Ask the server for a login link and print it on standard output. Open it in your browser and confirm:\n" +
37+ "the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" +
38+ "The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" +
39+ "printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. Opening\n" +
40+ "it only shows a confirmation page; confirming it signs the browser in.\n\n" +
41+ "JSON fields (--json): url, expires_at (RFC 3339 UTC).",
42+ Args: cobra.NoArgs,
43+ }
44+ asJSON := addJSONFlag(cmd)
45+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
46+ c, err := env.client(cmd, true)
47+ if err != nil {
48+ return err
49+ }
50+ res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{}))
51+ if err != nil {
52+ return c.Describe(err)
53+ }
54+ if *asJSON {
55+ return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())})
56+ }
57+ noticef(cmd, "open this link in your browser and confirm; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly"))
58+ _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl())
59+ return err
60+ }
61+ return cmd
62+}
63+
64+func newWebLogoutCmd(env *cliEnv) *cobra.Command {
65+ var all bool
66+ cmd := &cobra.Command{
67+ Use: "logout --all",
68+ Short: "Sign every browser out",
69+ Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" +
70+ "(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.",
71+ Args: cobra.NoArgs,
72+ }
73+ cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)")
74+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
75+ if !all {
76+ return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
77+ }
78+ c, err := env.client(cmd, true)
79+ if err != nil {
80+ return err
81+ }
82+ if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil {
83+ return c.Describe(err)
84+ }
85+ noticef(cmd, "every web session is signed out")
86+ return nil
87+ }
88+ return cmd
89+}
M cmd/ermite/e2e_test.go +6 -2
@@ -110,13 +110,17 @@ func TestRoutes(t *testing.T) {
110110 } {
111111 assert.Equal(t, 404, h.status("GET", path, nil), path)
112112 }
113- assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
113+ // A POST no Git route owns falls to the web, whose gate refuses it (a
114+ // browser POST needs an Origin and a session, ADR 0018).
115+ assert.Equal(t, 403, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
114116 // The web owns GET on every other path: a GET of a POST-only Git route
115117 // is its 404 page, not a 405.
116118 assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))
117119 assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")
118120 assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")
119- assert.Equal(t, 405, h.status("POST", "/", nil))
121+ // A browser POST needs an Origin and a session (ADR 0018): refused
122+ // before the router sees it.
123+ assert.Equal(t, 403, h.status("POST", "/", nil))
120124 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
121125 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
122126 // Anonymous receive-pack is a challenge even for a public repository.
@@ -110,13 +110,17 @@ func TestRoutes(t *testing.T) {
110 } {110 } {
111 assert.Equal(t, 404, h.status("GET", path, nil), path)111 assert.Equal(t, 404, h.status("GET", path, nil), path)
112 }112 }
113- assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))113+ // A POST no Git route owns falls to the web, whose gate refuses it (a
114+ // browser POST needs an Origin and a session, ADR 0018).
115+ assert.Equal(t, 403, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
114 // The web owns GET on every other path: a GET of a POST-only Git route116 // The web owns GET on every other path: a GET of a POST-only Git route
115 // is its 404 page, not a 405.117 // is its 404 page, not a 405.
116 assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))118 assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))
117 assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")119 assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")
118 assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")120 assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")
119- assert.Equal(t, 405, h.status("POST", "/", nil))121+ // A browser POST needs an Origin and a session (ADR 0018): refused
122+ // before the router sees it.
123+ assert.Equal(t, 403, h.status("POST", "/", nil))
120 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")124 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
121 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))125 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
122 // Anonymous receive-pack is a challenge even for a public repository.126 // Anonymous receive-pack is a challenge even for a public repository.
M cmd/ermite/forge.go +10 -2
@@ -69,6 +69,7 @@ type forge struct {
6969 // ServerService.GetSigningKey.
7070 MergeRequests *app.MergeRequests
7171 SigningKey *app.SigningKey
72+ Sessions *app.Sessions
7273 EventLog *app.Events
7374 API *rpc.API
7475 Bus ports.EventBus
@@ -187,7 +188,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
187188 })
188189 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
189190
190- // One budget of failed authentications per client, for Git and the API.
191+ f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
192+ if err != nil {
193+ return nil, err
194+ }
195+
196+ // One budget of failed authentications per client, for Git, the API and
197+ // the web login.
191198 limiter := clientlimit.NewLimiter(nil)
192199 f.API = rpc.New(rpc.Config{
193200 Auth: f.Auth,
@@ -197,6 +204,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
197204 Events: f.EventLog,
198205 MergeRequests: f.MergeRequests,
199206 SigningKey: f.SigningKey,
207+ Sessions: f.Sessions,
200208 Version: serverVersion(),
201209 TrustedProxies: cfg.TrustedProxies,
202210 Limiter: limiter,
@@ -216,7 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
216224 for _, p := range githttp.Patterns {
217225 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
218226 }
219- site, err := newWeb(cfg, f.API)
227+ site, err := newWeb(cfg, f.API, f.Sessions, limiter)
220228 if err != nil {
221229 return nil, fmt.Errorf("web: %w", err)
222230 }
@@ -69,6 +69,7 @@ type forge struct {
69 // ServerService.GetSigningKey.69 // ServerService.GetSigningKey.
70 MergeRequests *app.MergeRequests70 MergeRequests *app.MergeRequests
71 SigningKey *app.SigningKey71 SigningKey *app.SigningKey
72+ Sessions *app.Sessions
72 EventLog *app.Events73 EventLog *app.Events
73 API *rpc.API74 API *rpc.API
74 Bus ports.EventBus75 Bus ports.EventBus
@@ -187,7 +188,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
187 })188 })
188 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")189 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
189 190
190- // One budget of failed authentications per client, for Git and the API.191+ f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
192+ if err != nil {
193+ return nil, err
194+ }
195+
196+ // One budget of failed authentications per client, for Git, the API and
197+ // the web login.
191 limiter := clientlimit.NewLimiter(nil)198 limiter := clientlimit.NewLimiter(nil)
192 f.API = rpc.New(rpc.Config{199 f.API = rpc.New(rpc.Config{
193 Auth: f.Auth,200 Auth: f.Auth,
@@ -197,6 +204,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
197 Events: f.EventLog,204 Events: f.EventLog,
198 MergeRequests: f.MergeRequests,205 MergeRequests: f.MergeRequests,
199 SigningKey: f.SigningKey,206 SigningKey: f.SigningKey,
207+ Sessions: f.Sessions,
200 Version: serverVersion(),208 Version: serverVersion(),
201 TrustedProxies: cfg.TrustedProxies,209 TrustedProxies: cfg.TrustedProxies,
202 Limiter: limiter,210 Limiter: limiter,
@@ -216,7 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
216 for _, p := range githttp.Patterns {224 for _, p := range githttp.Patterns {
217 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})225 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
218 }226 }
219- site, err := newWeb(cfg, f.API)227+ site, err := newWeb(cfg, f.API, f.Sessions, limiter)
220 if err != nil {228 if err != nil {
221 return nil, fmt.Errorf("web: %w", err)229 return nil, fmt.Errorf("web: %w", err)
222 }230 }
M cmd/ermite/root.go +1 -1
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
2828 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env),
29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
3030 )
3131 return root
3232 }
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
26 root.AddCommand(26 root.AddCommand(
27 newServeCmd(), newHookCmd(),27 newServeCmd(), newHookCmd(),
28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env),29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
30 )30 )
31 return root31 return root
32 }32 }
M cmd/ermite/web.go +38 -2
@@ -3,13 +3,17 @@
33 package main
44
55 import (
6+ "context"
7+ "errors"
68 "net/http"
79 "net/netip"
10+ "time"
811
912 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1013 "bnema.dev/ermite/internal/adapters/httpserver"
1114 "bnema.dev/ermite/internal/adapters/rpc"
1215 "bnema.dev/ermite/internal/adapters/web"
16+ "bnema.dev/ermite/internal/app"
1317 "bnema.dev/ermite/internal/clientlimit"
1418 "bnema.dev/ermite/internal/config"
1519 "bnema.dev/ermite/internal/domain"
@@ -17,7 +21,10 @@ import (
1721
1822 // newWeb builds the web pages on the API of this process: the generated
1923 // Connect clients are bound to the handlers of api, with no socket (ADR 0020).
20-func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
24+//
25+// The owner's session cookie turns a request into the owner's (web.Server
26+// checks it with sessions); every other request is anonymous.
27+func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
2128 hc := api.InProcessClient()
2229 srv, err := web.New(web.Config{
2330 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
@@ -26,6 +33,10 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
2633 PublicURL: cfg.PublicURL,
2734 SourceURL: cfg.SourceURL,
2835 Version: serverVersion(),
36+
37+ Sessions: webSessions{sessions},
38+ Limiter: limiter,
39+ TrustedProxies: cfg.TrustedProxies,
2940 })
3041 if err != nil {
3142 return nil, err
@@ -33,10 +44,35 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
3344 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
3445 }
3546
47+// webSessions adapts the session use cases to web.SessionAuth: a valid token
48+// gives the context of the owner, whose principal the API calls of the page
49+// carry (rpc.API.InProcessClient).
50+type webSessions struct{ *app.Sessions }
51+
52+func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) {
53+ token, _, err := s.Sessions.Login(ctx, nonce)
54+ if errors.Is(err, domain.ErrUnauthenticated) {
55+ return "", 0, web.ErrLinkRejected
56+ }
57+ return token, app.SessionTTL, err
58+}
59+
60+func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) {
61+ p, err := s.Sessions.Verify(ctx, token)
62+ if errors.Is(err, domain.ErrUnauthenticated) {
63+ return ctx, false, nil
64+ }
65+ if err != nil {
66+ return ctx, false, err
67+ }
68+ return domain.ContextWithPrincipal(ctx, p), true, nil
69+}
70+
3671 // asPrincipal runs next with p as the caller of every request, attributed to
3772 // the browser's address: the API calls the web makes carry both
3873 // (rpc.API.InProcessClient), so the per-client limits of the API count each
39-// visitor. Browsers are anonymous until the web login of P5b brings a session.
74+// visitor. Browsers are anonymous; web.Server replaces the principal with the
75+// owner's when the request carries a valid session.
4076 func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler {
4177 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4278 ctx := domain.ContextWithPrincipal(r.Context(), p)
@@ -3,13 +3,17 @@
3 package main3 package main
4 4
5 import (5 import (
6+ "context"
7+ "errors"
6 "net/http"8 "net/http"
7 "net/netip"9 "net/netip"
10+ "time"
8 11
9 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"12 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
10 "bnema.dev/ermite/internal/adapters/httpserver"13 "bnema.dev/ermite/internal/adapters/httpserver"
11 "bnema.dev/ermite/internal/adapters/rpc"14 "bnema.dev/ermite/internal/adapters/rpc"
12 "bnema.dev/ermite/internal/adapters/web"15 "bnema.dev/ermite/internal/adapters/web"
16+ "bnema.dev/ermite/internal/app"
13 "bnema.dev/ermite/internal/clientlimit"17 "bnema.dev/ermite/internal/clientlimit"
14 "bnema.dev/ermite/internal/config"18 "bnema.dev/ermite/internal/config"
15 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
@@ -17,7 +21,10 @@ import (
17 21
18 // newWeb builds the web pages on the API of this process: the generated22 // newWeb builds the web pages on the API of this process: the generated
19 // Connect clients are bound to the handlers of api, with no socket (ADR 0020).23 // Connect clients are bound to the handlers of api, with no socket (ADR 0020).
20-func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {24+//
25+// The owner's session cookie turns a request into the owner's (web.Server
26+// checks it with sessions); every other request is anonymous.
27+func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
21 hc := api.InProcessClient()28 hc := api.InProcessClient()
22 srv, err := web.New(web.Config{29 srv, err := web.New(web.Config{
23 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),30 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
@@ -26,6 +33,10 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
26 PublicURL: cfg.PublicURL,33 PublicURL: cfg.PublicURL,
27 SourceURL: cfg.SourceURL,34 SourceURL: cfg.SourceURL,
28 Version: serverVersion(),35 Version: serverVersion(),
36+
37+ Sessions: webSessions{sessions},
38+ Limiter: limiter,
39+ TrustedProxies: cfg.TrustedProxies,
29 })40 })
30 if err != nil {41 if err != nil {
31 return nil, err42 return nil, err
@@ -33,10 +44,35 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
33 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil44 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
34 }45 }
35 46
47+// webSessions adapts the session use cases to web.SessionAuth: a valid token
48+// gives the context of the owner, whose principal the API calls of the page
49+// carry (rpc.API.InProcessClient).
50+type webSessions struct{ *app.Sessions }
51+
52+func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) {
53+ token, _, err := s.Sessions.Login(ctx, nonce)
54+ if errors.Is(err, domain.ErrUnauthenticated) {
55+ return "", 0, web.ErrLinkRejected
56+ }
57+ return token, app.SessionTTL, err
58+}
59+
60+func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) {
61+ p, err := s.Sessions.Verify(ctx, token)
62+ if errors.Is(err, domain.ErrUnauthenticated) {
63+ return ctx, false, nil
64+ }
65+ if err != nil {
66+ return ctx, false, err
67+ }
68+ return domain.ContextWithPrincipal(ctx, p), true, nil
69+}
70+
36 // asPrincipal runs next with p as the caller of every request, attributed to71 // asPrincipal runs next with p as the caller of every request, attributed to
37 // the browser's address: the API calls the web makes carry both72 // the browser's address: the API calls the web makes carry both
38 // (rpc.API.InProcessClient), so the per-client limits of the API count each73 // (rpc.API.InProcessClient), so the per-client limits of the API count each
39-// visitor. Browsers are anonymous until the web login of P5b brings a session.74+// visitor. Browsers are anonymous; web.Server replaces the principal with the
75+// owner's when the request carries a valid session.
40 func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler {76 func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler {
41 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {77 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42 ctx := domain.ContextWithPrincipal(r.Context(), p)78 ctx := domain.ContextWithPrincipal(r.Context(), p)
M cmd/ermite/web_events_test.go +2 -2
@@ -333,8 +333,8 @@ func TestWebEventsTopics(t *testing.T) {
333333 }
334334 assert.Equal(t, want, h.status("GET", path, nil), path)
335335 }
336- // POST is not allowed.
337- assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil))
336+ // POST is not allowed: a browser POST without Origin and session is a 403.
337+ assert.Equal(t, http.StatusForbidden, h.status("POST", "/-/events?topic=home", nil))
338338 }
339339
340340 // The pages connect to the stream of their topic.
@@ -333,8 +333,8 @@ func TestWebEventsTopics(t *testing.T) {
333 }333 }
334 assert.Equal(t, want, h.status("GET", path, nil), path)334 assert.Equal(t, want, h.status("GET", path, nil), path)
335 }335 }
336- // POST is not allowed.336+ // POST is not allowed: a browser POST without Origin and session is a 403.
337- assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil))337+ assert.Equal(t, http.StatusForbidden, h.status("POST", "/-/events?topic=home", nil))
338 }338 }
339 339
340 // The pages connect to the stream of their topic.340 // The pages connect to the stream of their topic.
A cmd/ermite/web_session_test.go +439 -0
@@ -0,0 +1,439 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/golang-jwt/jwt/v5"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+const (
24+ publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness
25+ sessionCookie = "__Host-ermite"
26+)
27+
28+// page is the answer of a request to the web.
29+type page struct {
30+ status int
31+ hdr http.Header
32+ body string
33+}
34+
35+func (p page) cookie(name string) *http.Cookie {
36+ for _, c := range (&http.Response{Header: p.hdr}).Cookies() {
37+ if c.Name == name {
38+ return c
39+ }
40+ }
41+ return nil
42+}
43+
44+// browser is a bare HTTP client: it follows no redirect and keeps no cookie,
45+// so that the tests say exactly what the browser sends.
46+type browser struct {
47+ h *harness
48+}
49+
50+func (b browser) do(method, path string, hdr map[string]string, body string) page {
51+ b.h.t.Helper()
52+ req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body))
53+ require.NoError(b.h.t, err)
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ if body != "" {
58+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
59+ }
60+ resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
61+ require.NoError(b.h.t, err)
62+ defer func() { _ = resp.Body.Close() }()
63+ out, err := io.ReadAll(resp.Body)
64+ require.NoError(b.h.t, err)
65+ return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
66+}
67+
68+func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") }
69+
70+// as is the header map of a browser holding the session token.
71+func as(token string) map[string]string {
72+ return map[string]string{"Cookie": sessionCookie + "=" + token}
73+}
74+
75+func post(token string) map[string]string {
76+ h := as(token)
77+ h["Origin"] = publicOrigin
78+ return h
79+}
80+
81+// loginPath turns the link the CLI printed into a path on the test server.
82+func loginPath(t *testing.T, link string) string {
83+ t.Helper()
84+ u, err := url.Parse(strings.TrimSpace(link))
85+ require.NoError(t, err)
86+ assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
87+ assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path)
88+ return u.Path
89+}
90+
91+// signIn runs `ermite web login` and confirms the link: it returns the token
92+// of the cookie the browser would hold.
93+func (c *cliHarness) signIn(t *testing.T) string {
94+ t.Helper()
95+ res := c.cli(t, adminToken, "web", "login").ok(t)
96+ p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
97+ require.Equal(t, http.StatusSeeOther, p.status, p.body)
98+ ck := p.cookie(sessionCookie)
99+ require.NotNil(t, ck)
100+ return ck.Value
101+}
102+
103+// AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI
104+// link to the private page and back out.
105+func TestWebLoginEndToEnd(t *testing.T) {
106+ c := newCLIHarness(t)
107+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
108+ c.seed("pub", domain.VisibilityPublic, "a.txt")
109+ b := browser{c.harness}
110+
111+ // Anonymous: the private repository is invisible; public caches as before.
112+ home := b.get("/", nil)
113+ assert.Contains(t, home.body, "pub")
114+ assert.NotContains(t, home.body, "secret")
115+ assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
116+ assert.Empty(t, home.hdr.Values("Vary"))
117+ assert.NotContains(t, home.body, "sign out")
118+ assert.Equal(t, 404, b.get("/secret", nil).status)
119+
120+ // The CLI asks for a link and prints it; stderr says when it ends.
121+ res := c.cli(t, adminToken, "web", "login").ok(t)
122+ assert.Contains(t, res.stderr, "works once")
123+ path := loginPath(t, res.stdout)
124+
125+ // GET shows the confirmation and never consumes the link.
126+ for range 3 {
127+ p := b.get(path, nil)
128+ assert.Equal(t, 200, p.status)
129+ assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"))
130+ assert.Equal(t, "no-referrer", p.hdr.Get("Referrer-Policy"))
131+ assert.Contains(t, p.body, `method="post"`)
132+ assert.Contains(t, p.body, `action="`+path+`"`)
133+ assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session")
134+ assertNoInline(t, p.body)
135+ }
136+ // A POST that is not from this site is refused and does not consume it.
137+ assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin")
138+ assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin")
139+ assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin")
140+ assert.Equal(t, 200, b.get(path, nil).status, "still pending")
141+
142+ // The POST consumes it and sets the cookie.
143+ login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "")
144+ require.Equal(t, http.StatusSeeOther, login.status, login.body)
145+ assert.Equal(t, "/", login.hdr.Get("Location"))
146+ assert.Equal(t, "no-store", login.hdr.Get("Cache-Control"))
147+ ck := login.cookie(sessionCookie)
148+ require.NotNil(t, ck)
149+ assert.True(t, ck.Secure && ck.HttpOnly)
150+ assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
151+ assert.Equal(t, "/", ck.Path)
152+ assert.Empty(t, ck.Domain, "no Domain: host only")
153+ assert.Equal(t, 12*60*60, ck.MaxAge)
154+ token := ck.Value
155+ claims := jwt.MapClaims{}
156+ _, _, err := jwt.NewParser().ParseUnverified(token, claims)
157+ require.NoError(t, err)
158+ assert.Equal(t, "owner", claims["sub"])
159+ assert.Equal(t, float64(0), claims["epoch"])
160+ exp, err := claims.GetExpirationTime()
161+ require.NoError(t, err)
162+ assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute)
163+ assert.NotNil(t, claims["iat"])
164+
165+ // Replayed and unknown links fail with the same page.
166+ for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} {
167+ g := b.get(p, nil)
168+ assert.Equal(t, 404, g.status, p)
169+ assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
170+ assert.Contains(t, g.body, "login link not valid")
171+ r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
172+ assert.Equal(t, 404, r.status, p)
173+ assert.Equal(t, g.body, r.body, "no detail tells why")
174+ assert.Nil(t, r.cookie(sessionCookie))
175+ }
176+
177+ // Signed in: private repositories show on every page, headers say private.
178+ for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} {
179+ got := b.get(p, as(token))
180+ if p == "/nope" {
181+ assert.Equal(t, 404, got.status)
182+ } else {
183+ assert.Equal(t, 200, got.status, p)
184+ }
185+ assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
186+ assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
187+ assertNoInline(t, got.body)
188+ }
189+ home = b.get("/", as(token))
190+ assert.Contains(t, home.body, "secret")
191+ assert.Contains(t, home.body, `<form class="st-owner" method="post" action="/-/logout">`)
192+ assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
193+ raw := b.get("/secret/raw/main/notes.txt", as(token))
194+ assert.Equal(t, 200, raw.status)
195+ assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control"))
196+ assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status)
197+ // Static assets stay public.
198+ assert.Contains(t, b.get("/", as(token)).body, "/-/static/")
199+
200+ // The live stream of a private repository is the owner's.
201+ assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status)
202+ sctx, stop := context.WithCancel(c.ctx)
203+ defer stop()
204+ sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil)
205+ require.NoError(t, err)
206+ sreq.Header.Set("Cookie", sessionCookie+"="+token)
207+ sres, err := http.DefaultClient.Do(sreq)
208+ require.NoError(t, err)
209+ assert.Equal(t, 200, sres.StatusCode)
210+ assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control"))
211+ assert.Contains(t, sres.Header.Values("Vary"), "Cookie")
212+ stop()
213+ _ = sres.Body.Close()
214+
215+ // The browser routes ignore Authorization: the admin token shows nothing.
216+ bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken})
217+ assert.NotContains(t, bearer.body, "secret")
218+ assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control"))
219+ assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status)
220+
221+ // The API ignores the cookie: no bearer is anonymous, so nothing private
222+ // and nothing that needs a caller.
223+ api := c.api("", connect.WithInterceptors(cookieInterceptor(token)))
224+ list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
225+ require.NoError(t, err)
226+ require.Len(t, list.Msg.GetRepos(), 1)
227+ assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName())
228+ _, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"}))
229+ assert.Equal(t, "not_found", codeName(err))
230+ _, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{}))
231+ assert.Equal(t, "unauthenticated", codeName(err))
232+ _, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
233+ assert.Equal(t, "unauthenticated", codeName(err))
234+ _, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
235+ assert.Equal(t, "unauthenticated", codeName(err))
236+
237+ // Logout: a POST from this site with the session; the cookie is deleted.
238+ assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin")
239+ assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status)
240+ assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session")
241+ out := b.do("POST", "/-/logout", post(token), "")
242+ assert.Equal(t, http.StatusSeeOther, out.status)
243+ assert.Equal(t, "/", out.hdr.Get("Location"))
244+ gone := out.cookie(sessionCookie)
245+ require.NotNil(t, gone)
246+ assert.Negative(t, gone.MaxAge)
247+ assert.Empty(t, gone.Value)
248+ assert.Equal(t, 404, b.get("/secret", nil).status)
249+ // A GET never signs out.
250+ g := b.get("/-/logout", as(token))
251+ assert.Equal(t, 404, g.status, "no page")
252+ assert.Nil(t, g.cookie(sessionCookie), "the session is untouched")
253+ assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry")
254+
255+ // logout --all: the cookie is rejected and cleared; anonymous again.
256+ c.cli(t, adminToken, "web", "logout", "--all").ok(t)
257+ after := b.get("/secret", as(token))
258+ assert.Equal(t, 404, after.status)
259+ assert.Equal(t, "no-cache", after.hdr.Get("Cache-Control"))
260+ cleared := after.cookie(sessionCookie)
261+ require.NotNil(t, cleared, "a rejected cookie is cleared")
262+ assert.Negative(t, cleared.MaxAge)
263+ assert.NotContains(t, b.get("/", as(token)).body, "secret")
264+ assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session")
265+
266+ // A new link gives a session of the new epoch.
267+ fresh := c.signIn(t)
268+ assert.Equal(t, 200, b.get("/secret", as(fresh)).status)
269+ assert.Equal(t, 404, b.get("/secret", as(token)).status)
270+}
271+
272+// cookieInterceptor makes a Connect client send the session cookie, as a
273+// browser-made call would, to show the API pays no attention to it.
274+func cookieInterceptor(token string) connect.Interceptor {
275+ return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
276+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
277+ req.Header().Set("Cookie", sessionCookie+"="+token)
278+ return next(ctx, req)
279+ }
280+ })
281+}
282+
283+// The cookie must be an unforgeable HS256 token for the key of the server.
284+func TestWebRejectsForgedSessions(t *testing.T) {
285+ c := newCLIHarness(t)
286+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
287+ b := browser{c.harness}
288+ good := c.signIn(t)
289+ require.Equal(t, 200, b.get("/secret", as(good)).status)
290+
291+ now := time.Now()
292+ claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
293+ none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)
294+ require.NoError(t, err)
295+ wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........"))
296+ require.NoError(t, err)
297+ wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........"))
298+ require.NoError(t, err)
299+ for name, tok := range map[string]string{
300+ "alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2],
301+ } {
302+ p := b.get("/secret", as(tok))
303+ assert.Equal(t, 404, p.status, name)
304+ assert.Equal(t, "no-cache", p.hdr.Get("Cache-Control"), name)
305+ ck := p.cookie(sessionCookie)
306+ require.NotNil(t, ck, name)
307+ assert.Negative(t, ck.MaxAge, name)
308+ assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name)
309+ }
310+}
311+
312+// Failed logins count in the limiter of failed authentications, like a bad
313+// token on the API: the eleventh attempt of one client is refused, and a valid
314+// link of that client is too.
315+func TestWebLoginFailuresAreRateLimited(t *testing.T) {
316+ h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
317+ c := &cliHarness{harness: h, url: h.srv.URL}
318+ b := browser{c.harness}
319+ from := func(ip string) map[string]string {
320+ return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip}
321+ }
322+ bad := "/-/login/" + strings.Repeat("b", 43)
323+ for range 10 {
324+ assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status)
325+ }
326+ blocked := b.do("POST", bad, from("203.0.113.9"), "")
327+ assert.Equal(t, http.StatusTooManyRequests, blocked.status)
328+ assert.NotEmpty(t, blocked.hdr.Get("Retry-After"))
329+
330+ link := c.cli(t, adminToken, "web", "login").ok(t).stdout
331+ assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent")
332+ assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected")
333+ // The same budget as the API's: a bad token from this client is refused.
334+ _, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
335+ require.NoError(t, err, "GetVersion needs no authentication")
336+ _, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
337+ assert.Equal(t, "resource_exhausted", codeName(err))
338+}
339+
340+func forwardedFor(ip string) connect.Interceptor {
341+ return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
342+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
343+ req.Header().Set("X-Forwarded-For", ip)
344+ return next(ctx, req)
345+ }
346+ })
347+}
348+
349+// Only the owner creates links: the admin token, or a token with every scope
350+// and no repository list.
351+func TestSessionServiceIsOwnerOnly(t *testing.T) {
352+ c := newCLIHarness(t)
353+ ctx := context.Background()
354+ c.seed("a", domain.VisibilityPublic)
355+ partial := c.token(domain.AllScopes()[:8])
356+ limited := c.token(domain.AllScopes(), "a")
357+ full := c.token(domain.AllScopes())
358+
359+ for name, tc := range map[string]struct{ secret, code string }{
360+ "anonymous": {"", "unauthenticated"},
361+ "bad token": {"nope", "unauthenticated"},
362+ "missing scope": {partial, "permission_denied"},
363+ "repo list": {limited, "permission_denied"},
364+ "every scope": {full, "ok"},
365+ "admin token": {adminToken, "ok"},
366+ } {
367+ a := c.api(tc.secret)
368+ _, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
369+ assert.Equal(t, tc.code, codeName(err), name)
370+ _, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
371+ if tc.code == "unauthenticated" {
372+ assert.Equal(t, tc.code, codeName(err), name)
373+ } else {
374+ assert.Equal(t, tc.code, codeName(err), name)
375+ }
376+ }
377+
378+ res := c.cli(t, partial, "web", "login")
379+ require.Error(t, res.err)
380+ assert.Contains(t, res.err.Error(), "permission denied")
381+ assert.Empty(t, res.stdout)
382+ res = c.cli(t, adminToken, "web", "logout")
383+ require.Error(t, res.err, "--all is required")
384+ res = c.cli(t, adminToken, "web", "login", "--json").ok(t)
385+ assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout))
386+}
387+
388+// Login links are bounded in number.
389+func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) {
390+ c := newCLIHarness(t)
391+ a := c.api(adminToken)
392+ var code string
393+ for range 20 {
394+ _, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
395+ if code = codeName(err); code != "ok" {
396+ break
397+ }
398+ }
399+ assert.Equal(t, "resource_exhausted", code)
400+}
401+
402+// A link, a session token and the signing key are secrets: no log line holds
403+// them, at the most verbose level.
404+func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) {
405+ c := newCLIHarness(t)
406+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
407+ b := browser{c.harness}
408+
409+ link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout)
410+ nonce := link[strings.LastIndex(link, "/")+1:]
411+ require.Len(t, nonce, 43)
412+ get := b.get(loginPath(t, link), nil)
413+ require.Equal(t, 200, get.status)
414+ p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "")
415+ token := p.cookie(sessionCookie).Value
416+ b.get("/secret", as(token))
417+ b.get("/-/login/"+strings.Repeat("z", 43), nil)
418+ b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "")
419+
420+ logs := c.logs.String()
421+ assert.NotContains(t, logs, nonce)
422+ assert.NotContains(t, logs, token)
423+ assert.NotContains(t, logs, strings.Repeat("z", 43))
424+ assert.Contains(t, logs, "login link created")
425+ assert.Contains(t, logs, "owner signed in on the web")
426+ key, err := c.forge.store.Sessions().Key(context.Background())
427+ require.NoError(t, err)
428+ assert.NotContains(t, logs, string(key))
429+}
430+
431+// The owner principal is what the API sees from a signed-in page, and is
432+// labelled "owner".
433+func TestWebOwnerIsLabelledInLogs(t *testing.T) {
434+ c := newCLIHarness(t)
435+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
436+ token := c.signIn(t)
437+ browser{c.harness}.get("/secret", as(token))
438+ assert.Contains(t, c.logs.String(), `"principal":"owner"`)
439+}
@@ -0,0 +1,439 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/golang-jwt/jwt/v5"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+const (
24+ publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness
25+ sessionCookie = "__Host-ermite"
26+)
27+
28+// page is the answer of a request to the web.
29+type page struct {
30+ status int
31+ hdr http.Header
32+ body string
33+}
34+
35+func (p page) cookie(name string) *http.Cookie {
36+ for _, c := range (&http.Response{Header: p.hdr}).Cookies() {
37+ if c.Name == name {
38+ return c
39+ }
40+ }
41+ return nil
42+}
43+
44+// browser is a bare HTTP client: it follows no redirect and keeps no cookie,
45+// so that the tests say exactly what the browser sends.
46+type browser struct {
47+ h *harness
48+}
49+
50+func (b browser) do(method, path string, hdr map[string]string, body string) page {
51+ b.h.t.Helper()
52+ req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body))
53+ require.NoError(b.h.t, err)
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ if body != "" {
58+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
59+ }
60+ resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
61+ require.NoError(b.h.t, err)
62+ defer func() { _ = resp.Body.Close() }()
63+ out, err := io.ReadAll(resp.Body)
64+ require.NoError(b.h.t, err)
65+ return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
66+}
67+
68+func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") }
69+
70+// as is the header map of a browser holding the session token.
71+func as(token string) map[string]string {
72+ return map[string]string{"Cookie": sessionCookie + "=" + token}
73+}
74+
75+func post(token string) map[string]string {
76+ h := as(token)
77+ h["Origin"] = publicOrigin
78+ return h
79+}
80+
81+// loginPath turns the link the CLI printed into a path on the test server.
82+func loginPath(t *testing.T, link string) string {
83+ t.Helper()
84+ u, err := url.Parse(strings.TrimSpace(link))
85+ require.NoError(t, err)
86+ assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
87+ assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path)
88+ return u.Path
89+}
90+
91+// signIn runs `ermite web login` and confirms the link: it returns the token
92+// of the cookie the browser would hold.
93+func (c *cliHarness) signIn(t *testing.T) string {
94+ t.Helper()
95+ res := c.cli(t, adminToken, "web", "login").ok(t)
96+ p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
97+ require.Equal(t, http.StatusSeeOther, p.status, p.body)
98+ ck := p.cookie(sessionCookie)
99+ require.NotNil(t, ck)
100+ return ck.Value
101+}
102+
103+// AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI
104+// link to the private page and back out.
105+func TestWebLoginEndToEnd(t *testing.T) {
106+ c := newCLIHarness(t)
107+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
108+ c.seed("pub", domain.VisibilityPublic, "a.txt")
109+ b := browser{c.harness}
110+
111+ // Anonymous: the private repository is invisible; public caches as before.
112+ home := b.get("/", nil)
113+ assert.Contains(t, home.body, "pub")
114+ assert.NotContains(t, home.body, "secret")
115+ assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
116+ assert.Empty(t, home.hdr.Values("Vary"))
117+ assert.NotContains(t, home.body, "sign out")
118+ assert.Equal(t, 404, b.get("/secret", nil).status)
119+
120+ // The CLI asks for a link and prints it; stderr says when it ends.
121+ res := c.cli(t, adminToken, "web", "login").ok(t)
122+ assert.Contains(t, res.stderr, "works once")
123+ path := loginPath(t, res.stdout)
124+
125+ // GET shows the confirmation and never consumes the link.
126+ for range 3 {
127+ p := b.get(path, nil)
128+ assert.Equal(t, 200, p.status)
129+ assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"))
130+ assert.Equal(t, "no-referrer", p.hdr.Get("Referrer-Policy"))
131+ assert.Contains(t, p.body, `method="post"`)
132+ assert.Contains(t, p.body, `action="`+path+`"`)
133+ assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session")
134+ assertNoInline(t, p.body)
135+ }
136+ // A POST that is not from this site is refused and does not consume it.
137+ assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin")
138+ assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin")
139+ assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin")
140+ assert.Equal(t, 200, b.get(path, nil).status, "still pending")
141+
142+ // The POST consumes it and sets the cookie.
143+ login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "")
144+ require.Equal(t, http.StatusSeeOther, login.status, login.body)
145+ assert.Equal(t, "/", login.hdr.Get("Location"))
146+ assert.Equal(t, "no-store", login.hdr.Get("Cache-Control"))
147+ ck := login.cookie(sessionCookie)
148+ require.NotNil(t, ck)
149+ assert.True(t, ck.Secure && ck.HttpOnly)
150+ assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
151+ assert.Equal(t, "/", ck.Path)
152+ assert.Empty(t, ck.Domain, "no Domain: host only")
153+ assert.Equal(t, 12*60*60, ck.MaxAge)
154+ token := ck.Value
155+ claims := jwt.MapClaims{}
156+ _, _, err := jwt.NewParser().ParseUnverified(token, claims)
157+ require.NoError(t, err)
158+ assert.Equal(t, "owner", claims["sub"])
159+ assert.Equal(t, float64(0), claims["epoch"])
160+ exp, err := claims.GetExpirationTime()
161+ require.NoError(t, err)
162+ assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute)
163+ assert.NotNil(t, claims["iat"])
164+
165+ // Replayed and unknown links fail with the same page.
166+ for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} {
167+ g := b.get(p, nil)
168+ assert.Equal(t, 404, g.status, p)
169+ assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
170+ assert.Contains(t, g.body, "login link not valid")
171+ r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
172+ assert.Equal(t, 404, r.status, p)
173+ assert.Equal(t, g.body, r.body, "no detail tells why")
174+ assert.Nil(t, r.cookie(sessionCookie))
175+ }
176+
177+ // Signed in: private repositories show on every page, headers say private.
178+ for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} {
179+ got := b.get(p, as(token))
180+ if p == "/nope" {
181+ assert.Equal(t, 404, got.status)
182+ } else {
183+ assert.Equal(t, 200, got.status, p)
184+ }
185+ assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
186+ assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
187+ assertNoInline(t, got.body)
188+ }
189+ home = b.get("/", as(token))
190+ assert.Contains(t, home.body, "secret")
191+ assert.Contains(t, home.body, `<form class="st-owner" method="post" action="/-/logout">`)
192+ assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
193+ raw := b.get("/secret/raw/main/notes.txt", as(token))
194+ assert.Equal(t, 200, raw.status)
195+ assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control"))
196+ assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status)
197+ // Static assets stay public.
198+ assert.Contains(t, b.get("/", as(token)).body, "/-/static/")
199+
200+ // The live stream of a private repository is the owner's.
201+ assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status)
202+ sctx, stop := context.WithCancel(c.ctx)
203+ defer stop()
204+ sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil)
205+ require.NoError(t, err)
206+ sreq.Header.Set("Cookie", sessionCookie+"="+token)
207+ sres, err := http.DefaultClient.Do(sreq)
208+ require.NoError(t, err)
209+ assert.Equal(t, 200, sres.StatusCode)
210+ assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control"))
211+ assert.Contains(t, sres.Header.Values("Vary"), "Cookie")
212+ stop()
213+ _ = sres.Body.Close()
214+
215+ // The browser routes ignore Authorization: the admin token shows nothing.
216+ bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken})
217+ assert.NotContains(t, bearer.body, "secret")
218+ assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control"))
219+ assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status)
220+
221+ // The API ignores the cookie: no bearer is anonymous, so nothing private
222+ // and nothing that needs a caller.
223+ api := c.api("", connect.WithInterceptors(cookieInterceptor(token)))
224+ list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
225+ require.NoError(t, err)
226+ require.Len(t, list.Msg.GetRepos(), 1)
227+ assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName())
228+ _, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"}))
229+ assert.Equal(t, "not_found", codeName(err))
230+ _, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{}))
231+ assert.Equal(t, "unauthenticated", codeName(err))
232+ _, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
233+ assert.Equal(t, "unauthenticated", codeName(err))
234+ _, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
235+ assert.Equal(t, "unauthenticated", codeName(err))
236+
237+ // Logout: a POST from this site with the session; the cookie is deleted.
238+ assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin")
239+ assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status)
240+ assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session")
241+ out := b.do("POST", "/-/logout", post(token), "")
242+ assert.Equal(t, http.StatusSeeOther, out.status)
243+ assert.Equal(t, "/", out.hdr.Get("Location"))
244+ gone := out.cookie(sessionCookie)
245+ require.NotNil(t, gone)
246+ assert.Negative(t, gone.MaxAge)
247+ assert.Empty(t, gone.Value)
248+ assert.Equal(t, 404, b.get("/secret", nil).status)
249+ // A GET never signs out.
250+ g := b.get("/-/logout", as(token))
251+ assert.Equal(t, 404, g.status, "no page")
252+ assert.Nil(t, g.cookie(sessionCookie), "the session is untouched")
253+ assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry")
254+
255+ // logout --all: the cookie is rejected and cleared; anonymous again.
256+ c.cli(t, adminToken, "web", "logout", "--all").ok(t)
257+ after := b.get("/secret", as(token))
258+ assert.Equal(t, 404, after.status)
259+ assert.Equal(t, "no-cache", after.hdr.Get("Cache-Control"))
260+ cleared := after.cookie(sessionCookie)
261+ require.NotNil(t, cleared, "a rejected cookie is cleared")
262+ assert.Negative(t, cleared.MaxAge)
263+ assert.NotContains(t, b.get("/", as(token)).body, "secret")
264+ assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session")
265+
266+ // A new link gives a session of the new epoch.
267+ fresh := c.signIn(t)
268+ assert.Equal(t, 200, b.get("/secret", as(fresh)).status)
269+ assert.Equal(t, 404, b.get("/secret", as(token)).status)
270+}
271+
272+// cookieInterceptor makes a Connect client send the session cookie, as a
273+// browser-made call would, to show the API pays no attention to it.
274+func cookieInterceptor(token string) connect.Interceptor {
275+ return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
276+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
277+ req.Header().Set("Cookie", sessionCookie+"="+token)
278+ return next(ctx, req)
279+ }
280+ })
281+}
282+
283+// The cookie must be an unforgeable HS256 token for the key of the server.
284+func TestWebRejectsForgedSessions(t *testing.T) {
285+ c := newCLIHarness(t)
286+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
287+ b := browser{c.harness}
288+ good := c.signIn(t)
289+ require.Equal(t, 200, b.get("/secret", as(good)).status)
290+
291+ now := time.Now()
292+ claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
293+ none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)
294+ require.NoError(t, err)
295+ wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........"))
296+ require.NoError(t, err)
297+ wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........"))
298+ require.NoError(t, err)
299+ for name, tok := range map[string]string{
300+ "alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2],
301+ } {
302+ p := b.get("/secret", as(tok))
303+ assert.Equal(t, 404, p.status, name)
304+ assert.Equal(t, "no-cache", p.hdr.Get("Cache-Control"), name)
305+ ck := p.cookie(sessionCookie)
306+ require.NotNil(t, ck, name)
307+ assert.Negative(t, ck.MaxAge, name)
308+ assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name)
309+ }
310+}
311+
312+// Failed logins count in the limiter of failed authentications, like a bad
313+// token on the API: the eleventh attempt of one client is refused, and a valid
314+// link of that client is too.
315+func TestWebLoginFailuresAreRateLimited(t *testing.T) {
316+ h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
317+ c := &cliHarness{harness: h, url: h.srv.URL}
318+ b := browser{c.harness}
319+ from := func(ip string) map[string]string {
320+ return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip}
321+ }
322+ bad := "/-/login/" + strings.Repeat("b", 43)
323+ for range 10 {
324+ assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status)
325+ }
326+ blocked := b.do("POST", bad, from("203.0.113.9"), "")
327+ assert.Equal(t, http.StatusTooManyRequests, blocked.status)
328+ assert.NotEmpty(t, blocked.hdr.Get("Retry-After"))
329+
330+ link := c.cli(t, adminToken, "web", "login").ok(t).stdout
331+ assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent")
332+ assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected")
333+ // The same budget as the API's: a bad token from this client is refused.
334+ _, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
335+ require.NoError(t, err, "GetVersion needs no authentication")
336+ _, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
337+ assert.Equal(t, "resource_exhausted", codeName(err))
338+}
339+
340+func forwardedFor(ip string) connect.Interceptor {
341+ return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
342+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
343+ req.Header().Set("X-Forwarded-For", ip)
344+ return next(ctx, req)
345+ }
346+ })
347+}
348+
349+// Only the owner creates links: the admin token, or a token with every scope
350+// and no repository list.
351+func TestSessionServiceIsOwnerOnly(t *testing.T) {
352+ c := newCLIHarness(t)
353+ ctx := context.Background()
354+ c.seed("a", domain.VisibilityPublic)
355+ partial := c.token(domain.AllScopes()[:8])
356+ limited := c.token(domain.AllScopes(), "a")
357+ full := c.token(domain.AllScopes())
358+
359+ for name, tc := range map[string]struct{ secret, code string }{
360+ "anonymous": {"", "unauthenticated"},
361+ "bad token": {"nope", "unauthenticated"},
362+ "missing scope": {partial, "permission_denied"},
363+ "repo list": {limited, "permission_denied"},
364+ "every scope": {full, "ok"},
365+ "admin token": {adminToken, "ok"},
366+ } {
367+ a := c.api(tc.secret)
368+ _, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
369+ assert.Equal(t, tc.code, codeName(err), name)
370+ _, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
371+ if tc.code == "unauthenticated" {
372+ assert.Equal(t, tc.code, codeName(err), name)
373+ } else {
374+ assert.Equal(t, tc.code, codeName(err), name)
375+ }
376+ }
377+
378+ res := c.cli(t, partial, "web", "login")
379+ require.Error(t, res.err)
380+ assert.Contains(t, res.err.Error(), "permission denied")
381+ assert.Empty(t, res.stdout)
382+ res = c.cli(t, adminToken, "web", "logout")
383+ require.Error(t, res.err, "--all is required")
384+ res = c.cli(t, adminToken, "web", "login", "--json").ok(t)
385+ assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout))
386+}
387+
388+// Login links are bounded in number.
389+func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) {
390+ c := newCLIHarness(t)
391+ a := c.api(adminToken)
392+ var code string
393+ for range 20 {
394+ _, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
395+ if code = codeName(err); code != "ok" {
396+ break
397+ }
398+ }
399+ assert.Equal(t, "resource_exhausted", code)
400+}
401+
402+// A link, a session token and the signing key are secrets: no log line holds
403+// them, at the most verbose level.
404+func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) {
405+ c := newCLIHarness(t)
406+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
407+ b := browser{c.harness}
408+
409+ link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout)
410+ nonce := link[strings.LastIndex(link, "/")+1:]
411+ require.Len(t, nonce, 43)
412+ get := b.get(loginPath(t, link), nil)
413+ require.Equal(t, 200, get.status)
414+ p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "")
415+ token := p.cookie(sessionCookie).Value
416+ b.get("/secret", as(token))
417+ b.get("/-/login/"+strings.Repeat("z", 43), nil)
418+ b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "")
419+
420+ logs := c.logs.String()
421+ assert.NotContains(t, logs, nonce)
422+ assert.NotContains(t, logs, token)
423+ assert.NotContains(t, logs, strings.Repeat("z", 43))
424+ assert.Contains(t, logs, "login link created")
425+ assert.Contains(t, logs, "owner signed in on the web")
426+ key, err := c.forge.store.Sessions().Key(context.Background())
427+ require.NoError(t, err)
428+ assert.NotContains(t, logs, string(key))
429+}
430+
431+// The owner principal is what the API sees from a signed-in page, and is
432+// labelled "owner".
433+func TestWebOwnerIsLabelledInLogs(t *testing.T) {
434+ c := newCLIHarness(t)
435+ c.seed("secret", domain.VisibilityPrivate, "notes.txt")
436+ token := c.signIn(t)
437+ browser{c.harness}.get("/secret", as(token))
438+ assert.Contains(t, c.logs.String(), `"principal":"owner"`)
439+}
M internal/adapters/client/client.go +5 -0
@@ -70,6 +70,11 @@ func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
7070 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
7171 }
7272
73+// Sessions returns the SessionService client.
74+func (c *Client) Sessions() ermiteconnect.SessionServiceClient {
75+ return ermiteconnect.NewSessionServiceClient(c.http, c.base, c.opts...)
76+}
77+
7378 // Events returns the EventService client.
7479 func (c *Client) Events() ermiteconnect.EventServiceClient {
7580 return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...)
@@ -70,6 +70,11 @@ func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
70 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)70 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
71 }71 }
72 72
73+// Sessions returns the SessionService client.
74+func (c *Client) Sessions() ermiteconnect.SessionServiceClient {
75+ return ermiteconnect.NewSessionServiceClient(c.http, c.base, c.opts...)
76+}
77+
73 // Events returns the EventService client.78 // Events returns the EventService client.
74 func (c *Client) Events() ermiteconnect.EventServiceClient {79 func (c *Client) Events() ermiteconnect.EventServiceClient {
75 return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...)80 return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +2 -2
@@ -140,9 +140,9 @@ func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey},
143- client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"}))
143+ client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession,
146146 }))
147147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148148 }
@@ -140,9 +140,9 @@ func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey},142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey},
143- client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"}))143+ client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests,145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession,
146 }))146 }))
147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148 }148 }
M internal/adapters/client/features.go +3 -1
@@ -17,11 +17,13 @@ const (
1717 FeatureMergeRequests = "merge_requests"
1818 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
1919 FeatureSigningKey = "signing_key"
20+ // FeatureSession is SessionService (`ermite web login`, `web logout`).
21+ FeatureSession = "session"
2022 )
2123
2224 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2325 func UsedFeatures() []string {
24- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey}
26+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession}
2527 }
2628
2729 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -17,11 +17,13 @@ const (
17 FeatureMergeRequests = "merge_requests"17 FeatureMergeRequests = "merge_requests"
18 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).18 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
19 FeatureSigningKey = "signing_key"19 FeatureSigningKey = "signing_key"
20+ // FeatureSession is SessionService (`ermite web login`, `web logout`).
21+ FeatureSession = "session"
20 )22 )
21 23
22 // UsedFeatures lists the features the CLI uses, in the order they are reported.24 // UsedFeatures lists the features the CLI uses, in the order they are reported.
23 func UsedFeatures() []string {25 func UsedFeatures() []string {
24- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey}26+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession}
25 }27 }
26 28
27 // MissingFeatures returns the features the CLI uses that have is lacking.29 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/httpserver/middleware.go +16 -2
@@ -4,6 +4,7 @@ package httpserver
44
55 import (
66 "net/http"
7+ "strings"
78 "time"
89
910 "github.com/bnema/zerowrap"
@@ -60,8 +61,21 @@ func (s *statusRecorder) Flush() {
6061 // Unwrap lets http.ResponseController reach Flush and friends.
6162 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
6263
64+// loginPathPrefix is where the web serves one-time login links: the rest of
65+// the path is a secret.
66+const loginPathPrefix = "/-/login/"
67+
68+// loggedPath is the path of a request as the log may show it.
69+func loggedPath(p string) string {
70+ if strings.HasPrefix(p, loginPathPrefix) {
71+ return loginPathPrefix + "…"
72+ }
73+ return p
74+}
75+
6376 // withRequestLog logs method, path, status and duration at debug level. It
64-// never logs the query string or any header: they can carry credentials.
77+// never logs the query string or any header: they can carry credentials. The
78+// secret of a login link is cut from the path.
6579 func withRequestLog(next http.Handler) http.Handler {
6680 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6781 start := time.Now()
@@ -74,7 +88,7 @@ func withRequestLog(next http.Handler) http.Handler {
7488 log := zerowrap.FromCtx(r.Context())
7589 log.Debug().
7690 Str("method", r.Method).
77- Str("path", r.URL.Path).
91+ Str("path", loggedPath(r.URL.Path)).
7892 Int("status", status).
7993 Dur("duration", time.Since(start)).
8094 Msg("request")
@@ -4,6 +4,7 @@ package httpserver
4 4
5 import (5 import (
6 "net/http"6 "net/http"
7+ "strings"
7 "time"8 "time"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
@@ -60,8 +61,21 @@ func (s *statusRecorder) Flush() {
60 // Unwrap lets http.ResponseController reach Flush and friends.61 // Unwrap lets http.ResponseController reach Flush and friends.
61 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }62 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
62 63
64+// loginPathPrefix is where the web serves one-time login links: the rest of
65+// the path is a secret.
66+const loginPathPrefix = "/-/login/"
67+
68+// loggedPath is the path of a request as the log may show it.
69+func loggedPath(p string) string {
70+ if strings.HasPrefix(p, loginPathPrefix) {
71+ return loginPathPrefix + "…"
72+ }
73+ return p
74+}
75+
63 // withRequestLog logs method, path, status and duration at debug level. It76 // withRequestLog logs method, path, status and duration at debug level. It
64-// never logs the query string or any header: they can carry credentials.77+// never logs the query string or any header: they can carry credentials. The
78+// secret of a login link is cut from the path.
65 func withRequestLog(next http.Handler) http.Handler {79 func withRequestLog(next http.Handler) http.Handler {
66 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {80 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67 start := time.Now()81 start := time.Now()
@@ -74,7 +88,7 @@ func withRequestLog(next http.Handler) http.Handler {
74 log := zerowrap.FromCtx(r.Context())88 log := zerowrap.FromCtx(r.Context())
75 log.Debug().89 log.Debug().
76 Str("method", r.Method).90 Str("method", r.Method).
77- Str("path", r.URL.Path).91+ Str("path", loggedPath(r.URL.Path)).
78 Int("status", status).92 Int("status", status).
79 Dur("duration", time.Since(start)).93 Dur("duration", time.Since(start)).
80 Msg("request")94 Msg("request")
M internal/adapters/rpc/api.go +6 -1
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
2727
2828 // Features are the optional capabilities of the contract that this server
2929 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}
30+var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
3131
3232 // Authenticator turns a token secret into a principal, or
3333 // domain.ErrUnauthenticated. app.Auth fits.
@@ -52,6 +52,8 @@ type Config struct {
5252 // it with the other adapters that authenticate, so that a client has one
5353 // budget of failures.
5454 Limiter *clientlimit.Limiter
55+ // Sessions are the owner's web sessions; nil serves no SessionService.
56+ Sessions *app.Sessions
5557 }
5658
5759 // API is the set of Connect handlers of the contract.
@@ -80,6 +82,9 @@ func New(cfg Config) *API {
8082 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
8183 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
8284 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
85+ if cfg.Sessions != nil {
86+ mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
87+ }
8388 return &API{handler: mux}
8489 }
8590
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
27 27
28 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
29 // implements, as GetVersion lists them.29 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}30+var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
31 31
32 // Authenticator turns a token secret into a principal, or32 // Authenticator turns a token secret into a principal, or
33 // domain.ErrUnauthenticated. app.Auth fits.33 // domain.ErrUnauthenticated. app.Auth fits.
@@ -52,6 +52,8 @@ type Config struct {
52 // it with the other adapters that authenticate, so that a client has one52 // it with the other adapters that authenticate, so that a client has one
53 // budget of failures.53 // budget of failures.
54 Limiter *clientlimit.Limiter54 Limiter *clientlimit.Limiter
55+ // Sessions are the owner's web sessions; nil serves no SessionService.
56+ Sessions *app.Sessions
55 }57 }
56 58
57 // API is the set of Connect handlers of the contract.59 // API is the set of Connect handlers of the contract.
@@ -80,6 +82,9 @@ func New(cfg Config) *API {
80 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))82 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
81 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))83 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
82 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))84 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
85+ if cfg.Sessions != nil {
86+ mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
87+ }
83 return &API{handler: mux}88 return &API{handler: mux}
84 }89 }
85 90
A internal/adapters/rpc/session.go +31 -0
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+)
13+
14+// sessionService implements ermiteconnect.SessionServiceHandler. The browser
15+// never calls it: sessions come from the link, and the API ignores cookies.
16+type sessionService struct{ sessions *app.Sessions }
17+
18+func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
19+ link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))
20+ if err != nil {
21+ return nil, connectError(ctx, err)
22+ }
23+ return connect.NewResponse(&pb.CreateLoginLinkResponse{Url: link.URL, ExpiresAt: timestamp(link.ExpiresAt)}), nil
24+}
25+
26+func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Request[pb.SignOutEverywhereRequest]) (*connect.Response[pb.SignOutEverywhereResponse], error) {
27+ if err := s.sessions.SignOutEverywhere(ctx, caller(ctx)); err != nil {
28+ return nil, connectError(ctx, err)
29+ }
30+ return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
31+}
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+)
13+
14+// sessionService implements ermiteconnect.SessionServiceHandler. The browser
15+// never calls it: sessions come from the link, and the API ignores cookies.
16+type sessionService struct{ sessions *app.Sessions }
17+
18+func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
19+ link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))
20+ if err != nil {
21+ return nil, connectError(ctx, err)
22+ }
23+ return connect.NewResponse(&pb.CreateLoginLinkResponse{Url: link.URL, ExpiresAt: timestamp(link.ExpiresAt)}), nil
24+}
25+
26+func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Request[pb.SignOutEverywhereRequest]) (*connect.Response[pb.SignOutEverywhereResponse], error) {
27+ if err := s.sessions.SignOutEverywhere(ctx, caller(ctx)); err != nil {
28+ return nil, connectError(ctx, err)
29+ }
30+ return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
31+}
M internal/adapters/web/events.go +6 -1
@@ -163,7 +163,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
163163 rc := http.NewResponseController(w)
164164 hdr := w.Header()
165165 hdr.Set("Content-Type", "text/event-stream")
166- hdr.Set("Cache-Control", "no-cache, no-transform")
166+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
167167 hdr.Set("X-Accel-Buffering", "no")
168168 _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
169169 w.WriteHeader(http.StatusOK)
@@ -211,6 +211,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
211211 return
212212 }
213213 case <-tick.C:
214+ // A stream opened by the owner ends when the session does
215+ // (sign out everywhere, expiry): the page reconnects as it can.
216+ if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
217+ return
218+ }
214219 if out.raw(": heartbeat\n\n") != nil {
215220 return
216221 }
@@ -163,7 +163,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
163 rc := http.NewResponseController(w)163 rc := http.NewResponseController(w)
164 hdr := w.Header()164 hdr := w.Header()
165 hdr.Set("Content-Type", "text/event-stream")165 hdr.Set("Content-Type", "text/event-stream")
166- hdr.Set("Cache-Control", "no-cache, no-transform")166+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
167 hdr.Set("X-Accel-Buffering", "no")167 hdr.Set("X-Accel-Buffering", "no")
168 _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))168 _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
169 w.WriteHeader(http.StatusOK)169 w.WriteHeader(http.StatusOK)
@@ -211,6 +211,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
211 return211 return
212 }212 }
213 case <-tick.C:213 case <-tick.C:
214+ // A stream opened by the owner ends when the session does
215+ // (sign out everywhere, expiry): the page reconnects as it can.
216+ if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
217+ return
218+ }
214 if out.raw(": heartbeat\n\n") != nil {219 if out.raw(": heartbeat\n\n") != nil {
215 return220 return
216 }221 }
M internal/adapters/web/pages.templ +17 -0
@@ -156,3 +156,20 @@ templ State(l Layout, p StatePage) {
156156 </section>
157157 }
158158 }
159+
160+// Login asks the owner to confirm a login link. Opening the link consumes
161+// nothing; only this form does. It is a state page with a button.
162+templ Login(l Layout, p LoginPage) {
163+ @Shell(l, nil) {
164+ <section class="state" aria-labelledby="state-title">
165+ <h1 id="state-title"><span class="code">login</span> { p.Title }</h1>
166+ for _, line := range p.Lines {
167+ <p class="dim">{ line }</p>
168+ }
169+ <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }>
170+ <button type="submit">{ p.Button }</button>
171+ </form>
172+ <p class="state-back"><a href="/">home</a></p>
173+ </section>
174+ }
175+}
@@ -156,3 +156,20 @@ templ State(l Layout, p StatePage) {
156 </section>156 </section>
157 }157 }
158 }158 }
159+
160+// Login asks the owner to confirm a login link. Opening the link consumes
161+// nothing; only this form does. It is a state page with a button.
162+templ Login(l Layout, p LoginPage) {
163+ @Shell(l, nil) {
164+ <section class="state" aria-labelledby="state-title">
165+ <h1 id="state-title"><span class="code">login</span> { p.Title }</h1>
166+ for _, line := range p.Lines {
167+ <p class="dim">{ line }</p>
168+ }
169+ <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }>
170+ <button type="submit">{ p.Button }</button>
171+ </form>
172+ <p class="state-back"><a href="/">home</a></p>
173+ </section>
174+ }
175+}
M internal/adapters/web/pages_templ.go +111 -0 generated file: collapsed
M internal/adapters/web/raw.go +1 -1
@@ -59,7 +59,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
5959 // Revalidated every time, even at a full commit id: a repository can turn
6060 // private, and a shared cache must not keep serving its files (AC2). The
6161 // ETag keeps the revalidation cheap.
62- h.Set("Cache-Control", "no-cache")
62+ cacheHeaders(ctx, h, "no-cache", sessionCache)
6363 if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {
6464 w.WriteHeader(http.StatusNotModified)
6565 return
@@ -59,7 +59,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
59 // Revalidated every time, even at a full commit id: a repository can turn59 // Revalidated every time, even at a full commit id: a repository can turn
60 // private, and a shared cache must not keep serving its files (AC2). The60 // private, and a shared cache must not keep serving its files (AC2). The
61 // ETag keeps the revalidation cheap.61 // ETag keeps the revalidation cheap.
62- h.Set("Cache-Control", "no-cache")62+ cacheHeaders(ctx, h, "no-cache", sessionCache)
63 if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {63 if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {
64 w.WriteHeader(http.StatusNotModified)64 w.WriteHeader(http.StatusNotModified)
65 return65 return
A internal/adapters/web/session.go +279 -0
@@ -0,0 +1,279 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+)
18+
19+// The owner's session on the web (ADR 0018).
20+//
21+// A browser signs in by opening the one-time link `ermite web login` printed:
22+// GET /-/login/<nonce> shows a confirmation and consumes nothing, so link
23+// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
24+// link and sets the cookie. The cookie holds a signed token; every request
25+// that carries a valid one runs as the owner, every other as anonymous.
26+//
27+// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
28+// the origin of the public URL, and a valid session, except the login POST,
29+// which needs the link instead. The routes read the cookie and never the
30+// Authorization header; the API does the opposite.
31+
32+// CookieName is the session cookie. The __Host- prefix makes browsers accept
33+// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
34+// subdomain.
35+const CookieName = "__Host-ermite"
36+
37+// loginPrefix is the path of login links, up to the nonce.
38+const loginPrefix = "/-/login/"
39+
40+// ErrLinkRejected is what SessionAuth.Login returns for a link that is
41+// unknown, expired or already used.
42+var ErrLinkRejected = errors.New("login link rejected")
43+
44+// SessionAuth is the session side of the use cases, as the web needs it.
45+type SessionAuth interface {
46+ // LoginLinkValid reports whether the link is pending, without using it.
47+ LoginLinkValid(nonce string) bool
48+ // Login uses the link and returns the session token and how long it
49+ // lasts, or ErrLinkRejected.
50+ Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
51+ // Verify checks a session token. When it is valid it returns the context
52+ // to serve the request with, which carries the owner as the caller of the
53+ // API; when it is not, ok is false and err nil. An error means the check
54+ // could not be made (the request then runs as anonymous).
55+ Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error)
56+}
57+
58+// FailureLimiter counts failed logins per client, with the failed
59+// authentications of the other routes. *clientlimit.Limiter fits.
60+type FailureLimiter interface {
61+ Blocked(ctx context.Context, addr string) (bool, time.Duration)
62+ Fail(ctx context.Context, addr string)
63+}
64+
65+type signedInKey struct{}
66+
67+// SignedIn reports whether the request carries a valid owner session. The
68+// page is then private: it was rendered for the owner and must not be cached
69+// by anyone else. Handlers of POST routes can rely on it: the gate refused
70+// the request otherwise.
71+func SignedIn(ctx context.Context) bool {
72+ _, ok := ctx.Value(signedInKey{}).(struct{})
73+ return ok
74+}
75+
76+// sessionCache is the Cache-Control of a page rendered for the owner.
77+const sessionCache = "private, no-store"
78+
79+// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
80+// the private variant with Vary: Cookie.
81+func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
82+ if SignedIn(ctx) {
83+ h.Set("Cache-Control", private)
84+ h.Add("Vary", "Cookie")
85+ return
86+ }
87+ h.Set("Cache-Control", public)
88+}
89+
90+// logPath is the path of a request as the log may show it: a login link is
91+// a secret.
92+func logPath(p string) string {
93+ if strings.HasPrefix(p, loginPrefix) {
94+ return loginPrefix + "…"
95+ }
96+ return p
97+}
98+
99+// withSession resolves the session of the request: the cookie, when valid,
100+// makes the request the owner's. A cookie that is not valid is cleared. A
101+// request without a cookie is untouched.
102+func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
103+ if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) {
104+ return r
105+ }
106+ c, err := r.Cookie(CookieName)
107+ if err != nil {
108+ return r
109+ }
110+ ctx := r.Context()
111+ owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
112+ switch {
113+ case err != nil:
114+ // Fail closed, and keep the cookie: the failure may be passing.
115+ log := zerowrap.FromCtx(ctx)
116+ log.Error().Err(err).Msg("check web session")
117+ return r
118+ case !ok:
119+ http.SetCookie(w, s.cookie("", -1))
120+ return r
121+ }
122+ return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{}))
123+}
124+
125+// sessionStillValid checks again the cookie a long-lived request started
126+// with. It fails closed: a check that cannot be made ends the session of the
127+// stream.
128+func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
129+ c, err := r.Cookie(CookieName)
130+ if err != nil {
131+ return false
132+ }
133+ _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
134+ return err == nil && ok
135+}
136+
137+// cookie builds the session cookie. maxAge < 0 deletes it.
138+func (s *Server) cookie(token string, maxAge int) *http.Cookie {
139+ // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
140+ // site links to a page (that first page shows as signed out, the next
141+ // navigation on the site is signed in), and not on any cross-site request,
142+ // on top of the Origin check of the POST gate.
143+ return &http.Cookie{
144+ Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
145+ Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
146+ }
147+}
148+
149+// gate refuses a state change that does not come from a page of this site
150+// with a session. It answers 403 and returns false. GET and HEAD pass: they
151+// never change anything.
152+func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
153+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
154+ return true
155+ }
156+ // The Origin is compared with the configured public URL, never with the
157+ // Host header, which the client chooses. A missing header, "null" and any
158+ // other origin are refused.
159+ if r.Header.Get("Origin") != s.origin {
160+ denyForbidden(w)
161+ return false
162+ }
163+ if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) {
164+ denyForbidden(w)
165+ return false
166+ }
167+ return true
168+}
169+
170+func denyForbidden(w http.ResponseWriter) {
171+ w.Header().Set("Cache-Control", "no-store")
172+ http.Error(w, "forbidden", http.StatusForbidden)
173+}
174+
175+// validNonce tells whether s has the shape of a login link secret, so that
176+// anything else is refused before it reaches the store.
177+func validNonce(s string) bool {
178+ if len(s) != nonceLen {
179+ return false
180+ }
181+ for _, c := range []byte(s) {
182+ switch {
183+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
184+ default:
185+ return false
186+ }
187+ }
188+ return true
189+}
190+
191+// nonceLen is the length of the secret of a login link: 32 bytes in unpadded
192+// base64url.
193+const nonceLen = 43
194+
195+// secretPage writes a page of the login flow: never cached, and its address,
196+// which holds the link, is not sent along when the visitor follows a link.
197+func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
198+ h := w.Header()
199+ h.Set("Cache-Control", "no-store")
200+ h.Set("Referrer-Policy", "no-referrer")
201+ s.write(r.Context(), w, r, status, c)
202+}
203+
204+// loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not
205+// use the link.
206+func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) {
207+ nonce := r.PathValue("nonce")
208+ if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) {
209+ s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
210+ return
211+ }
212+ l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}})
213+ s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
214+ Title: "sign in as the owner",
215+ Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
216+ Action: loginPrefix + nonce,
217+ Button: "[sign in]",
218+ }))
219+}
220+
221+// loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and
222+// goes home. A link that does not work answers the same page whatever the
223+// reason, and counts as a failed authentication of the client.
224+func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
225+ ctx := r.Context()
226+ addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
227+ if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked {
228+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
229+ s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts",
230+ "too many failed sign-in attempts from this address.", "try again later."))
231+ return
232+ }
233+ nonce := r.PathValue("nonce")
234+ var token string
235+ var lifetime time.Duration
236+ err := ErrLinkRejected
237+ if validNonce(nonce) {
238+ token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
239+ }
240+ switch {
241+ case errors.Is(err, ErrLinkRejected):
242+ s.cfg.Limiter.Fail(ctx, addr)
243+ s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
244+ return
245+ case err != nil:
246+ id := requestID()
247+ log := zerowrap.FromCtx(ctx)
248+ log.Error().Err(err).Str("request_id", id).Msg("web login failed")
249+ s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id))
250+ return
251+ }
252+ h := w.Header()
253+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
254+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
255+ h.Set("Cache-Control", "no-store")
256+ http.Redirect(w, r, "/", http.StatusSeeOther)
257+}
258+
259+// logout is POST /-/logout: it ends the session of this browser. The token
260+// itself stays valid until it expires, as a stateless token must; `ermite web
261+// logout --all` ends them all.
262+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
263+ w.Header().Del("Set-Cookie")
264+ http.SetCookie(w, s.cookie("", -1))
265+ w.Header().Set("Cache-Control", "no-store")
266+ http.Redirect(w, r, "/", http.StatusSeeOther)
267+}
268+
269+// stateOnly is a state page with a title and two lines, for the login flow.
270+func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {
271+ l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}})
272+ return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
273+}
274+
275+// loginRejectedPage is the answer for a link that is unknown, expired or used.
276+func (s *Server) loginRejectedPage() templ.Component {
277+ return s.stateOnly("sign in", "404", "login link not valid",
278+ "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
279+}
@@ -0,0 +1,279 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+)
18+
19+// The owner's session on the web (ADR 0018).
20+//
21+// A browser signs in by opening the one-time link `ermite web login` printed:
22+// GET /-/login/<nonce> shows a confirmation and consumes nothing, so link
23+// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
24+// link and sets the cookie. The cookie holds a signed token; every request
25+// that carries a valid one runs as the owner, every other as anonymous.
26+//
27+// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
28+// the origin of the public URL, and a valid session, except the login POST,
29+// which needs the link instead. The routes read the cookie and never the
30+// Authorization header; the API does the opposite.
31+
32+// CookieName is the session cookie. The __Host- prefix makes browsers accept
33+// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
34+// subdomain.
35+const CookieName = "__Host-ermite"
36+
37+// loginPrefix is the path of login links, up to the nonce.
38+const loginPrefix = "/-/login/"
39+
40+// ErrLinkRejected is what SessionAuth.Login returns for a link that is
41+// unknown, expired or already used.
42+var ErrLinkRejected = errors.New("login link rejected")
43+
44+// SessionAuth is the session side of the use cases, as the web needs it.
45+type SessionAuth interface {
46+ // LoginLinkValid reports whether the link is pending, without using it.
47+ LoginLinkValid(nonce string) bool
48+ // Login uses the link and returns the session token and how long it
49+ // lasts, or ErrLinkRejected.
50+ Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
51+ // Verify checks a session token. When it is valid it returns the context
52+ // to serve the request with, which carries the owner as the caller of the
53+ // API; when it is not, ok is false and err nil. An error means the check
54+ // could not be made (the request then runs as anonymous).
55+ Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error)
56+}
57+
58+// FailureLimiter counts failed logins per client, with the failed
59+// authentications of the other routes. *clientlimit.Limiter fits.
60+type FailureLimiter interface {
61+ Blocked(ctx context.Context, addr string) (bool, time.Duration)
62+ Fail(ctx context.Context, addr string)
63+}
64+
65+type signedInKey struct{}
66+
67+// SignedIn reports whether the request carries a valid owner session. The
68+// page is then private: it was rendered for the owner and must not be cached
69+// by anyone else. Handlers of POST routes can rely on it: the gate refused
70+// the request otherwise.
71+func SignedIn(ctx context.Context) bool {
72+ _, ok := ctx.Value(signedInKey{}).(struct{})
73+ return ok
74+}
75+
76+// sessionCache is the Cache-Control of a page rendered for the owner.
77+const sessionCache = "private, no-store"
78+
79+// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
80+// the private variant with Vary: Cookie.
81+func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
82+ if SignedIn(ctx) {
83+ h.Set("Cache-Control", private)
84+ h.Add("Vary", "Cookie")
85+ return
86+ }
87+ h.Set("Cache-Control", public)
88+}
89+
90+// logPath is the path of a request as the log may show it: a login link is
91+// a secret.
92+func logPath(p string) string {
93+ if strings.HasPrefix(p, loginPrefix) {
94+ return loginPrefix + "…"
95+ }
96+ return p
97+}
98+
99+// withSession resolves the session of the request: the cookie, when valid,
100+// makes the request the owner's. A cookie that is not valid is cleared. A
101+// request without a cookie is untouched.
102+func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
103+ if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) {
104+ return r
105+ }
106+ c, err := r.Cookie(CookieName)
107+ if err != nil {
108+ return r
109+ }
110+ ctx := r.Context()
111+ owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
112+ switch {
113+ case err != nil:
114+ // Fail closed, and keep the cookie: the failure may be passing.
115+ log := zerowrap.FromCtx(ctx)
116+ log.Error().Err(err).Msg("check web session")
117+ return r
118+ case !ok:
119+ http.SetCookie(w, s.cookie("", -1))
120+ return r
121+ }
122+ return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{}))
123+}
124+
125+// sessionStillValid checks again the cookie a long-lived request started
126+// with. It fails closed: a check that cannot be made ends the session of the
127+// stream.
128+func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
129+ c, err := r.Cookie(CookieName)
130+ if err != nil {
131+ return false
132+ }
133+ _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
134+ return err == nil && ok
135+}
136+
137+// cookie builds the session cookie. maxAge < 0 deletes it.
138+func (s *Server) cookie(token string, maxAge int) *http.Cookie {
139+ // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
140+ // site links to a page (that first page shows as signed out, the next
141+ // navigation on the site is signed in), and not on any cross-site request,
142+ // on top of the Origin check of the POST gate.
143+ return &http.Cookie{
144+ Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
145+ Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
146+ }
147+}
148+
149+// gate refuses a state change that does not come from a page of this site
150+// with a session. It answers 403 and returns false. GET and HEAD pass: they
151+// never change anything.
152+func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
153+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
154+ return true
155+ }
156+ // The Origin is compared with the configured public URL, never with the
157+ // Host header, which the client chooses. A missing header, "null" and any
158+ // other origin are refused.
159+ if r.Header.Get("Origin") != s.origin {
160+ denyForbidden(w)
161+ return false
162+ }
163+ if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) {
164+ denyForbidden(w)
165+ return false
166+ }
167+ return true
168+}
169+
170+func denyForbidden(w http.ResponseWriter) {
171+ w.Header().Set("Cache-Control", "no-store")
172+ http.Error(w, "forbidden", http.StatusForbidden)
173+}
174+
175+// validNonce tells whether s has the shape of a login link secret, so that
176+// anything else is refused before it reaches the store.
177+func validNonce(s string) bool {
178+ if len(s) != nonceLen {
179+ return false
180+ }
181+ for _, c := range []byte(s) {
182+ switch {
183+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
184+ default:
185+ return false
186+ }
187+ }
188+ return true
189+}
190+
191+// nonceLen is the length of the secret of a login link: 32 bytes in unpadded
192+// base64url.
193+const nonceLen = 43
194+
195+// secretPage writes a page of the login flow: never cached, and its address,
196+// which holds the link, is not sent along when the visitor follows a link.
197+func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
198+ h := w.Header()
199+ h.Set("Cache-Control", "no-store")
200+ h.Set("Referrer-Policy", "no-referrer")
201+ s.write(r.Context(), w, r, status, c)
202+}
203+
204+// loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not
205+// use the link.
206+func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) {
207+ nonce := r.PathValue("nonce")
208+ if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) {
209+ s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
210+ return
211+ }
212+ l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}})
213+ s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
214+ Title: "sign in as the owner",
215+ Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
216+ Action: loginPrefix + nonce,
217+ Button: "[sign in]",
218+ }))
219+}
220+
221+// loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and
222+// goes home. A link that does not work answers the same page whatever the
223+// reason, and counts as a failed authentication of the client.
224+func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
225+ ctx := r.Context()
226+ addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
227+ if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked {
228+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
229+ s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts",
230+ "too many failed sign-in attempts from this address.", "try again later."))
231+ return
232+ }
233+ nonce := r.PathValue("nonce")
234+ var token string
235+ var lifetime time.Duration
236+ err := ErrLinkRejected
237+ if validNonce(nonce) {
238+ token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
239+ }
240+ switch {
241+ case errors.Is(err, ErrLinkRejected):
242+ s.cfg.Limiter.Fail(ctx, addr)
243+ s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
244+ return
245+ case err != nil:
246+ id := requestID()
247+ log := zerowrap.FromCtx(ctx)
248+ log.Error().Err(err).Str("request_id", id).Msg("web login failed")
249+ s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id))
250+ return
251+ }
252+ h := w.Header()
253+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
254+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
255+ h.Set("Cache-Control", "no-store")
256+ http.Redirect(w, r, "/", http.StatusSeeOther)
257+}
258+
259+// logout is POST /-/logout: it ends the session of this browser. The token
260+// itself stays valid until it expires, as a stateless token must; `ermite web
261+// logout --all` ends them all.
262+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
263+ w.Header().Del("Set-Cookie")
264+ http.SetCookie(w, s.cookie("", -1))
265+ w.Header().Set("Cache-Control", "no-store")
266+ http.Redirect(w, r, "/", http.StatusSeeOther)
267+}
268+
269+// stateOnly is a state page with a title and two lines, for the login flow.
270+func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {
271+ l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}})
272+ return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
273+}
274+
275+// loginRejectedPage is the answer for a link that is unknown, expired or used.
276+func (s *Server) loginRejectedPage() templ.Component {
277+ return s.stateOnly("sign in", "404", "login link not valid",
278+ "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
279+}
A internal/adapters/web/session_test.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "net/url"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+)
20+
21+// fakeSessions accepts one token.
22+type fakeSessions struct {
23+ valid string
24+ err error
25+ used []string
26+}
27+
28+func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) }
29+
30+func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) {
31+ f.used = append(f.used, n)
32+ if n != strings.Repeat("a", 43) {
33+ return "", 0, ErrLinkRejected
34+ }
35+ return f.valid, time.Hour, nil
36+}
37+
38+func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) {
39+ return ctx, f.err == nil && tok == f.valid, f.err
40+}
41+
42+type countingLimiter struct{ fails int }
43+
44+func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
45+func (l *countingLimiter) Fail(context.Context, string) { l.fails++ }
46+
47+func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) {
48+ t.Helper()
49+ lim := &countingLimiter{}
50+ srv, err := New(Config{
51+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
52+ PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1",
53+ Sessions: sess, Limiter: lim,
54+ })
55+ require.NoError(t, err)
56+ return srv, lim
57+}
58+
59+func TestOriginOf(t *testing.T) {
60+ for in, want := range map[string]string{
61+ "https://forge.example": "https://forge.example",
62+ "https://Forge.Example/": "https://forge.example",
63+ "https://forge.example:443": "https://forge.example",
64+ "http://forge.example:80": "http://forge.example",
65+ "http://forge.example:8080": "http://forge.example:8080",
66+ "https://forge.example:8443": "https://forge.example:8443",
67+ "http://127.0.0.1:3000": "http://127.0.0.1:3000",
68+ "http://[::1]:3000": "http://[::1]:3000",
69+ "https://forge.example/sub/dir": "https://forge.example",
70+ } {
71+ u, err := url.Parse(in)
72+ require.NoError(t, err)
73+ assert.Equal(t, want, originOf(u), in)
74+ }
75+}
76+
77+func TestValidNonce(t *testing.T) {
78+ assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
79+ for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
80+ assert.False(t, validNonce(n), n)
81+ }
82+}
83+
84+// The gate: a POST needs the Origin of the public URL, whatever Host says, and
85+// a session unless it is the login.
86+func TestGate(t *testing.T) {
87+ srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
88+ do := func(path, origin, cookie string) int {
89+ req := httptest.NewRequest(http.MethodPost, path, nil)
90+ req.Host = "forge.example"
91+ if origin != "" {
92+ req.Header.Set("Origin", origin)
93+ }
94+ if cookie != "" {
95+ req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
96+ }
97+ rec := httptest.NewRecorder()
98+ srv.ServeHTTP(rec, req)
99+ return rec.Code
100+ }
101+ link := "/-/login/" + strings.Repeat("a", 43)
102+
103+ assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin")
104+ assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok"))
105+ assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok"))
106+ assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme")
107+ assert.Equal(t, 403, do("/-/logout", "null", "tok"))
108+ assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session")
109+ assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session")
110+ assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok"))
111+ assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated")
112+
113+ assert.Equal(t, 403, do(link, "", ""), "login without Origin")
114+ assert.Equal(t, 403, do(link, "https://evil.example", ""))
115+ assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
116+ assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
117+ assert.Equal(t, 1, lim.fails, "one failed login counted")
118+ assert.Equal(t, 404, do("/-/login/x", "https://forge.example", ""))
119+ assert.Equal(t, 2, lim.fails, "a malformed link counts too")
120+}
121+
122+// A session check that cannot be made fails closed (anonymous) and keeps the
123+// cookie; a check that says no clears it.
124+func TestWithSessionFailsClosed(t *testing.T) {
125+ broken := &fakeSessions{valid: "tok", err: errors.New("kv down")}
126+ srv, _ := newSessionServer(t, broken, "https://forge.example")
127+ req := httptest.NewRequest(http.MethodGet, "/", nil)
128+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
129+ rec := httptest.NewRecorder()
130+ r := srv.withSession(rec, req)
131+ assert.False(t, SignedIn(r.Context()))
132+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
133+
134+ srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
135+ req = httptest.NewRequest(http.MethodGet, "/", nil)
136+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
137+ rec = httptest.NewRecorder()
138+ r = srv.withSession(rec, req)
139+ assert.False(t, SignedIn(r.Context()))
140+ assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
141+
142+ req = httptest.NewRequest(http.MethodGet, "/", nil)
143+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
144+ assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context()))
145+}
146+
147+// Without sessions configured there is no login route and no cookie is read.
148+func TestNoSessionsNoRoutes(t *testing.T) {
149+ srv, err := New(Config{
150+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
151+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
152+ })
153+ require.NoError(t, err)
154+ rec := httptest.NewRecorder()
155+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
156+ assert.Equal(t, 404, rec.Code)
157+
158+ _, err = New(Config{
159+ Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
160+ PublicURL: "https://forge.example", Sessions: &fakeSessions{},
161+ })
162+ assert.Error(t, err, "sessions need a limiter")
163+}
164+
165+func TestLogPathHidesLinks(t *testing.T) {
166+ assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
167+ assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
168+}
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "net/url"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+)
20+
21+// fakeSessions accepts one token.
22+type fakeSessions struct {
23+ valid string
24+ err error
25+ used []string
26+}
27+
28+func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) }
29+
30+func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) {
31+ f.used = append(f.used, n)
32+ if n != strings.Repeat("a", 43) {
33+ return "", 0, ErrLinkRejected
34+ }
35+ return f.valid, time.Hour, nil
36+}
37+
38+func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) {
39+ return ctx, f.err == nil && tok == f.valid, f.err
40+}
41+
42+type countingLimiter struct{ fails int }
43+
44+func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
45+func (l *countingLimiter) Fail(context.Context, string) { l.fails++ }
46+
47+func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) {
48+ t.Helper()
49+ lim := &countingLimiter{}
50+ srv, err := New(Config{
51+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
52+ PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1",
53+ Sessions: sess, Limiter: lim,
54+ })
55+ require.NoError(t, err)
56+ return srv, lim
57+}
58+
59+func TestOriginOf(t *testing.T) {
60+ for in, want := range map[string]string{
61+ "https://forge.example": "https://forge.example",
62+ "https://Forge.Example/": "https://forge.example",
63+ "https://forge.example:443": "https://forge.example",
64+ "http://forge.example:80": "http://forge.example",
65+ "http://forge.example:8080": "http://forge.example:8080",
66+ "https://forge.example:8443": "https://forge.example:8443",
67+ "http://127.0.0.1:3000": "http://127.0.0.1:3000",
68+ "http://[::1]:3000": "http://[::1]:3000",
69+ "https://forge.example/sub/dir": "https://forge.example",
70+ } {
71+ u, err := url.Parse(in)
72+ require.NoError(t, err)
73+ assert.Equal(t, want, originOf(u), in)
74+ }
75+}
76+
77+func TestValidNonce(t *testing.T) {
78+ assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
79+ for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
80+ assert.False(t, validNonce(n), n)
81+ }
82+}
83+
84+// The gate: a POST needs the Origin of the public URL, whatever Host says, and
85+// a session unless it is the login.
86+func TestGate(t *testing.T) {
87+ srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
88+ do := func(path, origin, cookie string) int {
89+ req := httptest.NewRequest(http.MethodPost, path, nil)
90+ req.Host = "forge.example"
91+ if origin != "" {
92+ req.Header.Set("Origin", origin)
93+ }
94+ if cookie != "" {
95+ req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
96+ }
97+ rec := httptest.NewRecorder()
98+ srv.ServeHTTP(rec, req)
99+ return rec.Code
100+ }
101+ link := "/-/login/" + strings.Repeat("a", 43)
102+
103+ assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin")
104+ assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok"))
105+ assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok"))
106+ assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme")
107+ assert.Equal(t, 403, do("/-/logout", "null", "tok"))
108+ assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session")
109+ assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session")
110+ assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok"))
111+ assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated")
112+
113+ assert.Equal(t, 403, do(link, "", ""), "login without Origin")
114+ assert.Equal(t, 403, do(link, "https://evil.example", ""))
115+ assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
116+ assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
117+ assert.Equal(t, 1, lim.fails, "one failed login counted")
118+ assert.Equal(t, 404, do("/-/login/x", "https://forge.example", ""))
119+ assert.Equal(t, 2, lim.fails, "a malformed link counts too")
120+}
121+
122+// A session check that cannot be made fails closed (anonymous) and keeps the
123+// cookie; a check that says no clears it.
124+func TestWithSessionFailsClosed(t *testing.T) {
125+ broken := &fakeSessions{valid: "tok", err: errors.New("kv down")}
126+ srv, _ := newSessionServer(t, broken, "https://forge.example")
127+ req := httptest.NewRequest(http.MethodGet, "/", nil)
128+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
129+ rec := httptest.NewRecorder()
130+ r := srv.withSession(rec, req)
131+ assert.False(t, SignedIn(r.Context()))
132+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
133+
134+ srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
135+ req = httptest.NewRequest(http.MethodGet, "/", nil)
136+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
137+ rec = httptest.NewRecorder()
138+ r = srv.withSession(rec, req)
139+ assert.False(t, SignedIn(r.Context()))
140+ assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
141+
142+ req = httptest.NewRequest(http.MethodGet, "/", nil)
143+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
144+ assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context()))
145+}
146+
147+// Without sessions configured there is no login route and no cookie is read.
148+func TestNoSessionsNoRoutes(t *testing.T) {
149+ srv, err := New(Config{
150+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
151+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
152+ })
153+ require.NoError(t, err)
154+ rec := httptest.NewRecorder()
155+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
156+ assert.Equal(t, 404, rec.Code)
157+
158+ _, err = New(Config{
159+ Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
160+ PublicURL: "https://forge.example", Sessions: &fakeSessions{},
161+ })
162+ assert.Error(t, err, "sessions need a limiter")
163+}
164+
165+func TestLogPathHidesLinks(t *testing.T) {
166+ assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
167+ assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
168+}
M internal/adapters/web/shell.templ +6 -0
@@ -119,6 +119,12 @@ templ Statusbar(l Layout) {
119119 }
120120 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
121121 <span class="st-hints">{ l.Status.Hints }</span>
122+ if SignedIn(ctx) {
123+ <form class="st-owner" method="post" action="/-/logout">
124+ <span>owner</span>
125+ <button type="submit" title="sign out of this browser">[sign out]</button>
126+ </form>
127+ }
122128 @Footer(l)
123129 @Live(l)
124130 if l.Status.Cursor {
@@ -119,6 +119,12 @@ templ Statusbar(l Layout) {
119 }119 }
120 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>120 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
121 <span class="st-hints">{ l.Status.Hints }</span>121 <span class="st-hints">{ l.Status.Hints }</span>
122+ if SignedIn(ctx) {
123+ <form class="st-owner" method="post" action="/-/logout">
124+ <span>owner</span>
125+ <button type="submit" title="sign out of this browser">[sign out]</button>
126+ </form>
127+ }
122 @Footer(l)128 @Footer(l)
123 @Live(l)129 @Live(l)
124 if l.Status.Cursor {130 if l.Status.Cursor {
M internal/adapters/web/shell_templ.go +16 -10
@@ -561,10 +561,16 @@ func Statusbar(l Layout) templ.Component {
561561 if templ_7745c5c3_Err != nil {
562562 return templ_7745c5c3_Err
563563 }
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span>")
564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span> ")
565565 if templ_7745c5c3_Err != nil {
566566 return templ_7745c5c3_Err
567567 }
568+ if SignedIn(ctx) {
569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form class=\"st-owner\" method=\"post\" action=\"/-/logout\"><span>owner</span> <button type=\"submit\" title=\"sign out of this browser\">[sign out]</button></form>")
570+ if templ_7745c5c3_Err != nil {
571+ return templ_7745c5c3_Err
572+ }
573+ }
568574 templ_7745c5c3_Err = Footer(l).Render(ctx, templ_7745c5c3_Buffer)
569575 if templ_7745c5c3_Err != nil {
570576 return templ_7745c5c3_Err
@@ -574,25 +580,25 @@ func Statusbar(l Layout) templ.Component {
574580 return templ_7745c5c3_Err
575581 }
576582 if l.Status.Cursor {
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"st-pos num\" title=\"cursor position\" :text=\"(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()\">")
583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"st-pos num\" title=\"cursor position\" :text=\"(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()\">")
578584 if templ_7745c5c3_Err != nil {
579585 return templ_7745c5c3_Err
580586 }
581587 var templ_7745c5c3_Var30 string
582588 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(cursorText(l.Status.Count))
583589 if templ_7745c5c3_Err != nil {
584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 129, Col: 32}
590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 135, Col: 32}
585591 }
586592 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
587593 if templ_7745c5c3_Err != nil {
588594 return templ_7745c5c3_Err
589595 }
590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")
596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span>")
591597 if templ_7745c5c3_Err != nil {
592598 return templ_7745c5c3_Err
593599 }
594600 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</footer>")
601+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</footer>")
596602 if templ_7745c5c3_Err != nil {
597603 return templ_7745c5c3_Err
598604 }
@@ -623,33 +629,33 @@ func Footer(l Layout) templ.Component {
623629 templ_7745c5c3_Var31 = templ.NopComponent
624630 }
625631 ctx = templ.ClearChildren(ctx)
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"st-src\"><span class=\"st-ver\">ermite ")
632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"st-src\"><span class=\"st-ver\">ermite ")
627633 if templ_7745c5c3_Err != nil {
628634 return templ_7745c5c3_Err
629635 }
630636 var templ_7745c5c3_Var32 string
631637 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
632638 if templ_7745c5c3_Err != nil {
633- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 61}
639+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 61}
634640 }
635641 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
636642 if templ_7745c5c3_Err != nil {
637643 return templ_7745c5c3_Err
638644 }
639- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " · </span><a href=\"")
645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " · </span><a href=\"")
640646 if templ_7745c5c3_Err != nil {
641647 return templ_7745c5c3_Err
642648 }
643649 var templ_7745c5c3_Var33 templ.SafeURL
644650 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
645651 if templ_7745c5c3_Err != nil {
646- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 110}
652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 110}
647653 }
648654 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
649655 if templ_7745c5c3_Err != nil {
650656 return templ_7745c5c3_Err
651657 }
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a></span>")
658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a></span>")
653659 if templ_7745c5c3_Err != nil {
654660 return templ_7745c5c3_Err
655661 }
@@ -561,10 +561,16 @@ func Statusbar(l Layout) templ.Component {
561 if templ_7745c5c3_Err != nil {561 if templ_7745c5c3_Err != nil {
562 return templ_7745c5c3_Err562 return templ_7745c5c3_Err
563 }563 }
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span>")564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span> ")
565 if templ_7745c5c3_Err != nil {565 if templ_7745c5c3_Err != nil {
566 return templ_7745c5c3_Err566 return templ_7745c5c3_Err
567 }567 }
568+ if SignedIn(ctx) {
569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form class=\"st-owner\" method=\"post\" action=\"/-/logout\"><span>owner</span> <button type=\"submit\" title=\"sign out of this browser\">[sign out]</button></form>")
570+ if templ_7745c5c3_Err != nil {
571+ return templ_7745c5c3_Err
572+ }
573+ }
568 templ_7745c5c3_Err = Footer(l).Render(ctx, templ_7745c5c3_Buffer)574 templ_7745c5c3_Err = Footer(l).Render(ctx, templ_7745c5c3_Buffer)
569 if templ_7745c5c3_Err != nil {575 if templ_7745c5c3_Err != nil {
570 return templ_7745c5c3_Err576 return templ_7745c5c3_Err
@@ -574,25 +580,25 @@ func Statusbar(l Layout) templ.Component {
574 return templ_7745c5c3_Err580 return templ_7745c5c3_Err
575 }581 }
576 if l.Status.Cursor {582 if l.Status.Cursor {
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"st-pos num\" title=\"cursor position\" :text=\"(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()\">")583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"st-pos num\" title=\"cursor position\" :text=\"(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()\">")
578 if templ_7745c5c3_Err != nil {584 if templ_7745c5c3_Err != nil {
579 return templ_7745c5c3_Err585 return templ_7745c5c3_Err
580 }586 }
581 var templ_7745c5c3_Var30 string587 var templ_7745c5c3_Var30 string
582 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(cursorText(l.Status.Count))588 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(cursorText(l.Status.Count))
583 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 129, Col: 32}590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 135, Col: 32}
585 }591 }
586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))592 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
587 if templ_7745c5c3_Err != nil {593 if templ_7745c5c3_Err != nil {
588 return templ_7745c5c3_Err594 return templ_7745c5c3_Err
589 }595 }
590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span>")
591 if templ_7745c5c3_Err != nil {597 if templ_7745c5c3_Err != nil {
592 return templ_7745c5c3_Err598 return templ_7745c5c3_Err
593 }599 }
594 }600 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</footer>")601+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</footer>")
596 if templ_7745c5c3_Err != nil {602 if templ_7745c5c3_Err != nil {
597 return templ_7745c5c3_Err603 return templ_7745c5c3_Err
598 }604 }
@@ -623,33 +629,33 @@ func Footer(l Layout) templ.Component {
623 templ_7745c5c3_Var31 = templ.NopComponent629 templ_7745c5c3_Var31 = templ.NopComponent
624 }630 }
625 ctx = templ.ClearChildren(ctx)631 ctx = templ.ClearChildren(ctx)
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"st-src\"><span class=\"st-ver\">ermite ")632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"st-src\"><span class=\"st-ver\">ermite ")
627 if templ_7745c5c3_Err != nil {633 if templ_7745c5c3_Err != nil {
628 return templ_7745c5c3_Err634 return templ_7745c5c3_Err
629 }635 }
630 var templ_7745c5c3_Var32 string636 var templ_7745c5c3_Var32 string
631 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)637 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
632 if templ_7745c5c3_Err != nil {638 if templ_7745c5c3_Err != nil {
633- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 61}639+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 61}
634 }640 }
635 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))641 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
636 if templ_7745c5c3_Err != nil {642 if templ_7745c5c3_Err != nil {
637 return templ_7745c5c3_Err643 return templ_7745c5c3_Err
638 }644 }
639- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " · </span><a href=\"")645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " · </span><a href=\"")
640 if templ_7745c5c3_Err != nil {646 if templ_7745c5c3_Err != nil {
641 return templ_7745c5c3_Err647 return templ_7745c5c3_Err
642 }648 }
643 var templ_7745c5c3_Var33 templ.SafeURL649 var templ_7745c5c3_Var33 templ.SafeURL
644 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))650 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
645 if templ_7745c5c3_Err != nil {651 if templ_7745c5c3_Err != nil {
646- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 110}652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 110}
647 }653 }
648 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))654 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
649 if templ_7745c5c3_Err != nil {655 if templ_7745c5c3_Err != nil {
650 return templ_7745c5c3_Err656 return templ_7745c5c3_Err
651 }657 }
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a></span>")658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a></span>")
653 if templ_7745c5c3_Err != nil {659 if templ_7745c5c3_Err != nil {
654 return templ_7745c5c3_Err660 return templ_7745c5c3_Err
655 }661 }
M internal/adapters/web/static/css/20-shell.css +4 -0
@@ -55,6 +55,10 @@
5555 & .st-live::before { content: "○ "; }
5656 & .st-live[data-state="live"] { color: var(--bar-ink); }
5757 & .st-live[data-state="live"]::before { content: "● "; }
58+ & .st-owner { flex: none; display: flex; gap: 1ch; }
59+ & .st-owner span { color: var(--bar-ink); }
60+ & .st-owner button { color: var(--bar-dim); }
61+ & .st-owner button:hover { color: #fff; text-decoration: underline; }
5862 & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }
5963 }
6064
@@ -55,6 +55,10 @@
55 & .st-live::before { content: "○ "; }55 & .st-live::before { content: "○ "; }
56 & .st-live[data-state="live"] { color: var(--bar-ink); }56 & .st-live[data-state="live"] { color: var(--bar-ink); }
57 & .st-live[data-state="live"]::before { content: "● "; }57 & .st-live[data-state="live"]::before { content: "● "; }
58+ & .st-owner { flex: none; display: flex; gap: 1ch; }
59+ & .st-owner span { color: var(--bar-ink); }
60+ & .st-owner button { color: var(--bar-dim); }
61+ & .st-owner button:hover { color: #fff; text-decoration: underline; }
58 & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }62 & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }
59 }63 }
60 64
M internal/adapters/web/static/css/30-list.css +3 -0
@@ -62,6 +62,9 @@
6262 & h1 { margin-bottom: var(--lh); }
6363 & .code { display: inline-block; min-width: 6ch; }
6464 & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }
65+ & .state-act { margin-top: var(--lh); }
66+ & .state-act button { color: var(--ink-hi); font-weight: bold; }
67+ & .state-act button:hover { text-decoration: underline; }
6568 }
6669 .clone {
6770 margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;
@@ -62,6 +62,9 @@
62 & h1 { margin-bottom: var(--lh); }62 & h1 { margin-bottom: var(--lh); }
63 & .code { display: inline-block; min-width: 6ch; }63 & .code { display: inline-block; min-width: 6ch; }
64 & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }64 & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }
65+ & .state-act { margin-top: var(--lh); }
66+ & .state-act button { color: var(--ink-hi); font-weight: bold; }
67+ & .state-act button:hover { text-decoration: underline; }
65 }68 }
66 .clone {69 .clone {
67 margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;70 margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;
M internal/adapters/web/view.go +8 -0
@@ -179,3 +179,11 @@ type StatePage struct {
179179 Clone *Clone
180180 Back []Link
181181 }
182+
183+// LoginPage is the confirmation of a login link: a form with one button.
184+type LoginPage struct {
185+ Title string
186+ Lines []string
187+ Action string // where the form posts
188+ Button string
189+}
@@ -179,3 +179,11 @@ type StatePage struct {
179 Clone *Clone179 Clone *Clone
180 Back []Link180 Back []Link
181 }181 }
182+
183+// LoginPage is the confirmation of a login link: a form with one button.
184+type LoginPage struct {
185+ Title string
186+ Lines []string
187+ Action string // where the form posts
188+ Button string
189+}
M internal/adapters/web/web.go +51 -5
@@ -23,7 +23,9 @@ import (
2323 "errors"
2424 "fmt"
2525 "net/http"
26+ "net/netip"
2627 "net/url"
28+ "strings"
2729 "time"
2830
2931 "connectrpc.com/connect"
@@ -62,12 +64,22 @@ type Config struct {
6264 Version string
6365 // Now is the clock of relative dates; time.Now when nil.
6466 Now func() time.Time
67+ // Sessions signs the owner in (ADR 0018). Without it there are no login
68+ // routes and every visitor is anonymous.
69+ Sessions SessionAuth
70+ // Limiter counts the failed logins; required with Sessions. Share it with
71+ // the API and Git, so that a client has one budget of failures.
72+ Limiter FailureLimiter
73+ // TrustedProxies are the networks allowed to set X-Forwarded-For, to find
74+ // the address of the client that fails a login.
75+ TrustedProxies []netip.Prefix
6576 }
6677
6778 // Server serves the pages. It is an http.Handler.
6879 type Server struct {
6980 cfg Config
7081 instance string
82+ origin string // scheme://host of the public URL, what a POST must come from
7183 assets *assetSet
7284 md *markdown
7385 mux *http.ServeMux
@@ -89,7 +101,13 @@ func New(cfg Config) (*Server, error) {
89101 if err != nil {
90102 return nil, fmt.Errorf("web: load assets: %w", err)
91103 }
92- s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()}
104+ if cfg.Sessions != nil && cfg.Limiter == nil {
105+ return nil, errors.New("web: sessions need a failure limiter")
106+ }
107+ s := &Server{
108+ cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
109+ origin: originOf(u),
110+ }
93111 s.mux = s.routes()
94112 return s, nil
95113 }
@@ -100,6 +118,11 @@ func (s *Server) routes() *http.ServeMux {
100118 mux := http.NewServeMux()
101119 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
102120 mux.HandleFunc("GET /-/events", s.events)
121+ if s.cfg.Sessions != nil {
122+ mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
123+ mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
124+ mux.HandleFunc("POST /-/logout", s.logout)
125+ }
103126 mux.HandleFunc("GET /{$}", s.page(s.home))
104127 mux.HandleFunc("GET /{repo}", s.page(s.summary))
105128 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -117,6 +140,10 @@ func (s *Server) routes() *http.ServeMux {
117140
118141 // ServeHTTP implements http.Handler.
119142 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
143+ r = s.withSession(w, r)
144+ if !s.gate(w, r) {
145+ return
146+ }
120147 s.mux.ServeHTTP(w, r)
121148 }
122149
@@ -146,14 +173,18 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque
146173 if err := c.Render(ctx, &buf); err != nil {
147174 // The error page itself failed: a bare answer is all that is left.
148175 log := zerowrap.FromCtx(ctx)
149- log.Error().Err(err).Str("path", r.URL.Path).Msg("render page")
176+ log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
150177 http.Error(w, "internal error", http.StatusInternalServerError)
151178 return
152179 }
153180 h := w.Header()
154181 h.Set("Content-Type", "text/html; charset=utf-8")
155- // Public pages: the browser and any proxy revalidate each time.
156- h.Set("Cache-Control", "no-cache")
182+ // Public pages: the browser and any proxy revalidate each time. A page
183+ // rendered for the owner is private and never stored; a handler that set
184+ // its own Cache-Control keeps it.
185+ if h.Get("Cache-Control") == "" {
186+ cacheHeaders(ctx, h, "no-cache", sessionCache)
187+ }
157188 w.WriteHeader(status)
158189 if r.Method != http.MethodHead {
159190 _, _ = w.Write(buf.Bytes())
@@ -173,7 +204,7 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques
173204 default:
174205 id := requestID()
175206 log := zerowrap.FromCtx(ctx)
176- log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed")
207+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
177208 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
178209 }
179210 }
@@ -236,3 +267,18 @@ func (s *Server) errorPage(id string) templ.Component {
236267 Back: []Link{{Label: "home", Href: "/"}},
237268 })
238269 }
270+
271+// originOf is the origin of a URL as a browser writes it in the Origin
272+// header: scheme and host in lower case, the port only when it is not the
273+// default of the scheme.
274+func originOf(u *url.URL) string {
275+ scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
276+ if strings.Contains(host, ":") {
277+ host = "[" + host + "]"
278+ }
279+ defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
280+ if port != "" && !defaultPort {
281+ host += ":" + port
282+ }
283+ return scheme + "://" + host
284+}
@@ -23,7 +23,9 @@ import (
23 "errors"23 "errors"
24 "fmt"24 "fmt"
25 "net/http"25 "net/http"
26+ "net/netip"
26 "net/url"27 "net/url"
28+ "strings"
27 "time"29 "time"
28 30
29 "connectrpc.com/connect"31 "connectrpc.com/connect"
@@ -62,12 +64,22 @@ type Config struct {
62 Version string64 Version string
63 // Now is the clock of relative dates; time.Now when nil.65 // Now is the clock of relative dates; time.Now when nil.
64 Now func() time.Time66 Now func() time.Time
67+ // Sessions signs the owner in (ADR 0018). Without it there are no login
68+ // routes and every visitor is anonymous.
69+ Sessions SessionAuth
70+ // Limiter counts the failed logins; required with Sessions. Share it with
71+ // the API and Git, so that a client has one budget of failures.
72+ Limiter FailureLimiter
73+ // TrustedProxies are the networks allowed to set X-Forwarded-For, to find
74+ // the address of the client that fails a login.
75+ TrustedProxies []netip.Prefix
65 }76 }
66 77
67 // Server serves the pages. It is an http.Handler.78 // Server serves the pages. It is an http.Handler.
68 type Server struct {79 type Server struct {
69 cfg Config80 cfg Config
70 instance string81 instance string
82+ origin string // scheme://host of the public URL, what a POST must come from
71 assets *assetSet83 assets *assetSet
72 md *markdown84 md *markdown
73 mux *http.ServeMux85 mux *http.ServeMux
@@ -89,7 +101,13 @@ func New(cfg Config) (*Server, error) {
89 if err != nil {101 if err != nil {
90 return nil, fmt.Errorf("web: load assets: %w", err)102 return nil, fmt.Errorf("web: load assets: %w", err)
91 }103 }
92- s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()}104+ if cfg.Sessions != nil && cfg.Limiter == nil {
105+ return nil, errors.New("web: sessions need a failure limiter")
106+ }
107+ s := &Server{
108+ cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
109+ origin: originOf(u),
110+ }
93 s.mux = s.routes()111 s.mux = s.routes()
94 return s, nil112 return s, nil
95 }113 }
@@ -100,6 +118,11 @@ func (s *Server) routes() *http.ServeMux {
100 mux := http.NewServeMux()118 mux := http.NewServeMux()
101 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)119 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
102 mux.HandleFunc("GET /-/events", s.events)120 mux.HandleFunc("GET /-/events", s.events)
121+ if s.cfg.Sessions != nil {
122+ mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
123+ mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
124+ mux.HandleFunc("POST /-/logout", s.logout)
125+ }
103 mux.HandleFunc("GET /{$}", s.page(s.home))126 mux.HandleFunc("GET /{$}", s.page(s.home))
104 mux.HandleFunc("GET /{repo}", s.page(s.summary))127 mux.HandleFunc("GET /{repo}", s.page(s.summary))
105 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))128 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -117,6 +140,10 @@ func (s *Server) routes() *http.ServeMux {
117 140
118 // ServeHTTP implements http.Handler.141 // ServeHTTP implements http.Handler.
119 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {142 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
143+ r = s.withSession(w, r)
144+ if !s.gate(w, r) {
145+ return
146+ }
120 s.mux.ServeHTTP(w, r)147 s.mux.ServeHTTP(w, r)
121 }148 }
122 149
@@ -146,14 +173,18 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque
146 if err := c.Render(ctx, &buf); err != nil {173 if err := c.Render(ctx, &buf); err != nil {
147 // The error page itself failed: a bare answer is all that is left.174 // The error page itself failed: a bare answer is all that is left.
148 log := zerowrap.FromCtx(ctx)175 log := zerowrap.FromCtx(ctx)
149- log.Error().Err(err).Str("path", r.URL.Path).Msg("render page")176+ log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
150 http.Error(w, "internal error", http.StatusInternalServerError)177 http.Error(w, "internal error", http.StatusInternalServerError)
151 return178 return
152 }179 }
153 h := w.Header()180 h := w.Header()
154 h.Set("Content-Type", "text/html; charset=utf-8")181 h.Set("Content-Type", "text/html; charset=utf-8")
155- // Public pages: the browser and any proxy revalidate each time.182+ // Public pages: the browser and any proxy revalidate each time. A page
156- h.Set("Cache-Control", "no-cache")183+ // rendered for the owner is private and never stored; a handler that set
184+ // its own Cache-Control keeps it.
185+ if h.Get("Cache-Control") == "" {
186+ cacheHeaders(ctx, h, "no-cache", sessionCache)
187+ }
157 w.WriteHeader(status)188 w.WriteHeader(status)
158 if r.Method != http.MethodHead {189 if r.Method != http.MethodHead {
159 _, _ = w.Write(buf.Bytes())190 _, _ = w.Write(buf.Bytes())
@@ -173,7 +204,7 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques
173 default:204 default:
174 id := requestID()205 id := requestID()
175 log := zerowrap.FromCtx(ctx)206 log := zerowrap.FromCtx(ctx)
176- log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed")207+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
177 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))208 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
178 }209 }
179 }210 }
@@ -236,3 +267,18 @@ func (s *Server) errorPage(id string) templ.Component {
236 Back: []Link{{Label: "home", Href: "/"}},267 Back: []Link{{Label: "home", Href: "/"}},
237 })268 })
238 }269 }
270+
271+// originOf is the origin of a URL as a browser writes it in the Origin
272+// header: scheme and host in lower case, the port only when it is not the
273+// default of the scheme.
274+func originOf(u *url.URL) string {
275+ scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
276+ if strings.Contains(host, ":") {
277+ host = "[" + host + "]"
278+ }
279+ defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
280+ if port != "" && !defaultPort {
281+ host += ":" + port
282+ }
283+ return scheme + "://" + host
284+}
M internal/app/access.go +4 -2
@@ -75,10 +75,12 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error
7575 }
7676
7777 // authorOf names who acts, for the author of a merge request or a comment: the
78-// label of the token, "root" for the root token, "owner" for any other caller
79-// (a web session).
78+// label of the token, "root" for the root token, "owner" for the owner signed
79+// in on the web and for any other caller without a label.
8080 func authorOf(p domain.Principal) string {
8181 switch {
82+ case p.IsOwner():
83+ return "owner"
8284 case p.IsRoot():
8385 return "root"
8486 case p.TokenLabel() != "":
@@ -75,10 +75,12 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error
75 }75 }
76 76
77 // authorOf names who acts, for the author of a merge request or a comment: the77 // authorOf names who acts, for the author of a merge request or a comment: the
78-// label of the token, "root" for the root token, "owner" for any other caller78+// label of the token, "root" for the root token, "owner" for the owner signed
79-// (a web session).79+// in on the web and for any other caller without a label.
80 func authorOf(p domain.Principal) string {80 func authorOf(p domain.Principal) string {
81 switch {81 switch {
82+ case p.IsOwner():
83+ return "owner"
82 case p.IsRoot():84 case p.IsRoot():
83 return "root"85 return "root"
84 case p.TokenLabel() != "":86 case p.TokenLabel() != "":
M internal/app/export_test.go +5 -0
@@ -2,6 +2,8 @@
22
33 package app
44
5+import "bnema.dev/ermite/internal/domain"
6+
57 // Idle reports whether nothing is pending or running: tests wait for it
68 // instead of sleeping.
79 func (m *Maintainer) Idle() bool {
@@ -9,3 +11,6 @@ func (m *Maintainer) Idle() bool {
911 defer m.mu.Unlock()
1012 return len(m.entries) == 0
1113 }
14+
15+// AuthorOf exposes authorOf to the tests.
16+func AuthorOf(p domain.Principal) string { return authorOf(p) }
@@ -2,6 +2,8 @@
2 2
3 package app3 package app
4 4
5+import "bnema.dev/ermite/internal/domain"
6+
5 // Idle reports whether nothing is pending or running: tests wait for it7 // Idle reports whether nothing is pending or running: tests wait for it
6 // instead of sleeping.8 // instead of sleeping.
7 func (m *Maintainer) Idle() bool {9 func (m *Maintainer) Idle() bool {
@@ -9,3 +11,6 @@ func (m *Maintainer) Idle() bool {
9 defer m.mu.Unlock()11 defer m.mu.Unlock()
10 return len(m.entries) == 012 return len(m.entries) == 0
11 }13 }
14+
15+// AuthorOf exposes authorOf to the tests.
16+func AuthorOf(p domain.Principal) string { return authorOf(p) }
M internal/app/sessions_test.go +8 -0
@@ -291,3 +291,11 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
291291 _, err = f.sessions.Verify(ctx, c)
292292 assert.NoError(t, err, "a new session works")
293293 }
294+
295+// The owner signed in on the web signs what it writes as "owner", where root
296+// signs as "root" although both hold every right.
297+func TestAuthorOfOwnerSession(t *testing.T) {
298+ assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))
299+ assert.Equal(t, "root", app.AuthorOf(domain.Root()))
300+ assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
301+}
@@ -291,3 +291,11 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
291 _, err = f.sessions.Verify(ctx, c)291 _, err = f.sessions.Verify(ctx, c)
292 assert.NoError(t, err, "a new session works")292 assert.NoError(t, err, "a new session works")
293 }293 }
294+
295+// The owner signed in on the web signs what it writes as "owner", where root
296+// signs as "root" although both hold every right.
297+func TestAuthorOfOwnerSession(t *testing.T) {
298+ assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))
299+ assert.Equal(t, "root", app.AuthorOf(domain.Root()))
300+ assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
301+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close