Building Authentication: Part 2

Building Authentication: Part 2

Building the OAuth Callback, issuing JWTs and Cookies, and the first authenticated endpoint

·

13 min read

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 to authorization_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&hellip;</p>
    }

    return (
        <>
            <Head>
                <title>Home &mdash; Reread.to</title>
            </Head>

            <h1>
                Welcome {data.fullname} <Emoji symbol="👋" />
            </h1>
        </>
    )
}

And with this, user authentication of Reread is complete.

Welcome page which says 'Welcome A plain dev'