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:
@@ -2,8 +2,9 @@
|
|||||||
// sends to Microservices.
|
// sends to Microservices.
|
||||||
//
|
//
|
||||||
// An ExApp calls [NewSignerFromEnv] and [Signer.Mint] after AppAPI has named
|
// An ExApp calls [NewSignerFromEnv] and [Signer.Mint] after AppAPI has named
|
||||||
// the user. A Microservice calls [FromEnv] and [Auth.Middleware]. One process
|
// the user. A Microservice calls [FromEnv] and [Auth.Middleware] or
|
||||||
// trusts either a static public key or an OIDC issuer, not both.
|
// [Auth.UnaryServerInterceptor]. One process trusts either a static public
|
||||||
|
// key or an OIDC issuer, not both.
|
||||||
//
|
//
|
||||||
// The procedure, claims, and environment variables are in
|
// The procedure, claims, and environment variables are in
|
||||||
// knowledge/platforms/nextcloud/exapps/authentication.md.
|
// knowledge/platforms/nextcloud/exapps/authentication.md.
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
golang.org/x/crypto v0.36.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/oauth2 v0.28.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 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.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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
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 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user