Repository: supabase/gotrue
Pull request #471 (2022-05-05 00:34:05)

fix: add http timeout to add external provider requests
## What kind of change does this PR introduce?
* Adds a default 10 sec http timeout to all external oauth / sms provider requests


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0421ac48..b196dbe2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,7 +13,7 @@ GoTrue has a development container setup that makes it easy to get started contr
 
 If you would like to run GoTrue locally or learn more about what these containers are doing for you, continue reading the [Setup and Tooling](#setup-and-tooling) section below. Otherwise, you can skip ahead to the [How To Verify that GoTrue is Available](#how-to-verify-that-gotrue-is-available) section to learn about working with and developing GoTrue.
 
-Before using the containers, you will need to make sure an `.env` file exists by making a copy of `example.env` and configuring it for your needs. A required change for the containers is that the `DATABASE_URL` be changed to `postgres://supabase_auth_admin:root@localhost:5432/postgres` where the address of the Postgres container is changed to `localhost`.
+Before using the containers, you will need to make sure an `.env.docker` file exists by making a copy of `example.docker.env` and configuring it for your needs. The set of env vars in `example.docker.env` only contain the necessary env vars for gotrue to start in a docker environment. For the full list of env vars, please refer to `example.env` and copy over the necessary ones into your `.env.docker` file.
 
 The following are some basic commands. A full and up to date list of commands can be found in the project's `Makefile` or by running `make help`.
 
diff --git a/Dockerfile.dev b/Dockerfile.dev
index a21af293..de4e0779 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -14,9 +14,5 @@ COPY ./Makefile ./go.* ./
 RUN make deps
 
 # Development dependences
-RUN go get -u github.com/Thatooine/go-test-html-report
 RUN go get github.com/githubnemo/CompileDaemon
 RUN go install github.com/githubnemo/CompileDaemon
-
-RUN adduser -D -u 1000 netlify
-USER netlify
diff --git a/Makefile b/Makefile
index 519ec437..9ff09419 100644
--- a/Makefile
+++ b/Makefile
@@ -17,9 +17,6 @@ deps: ## Install dependencies.
 	@go install golang.org/x/lint/golint@latest
 	@go mod download
 
-image: ## Build the production Docker image.
-	docker build .
-
 lint: ## Lint the code.
 	golint $(CHECK_FILES)
 
@@ -36,12 +33,12 @@ vet: # Vet the code
 	go vet $(CHECK_FILES)
 
 dev: ## Run the development containers
-	# Start postgres first and apply migrations
-	docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres
-	docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_dev"
-	# Actually start the containers for dev
 	docker-compose -f $(DEV_DOCKER_COMPOSE) up
 
+down: ## Shutdown the development containers
+	# Start postgres first and apply migrations
+	docker-compose -f $(DEV_DOCKER_COMPOSE) down
+
 docker-test: ## Run the tests using the development containers
 	docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres
 	docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_test"
diff --git a/README.md b/README.md
index 322b8ef5..5fcb9fd1 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,15 @@ Create a `.env` file to store your own custom env vars. See [`example.env`](exam
 go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`"
 GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`" -o gotrue-arm64
 ```
-3. Execute the gotrue binary: `./gotrue` (if you're on x86) `./gotrue-arm64` (if you're on arm)
+3. Execute the gotrue binary: `./gotrue`
+
+### If you have docker installed...
+Create a `.env.docker` file to store your own custom env vars. See [`example.docker.env`](example.docker.env)
+
+1. `make build`
+2. `make dev`
+3. `docker ps` should show 2 docker containers (`gotrue_postgresql` and `gotrue_gotrue`)
+4. That's it! Visit the [health checkendpoint](http://localhost:9999/health) to confirm that gotrue is running. 
 
 ## Configuration
 
@@ -66,6 +74,15 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup
 
 Minimum password length, defaults to 6.
 
+`GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool`
+
+If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token.
+
+`GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` - `string`
+
+This setting is only applicable if `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` is enabled. The reuse interval for a refresh token allows for exchanging the refresh token multiple times during the interval to support concurrency or offline issues. During the reuse interval, gotrue will not consider using a revoked token as a malicious attempt and will simply return the child refresh token. 
+
+Only the previous revoked token can be reused. Using an old refresh token way before the current valid refresh token will trigger the reuse detection. 
 ### API
 
 ```properties
diff --git a/api/api.go b/api/api.go
index 5a0bbf83..cb98b37f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -124,7 +124,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.limitHandler(
 			// Allow requests at the specified rate per 5 minutes.
-			tollbooth.NewLimiter(api.config.RateLimitToken/(60*5), &limiter.ExpirableOptions{
+			tollbooth.NewLimiter(api.config.RateLimitTokenRefresh/(60*5), &limiter.ExpirableOptions{
 				DefaultExpirationTTL: time.Hour,
 			}).SetBurst(30),
 		)).Post("/token", api.Token)
diff --git a/api/provider/apple.go b/api/provider/apple.go
index 148841af..e04585a4 100644
--- a/api/provider/apple.go
+++ b/api/provider/apple.go
@@ -32,7 +32,6 @@ const (
 // AppleProvider stores the custom config for apple provider
 type AppleProvider struct {
 	*oauth2.Config
-	httpClient  *http.Client
 	UserInfoURL string
 }
 
diff --git a/api/provider/notion.go b/api/provider/notion.go
index 877fb671..db8f84d0 100644
--- a/api/provider/notion.go
+++ b/api/provider/notion.go
@@ -77,7 +77,7 @@ func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us
 	req.Header.Set("Notion-Version", notionApiVersion)
 	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	resp, err := client.Do(req)
 
 	if err != nil {
diff --git a/api/provider/provider.go b/api/provider/provider.go
index d06659e7..426f1875 100644
--- a/api/provider/provider.go
+++ b/api/provider/provider.go
@@ -5,11 +5,27 @@ import (
 	"context"
 	"encoding/json"
 	"io/ioutil"
+	"log"
 	"net/http"
+	"os"
+	"time"
 
 	"golang.org/x/oauth2"
 )
 
+var defaultTimeout time.Duration = time.Second * 10
+
+func init() {
+	timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT")
+	if timeoutStr != "" {
+		if timeout, err := time.ParseDuration(timeoutStr); err != nil {
+			log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error())
+		} else if timeout != 0 {
+			defaultTimeout = timeout
+		}
+	}
+}
+
 type Claims struct {
 	// Reserved claims
 	Issuer  string  `json:"iss,omitempty"`
@@ -103,6 +119,7 @@ func chooseHost(base, defaultHost string) string {
 
 func makeRequest(ctx context.Context, tok *oauth2.Token, g *oauth2.Config, url string, dst interface{}) error {
 	client := g.Client(ctx, tok)
+	client.Timeout = defaultTimeout
 	res, err := client.Get(url)
 	if err != nil {
 		return err
diff --git a/api/provider/twitch.go b/api/provider/twitch.go
index f4c52d7a..517109c7 100644
--- a/api/provider/twitch.go
+++ b/api/provider/twitch.go
@@ -92,7 +92,7 @@ func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us
 	req.Header.Set("Client-Id", t.Config.ClientID)
 	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	resp, err := client.Do(req)
 
 	if err != nil {
diff --git a/api/sms_provider/messagebird.go b/api/sms_provider/messagebird.go
index 88f61df4..4520f289 100644
--- a/api/sms_provider/messagebird.go
+++ b/api/sms_provider/messagebird.go
@@ -64,7 +64,7 @@ func (t *MessagebirdProvider) SendSms(phone string, message string) error {
 		"datacoding": {"unicode"},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/sms_provider.go b/api/sms_provider/sms_provider.go
index 11b3b3f2..7d535133 100644
--- a/api/sms_provider/sms_provider.go
+++ b/api/sms_provider/sms_provider.go
@@ -2,10 +2,26 @@ package sms_provider
 
 import (
 	"fmt"
+	"log"
+	"os"
+	"time"
 
 	"github.com/netlify/gotrue/conf"
 )
 
+var defaultTimeout time.Duration = time.Second * 10
+
+func init() {
+	timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT")
+	if timeoutStr != "" {
+		if timeout, err := time.ParseDuration(timeoutStr); err != nil {
+			log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error())
+		} else if timeout != 0 {
+			defaultTimeout = timeout
+		}
+	}
+}
+
 type SmsProvider interface {
 	SendSms(phone, message string) error
 }
diff --git a/api/sms_provider/textlocal.go b/api/sms_provider/textlocal.go
index 09e30f23..ac0f4b16 100644
--- a/api/sms_provider/textlocal.go
+++ b/api/sms_provider/textlocal.go
@@ -52,7 +52,7 @@ func (t *TextlocalProvider) SendSms(phone string, message string) error {
 		"numbers": {phone},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/twilio.go b/api/sms_provider/twilio.go
index a77d9c10..cfe2ff04 100644
--- a/api/sms_provider/twilio.go
+++ b/api/sms_provider/twilio.go
@@ -62,7 +62,7 @@ func (t *TwilioProvider) SendSms(phone string, message string) error {
 		"Body":    {message},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/vonage.go b/api/sms_provider/vonage.go
index fdc37525..75ed3577 100644
--- a/api/sms_provider/vonage.go
+++ b/api/sms_provider/vonage.go
@@ -52,7 +52,7 @@ func (t *VonageProvider) SendSms(phone string, message string) error {
 		"api_secret": {t.Config.ApiSecret},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/token.go b/api/token.go
index edd86b83..1ce341be 100644
--- a/api/token.go
+++ b/api/token.go
@@ -273,41 +273,50 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 		return oauthError("invalid_grant", "Invalid Refresh Token")
 	}
 
-	if !(config.External.Email.Enabled && config.External.Phone.Enabled) {
-		providers, err := models.FindProvidersByUser(a.db, user)
-		if err != nil {
-			return internalServerError(err.Error())
-		}
-		for _, provider := range providers {
-			if provider == "email" && !config.External.Email.Enabled {
-				return badRequestError("Email logins are disabled")
+	var newToken *models.RefreshToken
+	if token.Revoked {
+		a.clearCookieTokens(config, w)
+		err = a.db.Transaction(func(tx *storage.Connection) error {
+			validToken, terr := models.GetValidChildToken(tx, token)
+			if terr != nil {
+				if errors.Is(terr, models.RefreshTokenNotFoundError{}) {
+					// revoked token has no descendants
+					return nil
+				}
+				return terr
 			}
-			if provider == "phone" && !config.External.Phone.Enabled {
-				return badRequestError("Phone logins are disabled")
+			// check if token is the last previous revoked token
+			if validToken.Parent == storage.NullString(token.Token) {
+				refreshTokenReuseWindow := token.UpdatedAt.Add(time.Second * time.Duration(config.Security.RefreshTokenReuseInterval))
+				if time.Now().Before(refreshTokenReuseWindow) {
+					newToken = validToken
+				}
 			}
+			return nil
+		})
+		if err != nil {
+			return internalServerError("Error validating reuse interval").WithInternalError(err)
 		}
-	}
 
-	if token.Revoked {
-		a.clearCookieTokens(config, w)
-		if config.Security.RefreshTokenRotationEnabled {
-			// Revoke all tokens in token family
-			err = a.db.Transaction(func(tx *storage.Connection) error {
-				var terr error
-				if terr = models.RevokeTokenFamily(tx, token); terr != nil {
-					return terr
+		if newToken == nil {
+			if config.Security.RefreshTokenRotationEnabled {
+				// Revoke all tokens in token family
+				err = a.db.Transaction(func(tx *storage.Connection) error {
+					var terr error
+					if terr = models.RevokeTokenFamily(tx, token); terr != nil {
+						return terr
+					}
+					return nil
+				})
+				if err != nil {
+					return internalServerError(err.Error())
 				}
-				return nil
-			})
-			if err != nil {
-				return internalServerError(err.Error())
 			}
+			return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
 		}
-		return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
 	}
 
 	var tokenString string
-	var newToken *models.RefreshToken
 	var newTokenResponse *AccessTokenResponse
 
 	err = a.db.Transaction(func(tx *storage.Connection) error {
@@ -316,9 +325,11 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 			return terr
 		}
 
-		newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
-		if terr != nil {
-			return internalServerError(terr.Error())
+		if newToken == nil {
+			newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
+			if terr != nil {
+				return internalServerError(terr.Error())
+			}
 		}
 
 		tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
diff --git a/conf/configuration.go b/conf/configuration.go
index c99e840e..a9e28ee4 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -66,17 +66,17 @@ type GlobalConfiguration struct {
 		RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
 		ExternalURL     string `json:"external_url" envconfig:"API_EXTERNAL_URL"`
 	}
-	DB                 DBConfiguration
-	External           ProviderConfiguration
-	Logging            LoggingConfig `envconfig:"LOG"`
-	OperatorToken      string        `split_words:"true" required:"false"`
-	MultiInstanceMode  bool
-	Tracing            TracingConfig
-	SMTP               SMTPConfiguration
-	RateLimitHeader    string  `split_words:"true"`
-	RateLimitEmailSent float64 `split_words:"true" default:"30"`
-	RateLimitVerify    float64 `split_words:"true" default:"30"`
-	RateLimitToken     float64 `split_words:"true" default:"30"`
+	DB                    DBConfiguration
+	External              ProviderConfiguration
+	Logging               LoggingConfig `envconfig:"LOG"`
+	OperatorToken         string        `split_words:"true" required:"false"`
+	MultiInstanceMode     bool
+	Tracing               TracingConfig
+	SMTP                  SMTPConfiguration
+	RateLimitHeader       string  `split_words:"true"`
+	RateLimitEmailSent    float64 `split_words:"true" default:"30"`
+	RateLimitVerify       float64 `split_words:"true" default:"30"`
+	RateLimitTokenRefresh float64 `split_words:"true" default:"30"`
 }
 
 // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs.
@@ -181,6 +181,7 @@ type CaptchaConfiguration struct {
 type SecurityConfiguration struct {
 	Captcha                               CaptchaConfiguration `json:"captcha"`
 	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
+	RefreshTokenReuseInterval             int                  `json:"refresh_token_reuse_interval" split_words:"true"`
 	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
 }
 
@@ -208,7 +209,7 @@ type Configuration struct {
 func loadEnvironment(filename string) error {
 	var err error
 	if filename != "" {
-		err = godotenv.Load(filename)
+		err = godotenv.Overload(filename)
 	} else {
 		err = godotenv.Load()
 		// handle if .env file does not exist, this is OK
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 1d8f140d..49f4eb9c 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -7,19 +7,18 @@ services:
     build:
       context: ./
       dockerfile: Dockerfile.dev
-    # Not sure why ports aren't working. Using host as a hack
-    # ports:
-    #   - "9999:9999"
-    network_mode: "host"
+    ports:
+      - '9999:9999'
     environment:
       - GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations
     volumes:
       - ./:/go/src/github.com/netlify/gotrue
-    command: CompileDaemon --build="make build" --directory=/go/src/github.com/netlify/gotrue --recursive=true -pattern=(.+\.go|.+\.env) -exclude=gotrue -exclude=gotrue-arm64 --command=/go/src/github.com/netlify/gotrue/gotrue
+    command: CompileDaemon --build="make build" --directory=/go/src/github.com/netlify/gotrue --recursive=true -pattern=(.+\.go|.+\.env) -exclude=gotrue -exclude=gotrue-arm64 -exclude=.env --command="/go/src/github.com/netlify/gotrue/gotrue -c=.env.docker"
   postgres:
     image: postgres:13
     container_name: postgres
-    network_mode: "host"
+    ports:
+      - '5432:5432'
     volumes:
       - postgres_data:/var/lib/postgresql/data
       - ${PWD}/hack/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
diff --git a/example.docker.env b/example.docker.env
new file mode 100644
index 00000000..89eefe06
--- /dev/null
+++ b/example.docker.env
@@ -0,0 +1,7 @@
+GOTRUE_SITE_URL="http://localhost:3000"
+GOTRUE_JWT_SECRET=""
+GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations
+GOTRUE_DB_DRIVER=postgres
+DATABASE_URL=postgres://supabase_auth_admin:root@postgres:5432/postgres
+GOTRUE_API_HOST=0.0.0.0
+PORT=9999
diff --git a/example.env b/example.env
index e38ef11a..be0e6312 100644
--- a/example.env
+++ b/example.env
@@ -190,7 +190,9 @@ GOTRUE_EXTERNAL_SAML_SIGNING_KEY=""
 
 # Additional Security config
 GOTRUE_LOG_LEVEL="debug"
-GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED="false"
+GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false"
+GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0"
+GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false"
 GOTRUE_OPERATOR_TOKEN="unused-operator-token"
 GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For"
 GOTRUE_RATE_LIMIT_EMAIL_SENT="100"
diff --git a/hack/migrate.sh b/hack/migrate.sh
index 06c55f97..2d1f0e5e 100755
--- a/hack/migrate.sh
+++ b/hack/migrate.sh
@@ -9,7 +9,4 @@ export GOTRUE_DB_DRIVER="postgres"
 export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/$DB_ENV"
 export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
 
-echo soda -v
-soda drop -d -e $DB_ENV -c $DATABASE
-soda create -d -e $DB_ENV -c $DATABASE
 go run main.go migrate -c $DIR/test.env
diff --git a/hack/migrate_postgres.sh b/hack/migrate_postgres.sh
deleted file mode 100755
index 59a8e80c..00000000
--- a/hack/migrate_postgres.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-
-DB_ENV=$1
-
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-DATABASE="$DIR/database.yml"
-
-export GOTRUE_DB_DRIVER="postgres"
-export GOTRUE_DB_DATABASE_URL="postgres://postgres:root@localhost:5432/$DB_ENV?sslmode=disable"
-export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
-
-go run main.go migrate -c $DIR/test.env
diff --git a/models/refresh_token.go b/models/refresh_token.go
index 054806e4..2af42c50 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -1,6 +1,7 @@
 package models
 
 import (
+	"database/sql"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -57,19 +58,33 @@ func GrantRefreshTokenSwap(tx *storage.Connection, user *User, token *RefreshTok
 
 // RevokeTokenFamily revokes all refresh tokens that descended from the provided token.
 func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error {
+	tablename := (&pop.Model{Value: RefreshToken{}}).TableName()
 	err := tx.RawQuery(`
 	with recursive token_family as (
-		select id, user_id, token, revoked, parent from refresh_tokens where parent = ?
+		select id, user_id, token, revoked, parent from `+tablename+` where parent = ?
 		union
-		select r.id, r.user_id, r.token, r.revoked, r.parent from `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r inner join token_family t on t.token = r.parent
+		select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent
 	)
-	update `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
+	update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
+// GetValidChildToken returns the child token of the token provided if the child is not revoked.
+func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshToken, error) {
+	refreshToken := &RefreshToken{}
+	err := tx.Q().Where("parent = ? and revoked = false", token.Token).First(refreshToken)
+	if err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, RefreshTokenNotFoundError{}
+		}
+		return nil, err
+	}
+	return refreshToken, nil
+}
+
 // Logout deletes all refresh tokens for a user.
 func Logout(tx *storage.Connection, instanceID uuid.UUID, id uuid.UUID) error {
 	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE instance_id = ? AND user_id = ?", instanceID, id).Exec()


Test patch:
diff --git a/api/token_test.go b/api/token_test.go
index eaa5dcb5..ace039f9 100644
--- a/api/token_test.go
+++ b/api/token_test.go
@@ -57,7 +57,7 @@ func (ts *TokenTestSuite) SetupTest() {
 	require.NoError(ts.T(), err, "Error creating refresh token")
 }
 
-func (ts *TokenTestSuite) TestRateLimitToken() {
+func (ts *TokenTestSuite) TestRateLimitTokenRefresh() {
 	var buffer bytes.Buffer
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token", &buffer)
 	req.Header.Set("Content-Type", "application/json")
@@ -150,6 +150,95 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenGrantFailure() {
 	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
 }
 
+func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() {
+	u, err := models.NewUser(ts.instanceID, "foo@example.com", "password", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error creating test user model")
+	t := time.Now()
+	u.EmailConfirmedAt = &t
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving foo user")
+	first, err := models.GrantAuthenticatedUser(ts.API.db, u)
+	require.NoError(ts.T(), err)
+	second, err := models.GrantRefreshTokenSwap(ts.API.db, u, first)
+	require.NoError(ts.T(), err)
+	third, err := models.GrantRefreshTokenSwap(ts.API.db, u, second)
+	require.NoError(ts.T(), err)
+
+	cases := []struct {
+		desc                        string
+		refreshTokenRotationEnabled bool
+		reuseInterval               int
+		refreshToken                string
+		expectedCode                int
+		expectedBody                map[string]interface{}
+	}{
+		{
+			"Valid refresh within reuse interval",
+			true,
+			30,
+			second.Token,
+			http.StatusOK,
+			map[string]interface{}{
+				"refresh_token": third.Token,
+			},
+		},
+		{
+			"Invalid refresh, first token is not the previous revoked token",
+			true,
+			0,
+			first.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+		{
+			"Invalid refresh, revoked third token",
+			true,
+			0,
+			second.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+		{
+			"Invalid refresh, third token revoked by previous case",
+			true,
+			30,
+			third.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+	}
+
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			ts.Config.Security.RefreshTokenRotationEnabled = c.refreshTokenRotationEnabled
+			ts.Config.Security.RefreshTokenReuseInterval = c.reuseInterval
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+				"refresh_token": c.refreshToken,
+			}))
+			req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
+			req.Header.Set("Content-Type", "application/json")
+			w := httptest.NewRecorder()
+			ts.API.handler.ServeHTTP(w, req)
+			assert.Equal(ts.T(), c.expectedCode, w.Code)
+
+			data := make(map[string]interface{})
+			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+			for k, v := range c.expectedBody {
+				require.Equal(ts.T(), v, data[k])
+			}
+		})
+	}
+}
+
 func (ts *TokenTestSuite) createBannedUser() *models.User {
 	u, err := models.NewUser(ts.instanceID, "banned@example.com", "password", ts.Config.JWT.Aud, nil)
 	require.NoError(ts.T(), err, "Error creating test user model")