DEV Community

Cover image for Use gRPC interceptor for authorization with JWT
TECH SCHOOL
TECH SCHOOL

Posted on • Updated on

Use gRPC interceptor for authorization with JWT

Hello everyone and welcome back to the gRPC course!

Today we will learn how to use gRPC interceptors to authenticate and authorize users with JWT.

Here's the link to the full gRPC course playlist on Youtube
Github repository: pcbook-go and pcbook-java
Gitlab repository: pcbook-go and pcbook-java

What is a gRPC interceptor?

Well, basically, it’s like a middleware function that can be added on both server side and client side.

  • A server-side interceptor is a function that will be called by the gRPC server before reaching the actual RPC method. It can be used for multiple purposes such as logging, tracing, rate-limiting, authentication and authorization.
  • Similarly, a client-side interceptor is a function that will be called by the gRPC client before invoking the actual RPC.

what-is-grpc-interceptor

In this lecture, we will:

  • First, implement a server interceptor to authorize access to our gRPC APIs with JSON web token (JWT). With this interceptor, we will make sure that only the users with some specific roles can call a specific API on our server.
  • Then, we will implement a client interceptor to login user and attach the JWT to the request before calling the gRPC API.

A simple server interceptor

Alright, let’s start by adding a simple interceptor to our gRPC server. There are 2 types of interceptor: 1 for unary RPCs and the other for stream RPCs.

Unary interceptor

In this grpc.NewServer() function, let’s add a new grpc.UnaryInterceptor() option. It expects a unary server interceptor function as input.

interceptor

We can press Command and click on the function name to go to its definition, copy the function signature and paste it to our server/main.go file.

It has some input parameters, such as a context, a request, a unary server info, and the actual unary handler. It returns a response and an error.

func unaryInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    log.Println("--> unary interceptor: ", info.FullMethod)
    return handler(ctx, req)
}

func main() {
    ...
    grpcServer := grpc.NewServer(
       grpc.UnaryInterceptor(unaryInterceptor),
    )
    ...
}
Enter fullscreen mode Exit fullscreen mode

Here we just write a simple log saying "unary interceptor" together with the full method name of the RPC being called.

Then we call the actual handler with the original context and request, and return its result.

Stream interceptor

We can do the same for the stream interceptor:

  • Add grpc.StreamInterceptor() option.
  • Follow the definition to get the function signature.
  • Copy and paste it to the server/main.go file.
  • Pass that function to the stream interceptor option.
  • Write a log with the full RPC method name.
  • This time the handler will be called with the original server and stream parameter.
func streamInterceptor(
    srv interface{},
    stream grpc.ServerStream,
    info *grpc.StreamServerInfo,
    handler grpc.StreamHandler,
) error {
    log.Println("--> stream interceptor: ", info.FullMethod)
    return handler(srv, stream)
}

func main() {
    ...
    grpcServer := grpc.NewServer(
       grpc.UnaryInterceptor(unaryInterceptor),
       grpc.StreamInterceptor(streamInterceptor),
    )
    ...
}
Enter fullscreen mode Exit fullscreen mode

Run client & server

Alright, let’s try it! First start the server, Then open a new tab and run the client.

server-log

As you can see on the server logs, the unary interceptor is called 3 times for the create laptop RPC.

This is the full name of that RPC: /techschool.pcbook.LaptopService/CreateLaptop. It includes the package name techschool.pcbook and the service name LaptopService.

Now let’s enter y on the client to rate the 3 created laptops.

client-log

This time on the server side, the stream interceptor is called once.

server-stream-log

Awesome!

So now you’ve got some ideas of how the server interceptor works. We will extend its functionality to authenticate and authorize user requests.

To do that, we will need to add users to our system, and add a service to login user and return JWT access token.

User struct

Let’s create service/user.go file and define a new User struct. It will contain a username, a hashed_password, and a role.

type User struct {
    Username       string
    HashedPassword string
    Role           string
}
Enter fullscreen mode Exit fullscreen mode

We add a NewUser() function to make a new user, which takes a username, a password, and a role as inputs, and returns a User object and an error.

We should never store plaintext password in the system, so here we use bcrypt to hash the password first. Let’s use the default cost for now.

func NewUser(username string, password string, role string) (*User, error) {
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("cannot hash password: %w", err)
    }

    user := &User{
        Username:       username,
        HashedPassword: string(hashedPassword),
        Role:           role,
    }

    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

If an error occurs, just wrap and return it. Else, we create a new user object with the username, the hashed password, the role and return it.

Next we define a method on the user to check if a given password is correct or not. All we need to do is to call bcrypt.CompareHashAndPassword() function, pass in the user’s hashed password, and the given plaintext password. Then return true if error is nil.

func (user *User) IsCorrectPassword(password string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password))
    return err == nil
}
Enter fullscreen mode Exit fullscreen mode

I will add one more function to clone the user. This function will be useful for us later because we’re gonna store users in memory.

func (user *User) Clone() *User {
    return &User{
        Username:       user.Username,
        HashedPassword: user.HashedPassword,
        Role:           user.Role,
    }
}
Enter fullscreen mode Exit fullscreen mode

User store

Alright, Now let’s create service/user_store.go file and define a UserStore interface, just like what we did for laptop store and rating store in previous lectures.

It will have 2 functions:

  • One function for saving a user to the store.
  • And the other function to find a user in the store by username.
type UserStore interface {
    Save(user *User) error
    Find(username string) (*User, error)
}
Enter fullscreen mode Exit fullscreen mode

Now let’s implement this interface with the in-memory user store. It has a mutex to control concurrent access, and a users map with key is the username. I will write a fuction to build a new in-memory user store, and initialize the users map in it.

type InMemoryUserStore struct {
    mutex sync.RWMutex
    users map[string]*User
}

func NewInMemoryUserStore() *InMemoryUserStore {
    return &InMemoryUserStore{
        users: make(map[string]*User),
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we need to implement the Save and Find function of the interface.

In the Save function, first we acquire the write lock. Then check if a user with the same username already exists or not. It it does, we return an error. Otherwise, we just clone the input user and put it into the map.

func (store *InMemoryUserStore) Save(user *User) error {
    store.mutex.Lock()
    defer store.mutex.Unlock()

    if store.users[user.Username] != nil {
        return ErrAlreadyExists
    }

    store.users[user.Username] = user.Clone()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

For the Find function, we acquire a read lock. Then we get the user by username from the map. If it is nil, we just return nil. Else we return a clone of that user.

func (store *InMemoryUserStore) Find(username string) (*User, error) {
    store.mutex.RLock()
    defer store.mutex.RUnlock()

    user := store.users[username]
    if user == nil {
        return nil, nil
    }

    return user.Clone(), nil
}
Enter fullscreen mode Exit fullscreen mode

And that’s it. The user store is ready.

JWT manager

Now let’s implement a JWT manager to generate and verify access token for users.

The JWTManager struct contains 2 fields:

  • The secret key to sign and verify the access token,
  • And the valid duration of the token.

These 2 fields should be passed as input parameters of the NewJWTManager() function.

type JWTManager struct {
    secretKey     string
    tokenDuration time.Duration
}

func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager {
    return &JWTManager{secretKey, tokenDuration}
}
Enter fullscreen mode Exit fullscreen mode

In order to work with JWT, I will use the jwt-go library. Let's run this command in the terminal to install it:

go get github.com/dgrijalva/jwt-go
Enter fullscreen mode Exit fullscreen mode

OK, now back to our JWT manager. The JSON web token should contain a claims object, which has some useful information about the user who owns it.

So I’m gonna declare a custom UserClaims. It will contain the JWT StandardClaims as a composite field.

type UserClaims struct {
    jwt.StandardClaims
    Username string `json:"username"`
    Role     string `json:"role"`
}
Enter fullscreen mode Exit fullscreen mode

This standard claims has several useful information that we can set, but for the purpose of this demo, I’m just gonna use 1 field, which is the ExpiresAt time of the token.

As you might notice, the claims has a Valid() function, that will automatically check if the token is expired or not for us when we call the parse token function later.

Beside the standard claims, we also add a username field, and another field to store the role of the user because we want to do role-based authorization.

Generate & sign access token

Next, let’s write a function to generate and sign a new access token for a specific user.

In this function, we first create a new user claims object. For the standard claims, I only set the ExpiresAt field.

We have to add the token duration to the current time and convert it to Unix time. Then we set the username and role of the user.

func (manager *JWTManager) Generate(user *User) (string, error) {
    claims := UserClaims{
        StandardClaims: jwt.StandardClaims{
            ExpiresAt: time.Now().Add(manager.tokenDuration).Unix(),
        },
        Username: user.Username,
        Role:     user.Role,
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(manager.secretKey))
}
Enter fullscreen mode Exit fullscreen mode

We call jwt.NewWithClaims() function to generate a token object. For simplicity, here I use a HMAC based signing method, which is HS256. For production, you should consider using stronger methods, Such as RSA or Eliptic-Curve based digital signature algorithms.

The last and also the most important step is to sign the generated token with your secret key. This will make sure that no one can fake an access token, since they don’t have your secret key.

Parse & Verify access token

Now let’s add another function to verify an access token, and return a user claims if the token is valid.

We just have to call jwt.ParseWithClaims(), pass in the access token, an empty user claims, and a custom key function. In this function, It’s very important to check the signing method of the token to make sure that it matches with the algorithm our server uses, which in our case is HMAC. If it matches, then we just return the secret key that is used to sign the token.

func (manager *JWTManager) Verify(accessToken string) (*UserClaims, error) {
    token, err := jwt.ParseWithClaims(
        accessToken,
        &UserClaims{},
        func(token *jwt.Token) (interface{}, error) {
            _, ok := token.Method.(*jwt.SigningMethodHMAC)
            if !ok {
                return nil, fmt.Errorf("unexpected token signing method")
            }

            return []byte(manager.secretKey), nil
        },
    )

    if err != nil {
        return nil, fmt.Errorf("invalid token: %w", err)
    }

    claims, ok := token.Claims.(*UserClaims)
    if !ok {
        return nil, fmt.Errorf("invalid token claims")
    }

    return claims, nil
}
Enter fullscreen mode Exit fullscreen mode

The ParseWithClaims() function will return a token object and an error. If the error is not nil, we return invalid token.

Else we get the claims from the token and convert it to a UserClaims object. If the conversion fails, we return invalid token claims error. Otherwise, just return the user claims.

And we’re done!

Implement Auth service server

The next thing we need to do is to provide a new service for client to login and get the access token.

Let’s create a new proto/auth_service.proto file.

We define a LoginRequest message with 2 fields: a string username, and a string password. Then a LoginResponse message with only 1 field: the access_token.

message LoginRequest {
  string username = 1;
  string password = 2;
}

message LoginResponse { string access_token = 1; }

service AuthService {
  rpc Login(LoginRequest) returns (LoginResponse) {};
}
Enter fullscreen mode Exit fullscreen mode

We define a new AuthService. It has only 1 unary RPC: Login, which takes a LoginRequest as input, and returns a LoginResponse.

Alright, Now let’s run make gen in the terminal to generate new codes for this service.

Generated auth service

The pb/auth_service.pb.go file is generated. Now just like what we did with the laptop server in the previous lectures, I’m gonna create a new service/auth_server.go file to implement this new service.

The AuthServer struct will contains a UserStore and a JWTManager. Let’s add a function to build and return a new AuthServer object.

type AuthServer struct {
    userStore  UserStore
    jwtManager *JWTManager
}

func NewAuthServer(userStore UserStore, jwtManager *JWTManager) *AuthServer {
    return &AuthServer{userStore, jwtManager}
}
Enter fullscreen mode Exit fullscreen mode

Then we open the generated file pb/auth_service.pb.go file to copy the Login function signature and paste it to our service/auth_server.go file. Then make it a method of the AuthServer struct.

This method is very easy to implement. First we call userStore.Find() to find the user by username. If there’s an error, just return it with an Internal error code.

Else, if the user is not found or if the password is incorrect then we return status code NotFound with a message saying the username or password is incorrect.

func (server *AuthServer) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
    user, err := server.userStore.Find(req.GetUsername())
    if err != nil {
        return nil, status.Errorf(codes.Internal, "cannot find user: %v", err)
    }

    if user == nil || !user.IsCorrectPassword(req.GetPassword()) {
        return nil, status.Errorf(codes.NotFound, "incorrect username/password")
    }

    token, err := server.jwtManager.Generate(user)
    if err != nil {
        return nil, status.Errorf(codes.Internal, "cannot generate access token")
    }

    res := &pb.LoginResponse{AccessToken: token}
    return res, nil
}
Enter fullscreen mode Exit fullscreen mode

If the user is found and the password is correct, we call jwtManager.Generate() to generate a new access token. If an error occurs, we return it with Internal status code.

Otherwise, we create a new login response object with the generated access token, and return it to the client.

Add auth service to gRPC server

OK, now we have to add this new authentication service to the gRPC server. Let’s open cmd/server/main.go file.

First we need to create a new InMemoryUserStore. Then we create a new JWTManager with a secret key and token duration.

To make it simple, I just define them as constants here. In reality, we should load them from environment variables or a config file. For the demo purpose, let’s set the token duration to be 15 minutes.

const (
    secretKey     = "secret"
    tokenDuration = 15 * time.Minute
)

func main() {
    ...

    userStore := service.NewInMemoryUserStore()
    jwtManager := service.NewJWTManager(secretKey, tokenDuration)

    authServer := service.NewAuthServer(userStore, jwtManager)
    pb.RegisterAuthServiceServer(grpcServer, authServer)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Then we create a new AuthServer with the UserStore and JWTManager. And we call pb.RegisterAuthServiceServer to add it to the gRPC server.

Seed users

In order to test the new login API, we have to add some seed users. I will write a function to create a user given its username, password and role, and save it to the user store.

func createUser(userStore service.UserStore, username, password, role string) error {
    user, err := service.NewUser(username, password, role)
    if err != nil {
        return err
    }
    return userStore.Save(user)
}
Enter fullscreen mode Exit fullscreen mode

Then in the seedUsers() function, I call the createUser() function 2 times to create 1 admin user and 1 normal user. Let’s say they have the same secret password.

func seedUsers(userStore service.UserStore) error {
    err := createUser(userStore, "admin1", "secret", "admin")
    if err != nil {
        return err
    }
    return createUser(userStore, "user1", "secret", "user")
}
Enter fullscreen mode Exit fullscreen mode

Then in the main function, we call seedUsers() right after creating the user store.

func main() {
    ...

    userStore := service.NewInMemoryUserStore()
    err := seedUsers(userStore)
    if err != nil {
        log.Fatal("cannot seed users: ", err)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

Try the auth service with Evans CLI

Alright. Let’s try it! I will start the gRPC server. Then use the Evans client to connect to it.

When we call show service, we can see the new AuthService with a Login RPC in the result.

Let’s select the AuthService and call Login RPC with username admin1 and an incorrect password.

evans-incorrect-password

The server returns incorrect username/password as expected. Now let’s call login again, but this time enter a correct password for admin1.

evans-correct-password

The request is successful, and we’ve got an access token.

Let’s copy this token and go to jwt.io on the browser. Paste the token into the Encoded area.

decode-jwt

We can see on the Decoded area, the header part contains the algorithm HS256, and token type JWT. The payload part contains the user claims with an expire time, a username, and a role.

So the login RPC is working perfectly. However we haven’t written a proper interceptor to verify the token yet. So let’s go back to the code and do that.

Implement server's auth interceptor

I will create a new service/auth_interceptor.go file, and define a new AuthInterceptor struct inside it.

This interceptor will contain a JWT manager and a map that define for each RPC method a list of roles that can access it. The key of the map is the full method name, and its value is a slice of role names.

type AuthInterceptor struct {
    jwtManager      *JWTManager
    accessibleRoles map[string][]string
}
Enter fullscreen mode Exit fullscreen mode

Let’s write a NewAuthInterceptor() function to build and return a new auth interceptor object. The jwt manager and accessible roles map will be the input parameters of this function.

func NewAuthInterceptor(jwtManager *JWTManager, accessibleRoles map[string][]string) *AuthInterceptor {
    return &AuthInterceptor{jwtManager, accessibleRoles}
}
Enter fullscreen mode Exit fullscreen mode

Now I will add a new Unary() method to the auth interceptor object, which will create and return a gRPC unary server interceptor function.

Then let’s open cmd/server/main.go file, and copy the unaryInterceptor() function that we’ve written before. And paste it inside this Unary() function. It’s exactly the function that we need to return.

func (interceptor *AuthInterceptor) Unary() grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        log.Println("--> unary interceptor: ", info.FullMethod)

        // TODO: implement authorization

        return handler(ctx, req)
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, we add a new Stream() method, which will create and return a gRPC stream server interceptor function. Then copy and paste the stream interceptor function here.

func (interceptor *AuthInterceptor) Stream() grpc.StreamServerInterceptor {
    return func(
        srv interface{},
        stream grpc.ServerStream,
        info *grpc.StreamServerInfo,
        handler grpc.StreamHandler,
    ) error {
        log.Println("--> stream interceptor: ", info.FullMethod)

        // TODO: implement authorization

        return handler(srv, stream)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now in the main.go file, we have to create a new interceptor object with the jwt manager and a map of accessible roles. For this demo, I will just write a function to return this constant map.

Then in the grpc.NewServer() function, we can pass in the interceptor.Unary() and interceptor.Stream().

func main() {
    ...

    interceptor := service.NewAuthInterceptor(jwtManager, accessibleRoles())
    grpcServer := grpc.NewServer(
        grpc.UnaryInterceptor(interceptor.Unary()),
        grpc.StreamInterceptor(interceptor.Stream()),
    )

    ...
}
Enter fullscreen mode Exit fullscreen mode

Define accessible roles

Now for the accessibleRoles() function, we need to prepare a list of RPC methods and the roles that can access each of them.

To get the full RPC method name, let’s open the terminal and run make client. Then in the server logs, we can see the full method name of the CreateLaptop RPC: /techschool.pcbook.LaptopService/

full-method

We know that all methods of LaptopService will starts with the same path, so I define a constant for it here.

Then we can create and return a map like this:

  • The first method is CreateLaptop, which only admin users can call.
  • The UploadImage method is also accessible for admin only.
  • The RateLaptop method can be called by both admin and user.
  • And let’s say the SearchLaptop API is accessible by everyone, even for non-registered users. So the idea is: we don’t put SearchLaptop or any other publicly accessible RPCs in this map.
func accessibleRoles() map[string][]string {
    const laptopServicePath = "/techschool.pcbook.LaptopService/"

    return map[string][]string{
        laptopServicePath + "CreateLaptop": {"admin"},
        laptopServicePath + "UploadImage":  {"admin"},
        laptopServicePath + "RateLaptop":   {"admin", "user"},
    }
}
Enter fullscreen mode Exit fullscreen mode

OK, now let’s get back to the AuthInterceptor. We will define a new authorize() function, which will take a context and method as input, and will return an error if the request is unauthorized.

func (interceptor *AuthInterceptor) authorize(ctx context.Context, method string) error {
    // TODO: implement this
}
Enter fullscreen mode Exit fullscreen mode

Then in the Unary() function, we call interceptor.authorize() with the input context and info.FullMethod. If error is not nil, we return immediately.

In similar fashion, For the Stream() function, we call interceptor.authorize() with the stream context and info.FullMethod, and return right away if an error is returned.

func (interceptor *AuthInterceptor) Unary() grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        log.Println("--> unary interceptor: ", info.FullMethod)

        err := interceptor.authorize(ctx, info.FullMethod)
        if err != nil {
            return nil, err
        }

        return handler(ctx, req)
    }
}

// Stream returns a server interceptor function to authenticate and authorize stream RPC
func (interceptor *AuthInterceptor) Stream() grpc.StreamServerInterceptor {
    return func(
        srv interface{},
        stream grpc.ServerStream,
        info *grpc.StreamServerInfo,
        handler grpc.StreamHandler,
    ) error {
        log.Println("--> stream interceptor: ", info.FullMethod)

        err := interceptor.authorize(stream.Context(), info.FullMethod)
        if err != nil {
            return err
        }

        return handler(srv, stream)
    }
}
Enter fullscreen mode Exit fullscreen mode

Implement authorize function

Now let’s implement the authorize() function.

First we get the list of roles that can access the target RPC method. If it’s not in the map, then it means the RPC is publicly accessible, so we simply return nil in this case.

Else, we should get the access token from the context. To do that, we use the grpc/metadata package.

We call metadata.FromIncomingContext() to get the metadata of the request. If it’s not provided, we return an error with Unauthenticated status code.

Else we get the values from the authorization metadata key. If it’s empty, we return Unauthenticated code because the token is not provided.

func (interceptor *AuthInterceptor) authorize(ctx context.Context, method string) error {
    accessibleRoles, ok := interceptor.accessibleRoles[method]
    if !ok {
        // everyone can access
        return nil
    }

    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return status.Errorf(codes.Unauthenticated, "metadata is not provided")
    }

    values := md["authorization"]
    if len(values) == 0 {
        return status.Errorf(codes.Unauthenticated, "authorization token is not provided")
    }

    accessToken := values[0]
    claims, err := interceptor.jwtManager.Verify(accessToken)
    if err != nil {
        return status.Errorf(codes.Unauthenticated, "access token is invalid: %v", err)
    }

    for _, role := range accessibleRoles {
        if role == claims.Role {
            return nil
        }
    }

    return status.Error(codes.PermissionDenied, "no permission to access this RPC")
}
Enter fullscreen mode Exit fullscreen mode

Otherwise, the access token should be stored in the 1st element of the values. We then call jwtManager.Verify() to verify the token and get back the claims. If an error occurs, it means the token is invalid. So we just return Unauthenticated code to the client.

Else, we iterate through the accessible roles to check if the user’s role can access this RPC or not. If the user’s role is found in the list, we simply return nil. If not, we return PermissionDenied status code, and a message saying user doesn’t have permission to access this RPC.

And we’re done with the server authorization interceptor! Let’s test it on the terminal. First start the server. Then run the client.

unauthenticated

We’ve got unauthenticated error, with reason authorization token is not provided. That's exactly what we expect, because we haven’t login and add access token to the request on the client side yet.

So let’s do that now!

Implement Auth service client

As our client code is getting bigger, I will create a separate client package for it. Then create a new auth_client.go file inside this folder.

Let’s define the AuthClient struct to call authentication service. This struct will contain a service field, which is the AuthServiceClient generated by protoc.

It also contains a username and a password that will be used to login and get access token.

type AuthClient struct {
    service  pb.AuthServiceClient
    username string
    password string
}
Enter fullscreen mode Exit fullscreen mode

As usual, we define a function to create and return a new AuthClient object. It will have 3 input parameters: a grpc client connection, a username and a password.

func NewAuthClient(cc *grpc.ClientConn, username string, password string) *AuthClient {
    service := pb.NewAuthServiceClient(cc)
    return &AuthClient{service, username, password}
}
Enter fullscreen mode Exit fullscreen mode

The service is created by calling pb.NewAuthServiceClient() and pass in the connection.

Next we write a Login() function to call Login RPC to get access token. Just like other RPC, we create a context with timeout after 5 seconds, call defer cancel().

func (client *AuthClient) Login() (string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    req := &pb.LoginRequest{
        Username: client.username,
        Password: client.password,
    }

    res, err := client.service.Login(ctx, req)
    if err != nil {
        return "", err
    }

    return res.GetAccessToken(), nil
}
Enter fullscreen mode Exit fullscreen mode

Then we make a login request with the provided username and password. And call service.Login() with that request.

If error is not nil, we simply return it. Else, we return the responded access token to the caller. And the auth client is completed.

Implement client interceptor

Now let’s learn how to use it to build a client interceptor. I’m gonna create a new client/auth_interceptor.go file in the client packge.

The idea is: we will intercept all gRPC requests and attach an access token to them (if necessary) before invoking the server.

Similar to what we did on the server, I will define an AuthInterceptor struct. It contains an auth client object that will be used to login user, a map to tell us which method needs authentication, and the latest acquired access token.

type AuthInterceptor struct {
    authClient  *AuthClient
    authMethods map[string]bool
    accessToken string
}
Enter fullscreen mode Exit fullscreen mode

What does that mean? Well, basically what we will do is to launch a separate go routine to periodically call login API to get a new access token before the current token expired.

So in the NewAuthInterceptor() function, beside the auth client and auth methods, we also need a refresh token duration parameter. It will tell us how often we should call the login API to get a new token.

func NewAuthInterceptor(
    authClient *AuthClient,
    authMethods map[string]bool,
    refreshDuration time.Duration,
) (*AuthInterceptor, error) {
    interceptor := &AuthInterceptor{
        authClient:  authClient,
        authMethods: authMethods,
    }

    err := interceptor.scheduleRefreshToken(refreshDuration)
    if err != nil {
        return nil, err
    }

    return interceptor, nil
}
Enter fullscreen mode Exit fullscreen mode

In this function, first we will create a new interceptor object. Then we will call an internal function: scheduleRefreshToken() to schedule refreshing access token and pass in the refresh duration. If an error occurs, just return it. Or else, return the interceptor.

Setup schedule to refresh token

Now let’s implement the scheduleRefreshToken() function.

First we will need a function to just refresh token with no scheduling. In this function, we just use the auth client to login user.

func (interceptor *AuthInterceptor) refreshToken() error {
    accessToken, err := interceptor.authClient.Login()
    if err != nil {
        return err
    }

    interceptor.accessToken = accessToken
    log.Printf("token refreshed: %v", accessToken)

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Once the token is returned, we simply store it in the interceptor.accessToken field. We write a simple log here to observe later, then return nil at the end.

Now in the scheduleRefreshToken() function, we should make sure to call refreshToken() successfully for the first time so that a valid access token is always available to be used.

Then after that, we launch a new go routine. Here I use a wait variable to store how much time we need to wait before refreshing the token.

func (interceptor *AuthInterceptor) scheduleRefreshToken(refreshDuration time.Duration) error {
    err := interceptor.refreshToken()
    if err != nil {
        return err
    }

    go func() {
        wait := refreshDuration
        for {
            time.Sleep(wait)
            err := interceptor.refreshToken()
            if err != nil {
                wait = time.Second
            } else {
                wait = refreshDuration
            }
        }
    }()

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Then we launch an infinite loop, call time.Sleep() to wait. And after that amount of waiting time, call interceptor.refreshToken().

If an error occurs, we should only wait a short period of time, let’s say 1 second, before retrying it. If there’s no error, then we definitely should wait for refreshDuration.

And the token refreshing schedule is done.

Attach token to the request context

Now comes the important part: adding interceptors to attach the token to the request context.

Just like what we did on the server side, this time we define a Unary() function to return a gRPC unary client interceptor.

Inside this interceptor function, let’s write a simple log with the calling method name. Then check if this method needs authentication or not. If the method doesn’t require authentication, then nothing to be done, we simply invoke the RPC with the original context.

If it does, we must attach the access token to the context before invoking the actual RPC.

func (interceptor *AuthInterceptor) Unary() grpc.UnaryClientInterceptor {
    return func(
        ctx context.Context,
        method string,
        req, reply interface{},
        cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker,
        opts ...grpc.CallOption,
    ) error {
        log.Printf("--> unary interceptor: %s", method)

        if interceptor.authMethods[method] {
            return invoker(interceptor.attachToken(ctx), method, req, reply, cc, opts...)
        }

        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

Enter fullscreen mode Exit fullscreen mode

So I will define a new attachToken() function to attach the token to the input context and return the result.

func (interceptor *AuthInterceptor) attachToken(ctx context.Context) context.Context {
    return metadata.AppendToOutgoingContext(ctx, "authorization", interceptor.accessToken)
}
Enter fullscreen mode Exit fullscreen mode

In this function, we just use metadata.AppendToOutgoingContext(), pass in the input context together with an authorization key and the access token value.

Make sure that the authorization key string matches with the one we used on the server side.

The stream interceptor can be implemented in a similar way. We write a log, then check if the method requires authentication or not.

If it does, call streamer() with the context that has been attached with access token or else, just call streamer with the original one.

func (interceptor *AuthInterceptor) Stream() grpc.StreamClientInterceptor {
    return func(
        ctx context.Context,
        desc *grpc.StreamDesc,
        cc *grpc.ClientConn,
        method string,
        streamer grpc.Streamer,
        opts ...grpc.CallOption,
    ) (grpc.ClientStream, error) {
        log.Printf("--> stream interceptor: %s", method)

        if interceptor.authMethods[method] {
            return streamer(interceptor.attachToken(ctx), desc, cc, method, opts...)
        }

        return streamer(ctx, desc, cc, method, opts...)
    }
}
Enter fullscreen mode Exit fullscreen mode

And we’re done with the client-side auth interceptor.

Refactor the gRPC client

Now let’s open the cmd/client/main.go file. Before adding the interceptors to the client, I’m gonna refactor this a bit.

Let’s move this big chunk of code to a separate laptop_client.go file inside the new client package.

In this file, let’s define a LaptopClient struct. It will contain the LaptopServiceClient object. Then as usual, we define a function to create a new laptop client object. It will receive a client connection as input. And use that connection to make a new laptop service client.

// LaptopClient is a client to call laptop service RPCs
type LaptopClient struct {
    service pb.LaptopServiceClient
}

// NewLaptopClient returns a new laptop client
func NewLaptopClient(cc *grpc.ClientConn) *LaptopClient {
    service := pb.NewLaptopServiceClient(cc)
    return &LaptopClient{service}
}
Enter fullscreen mode Exit fullscreen mode

Now we must turn the CreateLaptop() function into a method of laptop client object, and make it public with a uppercase letter C.

Then we do similar things for the SearchLaptop(), UploadImage() and RateLaptop() function.

func (laptopClient *LaptopClient) CreateLaptop(laptop *pb.Laptop) {...}

func (laptopClient *LaptopClient) SearchLaptop(filter *pb.Filter) {...}

func (laptopClient *LaptopClient) UploadImage(laptopID string, imagePath string) {...}

func (laptopClient *LaptopClient) RateLaptop(laptopIDs []string, scores []float64) error {...}
Enter fullscreen mode Exit fullscreen mode

Now let’s go back to the cmd/client/main.go file.

We have to change the pb.LaptopServiceClient to client.LaptopClient. Then call laptopClient.CreateLaptop. We do the same things for all other test functions.

func testCreateLaptop(laptopClient *client.LaptopClient) {
    laptopClient.CreateLaptop(sample.NewLaptop())
}

func testSearchLaptop(laptopClient *client.LaptopClient) {...}

func testUploadImage(laptopClient *client.LaptopClient) {...}

func testRateLaptop(laptopClient *client.LaptopClient) {...}

func main() {
    ...
    authClient := client.NewAuthClient(cc1, username, password)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Add auth interceptor to the client

Now in the main function, we should call client.NewLaptopClient() and pass in the gRPC connection.

const (
    username        = "admin1"
    password        = "secret"
)

func main() {
    serverAddress := flag.String("address", "", "the server address")
    flag.Parse()
    log.Printf("dial server %s", *serverAddress)

    cc1, err := grpc.Dial(*serverAddress, grpc.WithInsecure())
    if err != nil {
        log.Fatal("cannot dial server: ", err)
    }

    authClient := client.NewAuthClient(cc1, username, password)
    ...
}
Enter fullscreen mode Exit fullscreen mode

This is a bit tricky, but we’re gonna need a separate connection for the auth client because it will be used to create an auth interceptor, which will be used to create another connection for the laptop client.

So I’ve changed the connection name to cc1, and create a new auth client with it. To make it simple, here I define username and password as constants.

Then let’s create a new interceptor with the auth client. The refresh duration will also be a constant, let’s say for this demo, we will refresh it every 30 seconds.

func main() {
    ...

    interceptor, err := client.NewAuthInterceptor(authClient, authMethods(), refreshDuration)
    if err != nil {
        log.Fatal("cannot create auth interceptor: ", err)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

And we also need to write a function authMethods() to define the list of methods that requires authentication.

I’m just gonna copy and paste them from the server interceptor, and change this to a map string bool, and change all these values to true.

func authMethods() map[string]bool {
    const laptopServicePath = "/techschool.pcbook.LaptopService/"

    return map[string]bool{
        laptopServicePath + "CreateLaptop": true,
        laptopServicePath + "UploadImage":  true,
        laptopServicePath + "RateLaptop":   true,
    }
}
Enter fullscreen mode Exit fullscreen mode

Alright, back to the main function. We will dial server to create another connection. But this time, we also add 2 dial options: the unary interceptor and the stream interceptor.

func main() {
    ...

    cc2, err := grpc.Dial(
        *serverAddress,
        grpc.WithInsecure(),
        grpc.WithUnaryInterceptor(interceptor.Unary()),
        grpc.WithStreamInterceptor(interceptor.Stream()),
    )
    if err != nil {
        log.Fatal("cannot dial server: ", err)
    }

    laptopClient := client.NewLaptopClient(cc2)
    testRateLaptop(laptopClient)
}
Enter fullscreen mode Exit fullscreen mode

And we’re done with the client.

Test the client's auth interceptor

Let’s open the terminal and try it. First run the server. Then run make client.

client-ok

This time we can see the token refreshed and unary interceptor logs, and 3 laptops were created successfully.

Let’s enter y to rate them.

server-ok

All successful! And we can now see the log for stream interceptor. On the server side, we also see some logs for both unary and stream interceptors.

And after a while, on the client side, we will see that the token is getting refreshed as well.

Before we finish, let’s change the username to user1 and see what happens.

permission-denied

As you might expect, we’ve got permission denied error, because only an user with the admin role can call create laptop API.

So everything is working perfectly. Awesome!

And that’s it for today’s lecture. I hope you enjoyed it.

Thanks for reading and I’ll catch you guys in the next one!


If you like the article, please subscribe to our Youtube channel and follow us on Twitter for more tutorials in the future.


If you want to join me on my current amazing team at Voodoo, check out our job openings here. Remote or onsite in Paris/Amsterdam/London/Berlin/Barcelona with visa sponsorship.

Top comments (5)

Collapse
 
obarbier profile image
Olivier Cedric Barbier

great Video and articles

Collapse
 
simp7 profile image
JeongHyeon Park

Perfect tutorial for using jwt in gRPC with golang. Thank you!

Collapse
 
ctirouzh profile image
Mohammad Kolagar

great article, thanks a lot.

Collapse
 
daviarauj profile image
Davi Araujo

ótimo curso, estou aprendendo muito, obrigadoo!!

Collapse
 
boxcolli profile image
yoonmin

This is great! Thank you.