Backend For Frontend Authentication in Go

mehulgohil

Mehul Gohil

Posted on May 15, 2023

Backend For Frontend Authentication in Go

In the realm of modern web development, creating secure and efficient user authentication flows is paramount. With the rise of microservices and distributed architectures, implementing a robust authentication strategy becomes even more crucial.

The Backend for Frontend (BFF) authentication pattern is a design approach that focuses on providing a dedicated backend to handle and address all authentication requirements and challenges of the frontend application (SPA).

OBJECTIVES

The primary objective of the tech blog is to educate readers about the Backend for Frontend (BFF) authentication pattern and its implementation in the Go programming language. The blog will provide a comprehensive overview of the pattern, its benefits, and the challenges it solves in the context of building authentication systems for front-end applications.

History

SPAs typically use token-based authentication, such as JSON Web Tokens (JWTs), to authenticate and authorize users. However, managing tokens securely in SPA can be challenging. Storing tokens in client-side storage (e.g., local storage or cookies) can expose them to potential attacks like cross-site request forgery (CSRF). Developers must implement stringent security measures to protect tokens and prevent unauthorized access or misuse.

The BFF pattern solves this problem by introducing an intermediate layer — the Backend for Frontend. This layer acts as a proxy between the front-end client and the main backend services, handling authentication-related concerns and providing a specialized authentication interface.

Flow Diagram

Image description

  1. When the frontend needs to authenticate the user, it calls an API endpoint (/login) on the BFF to start the login handshake.

  2. The BFF uses OAuth2 Authorization Code Flow to connect with Auth0 to authenticate and authorize the user and get the id and access tokens.

  3. The backend stores the user’s tokens in a cache.

  4. An encrypted cookie is issued for the frontend representing the user authentication session.

  5. When the frontend needs to call an external API, it passes the encrypted cookie to the BFF together with the URL and data to invoke the API.

  6. The BFF retrieves the access token from the cache and makes a call to the backend API including that token on the authorization header.

  7. When the external API returns a response to the BFF, this one forwards that response back to the frontend.

Implementation

Prerequisite

As a prerequisite to fully understand the proposed solution, I recommend that you get an idea about the following topics if not aware of them already.

  1. Register Regular Web Application in Auth0

  2. Iris Web Frame Work

  3. Authorization Code Flow

Project Structure

Project Structure

Get Started

In order to kickstart the implementation process, I referred to the Auth0 Guide to register my web application. Following the guide, I successfully set up user authentication for the web application using Auth0. With the initial authentication functionality in place, I proceeded to modify the code to incorporate the Backend for Frontend (BFF) Authentication Pattern router.go

router.go

In router.go I define all the routes that are required in order to authenticate and authorize a user.

/login: This endpoint is triggered when the user clicks on the login button in the frontend.

/logout: Frontend routes to this endpoint when user clicks on logout button.

/callback: This endpoint is responsible for the token exchange process with the authorization code.

/shorten: This is one of the backend APIs that will be consumed by the frontend.

type router struct{}
func (router *router) InitRouter(auth *authenticator.Authenticator, redis interfaces.IRedisLayer) *iris.Application {
 app := iris.New()

 loginHandler := controller.LoginHandler{Auth: auth}
 callbackHandler := controller.CallbackHandler{Auth: auth, RedisClient: redis}
 logoutHandler := controller.LogoutHandler{RedisClient: redis}
 backendApiHandler := controller.BackendApiHandler{RedisClient: redis}
 middlewareHandler := middleware.MiddlewareHandler{RedisClient: redis}

 app.Get("/login", loginHandler.Login)
 app.Get("/callback", callbackHandler.Callback)
 app.Get("/logout", logoutHandler.Logout)

 // Backend Api
 app.Post("/shorten", middlewareHandler.IsAuthenticated, backendApiHandler.WriterRedirect)

 return app
}
Enter fullscreen mode Exit fullscreen mode

It’s important to note the usage of the middlewareHandler.IsAuthenticated handler in the backend API route. This handler plays a vital role in validating the user’s login profile. You can find the implementation details in the Middleware section

Controller

In the controller, I have encapsulated the logic for all the routes mentioned earlier. This is where the implementation details for each route are defined and handled.

  • login.go

In the login route, the handler redirects the user to the Identity Provider (IDP) Universal login page. This page allows the user to perform a single sign-on and provide their consent.
After the user provides consent on the IDP Universal login page, the page is redirected back to the /callback endpoint along with the authorization code.

type LoginHandler struct {
 Auth *authenticator.Authenticator
}

func (l *LoginHandler) Login(ctx iris.Context) {
 ctx.Redirect(l.Auth.AuthCodeURL(state, oauth2.SetAuthURLParam("audience", config.EnvVariables.Auth0Audience)), http.StatusTemporaryRedirect)
}
Enter fullscreen mode Exit fullscreen mode

The audience parameter plays a crucial role in providing the audience claim within the payload, which aids in user authorization. For a more detailed understanding, referring to additional resources is recommended.
The key challenge was to include the audience URL parameter in the l.Auth.AuthCodeURL() function. By examining the source code of the oauth2 package and understanding its implementation, I gained insights on how to pass the audience parameter using oauth2.SetAuthURLParam().

  • callback.go

After the user visits the /login route, the /callback URL is called, passing the state and authorization code as parameters.
The handler responsible for the /callback URL validates the value of the state parameter to ensure its integrity and security.
Once the state value is successfully validated, the handler proceeds to exchange the authorization code for an access token.
In addition to token exchange, the handler takes the necessary steps to save the token and user profile information in Redis, a data store commonly used for caching and session management.

type CallbackHandler struct {
 Auth        *authenticator.Authenticator
 RedisClient interfaces.IRedisLayer
}

func (c *CallbackHandler) Callback(ctx iris.Context) {
 if ctx.URLParam("state") != state {
  ctx.StopWithJSON(http.StatusBadRequest, "Invalid state parameter.")
  return
 }

 // Exchange an authorization code for a token.
 token, err := c.Auth.Exchange(ctx.Request().Context(), ctx.URLParam("code"))
 if err != nil {
  ctx.StopWithJSON(http.StatusUnauthorized, "Failed to convert an authorization code into a token.")
  return
 }

 idToken, err := c.Auth.VerifyIDToken(ctx.Request().Context(), token)
 if err != nil {
  ctx.StopWithJSON(http.StatusInternalServerError, "Failed to verify ID Token.")
  return
 }

 var profile map[string]interface{}
 if err := idToken.Claims(&profile); err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 err = c.RedisClient.SetKeyValue(profile["email"].(string)+"_token", token.AccessToken, 24*time.Hour)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }
 err = c.RedisClient.HSetKeyValue(profile["email"].(string)+"_profile", profile, 24*time.Hour)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 ctx.SetCookieKV("logged_id_email", profile["email"].(string))

 // Redirect to logged in page.
 ctx.Redirect(config.EnvVariables.FrontendURL, http.StatusTemporaryRedirect)
}
Enter fullscreen mode Exit fullscreen mode

To store the token and profile information, I have implemented Redis as the caching layer. I create a key for each user by prefixing it with their email address to ensure uniqueness and easy retrieval of data.
In addition, the ctx.SetCookieKV() function is employed to set an encrypted HTTP-only cookie. This cookie is later sent by the frontend when making calls to the backend APIs

  • logout.go

When the user clicks on the logout button, the logout handler performs two main actions. Firstly, it clears out all the cached values, ensuring that any stored token or user profile information is removed. Secondly, it initiates a logout process with Auth0 (IDP), effectively logging out the user from the Identity Provider

type LogoutHandler struct {
 RedisClient interfaces.IRedisLayer
}

func (l *LogoutHandler) Logout(ctx iris.Context) {
 userCookie := ctx.GetCookie("logged_id_email")
 if userCookie == "" {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 // delete token key
 err := l.RedisClient.DeleteKey(userCookie + "_token")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 // delete profile key
 err = l.RedisClient.DeleteKey(userCookie + "_profile")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 logoutUrl, err := url.Parse("https://" + config.EnvVariables.Auth0Domain + "/v2/logout")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 returnTo, err := url.Parse(config.EnvVariables.ShortifyFrontendDomain)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 // remove the logged_id_email http-only cookie from context
 ctx.RemoveCookie("logged_id_email")

 parameters := url.Values{}
 parameters.Add("returnTo", returnTo.String())
 parameters.Add("client_id", config.EnvVariables.Auth0ClientID)
 logoutUrl.RawQuery = parameters.Encode()

 ctx.Redirect(logoutUrl.String(), http.StatusTemporaryRedirect)
}
Enter fullscreen mode Exit fullscreen mode

To delete the cached information, including the user profile and token stored in Redis, I utilize the RedisClient.DeleteKey() function. This ensures that the relevant data is removed from the cache. Additionally, calling Auth0’s /v2/logout API allows us to effectively log out the user from Auth0 as well.
To remove a specific cookie, ctx.RemoveCookie() is used. This function specifically targets and deletes the ‘logged_id_email’ cookie, which was initially set during the /callback handler.

  • backendApi.go

When the frontend makes a request to one of the backend APIs, the Backend for Frontend (BFF) ensures that it fetches the user token from the cache. This token is essential for authentication and authorization purposes.
The BFF then adds the fetched token to the Authorization Header of the request and forwards it to the backend API.
Once the backend API processes the request and generates a response, the BFF acts as a proxy and sends the response back to the frontend, allowing seamless communication between the frontend and the backend.

type BackendApiHandler struct {
 RedisClient interfaces.IRedisLayer
}

func (w *BackendApiHandler) WriterRedirect(ctx iris.Context) {
 raw, err := ctx.User().GetRaw()
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }
 profile := raw.(map[string]string)
 email := profile["email"]

 token, err := w.RedisClient.GetKeyValue(email + "_token")
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 client := &http.Client{}
 req, err := http.NewRequest(ctx.Request().Method, config.EnvVariables.BackendApi, ctx.Request().Body)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 req.Header.Add("Authorization", "Bearer "+token)
 req.Header.Add("Content-Type", "application/json")

 res, err := client.Do(req)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }
 defer res.Body.Close()

 body, err := io.ReadAll(res.Body)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 var respBody map[string]interface{}
 err = json.Unmarshal(body, &respBody)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 ctx.StopWithJSON(res.StatusCode, respBody)
}
Enter fullscreen mode Exit fullscreen mode

Note the ctx.User().GetRaw() fetches the user profile information which was set during the middleware.IsAuthenticated() handler defined below. With the help of email from ctx.User() I get the token from redis cache and add the authorization header.

Middleware

The middleware plays a crucial role in validating the encrypted cookie sent from the frontend along with the API request and also check if user is logged in.

  • isAuhtenticated.go
type MiddlewareHandler struct {
 RedisClient interfaces.IRedisLayer
}

// IsAuthenticated is a middleware that checks if
// the user has already been authenticated previously.
func (m *MiddlewareHandler) IsAuthenticated(ctx iris.Context) {
 userCookie := ctx.GetCookie("logged_id_email")
 if userCookie == "" {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 value, err := m.RedisClient.HGetKeyValue(userCookie + "_profile")
 if err != nil || value == nil {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 ctx.SetUser(value)
 ctx.Next()
}
Enter fullscreen mode Exit fullscreen mode

By utilizing ctx.SetUser(), the user profile information is stored in the Iris context. This information becomes accessible and can be utilized by the backend API handler during request processing.

Authenticator

Authenticator initializes new instance of an Oauth2 provider.

// Authenticator is used to authenticate our users.
type Authenticator struct {
 *oidc.Provider
 oauth2.Config
}

// New instantiates the *Authenticator.
func New() (*Authenticator, error) {
 provider, err := oidc.NewProvider(
  context.Background(),
  "https://"+config.EnvVariables.Auth0Domain+"/",
 )
 if err != nil {
  return nil, err
 }

 conf := oauth2.Config{
  ClientID:     config.EnvVariables.Auth0ClientID,
  ClientSecret: config.EnvVariables.Auth0ClientSecret,
  RedirectURL:  config.EnvVariables.Auth0CallbackURL,
  Endpoint:     provider.Endpoint(),
  Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
 }

 return &Authenticator{
  Provider: provider,
  Config:   conf,
 }, nil
}

// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
 rawIDToken, ok := token.Extra("id_token").(string)
 if !ok {
  return nil, errors.New("no id_token field in oauth2 token")
 }

 oidcConfig := &oidc.Config{
  ClientID: a.ClientID,
 }

 return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
Enter fullscreen mode Exit fullscreen mode

It is crucial to ensure that all the environment variables of the Auth0 application are properly configured and set. This step is necessary to successfully initialize the OAuth2 provider.

About the Flow

Here is what happens when the user authenticates with the application:

  1. The user clicks on the login button on the frontend UI.
  2. The frontend redirects the user to the /login endpoint in the Backend for Frontend (BFF).
  3. The BFF further redirects the user to the Universal Login Page of Auth0.
  4. The user provides their credentials and consents to the authentication process.
  5. After successful authentication, the callback request is sent back to the /callback endpoint in the application.
  6. The callback handler validates the received state parameter, ensuring its integrity and security.
  7. The callback handler exchanges the received authorization code for an access token over a secured channel.
  8. The received access token is validated.
  9. The access token and associated profile information are extracted and cached in a Redis server.
  10. The handler redirects the user back to the frontend UI, along with an HTTP-only cookie.
  11. When the user makes subsequent calls to backend APIs from the frontend, the cookie is included in the request.
  12. The cookie is validated by the middleware handler, which also verifies the user’s logged-in status and checks for the availability of profile information in the cache.
  13. The backend API handler manages the flow, extracting the JWT access token from the cache, and adding it to the Authorization Header of the forwarded request.
  14. The request is then forwarded to the backend API.
  15. Once the response is received from the backend, it is forwarded back to the frontend.

Conclusion

In conclusion, the Backend for Frontend (BFF) authentication pattern in Go provides a powerful and flexible approach to building authentication systems for front-end applications. By separating the authentication concerns into dedicated back-end services, tailored to each front-end client, developers can create robust, scalable, and secure authentication systems that meet the unique requirements of their applications.

I hope this tech blog has provided valuable insights and practical guidance for implementing the BFF authentication pattern in Go. Happy coding!

The source code of this example is available at mehulgohil/go-bffauth

💖 💪 🙅 🚩
mehulgohil
Mehul Gohil

Posted on May 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related