diff --git a/doc.go b/doc.go index 1160cc2..ed6fc95 100644 --- a/doc.go +++ b/doc.go @@ -2,8 +2,9 @@ // sends to Microservices. // // An ExApp calls [NewSignerFromEnv] and [Signer.Mint] after AppAPI has named -// the user. A Microservice calls [FromEnv] and [Auth.Middleware]. One process -// trusts either a static public key or an OIDC issuer, not both. +// the user. A Microservice calls [FromEnv] and [Auth.Middleware] or +// [Auth.UnaryServerInterceptor]. One process trusts either a static public +// key or an OIDC issuer, not both. // // The procedure, claims, and environment variables are in // knowledge/platforms/nextcloud/exapps/authentication.md. diff --git a/go.mod b/go.mod index 8a8862e..b4311c8 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,13 @@ require ( ) require ( - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 122417f..db2ebd9 100644 --- a/go.sum +++ b/go.sum @@ -4,17 +4,35 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grpc.go b/grpc.go new file mode 100644 index 0000000..8f70f7e --- /dev/null +++ b/grpc.go @@ -0,0 +1,45 @@ +package usertoken + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// UnaryServerInterceptor requires a bearer on every RPC. +// Success stores the Caller and the raw JWT on the handler context, same as +// [Auth.Middleware]. Native clients send metadata key "authorization". +// The HTTP gateway's forwarded header is "grpcgateway-authorization". +func (a *Auth) UnaryServerInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + raw, ok := bearerFromMetadata(ctx) + if !ok { + return nil, status.Error(codes.Unauthenticated, "unauthorized") + } + caller, err := a.Verify(ctx, raw) + if err != nil { + return nil, status.Error(codes.Unauthenticated, "unauthorized") + } + return handler(withAuth(ctx, caller, raw), req) + } +} + +func bearerFromMetadata(ctx context.Context) (string, bool) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "", false + } + for _, key := range []string{"authorization", "grpcgateway-authorization"} { + vals := md.Get(key) + if len(vals) == 0 { + continue + } + if raw, ok := bearerToken(vals[0]); ok { + return raw, true + } + } + return "", false +} diff --git a/grpc_test.go b/grpc_test.go new file mode 100644 index 0000000..6c2de19 --- /dev/null +++ b/grpc_test.go @@ -0,0 +1,104 @@ +package usertoken + +import ( + "context" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func TestUnaryInterceptorForwardsBearer(t *testing.T) { + key := testKey(t) + signer := testSigner(t, key) + auth := testAuth(t, key) + groups := []string{"family", "mgmnt-admin"} + raw, err := signer.Mint(time.Now(), MintInput{Subject: "konrad", Groups: &groups}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + var gotSubject string + var gotGroups []string + var gotRaw string + interceptor := auth.UnaryServerInterceptor() + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer "+raw)) + _, err = interceptor(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/mgmnt.lists.v1.ListService/GetMe"}, func(ctx context.Context, _ any) (any, error) { + c, ok := CallerFromContext(ctx) + if !ok { + t.Fatal("missing caller") + } + gotSubject = c.Subject + gotGroups = c.Groups + var okRaw bool + gotRaw, okRaw = BearerFromContext(ctx) + if !okRaw { + t.Fatal("missing bearer") + } + return nil, nil + }) + if err != nil { + t.Fatalf("interceptor: %v", err) + } + if gotSubject != "konrad" || gotRaw != raw { + t.Fatalf("subject %q raw match %v", gotSubject, gotRaw == raw) + } + if len(gotGroups) != 2 || gotGroups[0] != "family" || gotGroups[1] != "mgmnt-admin" { + t.Fatalf("groups = %#v", gotGroups) + } +} + +func TestUnaryInterceptorRejects(t *testing.T) { + key := testKey(t) + signer := testSigner(t, key) + auth := testAuth(t, key) + raw, err := signer.Mint(time.Now(), MintInput{Subject: "konrad"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + interceptor := auth.UnaryServerInterceptor() + handler := func(context.Context, any) (any, error) { + t.Fatal("handler ran") + return nil, nil + } + info := &grpc.UnaryServerInfo{FullMethod: "/mgmnt.lists.v1.ListService/GetMe"} + + cases := []struct { + name string + ctx context.Context + }{ + {name: "missing metadata", ctx: t.Context()}, + {name: "missing bearer", ctx: metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", ""))}, + {name: "bad signature", ctx: metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer "+raw+"x"))}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, err := interceptor(tt.ctx, nil, info, handler) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("code = %v, err = %v", status.Code(err), err) + } + }) + } + + wrongIss, err := New(t.Context(), Config{PublicKey: &key.PublicKey, Issuer: "other", Audience: "mgmnt"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer "+raw)) + _, err = wrongIss.UnaryServerInterceptor()(ctx, nil, info, handler) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("wrong issuer code = %v, err = %v", status.Code(err), err) + } + + wrongAud, err := New(t.Context(), Config{PublicKey: &key.PublicKey, Issuer: "manager", Audience: "other"}) + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = wrongAud.UnaryServerInterceptor()(ctx, nil, info, handler) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("wrong audience code = %v, err = %v", status.Code(err), err) + } +}