diff --git a/auth/context.go b/auth/context.go new file mode 100644 index 0000000..22b4d6a --- /dev/null +++ b/auth/context.go @@ -0,0 +1,24 @@ +package auth + +import ( + "context" +) + +type authKey struct{} + +func Context[T any](ctx context.Context, auth T) context.Context { + return context.WithValue(ctx, authKey{}, auth) +} + +func FromContext[T any](ctx context.Context) (T, bool) { + auth, ok := ctx.Value(authKey{}).(T) + return auth, ok +} + +func MustTokenFromContext[T any](ctx context.Context) T { + auth, ok := FromContext[T](ctx) + if !ok { + panic("no auth in context") + } + return auth +} diff --git a/auth/session.go b/auth/session.go new file mode 100644 index 0000000..1191b0c --- /dev/null +++ b/auth/session.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" +) + +// TokenResponse is a proto message that contains a token. +// It typically is a response from a login service. +type TokenResponse interface { + proto.Message + GetToken() string +} + +// Handler is an interface that can be used to handle token forwarding. +// It can be used to forward tokens from grpc messages to cookies and vice versa. +// T is the token response type that creates the session and contains the token to be forwarded as a cookie. +// U is the message that closes the session. +type Handler interface { + CookieToAuth(ctx context.Context) context.Context + ForwardTokenAsCookie(ctx context.Context, w http.ResponseWriter, p proto.Message) error + ForwardCookieAsToken(ctx context.Context, request *http.Request) metadata.MD + SendAuthCookie(ctx context.Context, token string) error +} + +func NewHandler(sessionName string, sessions *sessions.CookieStore) Handler { + return &handler{sessionName: sessionName, sessions: sessions} +} + +type handler struct { + sessionName string + sessions *sessions.CookieStore +} + +// ForwardTokenAsCookie forwards the token from the message to the cookie store. +func (h *handler) ForwardTokenAsCookie(_ context.Context, w http.ResponseWriter, p proto.Message) error { + switch m := any(p).(type) { + case TokenResponse: + sess := h.newSession(h.sessionName) + sess.Values["token"] = m.GetToken() + // request is not available in the context, but session.Save does not actually use it + return h.sessions.Save(nil, w, sess) + default: + sess := h.newSession(h.sessionName) + sess.Options.MaxAge = -1 + return h.sessions.Save(nil, w, sess) + } +} + +// ForwardCookieAsToken forwards the token from the cookie store to the grpc metadata. +func (h *handler) ForwardCookieAsToken(_ context.Context, request *http.Request) metadata.MD { + sess, err := h.sessions.Get(request, h.sessionName) + if err != nil { + return nil + } + tk, ok := sess.Values["token"] + if !ok { + return nil + } + return metadata.Pairs("authorization", fmt.Sprintf("Bearer %v", tk)) +} + +// SendAuthCookie sets the cookie in the response. +func (h *handler) SendAuthCookie(ctx context.Context, token string) error { + sess := h.newSession(h.sessionName) + sess.Values["token"] = token + if token == "" { + sess.Options.MaxAge = -1 + } + encoded, err := securecookie.EncodeMulti(sess.Name(), sess.Values, h.sessions.Codecs...) + if err != nil { + return err + } + return grpc.SetHeader(ctx, metadata.Pairs("set-cookie", sessions.NewCookie(sess.Name(), encoded, sess.Options).String())) +} + +func (h *handler) newSession(name string) *sessions.Session { + session := sessions.NewSession(h.sessions, name) + opts := *h.sessions.Options + session.Options = &opts + session.IsNew = true + return session +} + +// CookieToAuth forwards the cookie authorization to the grpc metadata. +func (h *handler) CookieToAuth(ctx context.Context) context.Context { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return ctx + } + // if we have an authorization header, we don't need to check for cookies here + if len(md.Get("authorization")) > 0 { + return ctx + } + // no other way to easily parse cookies + header := http.Header{} + for _, v := range md.Get("cookie") { + header.Add("Cookie", v) + } + md.Delete("cookie") + sess, err := h.sessions.Get(&http.Request{Header: header}, h.sessionName) + if err != nil { + return ctx + } + if v, ok := sess.Values["token"]; ok { + md["authorization"] = []string{fmt.Sprintf("Bearer %s", v)} + } + return metadata.NewIncomingContext(ctx, md) +} diff --git a/go.mod b/go.mod index 2a4e3f6..a9d7387 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.linka.cloud/grpc-toolkit -go 1.22.0 +go 1.23 toolchain go1.23.2 @@ -15,6 +15,8 @@ require ( github.com/go-logr/logr v1.4.2 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 + github.com/gorilla/securecookie v1.1.2 + github.com/gorilla/sessions v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 diff --git a/go.sum b/go.sum index a96edc1..ad7b5a9 100644 --- a/go.sum +++ b/go.sum @@ -278,6 +278,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -309,6 +311,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=