diff --git a/idptoken/grpc_client.go b/idptoken/grpc_client.go index 824f54a..5605bca 100644 --- a/idptoken/grpc_client.go +++ b/idptoken/grpc_client.go @@ -8,8 +8,10 @@ package idptoken import ( "context" + "errors" "fmt" "strconv" + "sync/atomic" "time" "github.com/acronis/go-appkit/log" @@ -33,6 +35,7 @@ const DefaultGRPCClientRequestTimeout = time.Second * 30 const ( grpcMetaAuthorization = "authorization" grpcMetaRequestID = "x-request-id" + grpcMetaSessionID = "x-session-id" ) // GRPCClientOpts contains options for the GRPCClient. @@ -62,6 +65,8 @@ type GRPCClient struct { reqTimeout time.Duration promMetrics *metrics.PrometheusMetrics requestIDProvider func(ctx context.Context) string + + sessionID atomic.Value } // NewGRPCClient creates a new GRPCClient instance that communicates with the IDP token service. @@ -126,20 +131,33 @@ func (c *GRPCClient) IntrospectToken( req.ScopeFilter[i] = &pb.IntrospectionScopeFilter{ResourceNamespace: scopeFilter[i].ResourceNamespace} } - ctx = metadata.AppendToOutgoingContext(ctx, grpcMetaAuthorization, makeBearerToken(accessToken)) + if sessID := c.getSessionID(); sessID != "" { + ctx = metadata.AppendToOutgoingContext(ctx, grpcMetaSessionID, sessID) + } else { + ctx = metadata.AppendToOutgoingContext(ctx, grpcMetaAuthorization, makeBearerToken(accessToken)) + } if c.requestIDProvider != nil { ctx = metadata.AppendToOutgoingContext(ctx, grpcMetaRequestID, c.requestIDProvider(ctx)) } + var headerMD metadata.MD + var opts = []grpc.CallOption{grpc.Header(&headerMD)} var resp *pb.IntrospectTokenResponse if err := c.do(ctx, "IDPTokenService/IntrospectToken", func(ctx context.Context) error { var innerErr error - resp, innerErr = c.client.IntrospectToken(ctx, &req) + resp, innerErr = c.client.IntrospectToken(ctx, &req, opts...) return innerErr }); err != nil { + if errors.Is(err, ErrUnauthenticated) { + c.setSessionID("") + } return nil, err } + if sessionIDMeta := headerMD.Get(grpcMetaSessionID); len(sessionIDMeta) > 0 { + c.setSessionID(sessionIDMeta[0]) + } + claims := jwt.DefaultClaims{ RegisteredClaims: jwtgo.RegisteredClaims{ Issuer: resp.GetIss(), @@ -177,6 +195,18 @@ func (c *GRPCClient) IntrospectToken( }, nil } +func (c *GRPCClient) getSessionID() string { + id, ok := c.sessionID.Load().(string) + if !ok { + return "" + } + return id +} + +func (c *GRPCClient) setSessionID(id string) { + c.sessionID.Store(id) +} + type exchangeTokenOptions struct { notRequiredIntrospection bool } diff --git a/idptoken/grpc_client_test.go b/idptoken/grpc_client_test.go index 06a17a8..a65d167 100644 --- a/idptoken/grpc_client_test.go +++ b/idptoken/grpc_client_test.go @@ -8,12 +8,17 @@ package idptoken_test import ( "context" + "fmt" + "strconv" gotesting "testing" "time" jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" "github.com/acronis/go-authkit/idptest" "github.com/acronis/go-authkit/idptoken" @@ -23,6 +28,214 @@ import ( "github.com/acronis/go-authkit/jwt" ) +func TestGRPCClient_IntrospectToken(t *gotesting.T) { + const validAccessToken = "access-token-with-introspection-permission" + var validSessionID = testing.GenerateSessionID(validAccessToken) + + opaqueToken := "opaque-token-" + uuid.NewString() + opaqueTokenScope := []jwt.AccessPolicy{{ + TenantUUID: uuid.NewString(), + ResourceNamespace: "account-server", + Role: "admin", + ResourcePath: "resource-" + uuid.NewString(), + }} + opaqueTokenRegClaims := jwtgo.RegisteredClaims{ + ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)), + } + + jwtScopeToGRPC := func(jwtScope []jwt.AccessPolicy) []*pb.AccessTokenScope { + grpcScope := make([]*pb.AccessTokenScope, len(jwtScope)) + for i, scope := range jwtScope { + grpcScope[i] = &pb.AccessTokenScope{ + TenantUuid: scope.TenantUUID, + ResourceNamespace: scope.ResourceNamespace, + RoleName: scope.Role, + ResourcePath: scope.ResourcePath, + } + } + return grpcScope + } + + grpcServerTokenIntrospector := testing.NewGRPCServerTokenIntrospectorMock() + grpcServerTokenIntrospector.SetAccessTokenForIntrospection(validAccessToken) + grpcServerTokenIntrospector.SetResultForToken(opaqueToken, &pb.IntrospectTokenResponse{ + Active: true, + TokenType: idputil.TokenTypeBearer, + Aud: opaqueTokenRegClaims.Audience, + Exp: opaqueTokenRegClaims.ExpiresAt.Unix(), + Scope: jwtScopeToGRPC(opaqueTokenScope), + }) + + grpcIDPSrv := idptest.NewGRPCServer(idptest.WithGRPCTokenIntrospector(grpcServerTokenIntrospector)) + require.NoError(t, grpcIDPSrv.StartAndWaitForReady(time.Second)) + defer func() { grpcIDPSrv.GracefulStop() }() + + type introspectionRequest struct { + requestNumber int + tokenToIntrospect string + accessToken string + serverRespCode codes.Code // ask server for a specific resp code despite actual auth info + expectedResult idptoken.IntrospectionResult + serverLastAuthorizationMetaExpected string + serverLastSessionMetaExpected string + checkError func(t *gotesting.T, err error) + } + + tCases := []struct { + name string + requestSeries []introspectionRequest + }{ + { + name: "Send valid access token to server on 1st introspection request", + requestSeries: []introspectionRequest{ + { + requestNumber: 1, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "Bearer " + validAccessToken, + serverLastSessionMetaExpected: "", + }, + }, + }, + { + name: "Send valid session id to server upon 2nd introspection request", + requestSeries: []introspectionRequest{ + { + requestNumber: 1, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "Bearer " + validAccessToken, + serverLastSessionMetaExpected: "", + }, + { + requestNumber: 2, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "", + serverLastSessionMetaExpected: validSessionID, + }, + }, + }, + { + name: "Drop session id when 3rd of 4 introspection requests receives 401 from server", + requestSeries: []introspectionRequest{ + { + requestNumber: 1, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "Bearer " + validAccessToken, + serverLastSessionMetaExpected: "", + }, + { + requestNumber: 2, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "", + serverLastSessionMetaExpected: validSessionID, + }, + { + requestNumber: 3, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + serverRespCode: codes.Unauthenticated, // ask server for 401 to invalidate session id in client + checkError: func(t *gotesting.T, err error) { + require.ErrorIs(t, err, idptoken.ErrUnauthenticated) + }, + serverLastAuthorizationMetaExpected: "", + serverLastSessionMetaExpected: validSessionID, + }, + { + requestNumber: 4, + tokenToIntrospect: opaqueToken, + accessToken: validAccessToken, + expectedResult: &idptoken.DefaultIntrospectionResult{ + Active: true, + TokenType: idputil.TokenTypeBearer, + DefaultClaims: jwt.DefaultClaims{ + RegisteredClaims: jwtgo.RegisteredClaims{ExpiresAt: opaqueTokenRegClaims.ExpiresAt}, + Scope: opaqueTokenScope, + }, + }, + serverLastAuthorizationMetaExpected: "Bearer " + validAccessToken, + serverLastSessionMetaExpected: "", // prev 401 drops session id in client so access token ust be used + }, + }, + }, + } + + for _, tc := range tCases { + t.Run(tc.name, func(t *gotesting.T) { + grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) + require.NoError(t, err) + defer func() { require.NoError(t, grpcClient.Close()) }() + grpcServerTokenIntrospector.ResetCallsInfo() + + for _, req := range tc.requestSeries { + ctx := context.Background() + if req.serverRespCode != 0 { + ctx = metadata.AppendToOutgoingContext( + ctx, testing.TestMetaRequestedRespCode, strconv.FormatUint(uint64(req.serverRespCode), 10), + ) + } + result, introspectErr := grpcClient.IntrospectToken(ctx, req.tokenToIntrospect, nil, req.accessToken) + if req.checkError != nil { + req.checkError(t, introspectErr) + } else { + require.Equal(t, req.expectedResult, result) + } + require.Equal(t, req.serverLastAuthorizationMetaExpected, grpcServerTokenIntrospector.LastAuthorizationMeta, + fmt.Sprintf("unexpected server auth meta with introspection request number %d", req.requestNumber)) + require.Equal(t, req.serverLastSessionMetaExpected, grpcServerTokenIntrospector.LastSessionMeta, + fmt.Sprintf("unexpected server session meta with introspection request number %d", req.requestNumber)) + if req.expectedResult != nil { + require.Equal(t, req.tokenToIntrospect, grpcServerTokenIntrospector.LastRequest.Token, + fmt.Sprintf("unexpected introspection result with introspection request number %d", req.requestNumber)) + } + } + }) + } +} + func TestGRPCClient_ExchangeToken(t *gotesting.T) { tokenExpiresIn := time.Hour tokenExpiresAt := time.Now().Add(time.Hour) diff --git a/idptoken/introspector_test.go b/idptoken/introspector_test.go index 12cdf5d..58e9894 100644 --- a/idptoken/introspector_test.go +++ b/idptoken/introspector_test.go @@ -43,10 +43,6 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { require.NoError(t, grpcIDPSrv.StartAndWaitForReady(time.Second)) defer func() { grpcIDPSrv.GracefulStop() }() - grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) - require.NoError(t, err) - defer func() { require.NoError(t, grpcClient.Close()) }() - jwtScopeToGRPC := func(jwtScope []jwt.AccessPolicy) []*pb.AccessTokenScope { grpcScope := make([]*pb.AccessTokenScope, len(jwtScope)) for i, scope := range jwtScope { @@ -159,6 +155,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { tests := []struct { name string + useGRPCClient bool introspectorOpts idptoken.IntrospectorOpts tokenToIntrospect string accessToken string @@ -390,9 +387,9 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedHTTPSrvCalled: true, }, { - name: "ok, grpc introspection endpoint, opaque token", + name: "ok, grpc introspection endpoint, opaque token", + useGRPCClient: true, introspectorOpts: idptoken.IntrospectorOpts{ - GRPCClient: grpcClient, ScopeFilter: jwt.ScopeFilter{ {ResourceNamespace: "account-server"}, {ResourceNamespace: "tenant-manager"}, @@ -414,10 +411,9 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { }, }, { - name: "error, grpc introspection endpoint, opaque token, unauthenticated", - introspectorOpts: idptoken.IntrospectorOpts{ - GRPCClient: grpcClient, - }, + name: "error, grpc introspection endpoint, opaque token, unauthenticated", + useGRPCClient: true, + introspectorOpts: idptoken.IntrospectorOpts{}, tokenToIntrospect: opaqueToken, accessToken: "invalid-access-token", checkError: func(t *gotesting.T, err error) { @@ -426,9 +422,9 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedGRPCSrvCalled: true, }, { - name: "error, grpc introspection endpoint, jwt token, invalid audience", + name: "error, grpc introspection endpoint, jwt token, invalid audience", + useGRPCClient: true, introspectorOpts: idptoken.IntrospectorOpts{ - GRPCClient: grpcClient, RequireAudience: true, ExpectedAudience: []string{"https://rs.my-service.com"}, }, @@ -440,9 +436,9 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { expectedGRPCSrvCalled: true, }, { - name: "error, grpc introspection endpoint, opaque token, audience is missing", + name: "error, grpc introspection endpoint, opaque token, audience is missing", + useGRPCClient: true, introspectorOpts: idptoken.IntrospectorOpts{ - GRPCClient: grpcClient, RequireAudience: true, ExpectedAudience: []string{"https://rs.my-service.com"}, }, @@ -458,6 +454,13 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) { } for _, tt := range tests { t.Run(tt.name, func(t *gotesting.T) { + if tt.useGRPCClient { + // gRPC client is created and used by condition to avoid preserving its state (sessionID) between tests + grpcClient, err := idptoken.NewGRPCClient(grpcIDPSrv.Addr(), insecure.NewCredentials()) + require.NoError(t, err) + defer func() { require.NoError(t, grpcClient.Close()) }() + tt.introspectorOpts.GRPCClient = grpcClient + } if tt.accessToken == "" { tt.accessToken = validAccessToken } diff --git a/internal/testing/server_token_introspector_mock.go b/internal/testing/server_token_introspector_mock.go index cb9aef0..f57db8c 100644 --- a/internal/testing/server_token_introspector_mock.go +++ b/internal/testing/server_token_introspector_mock.go @@ -9,9 +9,12 @@ package testing import ( "context" "crypto/sha256" + "encoding/base64" "net/http" "net/url" + "strconv" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -95,6 +98,10 @@ func (m *HTTPServerTokenIntrospectorMock) ResetCallsInfo() { m.LastFormValues = nil } +const ( + TestMetaRequestedRespCode = "x-requested-resp-code" +) + type GRPCServerTokenIntrospectorMock struct { JWTParser JWTParser @@ -105,6 +112,7 @@ type GRPCServerTokenIntrospectorMock struct { Called bool LastAuthorizationMeta string + LastSessionMeta string LastRequest *pb.IntrospectTokenRequest } @@ -131,19 +139,48 @@ func (m *GRPCServerTokenIntrospectorMock) IntrospectToken( ctx context.Context, req *pb.IntrospectTokenRequest, ) (*pb.IntrospectTokenResponse, error) { m.Called = true - if mdVal := metadata.ValueFromIncomingContext(ctx, "authorization"); len(mdVal) != 0 { + md, found := metadata.FromIncomingContext(ctx) + if !found { + return nil, status.Error(codes.Internal, "incoming context contains no metadata") + } + if mdVal := md.Get("authorization"); len(mdVal) != 0 { m.LastAuthorizationMeta = mdVal[0] } else { m.LastAuthorizationMeta = "" } + if mdVal := md.Get("x-session-id"); len(mdVal) != 0 { + m.LastSessionMeta = mdVal[0] + } else { + m.LastSessionMeta = "" + } + var requestedResponseCode codes.Code + if mdVal := md.Get(TestMetaRequestedRespCode); len(mdVal) != 0 { + if code, err := strconv.ParseUint(mdVal[0], 10, 32); err == nil { + requestedResponseCode = codes.Code(code) + } + } else { + requestedResponseCode = 0 + } m.LastRequest = req - if m.LastAuthorizationMeta == "" { - return nil, status.Error(codes.Unauthenticated, "Access Token is missing") + if requestedResponseCode != 0 { + return nil, status.Error(requestedResponseCode, "Explicitly requested response code is returned") } - if m.LastAuthorizationMeta != "Bearer "+m.accessTokenForIntrospection { + + if m.LastAuthorizationMeta == "" && m.LastSessionMeta == "" { + return nil, status.Error(codes.Unauthenticated, "Access Token or Session ID is missing") + } + if m.LastAuthorizationMeta != "" && m.LastAuthorizationMeta != "Bearer "+m.accessTokenForIntrospection { return nil, status.Error(codes.Unauthenticated, "Access Token is invalid") } + if m.LastSessionMeta != "" && m.LastSessionMeta != GenerateSessionID(m.accessTokenForIntrospection) { + return nil, status.Error(codes.Unauthenticated, "Session ID is invalid") + } + + sessionMD := metadata.Pairs("x-session-id", GenerateSessionID(m.accessTokenForIntrospection)) + if err := grpc.SetHeader(ctx, sessionMD); err != nil { + return nil, status.Error(codes.Internal, "set x-session-id header") + } if result, ok := m.introspectionResults[tokenToKey(req.Token)]; ok { return result, nil @@ -172,9 +209,15 @@ func (m *GRPCServerTokenIntrospectorMock) IntrospectToken( func (m *GRPCServerTokenIntrospectorMock) ResetCallsInfo() { m.Called = false m.LastAuthorizationMeta = "" + m.LastSessionMeta = "" m.LastRequest = nil } func tokenToKey(token string) [sha256.Size]byte { return sha256.Sum256([]byte(token)) } + +func GenerateSessionID(token string) string { + sha := sha256.Sum256([]byte(token)) + return base64.StdEncoding.EncodeToString(sha[:sha256.Size]) +}