Building Authentication: Part 2
Building the OAuth Callback, issuing JWTs and Cookies, and the first authenticated endpoint
Continuing from Part 1 of this sub-series, this article I'll go over how to handle the parts of the OAuth flow that happen after the user has allowed access to the application. I will also talk about how to record the access tokens, and create a JWT for the Reread app. By the end of this, reread should have a concept of a logged in user, and is able to restrict access to different parts of the application.
I left off at the user landing on a Provider’s authorization page. On the page, the user chooses whether they would like to authorize the Requester (Reread in this case) or not. Either way, the Provider calls back the Requester. If no access was granted, there is an error specified, and if the user allows access to the Requester, the callback contains an ‘authorization code’.
When we say ‘calls back the requester’, the location is a URL specified by the Requester which the user is redirected to after the authorization step. The callback URL is called redirect_uri
in OAuth, and it is specified in a couple of places. First is during the registration process of the Requester. For reread, I told Google and Github which URLs can be used with the application. Giving this information to the Provider upfront prevents someone from hijacking the application with a different requester. Even if the authorization page's URL contains a redirect_uri
that's incorrect, most providers will error out and not call that URL. When registering the Requester, it’s important that both development and production URLs are whitelisted. In reread’s case, I whitelist both https://reread.to/auth/<provider>/callback
and http://localhost:8080/auth/<provider>/callback
.
When the Provider calls back reread, it will contain a 'code'. This code is not an access key, but just a password to exchange for an access key that can then be used for talking to the Provider's API. Within the Go oauth2
library, that's as simple as calling Exchange
with the received code.
For this article, most of the the high-level code is present in the CompleteUserAuthHandler
method. I’ll continue to add code to it as the article progresses.
func CompleteUserAuthHandler(ctx echo.Context) (string, *echo.HTTPError) {
// read the query parameters of the url
params := ctx.QueryParams()
errMessage := params["error"]
if len(errMessage) > 0 {
logger.Error("Error completing oauth2: ", errMessage)
return "", echo.NewHTTPError(http.StatusUnauthorized, errMessage)
}
// if there was no error, check the state if it matches
code := params["code"][0]
state := params["state"][0]
if err := states.validateState(ctx, state); err != nil {
logger.Error("unable to validate state: ", err)
return "", echo.ErrUnauthorized
}
// since everything seems good, exchange the
// authorization code to obtain a token
token, err := providers.ExchangeToken(ctx, code)
if err != nil {
logger.Error("unable to exchange token: ", err)
return "", echo.ErrUnauthorized
}
// …
}
This is the first part of the OAuth callback handler. Right now, /it checks if the authorization was successful, and if so, whether the state matches the one that was sent originally. If there are no errors and the state is validated, the ExchangeToken
function is called. The ExchangeToken
takes the authorization code, and returns a JSON response containing things like an access token, a refresh token, and expiry information. Different providers have different formats for the response, but these fields are usually present.
While ExchangeToken
does all this, neatly wrapped in a single function, essentially what happens is that a POST call is made to the Provider containing the following information:
grant_type
: This is a constant and set toauthorization_code
code
: The code we pass along and what we received as part of the callback- Some form of Client Authentication: This varies from provider to provider, but usually the secret keys received during registration are included with the request.
As much as it would be fun to have a single way of working with all OAuth providers, there are always implementation details that are different. For this reason, using third party libraries that support multiple providers and abstract away some of these details away are the way to go.
Now that we have access keys, we just need to make sure to include them in the calls we make to the Provider. Here too oauth2
comes to the rescue with its Client
method. The Client
method takes the JSON response from the Provider containing the access token, refresh token etc, and facilitates calls to the Provider without any extra steps.
In order to fetch the User email and name, we will need to hit a specific API endpoint on the Provider. For Google, that endpoint is https://www.googleapis.com/oauth2/v3/user
, and it responds with JSON with the following structure:
type GoogleUserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Profile string `json:"profile"`
Picture string `json:"picture"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Gender string `json:"gender"`
}
We only need the Name, and Email contained, so the provider interface exposes a new type:
type ProviderUserInfo struct {
Username string
Name string
Email string
}
Thus, each provider implementation takes in a token, and using the oauth2.Client
object populates the ProviderUserInfo
to be stored in the database. The Google provider populates the ProviderUserInfo
with the following code:
func (p *GoogleProvider) FetchUserInfo(ctx context.Context, token *oauth2.Token) (ProviderUserInfo, error) {
// Create the oauth2 Client
client := p.config.Client(ctx, token)
// Use it to make a call to the user profile endpoint
resp, err := client.Get(p.userProfileRequestUrl)
if err != nil {
return ProviderUserInfo{}, err
}
// Close the body reader when the function ends
defer resp.Body.Close()
// read the entire body
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return ProviderUserInfo{}, err
}
// Convert the JSON returned into a GoogleUserInfo object
var result GoogleUserInfo
if err := json.Unmarshal(data, &result); err != nil {
return ProviderUserInfo{}, err
}
// Extract and convert the GoogleUserInfo object
// into a ProviderUserInfo object
res := ProviderUserInfo{
Username: fmt.Sprintf("user_%s", result.Sub),
Name: result.Name,
Email: result.Email,
}
return res, nil
}
With Github, the process is a bit more complicated. This is because it requires two API calls to obtain both the User’s name and Email. This is again where refactoring the code to allow Provider specific behavior really pays off.
Storing the user information
As mentioned in part 1, We have a schema that allows the access tokens to be stored in a Provider agnostic fashion. In order to create a new User in the database, the following code is used:
func CreateUser(ctx echo.Context, user User) error {
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
return database.WithTransaction(func(d *goqu.TxDatabase) error {
insert := d.Insert(UserTableName).Rows(user).Executor()
if _, err := insert.ExecContext(ctx.Request().Context()); err != nil {
return err
}
return nil
})
}
The database.WithTransaction
is a utility function to abstract away getting a connection, creating a transaction, dealing with rollbacks etc. It allows the model to have cleaner code. In the above CreateUser
function, all I’m doing is inserting a new user to the users
table.
As you can see, I’m not doing any checking whether the user already exists. I opted to keep this logic on the layer above this function. Each method in the models package should be as close to a single SQL query as possible. In this case, the layer responsible for doing sanity checks is the callback handler: CompleteUserAuthHandler
. It is called each time the /auth/<provider>/callback
API is hit. The flow of the callback request handler now looks like this:
func CompleteUserAuthHandler(ctx echo.Context) (string, *echo.HTTPError) {
// … same as before (see earlier code block)
userInfo, err := providers.FetchUserInformation(ctx, token)
if err != nil {
logger.Error("unable to fetch user info from oauth provider: ", err)
return "", echo.ErrUnauthorized
}
// check if user with username exists
user, err := models.GetUserFromUsername(ctx, userInfo.Username)
if err != nil {
logger.Error("unable to look up existing user", err.Error())
return "", echo.ErrInternalServerError
}
if user.Username != "" {
logger.Info("existing user found, not creating")
}
tokenJson, err := json.Marshal(token)
if err != nil {
logger.Error("unable to marshal token json")
return "", echo.ErrInternalServerError
}
// User not found, creating
user = models.User{
Username: userInfo.Username,
Fullname: userInfo.Name,
Email: userInfo.Email,
AuthToken: string(tokenJson),
AuthProviderName: ctx.Param("provider"),
}
err = models.CreateUser(ctx, user)
if err != nil {
logger.Error("unable to create user", err)
return "", echo.ErrInternalServerError
}
The GetUserFromUsername
function hasn’t been explained, but it’s just executing a select query where it checks if a user row with the same username exists. If there is an existing user, I short circuit the handler. If a user with the username doesn’t exist, I call the CreateUser
function.
With the logic now in place for creating the user in the database, the OAuth aspect of authentication is complete. Next up, how to communicate with the front-end client that the user is authenticated.
JWT, and Cookies
Sharing distributed state, in this case between the browser and the API service, deserves a whole article on its own. For now, I'll talk about how I implemented it in Reread. The plan is to use JWTs with the minimum user information (username, user ID, etc) to verify the identity of the caller. While there are various ways of sending JWTs (via authorization headers is the popular way), for now I've decided to use Cookies instead.
Cookies are sent automatically via the browser when it makes request, so they are a really easy way of sending in information with each request from the browser without any client code. Cookies have a host of insecurities, but most can be mitigated reasonably well. In Reread's case, I'll only be using them for storing the JWT token.
JWT, or JSON Web Tokens, is a signed or encrypted JSON blob that's converted into a set of three Base64 strings joined by dots (.). While JWTs can be encrypted, commonly they aren’t for the kind of data I'd like to store for now, instead they are just signed. Using a cryptographic hash algorithm (like HMAC SHA256), the JWT token contains a hash of the token. While signing doesn't hide the contents of the token, it allows the receiver of the JWT to validate the contents.
In order to create a JWT, I use the jwt-go package. In the following code snippets, the word 'Claim' is used. Claims are pieces of information within the JWT that represent the identity of the requester. It's just a piece of jargon that's used by the JWT spec, but for the purposes of understanding, it's just a piece of information that's encoded into the JWT.
const defaultExpiryInSeconds = 30 * 24 * time.Hour
const issuer string = "RereadApiService"
// A Custom type containing all the information we'd like to
// encode in the JWT
type CustomClaims struct {
Username string `json:"username"`
Fullname string `json:"fullname"`
jwt.StandardClaims
}
func MakeToken(ctx echo.Context, user models.User) (string, error) {
config := config.GetConfig()
// all the claims we'd like to make in the JWT
claims := CustomClaims{
user.Username,
user.Fullname,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(defaultExpiryInSeconds).Unix(),
Id: uuid.NewString(),
IssuedAt: time.Now().Unix(),
Issuer: issuer,
NotBefore: time.Now().Unix(),
Subject: user.Username,
},
}
// Specify HMAC-SHA256 as the signer of the token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// convert the JWT into a base64 string, along with signing it
tokenString, err := token.SignedString([]byte(config.JwtSigningKey))
if err != nil {
ctx.Logger().Error("unable to sign key: ", err)
return "", err
}
return tokenString, nil
}
This function returns a string that can then be sent to the client. The JwtSigningKey
is a configuration variable that has to be a secret. If this is leaked, then an attacker can forge a new JWT with fake information and use it to obtain information intended for another user.
With the JWT token string at hand, I can now update the callback handler to generate and send this string back to the client.
jwtString, err := MakeToken(ctx, user)
if err != nil {
return "", echo.ErrInternalServerError
}
return jwtString, nil
This marks the end of the CompleteUserAuthHandler
function. Moving back up to the actual handler of the /auth/<provider>/callback
route, we now have:
func (h *authHandlerConfig) authCallbackHandler(context echo.Context) error {
cfg := config.GetConfig()
// Do all the work for creating the user, and send back a JWT
jwt, err := auth.CompleteUserAuthHandler(context)
if err != nil {
// In case of an error, add a 'error' query parameter
// when redirecting back to the signin page
u, parseError := url.Parse(cfg.ClientUrl + "/auth/signin")
if parseError != nil {
return context.String(http.StatusInternalServerError, "Unexpected error")
}
params := url.Values{}
params.Add("error", err.Error())
u.RawQuery = params.Encode()
return context.Redirect(http.StatusTemporaryRedirect, u.String())
}
// In case of success, create a cookie with the JWT token
util.AddCookie(context, "auth", jwt, time.Now().Add(24*30*time.Hour))
return context.Redirect(http.StatusTemporaryRedirect, config.GetConfig().AuthedHomePath)
}
The util.AddCookie
function is a custom helper method I use to create a cookie with the right configurations:
func AddCookie(ctx echo.Context, name, content string, expiry time.Time) {
cfg := config.GetConfig()
isProduction := cfg.IsProduction
cookie := new(http.Cookie)
cookie.Name = name
cookie.Value = content
cookie.Expires = expiry
cookie.Secure = isProduction
cookie.Path = "/"
cookie.Domain = cfg.Domain
cookie.SameSite = http.SameSiteLaxMode
cookie.HttpOnly = true
ctx.SetCookie(cookie)
}
I won't go too deep here as this information is easily google-able. One thing is that the cookie is not visible by the client app, since I have HTTPOnly
set. Also, on development the cookie is not secure, but is set on production. This will prevent the cookie from ever being sent via an unencrypted connection, like HTTP in production.
Securing endpoints
The client is now able to access private endpoints per user. Each request, if authenticated, will now contain a cookie that contains a JWT that identifies the user. In order to test out this, I created a /user
route that will allow the client to read the currently logged in user's data.
func (h *userHandlerConfig) getUserHandler(context echo.Context) error {
logger := context.Logger()
jwtUser := context.Get("user").(models.User)
user, err := models.GetUserFromUsername(context, jwtUser.Username)
if err != nil {
logger.Error("problem getting user", err)
return context.JSON(http.StatusInternalServerError, nil)
}
if user.Username != jwtUser.Username {
logger.Error("unable to find user with username: ", jwtUser.Username)
return context.JSON(http.StatusUnauthorized, nil)
}
return context.JSON(http.StatusOK, user)
}
The interesting line above is this:
jwtUser := context.Get("user").(models.User)
How did the context get a user
field? In order to prevent having to validate the JWT token, I added a 'middleware' for the router. Middlewares are pieces of code that are shared between all routes. Authentication is a common need for a majority of the API server's endpoints, so it makes sense to use a middleware. Luckily, echo already had a middleware for JWT based authentication that's easy to use:
func makeJwtMiddleware(e *echo.Echo, config *config.Config) {
// `middleware.JWTConfig` is an echo built-in middleware
e.Use(middleware.JWTWithConfig(middleware.JWTConfig{
// Used to verify whether the token was tampered with
SigningKey: []byte(config.JwtSigningKey),
// Where to find the token. In this case, a cookie that's
// called 'auth'
TokenLookup: "cookie:auth",
// Our custom JWT validation function
ParseTokenFunc: auth.ValidateToken,
// Which routes are public, and shouldn't be authenticated
Skipper: func(c echo.Context) bool {
path := c.Request().URL.Path
if strings.Contains(path, "auth/google") {
return true
} else if strings.Contains(path, "auth/github") {
return true
} else if strings.Contains(path, "_health") {
return true
} else if strings.Contains(path, "stats") {
return true
}
return false
},
// Minor tweaks to failure handling
ErrorHandlerWithContext: func(err error, ctx echo.Context) error {
ctx.Logger().Error("Handling JWT Error: ", err)
// We don't want 400 errors on missing tokens, just 401s please
echoError := err.(*echo.HTTPError)
if echoError.Code == 400 {
return echo.ErrUnauthorized
}
return err
},
}))
}
With the middleware now in progress, we can create an authenticated home page that checks whether the user is authenticated, and reads in their full name to display.
// What kind of information we're getting from the /user endpoint
interface UserInfo {
username: string
fullname: string
email: string
}
// A simple fetcher to obtain user information
async function fetcher(url: string): Promise<UserInfo> {
const resp = await fetch(url)
if (!resp.ok) {
const error = new Error('Error in fetching data')
// @ts-ignore
error.info = await resp.json()
// @ts-ignore
error.status = resp.status
throw error
}
return resp.json()
}
function Home(): JSX.Element {
const { data, error } = useSWR('/api/auth/user', fetcher)
useEffect(() => {
// In case we get back a 401 status code (Unauthorized)
// redirect the user back to the sign in page
if (error && error.status == 401) {
Router.replace('/auth/signin')
}
}, [error])
if (error) {
return (
<p>
Error fetching profile: <strong>{error.toString()}</strong>
</p>
)
}
if (!data) {
return <p>Loading…</p>
}
return (
<>
<Head>
<title>Home — Reread.to</title>
</Head>
<h1>
Welcome {data.fullname} <Emoji symbol="👋" />
</h1>
</>
)
}
And with this, user authentication of Reread is complete.