Check the same user token on gRPC as on HTTP.

Microservices can forward the raw bearer from the interceptor context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-28 17:37:08 +02:00
co-authored by Cursor
parent f082561cc6
commit b344df5c1b
5 changed files with 179 additions and 5 deletions
+3 -2
View File
@@ -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.
+9 -3
View File
@@ -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
)
+18
View File
@@ -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=
+45
View File
@@ -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
}
+104
View File
@@ -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)
}
}