chatdesk-ui/auth_v2.169.0/internal/api/token_refresh.go

275 lines
8.6 KiB
Go

package api
import (
"context"
mathRand "math/rand"
"net/http"
"time"
"github.com/supabase/auth/internal/metering"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)
const retryLoopDuration = 5.0
// RefreshTokenGrantParams are the parameters the RefreshTokenGrant method accepts
type RefreshTokenGrantParams struct {
RefreshToken string `json:"refresh_token"`
}
// RefreshTokenGrant implements the refresh_token grant type flow
func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
db := a.db.WithContext(ctx)
config := a.config
params := &RefreshTokenGrantParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}
if params.RefreshToken == "" {
return oauthError("invalid_request", "refresh_token required")
}
// A 5 second retry loop is used to make sure that refresh token
// requests do not waste database connections waiting for each other.
// Instead of waiting at the database level, they're waiting at the API
// level instead and retry to refresh the locked row every 10-30
// milliseconds.
retryStart := a.Now()
retry := true
for retry && time.Since(retryStart).Seconds() < retryLoopDuration {
retry = false
user, token, session, err := models.FindUserWithRefreshToken(db, params.RefreshToken, false)
if err != nil {
if models.IsNotFoundError(err) {
return badRequestError(ErrorCodeRefreshTokenNotFound, "Invalid Refresh Token: Refresh Token Not Found")
}
return internalServerError(err.Error())
}
if user.IsBanned() {
return badRequestError(ErrorCodeUserBanned, "Invalid Refresh Token: User Banned")
}
if session == nil {
// a refresh token won't have a session if it's created prior to the sessions table introduced
if err := db.Destroy(token); err != nil {
return internalServerError("Error deleting refresh token with missing session").WithInternalError(err)
}
return badRequestError(ErrorCodeSessionNotFound, "Invalid Refresh Token: No Valid Session Found")
}
result := session.CheckValidity(retryStart, &token.UpdatedAt, config.Sessions.Timebox, config.Sessions.InactivityTimeout)
switch result {
case models.SessionValid:
// do nothing
case models.SessionTimedOut:
return badRequestError(ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired (Inactivity)")
default:
return badRequestError(ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired")
}
// Basic checks above passed, now we need to serialize access
// to the session in a transaction so that there's no
// concurrent modification. In the event that the refresh
// token's row or session is locked, the transaction is closed
// and the whole process will be retried a bit later so that
// the connection pool does not get exhausted.
var tokenString string
var expiresAt int64
var newTokenResponse *AccessTokenResponse
err = db.Transaction(func(tx *storage.Connection) error {
user, token, session, terr := models.FindUserWithRefreshToken(tx, params.RefreshToken, true /* forUpdate */)
if terr != nil {
if models.IsNotFoundError(terr) {
// because forUpdate was set, and the
// previous check outside the
// transaction found a refresh token
// and session, but now we're getting a
// IsNotFoundError, this means that the
// refresh token row and session are
// probably locked so we need to retry
// in a few milliseconds.
retry = true
return terr
}
return internalServerError(terr.Error())
}
if a.config.Sessions.SinglePerUser {
sessions, terr := models.FindAllSessionsForUser(tx, user.ID, true /* forUpdate */)
if models.IsNotFoundError(terr) {
// because forUpdate was set, and the
// previous check outside the
// transaction found a user and
// session, but now we're getting a
// IsNotFoundError, this means that the
// user is locked and we need to retry
// in a few milliseconds
retry = true
return terr
} else if terr != nil {
return internalServerError(terr.Error())
}
sessionTag := session.DetermineTag(config.Sessions.Tags)
// go through all sessions of the user and
// check if the current session is the user's
// most recently refreshed valid session
for _, s := range sessions {
if s.ID == session.ID {
// current session, skip it
continue
}
if s.CheckValidity(retryStart, nil, config.Sessions.Timebox, config.Sessions.InactivityTimeout) != models.SessionValid {
// session is not valid so it
// can't be regarded as active
// on the user
continue
}
if s.DetermineTag(config.Sessions.Tags) != sessionTag {
// if tags are specified,
// ignore sessions with a
// mismatching tag
continue
}
// since token is not the refresh token
// of s, we can't use it's UpdatedAt
// time to compare!
if s.LastRefreshedAt(nil).After(session.LastRefreshedAt(&token.UpdatedAt)) {
// session is not the most
// recently active one
return badRequestError(ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired (Revoked by Newer Login)")
}
}
// this session is the user's active session
}
// refresh token row and session are locked at this
// point, cannot be concurrently refreshed
var issuedToken *models.RefreshToken
if token.Revoked {
activeRefreshToken, terr := session.FindCurrentlyActiveRefreshToken(tx)
if terr != nil && !models.IsNotFoundError(terr) {
return internalServerError(terr.Error())
}
if activeRefreshToken != nil && activeRefreshToken.Parent.String() == token.Token {
// Token was revoked, but it's the
// parent of the currently active one.
// This indicates that the client was
// not able to store the result when it
// refreshed token. This case is
// allowed, provided we return back the
// active refresh token instead of
// creating a new one.
issuedToken = activeRefreshToken
} else {
// For a revoked refresh token to be reused, it
// has to fall within the reuse interval.
reuseUntil := token.UpdatedAt.Add(
time.Second * time.Duration(config.Security.RefreshTokenReuseInterval))
if a.Now().After(reuseUntil) {
// not OK to reuse this token
if config.Security.RefreshTokenRotationEnabled {
// Revoke all tokens in token family
if err := models.RevokeTokenFamily(tx, token); err != nil {
return internalServerError(err.Error())
}
}
return storage.NewCommitWithError(badRequestError(ErrorCodeRefreshTokenAlreadyUsed, "Invalid Refresh Token: Already Used").WithInternalMessage("Possible abuse attempt: %v", token.ID))
}
}
}
if terr = models.NewAuditLogEntry(r, tx, user, models.TokenRefreshedAction, "", nil); terr != nil {
return terr
}
if issuedToken == nil {
newToken, terr := models.GrantRefreshTokenSwap(r, tx, user, token)
if terr != nil {
return terr
}
issuedToken = newToken
}
tokenString, expiresAt, terr = a.generateAccessToken(r, tx, user, issuedToken.SessionId, models.TokenRefresh)
if terr != nil {
httpErr, ok := terr.(*HTTPError)
if ok {
return httpErr
}
return internalServerError("error generating jwt token").WithInternalError(terr)
}
refreshedAt := a.Now()
session.RefreshedAt = &refreshedAt
userAgent := r.Header.Get("User-Agent")
if userAgent != "" {
session.UserAgent = &userAgent
} else {
session.UserAgent = nil
}
ipAddress := utilities.GetIPAddress(r)
if ipAddress != "" {
session.IP = &ipAddress
} else {
session.IP = nil
}
if terr := session.UpdateOnlyRefreshInfo(tx); terr != nil {
return internalServerError("failed to update session information").WithInternalError(terr)
}
newTokenResponse = &AccessTokenResponse{
Token: tokenString,
TokenType: "bearer",
ExpiresIn: config.JWT.Exp,
ExpiresAt: expiresAt,
RefreshToken: issuedToken.Token,
User: user,
}
return nil
})
if err != nil {
if retry && models.IsNotFoundError(err) {
// refresh token and session row were likely locked, so
// we need to wait a moment before retrying the whole
// process anew
time.Sleep(time.Duration(10+mathRand.Intn(20)) * time.Millisecond) // #nosec
continue
} else {
return err
}
}
metering.RecordLogin("token", user.ID)
return sendJSON(w, http.StatusOK, newTokenResponse)
}
return conflictError("Too many concurrent token refresh requests on the same session or refresh token")
}