Introducing new capabilities for teams, third-party authentication and real-time database streaming!
Read More ->
Fauna logo
FeaturesPricing
Learn
Customers
Company
Support
Log InSign Up
Fauna logo
FeaturesPricing
Customers
Sign Up
© 2021 Fauna, Inc. All Rights Reserved.
<- Back
leaked-tokens

Detecting leaked authentication tokens in FQL

Brecht De Rooms|May 5th, 2021|

Categories:

Authentication

Introduction

The previous post in this series showed you how to implement refresh tokens with a simple blueprint. This post shows you how to implement an advanced refresh token workflow in FQL. The accompanying blueprint rotates refresh tokens and detects leaked tokens in pure FQL.

User-defined functions (UDFs) are the key to this implementation. When you create a UDF, you encapsulate an FQL query and store it in the database. Encapsulating your logic in functions has many advantages, including reusability. Once you load these functions into your database, your FQL queries can reuse the login, logout, refresh, and register logic.

FunctionDescription
loginVerifies credentials and provides refresh and access tokens
logoutRemoves all the access tokens related to a given refresh token or account
refreshCreates new access token
registerCreates a new account

This article assumes basic familiarity with FQL and an understanding of the simple refresh workflow blueprint. To learn more about FQL, visit this series of articles.

Deploying to your own account

The blueprint format allows you to set up or tear down the provided resources with the experimental fauna-schema-migrate tool. To deploy the blueprint to your own Fauna account, follow the “Set up a blueprint” instructions in the repository README.

Implementation

The previous implementation refreshes only the access token and assumes the refresh token is securely stored. However, if the refresh token is exposed, a pattern such as Refresh token rotation forms an extra line of defense.

The login logic, register logic, and access roles are similar to the previous implementation. However, you verify, refresh, and remove tokens differently with the refresh token rotation pattern.

Token verification

This blueprint implements the refresh logic in a separate ‘RefreshToken‘ function. The implementation does not invalidate refresh tokens by deleting them. Instead, it marks tokens as expired, logged out, or used, enabling detection of leaked tokens. Since a token’s presence does not guarantee that it’s still valid, the RefreshToken function first verifies the refresh token and then rotates the tokens.

> fauna/src/refresh.js

export function RefreshToken (...) {
  return VerifyRefreshToken(
    {
      tokens: RotateAccessAndRefreshToken(...),
      account: Get(CurrentIdentity())
    }, ‘refresh’)
}

Reuse detection

The first step in token verification determines whether a refresh token has already been used. Refresh tokens are rotated on use, so they should be used exactly once. A refresh request initiated with a used token can indicate that a malicious actor is using or has already used a leaked token.

export function VerifyRefreshToken (fqlStatement, action) {
  ...
  If(And(IsTokenUsed(), ...)
  ...
}

If a user refreshes multiple browser tabs simultaneously, those tabs can send refresh requests using the same token. This can log out users who open your application in multiple tabs.

To avoid this unexpected behavior, make sure your client never sends simultaneous requests. This implementation avoids the risk by implementing a grace period where a used refresh token is still accepted. The following snippet verifies whether the token has been used outside the grace period.

export function VerifyRefreshToken (fqlStatementOnSuccessfulVerification, action) {
  return If(
    And(IsTokenUsed(), Not(IsWithinGracePeriod())),
    ...,
    ...
  )
}

IsTokenUsed uses CurrentToken() to determine whether the refresh token has been used. When a token is rotated, the value of ‘used’ is set to true.

export function IsTokenUsed () {
  return Select([‘data’, ‘used’], Get(CurrentToken()))
}

When you request a refresh, the current refresh token sets a future timestamp in the ‘gracePeriodUntil’ property. If you have used the token before, IsGracePeriodExpired verifies whether that usage took place before that timestamp.

function IsWithinGracePeriod () {
  return GT(Select([‘data’, ‘gracePeriodUntil’], Get(CurrentToken())), Now())
}

Token expiration

This implementation does not rely on TTL, as it does not delete expired tokens. Keeping expired tokens allows you to understand how often users try to refresh with expired tokens and their age. Instead of relying on TTL, you add an expiration timestamp to the token upon creation. Add the following condition to VerifyRefreshToken to determine whether the refresh token is still valid.

export function VerifyRefreshToken (fqlStatementOnSuccessfulVerification, action) {
  return If(And(IsTokenUsed(), Not(IsWithinGracePeriod())),
    ...
    If(IsTokenStillValid(),
      ...,
      ...
    )
  )
}

Verification of logged out tokens

For the same reason, logging out tokens does not remove them, but marks them as logged out. The final condition verifies whether the token is logged out.

export function VerifyRefreshToken (fqlStatementOnSuccessfulVerification, action) {
  return If(And(IsTokenUsed(), Not(IsWithinGracePeriod())),
    ...,
    If(IsTokenStillValid(),
      If(Not(IsTokenLoggedOut()),
        ...,
        ...
      ),
      ...
    )
  )
}

Logging anomalies

Attempts to use expired or logged-out tokens require action to determine what has occurred. This implementation uses the LogAnomaly function to log these events.

export function VerifyRefreshToken (fqlStatement, action) {
  return If(And(IsTokenUsed(), Not(IsWithinGracePeriod())),
    LogAnomaly(REFRESH_TOKEN_REUSE_ERROR, action),
    If(IsTokenStillValid(),
      If(Not(IsTokenLoggedOut()),
        fqlStatement,
        LogAnomaly(REFRESH_TOKEN_USED_AFTER_LOGOUT, action)
      ),
      LogAnomaly(REFRESH_TOKEN_EXPIRED, action)
    )
  )
}

LogAnomaly writes the event to a separate ‘anomalies’ collection, provides some context, and returns the error. You can adapt the implementation of LogAnomaly to take more restrictive action when token theft is detected, for example, locking the account.

> fauna/src/anomalies.js

export function LogAnomaly (error, action) {
  return Do(
    Create(Collection(‘anomalies’), {
      data: {
        error: error,
        token: CurrentToken(),
        account: CurrentIdentity(),
        action: action
      }
    }),
    error
  )
}

Refresh tokens

Once you verify the current refresh token, the next step is to provide new tokens. The implementation delegates this to RotateAccessAndRefreshToken.

> fauna/src/refresh.js

export function RefreshToken (...) {
  return VerifyRefreshToken(
    {
      tokens: RotateAccessAndRefreshToken(...),
      account: Get(CurrentIdentity())
    }, ‘refresh’)
}

The function invalidates the current refresh token and creates new access and refresh tokens.

> fauna/src/tokens.js

export function RotateAccessAndRefreshToken (...) {
  return Do(
    InvalidateRefreshToken(...),
    CreateAccessAndRefreshToken(...)
  )
}

As before, invalidating the refresh token does not delete it. Instead, it updates the token to mark it as used and sets the grace period’s expiration time.

export function InvalidateRefreshToken (refreshTokenRef) {
  return Update(refreshTokenRef, {
    data: {
      used: true,
      gracePeriodUntil: TimeAdd(Now(), GRACE_PERIOD_SECONDS, ‘seconds’)
    }
  })
}

Creating the access and refresh token with CreateAccessAndRefreshToken does not change significantly from the simple refresh tokens blueprint.

export function CreateAccessAndRefreshToken (instance, accessTtlSeconds, refreshTtlSeconds) {
  return Let(
    {
      refresh: CreateRefreshToken(instance, refreshTtlSeconds),
      access: CreateAccessToken(instance, Select([‘ref’], Var(‘refresh’)), accessTtlSeconds)
    },
    {
      refresh: Var(‘refresh’),
      access: Var(‘access’)
    }
  )
}

How you create refresh tokens does change slightly, however. Several properties are added:

  • used: a boolean that indicates whether the refresh token is used.
  • sessionId: a unique identifier that identifies all tokens from the same login session regardless of whether they are used, logged out, or no longer valid.
  • validUntil: the expiration time of the token (replaces TTL)
  • loggedOut: a boolean that indicates whether the token is invalidated due to a logout.

Although validUntil replaces the functionality of TTL, you can still configure TTL to prevent long-term accumulation and storage of tokens. Once a token’s TTL expires, you can no longer use it to detect whether it has leaked, so it makes little sense to keep them around forever.

export function CreateRefreshToken (...) {
  return Create(Tokens(), {
    instance: accountRef,
    data: {
      type: ‘refresh’,
      used: false,
      sessionId: CreateOrReuseId(),
      validUntil: TimeAdd(Now(), REFRESH_TOKEN_LIFETIME_SECONDS, ‘seconds’),
      loggedOut: false
    },
    ttl: TimeAdd(Now(), REFRESH_TOKEN_RECLAIMTIME_SECONDS, ‘seconds’)
  })
}

Logout

Finally, the Logout functionality changes slightly, retrieving refresh tokens based on the session and whether they are logged out.

> fauna/src/logout.js

export function Logout (all) {
  return VerifyRefreshToken(If(
    all,
    LogoutAll(),
    LogoutOne()
  ), ‘logout’)
}

// Logout the access and refresh token for the refresh token provided (which corresponds to one browser)
function LogoutOne () {
  return Let(
    {
      refreshTokens: Paginate(
        Match(
          Index(‘tokens_by_instance_sessionid_type_and_loggedout’),
          CurrentIdentity(), GetSessionId(), ‘refresh’, false
        ), { size: 100000 })
    },
    q.Map(Var(‘refreshTokens’), Lambda([‘token’], LogoutAccessAndRefreshToken(Var(‘token’))))
  )
}

// Logout all tokens for an accounts (which could be on different machines or different browsers)
function LogoutAll () {
  return Let(
    {
      refreshTokens: Paginate(
        Match(
          Index(‘tokens_by_instance_type_and_loggedout’),
          CurrentIdentity(), ‘refresh’, false),
        { size: 100000 }
      )
    },
    q.Map(Var(‘refreshTokens’), Lambda([‘token’], LogoutAccessAndRefreshToken(Var(‘token’))))
  )
}

Once you retrieve the refresh tokens, mark them as logged out by setting the ‘loggedOut’ attribute to true and delete the access tokens.

> fauna/src/tokens.js

export function LogoutAccessAndRefreshToken (refreshTokenRef) {
  return Do(
    InvalidateAccessToken(refreshTokenRef),
    LogoutRefreshToken(refreshTokenRef)
  )
}

function LogoutRefreshToken (refreshTokenRef) {
  return Update(refreshTokenRef, { data: { loggedOut: true } })
}

function InvalidateAccessToken (refreshTokenRef) {
  return If(
    Exists(Match(Index(‘access_token_by_refresh_token’), refreshTokenRef)),
    Delete(Select([‘ref’], Get(Match(Index(‘access_token_by_refresh_token’), refreshTokenRef)))),
    false
  )
}

Conclusion

The Fauna advanced refresh tokens blueprint provides an example implementation that you can learn from, customize or use in your own application. In this article, you learned how to implement an advanced refresh token workflow how it improves upon the simple refresh tokens blueprint.

To implement more common authentication tasks in FQL, see the registration, password reset, and email verification blueprints.

Deploy this blueprint to your own Fauna database today by following the instructions in the README. Share your thoughts in the Fauna forums and let us know which blueprints you would like to see next!

If you enjoyed our blog, and want to work on systems and challenges related to globally distributed systems, serverless databases, GraphQL, and Jamstack, Fauna is hiring!

Share this post

TwitterLinkedIn

Subscribe to Fauna blogs & newsletter

Get latest blog posts, development tips & tricks, and latest learning material delivered right to your inbox.

<- Back