rotateRefreshToken method

Future<TokenPair> rotateRefreshToken(
  1. Session session, {
  2. required String refreshToken,
  3. Transaction? transaction,
})

Returns a new refresh / access token pair.

This invalidates the previous refresh token. Previously created access tokens for this refresh token will continue to work until they expire.

Implementation

Future<TokenPair> rotateRefreshToken(
  final Session session, {
  required final String refreshToken,
  final Transaction? transaction,
}) async {
  final RefreshTokenStringData refreshTokenData;

  try {
    refreshTokenData = RefreshTokenString.parseRefreshTokenString(
      refreshToken,
    );
  } catch (e, stackTrace) {
    session.log(
      'Received malformed refresh token',
      exception: e,
      stackTrace: stackTrace,
      level: LogLevel.debug,
    );

    throw RefreshTokenMalformedServerException();
  }

  var refreshTokenRow = await RefreshToken.db.findById(
    session,
    refreshTokenData.id,
    transaction: transaction,
  );

  if (refreshTokenRow == null ||
      !uint8ListAreEqual(
        Uint8List.sublistView(refreshTokenRow.fixedSecret),
        refreshTokenData.fixedSecret,
      )) {
    throw RefreshTokenNotFoundServerException();
  }

  if (refreshTokenRow.isExpired(_refreshTokenLifetime)) {
    await RefreshToken.db.deleteRow(
      session,
      refreshTokenRow,
      transaction: transaction,
    );

    throw RefreshTokenExpiredServerException(
      refreshTokenId: refreshTokenRow.id!,
      authUserId: refreshTokenRow.authUserId,
    );
  }

  if (!await _refreshTokenSecretHash.validateHashFromBytes(
    secret: refreshTokenData.rotatingSecret,
    hashString: refreshTokenRow.rotatingSecretHash,
  )) {
    await RefreshToken.db.deleteRow(
      session,
      refreshTokenRow,
      transaction: transaction,
    );

    throw RefreshTokenInvalidSecretServerException(
      refreshTokenId: refreshTokenRow.id!,
      authUserId: refreshTokenRow.authUserId,
    );
  }

  // Checked only once the caller has proven possession of the refresh token,
  // so this does not become an oracle for the state of an account whose
  // token the caller does not hold.
  //
  // A rotation re-establishes access for another full refresh token
  // lifetime, so `blocked` has to be consulted here and not only in
  // `createTokens` - otherwise blocking a user leaves any session they
  // already hold running indefinitely. The token is deliberately left in
  // place rather than deleted, so that lifting the block restores it.
  //
  // Read directly rather than through `AuthUsers.get`, which opens a
  // transaction of its own. Rotations are not serialised, so nesting one
  // here corrupts the savepoint stack when several run on the same session.
  final authUser = await AuthUser.db.findById(
    session,
    refreshTokenRow.authUserId,
    transaction: transaction,
  );

  if (authUser == null) {
    throw AuthUserNotFoundException();
  }

  if (authUser.blocked) {
    throw AuthUserBlockedException();
  }

  final newSecret = _generateRefreshTokenRotatingSecret();
  final newHash = await _refreshTokenSecretHash.createHashFromBytes(
    secret: newSecret,
  );

  refreshTokenRow = await RefreshToken.db.updateRow(
    session,
    refreshTokenRow.copyWith(
      rotatingSecretHash: newHash,
      lastUpdatedAt: clock.now(),
    ),
    transaction: transaction,
  );

  return TokenPair(
    refreshToken: RefreshTokenString.buildRefreshTokenString(
      refreshToken: refreshTokenRow,
      rotatingSecret: newSecret,
    ),
    accessToken: _jwtUtil.createJwt(refreshTokenRow),
  );
}