Skip to content

Commit

Permalink
Support persistent session in IntrospectToken method of GRPC client, …
Browse files Browse the repository at this point in the history
…partly PLTFRM-72444
  • Loading branch information
evgeniy.pomortsev authored and vasayxtx committed Feb 10, 2025
1 parent 094a015 commit 4a6a2c2
Show file tree
Hide file tree
Showing 4 changed files with 309 additions and 20 deletions.
34 changes: 32 additions & 2 deletions idptoken/grpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ package idptoken

import (
"context"
"errors"
"fmt"
"strconv"
"sync/atomic"
"time"

"github.com/acronis/go-appkit/log"
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
Expand Down
213 changes: 213 additions & 0 deletions idptoken/grpc_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
31 changes: 17 additions & 14 deletions idptoken/introspector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -159,6 +155,7 @@ func TestIntrospector_IntrospectToken(t *gotesting.T) {

tests := []struct {
name string
useGRPCClient bool
introspectorOpts idptoken.IntrospectorOpts
tokenToIntrospect string
accessToken string
Expand Down Expand Up @@ -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"},
Expand All @@ -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) {
Expand All @@ -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"},
},
Expand All @@ -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"},
},
Expand All @@ -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
}
Expand Down
Loading

0 comments on commit 4a6a2c2

Please sign in to comment.