In part one of the topic, I started the initial implementation of a secure authentication system for single-page applications (SPAs) using the Backend for Frontend (BFF) pattern. Here’s a recap of the key points I covered in part one:
- Understanding the BFF Pattern: We discussed the BFF pattern, which involves creating a separate backend service specifically designed to cater to the needs of the frontend.
- Routing: We defined the necessary routes in the router. This included routes for login, logout, callback, and backend APIs.
- Authenticator: We have defined authenticator client and related methods create new client and verify ID tokens.
The main objective of this blog is to explore and demonstrate how the controller handlers for each of the routes id is defined. Throughout the discussion, I will provide code snippets and explanation to illustrate the implementation of controller layer.
Implementation of Authentication Processes
Controller: The controller handles the authentication flows, token exchange, and caching of user profile information.
1. 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 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)
}
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, please check out the additional resources.
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().
2. callback.go
The callback handler plays a significant role here, it handles several crucial tasks, including authorization code exchange, token validation, caching and redirecting user to home page.
I. State Validation with Auth Code Exchange
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
}
The handler starts by validating the “state” parameter received in the callback URL. It compares the parameter value with the predefined “state” variable to ensure its integrity. After that, it exchanges the authorization code received in the callback URL with a token. Once the token is received the handler verifies it using the Auth.VerifyIDToken() method
II. Profile Extraction and Caching
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
}
The handler extracts the user profile information from the ID token’s claims and stores it in a Redis cache. It sets the access token value in the Redis cache with a key based on the user’s email address appended with “_token”. Similarly, it stores the entire profile object in the Redis cache with a key based on the user’s email address appended with “_profile”. Both cached values have an expiration time of 24 hours.
III. Cookie Setting and Redirect
ctx.SetCookieKV("logged_id_email", profile["email"].(string))
// Redirect to logged in page.
ctx.Redirect(config.EnvVariables.FrontendURL, http.StatusTemporaryRedirect)
The handler sets an HTTP-only cookie named “logged_id_email” with the user’s email address as the value. This cookie is used for subsequent requests to authenticate the user and extracting the token from cache. Finally, the handler redirects the user to the logged-in page of the frontend application using the ctx.Redirect() method.
3. logout.go
Overall, the logout handler performs the necessary steps to clear the cached token and profile information. Then remove the authentication cookie and redirect the user to the logout URL which ensures user also logs out from the IDP.
I. Deleting Cached Keys
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
}
The handler utilizes the user cookie to facilitate the deletion of the cached token and profile information that was stored during the callback process.
II. Removing Cookie
// remove the logged_id_email http-only cookie from context
ctx.RemoveCookie("logged_id_email")
The handler removes the “logged_id_email” cookie from the context using the ctx.RemoveCookie() method. This ensures that the cookie is cleared and no longer sent in subsequent requests.
III. Constructing Logout URL and Redirecting
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.FrontendURL)
if err != nil {
ctx.StopWithError(http.StatusInternalServerError, err)
return
}
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)
The handler constructs the logout URL by parsing the Auth0 logout endpoint and appending the necessary parameters. Additionally, it sets the returnTo URL to the frontend URL configured in the environment variables. Finally, the handler redirects the user to the constructed logout URL using the ctx.Redirect() method. This initiates the logout process with Auth0 and redirects the user to the specified returnTo URL, indicating a successful logout.
4. backendApi.go
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)
The handler retrieves the user’s token from the cache, adds it to the request header, sends the request to the backend API, and returns the response as JSON. This enables the BFF to act as a proxy, forwarding requests from the frontend to the backend API while ensuring the proper authentication and authorization of the user.
Middleware
The middleware handler checks if the user is authenticated by verifying the presence of a specific cookie and retrieving the user’s profile information from the Redis cache. It ensures that only authenticated users can proceed to subsequent handlers in the request flow.
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()
}
The handler retrieves the value of the “logged_id_email” cookie from the request context using ctx.GetCookie().It then attempts to retrieve the user’s profile information from the Redis cache using the m.RedisClient.HGetKeyValue() method. If it successfully gets the cache profile value, the profile information is set in the context using ctx.SetUser(). Finally, the handler calls ctx.Next() to proceed to the next handler in the request chain.
Conclusion
The BFF pattern provides several benefits, including enhanced security, separation of concerns, and improved performance. By centralizing the authentication logic in the BFF layer, we ensure that the frontend and backend communicate securely, and only authenticated users can access protected resources. Achieving a robust and scalable authentication solution is also possible by leveraging technologies like Auth0, Redis, and HTTP cookies.
Thank you for joining me on this journey exploring the Backend for Frontend authentication pattern in Golang. I hope that the insights and examples provided in this blog have been valuable to you.
Happy coding!
The source code of this example is available at mehulgohil/go-bffauth