1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "errors"
8 "math"
9 "net/http"
10 "net/netip"
11 "strconv"
12 "strings"
13 "time"
14
15 "connectrpc.com/connect"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19 "bnema.dev/ermite/internal/clientlimit"
20 "bnema.dev/ermite/internal/domain"
21)
22
23var errInternal = errors.New("internal error")
24
25// interceptor authenticates the caller of every call and logs it.
26//
27// A request that carries "Authorization: Bearer <secret>" is authenticated
28// with it; one without the header is anonymous. A secret that is not valid is
29// unauthenticated and counts as a failure of the client address in the
30// limiter; a client that used up its failures gets resource_exhausted (429 on
31// the Connect protocol) whatever it presents, like on the Git routes.
32//
33// A call made through InProcessClient is trusted with the principal its
34// context carries: that is how the web calls the handlers in process. What
35// marks it is a value only the in-process transport can put in the context
36// (see inProcessKey); a principal in the context of a network request, say one
37// put there by a server's base context, is ignored and the request is
38// authenticated from its headers.
39type interceptor struct {
40 auth Authenticator
41 limiter *clientlimit.Limiter
42 trusted []netip.Prefix
43}
44
45func newInterceptor(cfg Config) *interceptor {
46 return &interceptor{auth: cfg.Auth, limiter: cfg.Limiter, trusted: cfg.TrustedProxies}
47}
48
49// authenticate returns the context to run the call in.
50func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
51 if inProcess(ctx) {
52 if _, ok := domain.PrincipalFromContext(ctx); ok {
53 return ctx, nil
54 }
55 }
56 // Whatever the context carried, the caller is anonymous until proven
57 // otherwise.
58 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
59 if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
60 // It needs no authentication, so that a client can check compatibility
61 // first, even with a token that has expired.
62 return ctx, nil
63 }
64 log := zerowrap.FromCtx(ctx)
65 addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
66 ctx = context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
67 if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
68 log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
69 err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
70 err.Meta().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
71 return ctx, err
72 }
73
74 secret, present := bearer(h)
75 if !present {
76 return ctx, nil
77 }
78 p, err := i.auth.Authenticate(ctx, secret)
79 switch {
80 case errors.Is(err, domain.ErrUnauthenticated):
81 i.limiter.Fail(ctx, addr)
82 log.Debug().Str("client", addr).Msg("invalid api credentials")
83 return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
84 case err != nil:
85 return ctx, connectError(ctx, err)
86 }
87 return domain.ContextWithPrincipal(ctx, p), nil
88}
89
90type clientKey struct{}
91
92// ContextWithClient attributes the in-process calls made with ctx to the
93// client at addr (clientlimit.ClientAddr of the browser's request), so that
94// the per-client limits of the API, such as the event streams one, apply to
95// each visitor of the web rather than to the web as a whole. A network call
96// is always attributed to its own peer: the interceptor overwrites it.
97func ContextWithClient(ctx context.Context, addr string) context.Context {
98 return context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
99}
100
101// clientFromContext returns the normalized address of the client of a call:
102// its peer for a network call, the one given to ContextWithClient for an
103// in-process call, empty when there is none.
104func clientFromContext(ctx context.Context) string {
105 c, _ := ctx.Value(clientKey{}).(string)
106 return c
107}
108
109// bearer extracts the secret of an "Authorization: Bearer" header. present is
110// true when the header exists, even when it carries nothing usable, which
111// then fails authentication.
112func bearer(h http.Header) (secret string, present bool) {
113 auth := h.Get("Authorization")
114 if auth == "" {
115 return "", false
116 }
117 scheme, rest, ok := strings.Cut(auth, " ")
118 if !ok || !strings.EqualFold(scheme, "Bearer") {
119 return "", true
120 }
121 return strings.TrimSpace(rest), true
122}
123
124// logCall logs a finished call at debug level: procedure, code, caller and
125// duration. Never headers or messages: they can carry credentials.
126func logCall(ctx context.Context, procedure string, start time.Time, err error) {
127 code := "ok"
128 if err != nil {
129 code = connect.CodeOf(err).String()
130 }
131 p, _ := domain.PrincipalFromContext(ctx)
132 log := zerowrap.FromCtx(ctx)
133 log.Debug().
134 Str("procedure", procedure).
135 Str("code", code).
136 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137 Dur("duration", time.Since(start)).
138 Msg("api call")
139}
140
141// WrapUnary implements connect.Interceptor.
142func (i *interceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
143 return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
144 start := time.Now()
145 ctx, err := i.authenticate(ctx, req.Spec().Procedure, req.Peer().Addr, req.Header())
146 var resp connect.AnyResponse
147 if err == nil {
148 resp, err = next(ctx, req)
149 }
150 logCall(ctx, req.Spec().Procedure, start, err)
151 return resp, err
152 }
153}
154
155// WrapStreamingClient implements connect.Interceptor; the server has no
156// client side.
157func (i *interceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
158 return next
159}
160
161// WrapStreamingHandler implements connect.Interceptor.
162func (i *interceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
163 return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
164 start := time.Now()
165 ctx, err := i.authenticate(ctx, conn.Spec().Procedure, conn.Peer().Addr, conn.RequestHeader())
166 if err == nil {
167 err = next(ctx, conn)
168 }
169 logCall(ctx, conn.Spec().Procedure, start, err)
170 return err
171 }
172}